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 的访问路径由物理布局决定,做这些要么全表扫、要么应用层拼装、要么牺牲一致性。迁移的判据是 “查询模式是否已经超出当初选型时的那一条路径”。