"因为要存 JSON,所以选了文档库。"这句话里,"存得进去"和"查得出来"是两件事。JSON 在 MySQL 5.7 之后也能存,也有函数索引;让关系库真正难受的不是"字段是 JSON",而是模式不稳定到无法预建索引、查询形态又还没定——而这两条恰恰说明此刻选 NoSQL 更危险。
这篇给判据与代价结构:四类模型各自的本质、能把 join 替代到什么程度、分片键选错怎么还债、以及"先关系库再迁移"和"一开始就 NoSQL"分别付什么账。
1. 需求翻译:四种常见误判
| 需求表述 |
常见误判 |
该问的判据 |
| 要存 JSON |
所以用文档库 |
这些字段要按内部路径过滤、排序吗?需要跨文档一致性吗? |
| 写入量太大 |
所以上宽列 |
写放大来自索引还是并发?单机能否承载(按写入字节/秒实测) |
| 关系复杂 |
所以上图库 |
查询是定长路径还是变长遍历?深度可否枚举? |
| 要极低延迟 |
所以上 KV |
瓶颈在网络 RTT 还是存储?命中率多少? |
四个误判共用一个错误:把"数据长什么样"当成了"访问路径长什么样"。选型按访问路径定,不按数据形状定——数据形状决定编码,访问路径决定引擎。
2. 关系库真正的边界
| 判据 |
关系库合适 |
该考虑 NoSQL |
| 模式稳定性 |
字段基本确定,变更走迁移 |
字段按租户或来源高度异构 |
| 事务范围 |
多表多实体需要原子 |
单聚合内原子即可 |
| 查询形态 |
未知,需要 ad-hoc join 与聚合 |
固定几种访问路径,可预先建模 |
| 数据量 |
单机可容纳(按容量规划核对) |
单机放不下且必须水平扩 |
| 团队 |
有 SQL 与索引经验 |
有对应引擎的运维经验 |
关系库的边界不是性能,是"模式稳定性 + 多实体事务"。绝大多数"关系库撑不住"的案例,会先被索引调整、查询改写、读写分离解决;真到单机放不下,那才轮到换引擎。
3. 四类模型的本质
| 类型 |
模型本质 |
天然擅长 |
天然不擅长 |
| 文档 |
聚合:一个文档是一次读写的原子单位 |
按主键或二级索引取整个聚合 |
跨文档 join、多文档事务受限 |
| 宽列 |
稀疏行:按 partition key 聚集,分区内按 clustering key 排序 |
按 key 顺序扫描、海量写入 |
跨分区查询、二级索引受限 |
| 图 |
顶点与边都是一等公民,遍历是原生操作 |
变长路径、邻接展开 |
全量扫描聚合、超级节点 |
| KV |
纯寻址,值对引擎不透明 |
极低延迟单键读写、缓存 |
任何基于值内容的查询 |
一句话概括宽列与 KV 的差别:宽列是"有序的 KV"。只有分区内有序,范围扫描与"取最近 N 条"才成立;KV 没有这一层,需要顺序就得在应用里维护。
4. 查询能力对照
| 能力 |
文档 |
宽列 |
图 |
KV |
关系库 |
| 主键取 |
是 |
是 |
是 |
是 |
是 |
| 二级索引 |
有(显式建,写放大) |
受限(物化视图/SAI) |
属性索引 |
无,需外部索引 |
是 |
| 范围扫描 |
索引上可以 |
分区内天然有序 |
邻接上有序 |
无 |
是 |
| 聚合 |
聚合管道 |
需预聚合表 |
局部 |
无 |
是 |
| join |
$lookup(代价高) |
应用侧拼装 |
遍历即 join |
应用侧拼装 |
原生 |
| 排序分页 |
索引或内存排序 |
需按排序键建模 |
局部 |
无 |
是 |
判据:表里写着"无"的那几格,就是未来要写进应用层的代码。评审时把这些"无"折算成工作量与维护面,比争论基准测试数字有用得多——数字随版本变化,缺的能力不会自己长出来。
5. 一致性:按操作分级,而不是按库定
| 模型 |
语义 |
典型实现 |
业务判据 |
| 强一致(线性化) |
读到的必是最新已提交 |
单主复制;Quorum 且 R+W>N |
余额、库存、唯一约束 |
| 最终一致 |
一段时间后收敛 |
异步复制、gossip |
信息流、计数、搜索索引 |
| 读己之写 |
同一会话读到自己刚写的 |
会话粘滞或读主 |
提交后立刻回显 |
| 单调读 |
不会读到更旧的版本 |
版本号过滤、粘滞 |
分页浏览 |
多数"业务不能接受最终一致"其实是"某个具体操作不能"。拆开之后往往只剩两三个接口需要强一致,成本比整库选强一致低一个数量级。
# 一致性与事务一般按操作可调,不必全局设定(按各引擎当前版本文档核对选项名)
MongoDB : insertOne(doc, {writeConcern:{w:"majority", j:true}})
find(q).readConcern("majority").readPref("primary")
Cassandra: SELECT ... WHERE user_id = ? -- CONSISTENCY LOCAL_QUORUM
DynamoDB : GetItem(..., ConsistentRead = true)
6. 分片键:一次选错,长期还债
| 选错的形态 |
症状 |
修复代价 |
| 单调递增(时间戳、自增) |
写全部打到末端分片 |
换键 + 全量重分布 |
| 低基数字段(状态、地区) |
分片倾斜、热点分片 |
加盐或复合键,所有查询跟着改 |
| 与查询不对齐的高基数字段 |
每次查询 scatter-gather |
改模型或建物化查询表 |
| 会变化的字段 |
更新即搬移,产生墓碑 |
改不可变键 + 应用侧重构 |
-- 宽列:partition key 决定落点,clustering key 决定分区内顺序
CREATE TABLE events (
tenant_id uuid, bucket date, event_id timeuuid, payload text,
PRIMARY KEY ((tenant_id, bucket), event_id)
) WITH CLUSTERING ORDER BY (event_id DESC);
-- 查询必须带完整 partition key,否则就是跨分区扫描
规则一句:查询必须先落到一个分区,模型是围绕这句话反推出来的。bucket 列不是为了美观,它是把单分区体积压住的唯一手段——单分区无限增长是宽列库最常见的长期事故。
7. 两条路线与混合架构的同步
| 阶段 |
先关系库再迁移 |
一开始就 NoSQL |
| 前期 |
快,SQL 表达力完整 |
建模与访问路径必须先定死 |
| 中期 |
模式不稳时改表、加索引 |
访问路径一变就要改模型 + 回填 |
| 转折点 |
双写 + 校验 + 切换,按月计 |
无迁移,但早期建模错要重来 |
| 失败模式 |
迁移半途,两套系统长期并存 |
模型错,所有查询一起退化 |
混合架构不是"三选一"的反面,它把同步问题显性化了:
| 组合 |
同步方向 |
风险 |
| 关系库主数据 + KV 缓存 |
写后失效,或先失效后写 |
脏读窗口 |
| 关系库 + 搜索库 |
CDC/binlog,或应用双写 |
索引落后、事件乱序 |
| 关系库 + 图库 |
关系变更事件 |
图滞后,遍历结果缺边 |
-- outbox:与业务写在同一事务内落一条事件,再由 CDC 投递
BEGIN;
INSERT INTO orders (id, user_id, amount) VALUES (?, ?, ?);
INSERT INTO outbox (id, aggregate, payload, created_at) VALUES (?, 'order', ?, now());
COMMIT;
# 消费端幂等:按事件 id 去重,再写目标库
if seen(event.id): return
target.upsert(key=event.aggregate_id, doc=event.payload)
seen.put(event.id, ttl=7d)
两条纪律:同步只走"事件 + 幂等",不走"双写 + 重试";目标库允许落后,但必须能从源库重放重建,做不到就不算设计完成。
8. 运维与迁移
| 维度 |
关系库 |
文档 |
宽列 |
图 |
| 备份 |
逻辑 + 物理,工具成熟 |
mongodump 或快照 |
快照(修复代价高) |
逻辑导出 |
| 恢复演练 |
常规操作 |
需专门演练 |
节点重建慢,需演练 |
少有人做 |
| 扩容 |
纵向为主 |
分片 + 均衡 |
加节点即扩,需重平衡 |
随图规模增长 |
| 监控要点 |
索引、慢查询 |
分片均衡、连接数 |
compaction、墓碑、GC 停顿 |
遍历深度 |
阶段1 双写:旧库为准,新库旁路,只观察差异
阶段2 按比例灰度读,保留回退开关
阶段3 新库为准,旧库只读,跑全量校验
阶段4 停双写,保留快照 N 天后下线
| 迁移阶段 |
通过判据 |
| 双写 |
差异率稳定在抽样噪声内,且每一条差异可解释 |
| 灰度读 |
错误率与 P99 不劣于同期基线 |
| 以新为准 |
抽样字段一致,业务侧核对无异常 |
| 下线旧库 |
回滚演练过,快照可恢复 |
9. 排错:五种"能写进去但查不出来"
| 现象 |
原因 |
处理 |
| 写入延迟随数据量线性上升 |
分片键单调,或单分区过大 |
换键、加分桶,检查分区体积 |
| 查询偶发超时,重试就好 |
跨分片 scatter-gather 的尾部延迟 |
补全 partition key,或建物化表 |
| 计数类结果不准 |
最终一致下的读 |
按操作分级一致性,关键读走主/quorum |
| 索引建了但查询没走 |
索引前缀与查询字段顺序不匹配 |
按查询反推索引顺序 |
| 迁移后磁盘暴涨 |
墓碑累积、未触发 compaction |
手动 compaction,检查 TTL 与删除模式 |
10. 边界:什么时候不该选 NoSQL
| 场景 |
结论 |
| 数据量在单机能力内(按容量规划核对) |
关系库 + 索引,不引入新引擎 |
| 查询形态还在变 |
关系库;访问路径稳定后再谈迁移 |
| 团队没有该引擎的运维经验 |
不选;运维成本会盖过建模收益 |
| 只需要缓存语义 |
KV 做缓存,不当主库 |
| 需要多实体强事务 |
关系库,或明确接受受限事务语义 |
| 关系深度可枚举且只有少数几跳 |
关系库 join 足够,图库收益要能量化 |
收束:NoSQL 买到的是"按已知访问路径水平扩展",付出的是"查询自由"与"运维新技能"。 访问路径没定下来之前,这笔交易是亏的。
11. 自测
- "要存 JSON"为什么不足以成为选文档库的理由?
- 判断"关系库是否到边界"的两个核心判据是什么?
- 宽列与 KV 的模型差别,为什么可以概括为"有序的 KV"?
- 查询能力表里出现"无"的那几格,应该折算成什么成本?
- 分片键选了单调递增字段,为什么修复代价不是"改个配置"?
- 混合架构里,为什么"目标库能从源库重放重建"比"同步实时性"更该先确认?