44.15 NoSQL 四类 (KV / 文档 / 列族 / 图)

一句话

四类 NoSQL 的差别不是“没有 schema”,而是物理布局决定访问路径:KV 只能按 key 找、文档按整块读写、列族按列扫、图按关系跳——选错的代价不是“慢一点”,而是“这类查询根本做不了”。

四类各是什么形状

类型 数据模型 只能怎么查 写放大 典型产品 适合
KV key → value(不透明字节) 按 key 精确取/覆写 低 Redis、RocksDB、etcd 缓存、会话、元数据、计数
文档 JSON/BSON 文档,按集合组织 按 _id、按二级索引、按字段过滤 中(整文档重写) MongoDB、Couchbase、ES(倒排) 内容、订单快照、结构多变的数据
列族 row key → 列族 → 列限定符 → 值 按 row key 前缀扫描,按列族读 低(追加) HBase、Cassandra、Bigtable 时序、日志、宽表、海量写入
图 顶点 + 边(带属性) 从某点出发遍历 N 跳 中(邻接表维护) Neo4j、JanusGraph、Nebula 社交关系、风控关联、知识图谱

🔑 一句话判据:“我的查询是否总是从一个明确的 key 出发?” 是 → KV/列族;需要按多个字段过滤 → 文档/搜索;需要“沿关系走多跳” → 图。

代码:同一份数据,三种查询读多少字节

package main

import "fmt"

const (
	cols     = 20
	colBytes = 8.0  // 每列 8 字节(bigint/double)
	rowKB    = 0.05 // 文档的额外开销(key、类型标记、填充),按 50B 估
)

func main() {
	rowBytes := float64(cols) * colBytes
	docBytes := rowBytes + rowKB*1024

	fmt.Printf("一张表:%d 列 × %.0f 字节 = 每行 %.0f 字节;文档形态每行 %.0f 字节\n\n",
		cols, colBytes, rowBytes, docBytes)

	fmt.Printf("%-32s %-14s %-14s %-14s %s\n", "查询", "行存", "列存", "文档/KV", "结论")
	fmt.Printf("%-32s %-14s %-14s %-14s %s\n",
		"点查 1 行(SELECT *)",
		fmt.Sprintf("%.0f B", rowBytes),
		fmt.Sprintf("%.0f B", rowBytes+32), // 列存要把 20 个列块各读一次
		fmt.Sprintf("%.0f B", docBytes),
		"行存/文档读得少")

	fmt.Printf("%-32s %-14s %-14s %-14s %s\n",
		"取 1 列 × 100 行",
		fmt.Sprintf("%.0f B", rowBytes*100),
		fmt.Sprintf("%.0f B", colBytes*100),
		fmt.Sprintf("%.0f B", docBytes*100),
		"列存省 20 倍")

	const bigRows = 100_000
	fmt.Printf("%-32s %-14s %-14s %-14s %s\n",
		fmt.Sprintf("全表 SUM(1 列) × %d 行", bigRows),
		fmt.Sprintf("%.1f MB", rowBytes*bigRows/1024/1024),
		fmt.Sprintf("%.1f MB", colBytes*bigRows/1024/1024),
		"—",
		"列存是 OLAP 的底座")

	// 图:3 度好友,平均每个点 20 条边
	deg := 20
	visited := 1 + deg + deg*deg + deg*deg*deg
	fmt.Printf("\n图:3 度好友遍历,平均度 %d → 访问约 %d 个顶点(按边跳,不做全表 join)\n", deg, visited)
	fmt.Printf("关系库做同样的事:3 次自连接,每次要按索引回查,中间结果随度数膨胀\n")
}

本机实测(macOS,Apple M1,Go 1.27):

一张表:20 列 × 8 字节 = 每行 160 字节;文档形态每行 211 字节

查询                               行存             列存             文档/KV          结论
点查 1 行(SELECT *)                 160 B          192 B          211 B          行存/文档读得少
取 1 列 × 100 行                    16000 B        800 B          21120 B        列存省 20 倍
全表 SUM(1 列) × 100000 行           15.3 MB        0.8 MB         —              列存是 OLAP 的底座

图:3 度好友遍历,平均度 20 → 访问约 8421 个顶点(按边跳,不做全表 join)
关系库做同样的事:3 次自连接,每次要按索引回查,中间结果随度数膨胀

读数:

查询 赢家 差距
点查整行 行存 / 文档 列存要读 20 个列块,反而更慢
取 1 列 × 100 行 列存 行存读 100 整行(16 KB)vs 列存读 800 B,20 倍
全表聚合 列存 15.3 MB vs 0.8 MB,且列存可只解压被查询的列
3 度好友 图 图按边跳约 8,421 个顶点;关系库是 3 次 join,中间结果爆炸

🔑 列存不是“压扁的行存”,它是为“读少列、扫多行”优化的布局。用它做点查(读整行)反而比行存慢——这也是为什么 HTAP 系统要维护行存副本。

选型决策表

你的情况 选什么 理由
缓存、会话、计数器 KV(Redis) 单 key 读写、O(1)、有过期
单机嵌入式存储、需要有序遍历 KV(RocksDB/LSM) 按 key 前缀扫描 + 高写入吞吐
结构多变的内容/订单快照 文档 整块读写,不必预定义全部列
海量写入 + 按时间/前缀范围读 列族 追加写、row key 设计决定一切
多跳关系(社交、风控、知识图谱) 图 邻接遍历,避免 join 爆炸
复杂聚合分析 列存 / OLAP(44.16 OLAP 与列式存储) 只读需要的列

反模式

反模式 后果
用 KV 做“按某字段查询” 只能全量扫描 key 或自己维护倒排索引
用文档库做跨文档 join/聚合 靠应用层拼装,N+1 与一致性都很难
用列族存“需要随机更新单列”的数据 列族适合追加与按 row key 读,随机更新代价高
用图库做全表统计 图库不擅长全表扫描,聚合性能差
以为 NoSQL “不需要设计数据模型” 恰恰相反:没有 join,模型必须按查询设计(44.14 的分片键同理)
用搜索库当主库 倒排索引的写入与事务能力不足以做权威存储

自检

Q1 为什么“取一列 100 行”,列存只要 800 字节,而行存要 16,000 字节?

▸ 答案:因为行存把一行的 20 列连续放在一起,读 1 列也必须把整行读进来(16 KB);列存把同一列的所有行连续存放,读 1 列 100 行就是 100 × 8 = 800 B。这就是“读放大”的差别:行存的读放大率 = 列数(20 倍),列存的读放大率 = 1(只读需要的列)。反过来,点查整行时列存要拼 20 个列块,行存一次 I/O 就够。

Q2 为什么“图数据库适合多跳查询”,而关系库不适合?

▸ 答案:关系库做多跳要走 join,每次 join 都要按索引回查并产生中间结果,中间结果随度数指数膨胀(3 度、平均度 20 → 8,421 条路径),优化器很难处理;图库用“免索引邻接”(每个顶点直接存指向邻居的指针),跳一步就是一次指针跟随,成本与“访问的顶点数”成正比,而不是与“全表规模”成正比。

Q3 一个团队用 HBase(列族)存订单,查询是“按用户 ID 查最近 10 单”。这个设计合理吗?

▸ 答案:取决于 row key 设计。列族库只能按 row key 前缀高效扫描,所以 row key 要设计成 userID + 倒序时间戳,这样“某用户最近 10 单”是一次前缀扫描 + LIMIT 10。如果 row key 是 orderID,那只能全表扫或另建索引表(自己维护倒排),成本和一致性都要自己扛。列族库的成败 90% 在 row key 设计。

Q4 为什么在文档库里 “往嵌套数组里追加元素” 代价很高?

▸ 答案:因为文档是整体存储的:修改嵌套数组要读出整个文档、改、再写回(可能还触发文档移动/重写)。数组越大,写放大越明显,并发更新同一文档还会互相覆盖。高频追加应改成 “子文档集合/独立表”,而不是无限增长的数组。

Q5 什么情况下该从 NoSQL 迁回关系库?

▸ 答案:当出现 “多条件组合查询 + 事务 + 强一致 + 关联” 的需求时:NoSQL 的访问路径由物理布局决定,做这些要么全表扫、要么应用层拼装、要么牺牲一致性。迁移的判据是 “查询模式是否已经超出当初选型时的那一条路径”。

关联