"用了最终一致,用户刚改完就看不到自己的修改"——根因几乎从不在模型本身,而在建模顺序:先选了模型,再想业务能不能容忍。顺序该反过来:先写业务不变量,再问哪个模型守得住,以及守它要付什么钱。
1. 先把不变量写下来,模型才有意义
| 业务不变量 | 需要的最低强度 | 常见实现手段 |
|---|---|---|
| 余额不能为负、不能多扣 | 线性一致(单点裁决) | 单库事务、条件更新 where balance >= x |
| 库存不能超卖 | 线性一致或分段配额 | 行锁、Redis 原子扣减 + 对账 |
| 同一用户不能重复下单 | 幂等键 + 唯一约束 | 唯一索引、去重表、请求指纹 |
| 同一订单状态不能回退 | 单调写(版本号/状态机) | 乐观锁 version、状态迁移表 |
| 用户看到自己的修改 | 读己之写 | 会话粘滞、版本令牌、主库读 |
| 列表页看到"差不多新"的数据 | 最终一致 | 缓存 + TTL、异步索引 |
判据:不变量要能一句话说清"什么情况算违反",且可检测。说不清的,是愿望不是不变量。
2. 五种强度的实际含义与代价递增
| 模型 | 实际保证 | 实现代价 | 典型场景 |
|---|---|---|---|
| 强一致(严格可串行化) | 事务效果等价于某个串行顺序 | 最高:锁/冲突回滚,吞吐随争用骤降 | 单库资金、库存扣减 |
| 线性一致 | 所有操作看起来在单一时间点上原子生效 | 高:需要共识或单点裁决,跨机房要付 RTT | 分布式锁、选主、配置中心 |
| 顺序一致 | 所有节点看到相同的操作顺序,但不保证实时 | 中高:单写者 + 全序日志 | 事件溯源、变更日志 |
| 因果一致 | 有因果关系的操作保序,无关操作可乱序 | 中:需要版本向量或因果令牌 | 协作编辑、评论回复 |
| 最终一致 | 停止写入后收敛,无时间界 | 低:异步复制,可能长时间不一致 | 计数、搜索索引、推荐特征 |
代价是单调递增的,但收益不是。从最终一致提到因果一致,往往能消掉大部分"看起来乱序"的用户抱怨;从因果再提到线性,成本会跳一个量级,收益却只落在少数关键路径上。所以正确的做法是按不变量分段,而不是给整个系统选一个模型。
3. 三个"便宜的补丁"能解决多少问题
大多数"最终一致不好用"的抱怨,其实只需要读己之写。
| 补丁 | 解决什么 | 成本 | 失效场景 |
|---|---|---|---|
| 读己之写 | 用户看不到自己的修改 | 低:会话粘滞或版本令牌 | 多设备、跨账号协作 |
| 单调读 | 刷新后看到旧数据(回退) | 低:按版本选副本 | 副本故障切换瞬间 |
| 会话一致 | 同会话内操作保序 | 低:会话固定路由 | 会话跨节点漂移 |
// 读己之写:写完带版本号返回,读时要求副本版本 >= 该值
long v = writeService.update(cmd); // 返回提交版本
response.setHeader("X-Read-Version", v);
// 读侧:副本版本不足则回源主库或等待追平(有上限)
return readService.read(key, v, Duration.ofMillis(200));
三个补丁能覆盖"改完看不到""刷新变旧""自己操作乱序"三类抱怨,成本只是一层路由加一次版本比较。先上补丁,再决定要不要共识:跳过这一步直接上强一致,最后往往把延迟问题归因于"分布式"。
4. 共识的真实成本
| 成本项 | 具体表现 | 工程后果 |
|---|---|---|
| RTT 下限 | 一次写要等多数的确认,最少一个往返 | 跨机房写延迟由物理距离决定,优化空间很小 |
| 可用性牺牲 | 多数派不可用时拒绝写 | 少数派机房只能读,或整体降级 |
| 运维复杂度 | 成员变更、快照、日志压缩、脑裂恢复 | 需要专人值班与演练 |
| 吞吐上限 | 写串行化在 leader 上 | 热点 key 会把整个集群拖慢 |
判断:共识适合低频、高风险、必须唯一裁决的写(选主、配额、锁、配置),不适合高频业务写。把订单主流程塞进共识路径,等于用延迟换一个业务并不需要的保证。
5. CAP 与 BASE 常被误用的地方
| 误用说法 | 问题 | 更准确的表述 |
|---|---|---|
| "三选二" | 分区不是可选项,是必然发生的 | 分区期间在 C 与 A 之间选;无分区时两者都可要 |
| "我们选 AP" | 没说明哪些操作可以不一致 | 按操作分类:哪些必须拒绝写,哪些可以降级 |
| "最终一致就够了" | 缺少收敛时间界与对账 | 必须定义"多久算不一致"和发现后怎么修 |
| "BASE 就是不管一致性" | 把工程责任当成模型属性 | BASE 是"基本可用 + 软状态 + 最终一致",软状态要有人收敛 |
CAP 的实用版本是:分区发生时,这个接口是拒绝写(保一致)还是接受写但可能冲突(保可用)。这个选择必须逐接口做,不能全系统一句话。
6. 补偿与对账:最终一致的收尾工作
最终一致不是"发个消息就完事",它必须配一套收敛机制:补偿(发现失败后反向操作)+ 对账(周期性比对两侧状态)。
-- 对账:找出超过阈值仍未收敛的差异
SELECT o.id, o.status, p.status
FROM orders o JOIN payments p ON p.order_id = o.id
WHERE o.status <> p.status
AND o.updated_at < NOW() - INTERVAL 10 MINUTE;
| 机制 | 触发方式 | 适用 | 风险 |
|---|---|---|---|
| 即时补偿 | 消费失败/超时 | 短链路、可逆操作 | 补偿本身失败会放大问题 |
| 定时对账 | 周期扫描差异 | 资金、库存、订单 | 扫描成本高,需分批限流 |
| 人工工单 | 对账差异转工单 | 低频高价值 | 处理时效依赖人 |
判断标准很朴素:如果这条链路出问题时没人能在一小时内发现,那它就不该用最终一致。
7. 至少一次投递,意味着你必须幂等
消息系统给的通常是"至少一次",重复是常态而不是异常。幂等的实现不是"业务判断一下",而是一张去重表加一个唯一约束:
INSERT INTO msg_dedup(msg_id, biz_key, created_at)
VALUES (?, ?, NOW()); -- 唯一索引冲突 = 已处理,直接返回成功
try {
dedupDao.insert(msgId, bizKey); // 唯一键 (msg_id) 或 (biz_key)
} catch (DuplicateKeyException e) {
return Ack.OK; // 幂等返回,不重复执行业务
}
幂等键的选择比实现更重要:用 msg_id 只能挡住同一消息的重投,挡不住"上游重发了一条新消息";用业务键(订单号+操作类型)才能挡住业务级重复。两者叠加最稳。
8. 跨服务事务的四条路与失败模式
| 方案 | 一致性强度 | 失败模式 | 适用 |
|---|---|---|---|
| 本地消息表 | 最终一致 | 消息表与业务表同库,跨库仍要两段 | 单库业务 + 异步下游 |
| 事务性发件箱(outbox) | 最终一致 | 投递重复、顺序错乱 | 需要可靠投递的事件驱动 |
| Saga | 最终一致(可补偿) | 补偿失败、中间态可见、缺隔离 | 长流程、跨多个服务 |
| TCC | 接近强一致(业务层) | Try 悬挂、空回滚、Confirm 重试 | 资金、库存等强约束 |
发件箱的最小实现是把"改业务"与"登记事件"放进同一个本地事务:
BEGIN;
UPDATE orders SET status = 'PAID' WHERE id = ? AND status = 'PENDING';
INSERT INTO outbox(id, topic, payload, status) VALUES (?, 'order.paid', ?, 'NEW');
COMMIT;
-- 独立投递进程按 status='NEW' 扫描并发送,成功后置 SENT
判断顺序:能单库事务就别拆;拆了先看能不能用本地消息表;需要长流程补偿才上 Saga;TCC 只在"中间态不能对外可见"时才有必要。 TCC 的三个坑(悬挂、空回滚、幂等)不是实现细节,是这种模式的固有成本。
9. 可见性延迟怎么向用户表达
技术选择最终要落到文案上。同一件事,说法不同,容忍度差很多:
| 场景 | 差的表达 | 好的表达 |
|---|---|---|
| 异步索引,搜索延迟 | 搜索结果可能不是最新 | 刚发布的内容约 10 秒后可被搜索到 |
| 跨机房同步 | 数据同步中 | 已保存,其他地域稍后可见 |
| 异步对账 | 处理中 | 预计 2 分钟内完成,完成后通知你 |
原则:给时间界,不给"可能"。用户能接受"10 秒",不能接受"不确定"。给不出时间界,说明收敛时间从未被测量——那是工程问题,不是文案问题。
10. 怎么测一致性
| 手段 | 验证什么 | 做法 |
|---|---|---|
| 故障注入 | 分区/节点挂掉时的行为 | 断网、停副本、延迟注入,观察是否拒绝写而非错写 |
| 延迟注入 | 乱序与重复的容忍度 | 给消息加随机延迟与重复投递 |
| 并发对账 | 不变量是否被破坏 | 高并发压测后跑对账 SQL,差异必须为 0 |
| 线性一致检查 | 是否出现"读到旧值再读到新值" | 记录读写历史,检查可否线性化排序 |
最有价值的是第三项:不变量检查比模型名称可靠。自称强一致没用,并发一千次后余额对不上就是不对。
11. 排错
| 现象 | 根因 | 处理方向 |
|---|---|---|
| 刚写完读不到 | 读走副本、无读己之写 | 加版本令牌或会话粘滞 |
| 刷新后数据回退 | 单调读缺失、副本间延迟不一 | 按版本选副本,禁止回退读 |
| 偶发重复扣款 | 非幂等消费 + 至少一次投递 | 幂等键 + 唯一索引 |
| 库存超卖 | 先查后改,非原子 | 条件更新或原子扣减 |
| Saga 卡在中间态 | 补偿失败无重试 | 补偿也要幂等 + 重试 + 人工兜底 |
| 跨机房写延迟高 | 把共识放进了热路径 | 拆出关键裁决点,其余走本地 |
12. 边界
| 场景 | 判断 |
|---|---|
| 单库单实例 | 不必谈分布式一致性,事务足够 |
| 纯日志、埋点、推荐特征 | 弱到底,丢了也不影响业务 |
| 强一致要求且流量不高 | 不要拆服务,单库最省 |
| 只读展示、允许分钟级延迟 | 最终一致 + 缓存,别上共识 |
| 跨地域强一致写 | 先问业务是否真需要,通常答案是"局部需要" |
一致性模型是一条按路径分段的选择题:登录、下单、支付、报表可以各自不同。给整个系统贴一个标签,是最常见、也最贵的建模错误。
13. 自测
- 为什么"先选模型再想业务"几乎必然出问题?
- 读己之写、单调读、会话一致,各自挡住哪一类用户抱怨?
- 共识的成本里,哪一项无法靠优化消除?
- CAP 在无分区时该怎么说才不失真?
- 幂等键用
msg_id和用业务键,挡住的东西有什么不同? - 为什么"不变量检查"比"模型名称"更可靠?