02 一致性与性能的权衡:为什么”强一致”和”快”天生打架
开篇提问
先请你做一次选择。假设你在设计一个分布式缓存系统,三个节点。现在要写入一个值,你有两个选择:
- 方案 A:写操作必须等三个节点都确认写入成功,才返回”写入成功”。
- 方案 B:写操作只要主节点写入成功就返回,其余节点异步慢慢同步。
凭直觉,你会选哪个?绝大多数人会选 B——因为 A 太慢了。
但我想请你回答一个更根本的问题:
方案 B 快在哪?它”省”掉的到底是什么?而且——省掉的这个东西,是真的消失了吗,还是被转移到了别处?
想清楚这个问题,你就理解了分布式系统里最深的那道裂缝:一致性和性能的冲突不是”设计得不够好”,它有着几乎和 CAP 一样硬的根源。
子主题一:一致性的本质——“所有人看到同一个世界”
先把”一致性”这个词掰开。它到底在说什么?
一致性说的是:多个观察者(节点、客户端),在”看同一份数据”时,看到的内容是否一致、以及什么时候一致。
关键在那个”什么时候”。因为在一个分布式系统里,数据从一个节点传播到另一个节点需要时间——这不是设计缺陷,这是物理限制(网络延迟、光速)。
于是就出现了无法回避的缝隙:在主节点写入成功、但还没同步到从节点的那个极短窗口里,读从节点的客户端看到的是旧值。
这个窗口,就是所有一致性问题的源头。
- 强一致(线性一致):不允许这个窗口存在——读任何节点都必须看到最新写入。
- 最终一致:允许这个窗口存在,只保证”如果没有新写入,最终所有节点会收敛到同一个值”。
- 因果一致、会话一致、读己之所写:在这两者之间的各种折中,各有各的保证强度。
看到结构了吗?一致性的强弱,本质是在回答”允许多大的不一致窗口”。 窗口为零就是强一致,窗口无限就是最终一致。
子主题二:为什么强一致必然慢
现在回答开篇那个问题:方案 A 慢在哪?
慢在”等待”。
强一致要求”所有人看到同一个世界”,而世界同步需要时间。所以强一致的实现,本质上就是:在返回之前,把该同步的都同步完,把该确认的都确认完。
这些”等待”具体花在哪?
- 等待多数派确认:写入要等 W 个副本确认(Quorum),最慢的那个副本决定了总耗时。
- 等待锁/事务提交:并发写入要串行化,后一个要等前一个。
- 等待跨节点协调:分布式事务要走两阶段提交,多轮网络往返。
- 等待持久化:为了不丢失,要等数据落盘(fsync)。
每一条都是”等待”,而等待 = 延迟。强一致不是”算得慢”,是”等得久”。
这里有个非常锋利的洞察:
强一致的代价主要不是 CPU,而是”等待时间”。而这个等待时间,受物理约束(网络延迟、磁盘 IO)支配,几乎无法通过”优化代码”来消除。
这就是为什么”加机器”不能解决强一致的性能问题——加机器能分摊计算,但分摊不了”必须等某个事件完成”这件事。你可以把活分给 100 个人干,但你没法让”等一封信寄到”这件事快 100 倍。
子主题三:最终一致”省”掉的东西去哪了
回到开篇第二个问题:方案 B 省掉的”等待”是真的消失了吗?
没有。它被转移了——从”写入路径”转移到了”读取路径”和”应用逻辑”。
这是全章最值得记住的一句话。展开来说:
转移到了读取路径:写入时不强制同步,那么读的时候就可能读到旧值。你要么接受读到旧值(业务上能容忍),要么就得在读取时做额外的事(读修复、多副本对比)。
转移到了应用逻辑:系统不再保证”你写进去的一定能被立刻读到”,于是应用代码必须自己处理”可能读到旧数据”这件事——比如用户改完昵称后刷新还是旧的,你得在前端做本地覆盖、或者加版本号。
转移到了运维和兜底:异步同步可能失败、可能延迟很久,于是你需要对账、需要补偿、需要人工介入。
所以最终一致的真实成本是:你把”系统的复杂度”换成了”应用的复杂度”和”运维的复杂度”。
这不是免费的。很多团队选了最终一致,结果在业务代码里写满了各种兜底逻辑、补偿任务、幂等处理——最后发现总复杂度反而更高了,只是复杂度从”看不见的系统层”转移到了”看得见但管不住的业务层”。
这是一个极其重要的工程教训:权衡不等于消除。把一个成本转移到一个”看起来更便宜”的地方,往往只是把它移到了一个”更难管理”的地方。
子主题四:怎么选——一致性不是越高越好
既然强一致慢、最终一致有代价,那怎么选?给你几个真实的判断依据:
看业务能不能容忍旧数据。
- 银行转账余额:不能容忍 → 强一致(宁可慢)
- 社交媒体的点赞数:能容忍差几个 → 最终一致(追求快)
- 商品库存:看情况,超卖的代价高就往强一致靠
看不一致窗口里会发生什么。 关键问题是:如果有人读到了旧值,最坏的后果是什么? 是”用户体验略差”,还是”资损”,还是”安全漏洞”?后果越严重,越该往强一致靠。
看能不能用”局部强一致”替代”全局强一致”。 这是工程上最实用的一招:你不需要所有数据都强一致,只需要”关键的那部分”强一致。
- 用户余额强一致,用户昵称最终一致
- 同一用户的操作强一致(会话一致),不同用户之间最终一致
- 同一个分区内强一致,跨分区最终一致
看能不能把”一致性”换成”幂等 + 补偿”。 有时候真正的解法不是提高一致性,而是让重复操作无害(幂等),加上事后对账补偿。这是很多金融系统的真实做法——它们承认不一致会发生,但保证”最终能算对”。
子主题五:PACELC——比 CAP 更实用的框架
你可能听过 CAP(一致性、可用性、分区容错三者只能取二)。但 CAP 有个重大局限:它只讨论了”发生网络分区”这一个时刻的取舍,没说系统正常运行时该怎么权衡。
PACELC 修正了这一点,它的表述是:
如果有分区(P),则在可用性(A)和一致性(C)之间取舍;否则(E,正常运行时),在延迟(L)和一致性(C)之间取舍。
这才是真实世界的完整图景:绝大多数时候你的网络是正常的,此时你面对的权衡不是”要不要一致”,而是”要多低的延迟”——延迟和一致性,才是日常的主战场。
PACELC 的价值在于:它让你意识到一致性不是开关,是连续可调的旋钮,而且这个旋钮和延迟直接挂钩。你可以为每个操作、每类数据单独调这个旋钮——这正是现代分布式系统(比如 Dynamo 系的数据库)的核心设计思路。
子主题六:一致性的完整谱系——从”全世界同一时刻”到”我自己别出毛病”
前面我们把一致性粗略地分成了”强”和”最终”两档。但真实工程里这两档太粗了,粗到没法用。
先给你一个场景,我们整个子主题都用它串:
一个社交网络的时间线。用户 A 发了一条帖子 P1;用户 B 看到后评论了 C1;与此同时(没人看到 P1),用户 C 发了一条毫不相关的帖子 P2;用户 D 正在刷自己的时间线。
现在反问你四个问题,先凭直觉答:
- D 刷新,看到的顺序是 [P2, P1, C1](P2 跑到了 P1 前面)——这算错吗?
- D 看到的顺序是 [C1, P1](评论出现在帖子之前)——这算错吗?
- A 发完帖子立刻刷新,看不到自己刚发的 P1——这算错吗?
- D 第一次刷新看到 5 条,第二次刷新只剩 4 条——这算错吗?
这四个问题,恰好对应四档不同的保证。答完你就有谱了。
从强到弱,逐个拆
线性一致(Linearizability)——全世界只有一个”现在”
最直觉也最强。所有操作看起来像是在某一个瞬间原子地生效的,而这个瞬间落在操作的调用和返回之间。翻译成人话:写入一旦返回成功,从此刻起任何人的任何读都必须看见它。
所以第 3 问在第 1 问之前就该答:A 发完看不到自己的帖子,在线性一致下是错的。
它是”一个全局单一副本”的假象。你的三个节点,对客户端来说必须看起来像一个节点。代价就是子主题二说的:你必须在返回之前把同步做完。
顺序一致(Sequential Consistency)——顺序不能乱,但可以整体慢半拍
比线性一致弱一档。它只要求:所有进程看到的操作顺序,都能被排成一个单一的全局序列,且这个序列不违反任何单个进程自己的程序顺序。 它不要求这个序列尊重真实时间。
第 2 问的答案:如果 D 看到 [C1, P1],违反顺序一致。因为 A 发 P1 在 B 评论 C1 之前,这是个”程序顺序”,全局序列不能把它翻过来。 第 1 问的答案:D 看到 [P2, P1, C1],不违反顺序一致。因为 P2 和 P1 是并发的(没人看到 P1 才发 P2),谁排前面都行。
那它跟线性一致的差别在哪?差别就在”迟到”。假设 A 发帖成功返回了,A 转头给 D 打了个电话说”我发了”,D 刷新——线性一致要求 D 必须看到,顺序一致不要求(因为”打电话”不在系统里,不构成系统内的顺序约束)。
一个狠一点的类比:顺序一致就像录播,顺序和节目单一致,但播出时间可以晚;线性一致是直播,顺序和时间都得对。你猜哪个贵?当然是直播。
因果一致(Causal Consistency)——只管”有关系的”,没关系的随便
再弱一档,也是最实用的一档。它只保证:有因果关系(happens-before)的操作,所有副本必须以相同的顺序看到;并发的操作,顺序随意。
回到场景:B 评论 C1 是因为 B 看到了 P1 —— 这构成因果,所以任何人都不能先看到 C1 后看到 P1。但 C 的 P2 跟 P1 毫无因果,所以 P2 在 P1 前面还是后面,各个副本爱怎么排怎么排。
注意这里有个容易说错的细节:因果一致已经蕴含了”读己之所写”——因为”你自己写 → 你自己读”本来就是一条因果链。但反过来不成立:只保证读己之所写,管不了别人回你的帖子会不会排到你的帖子前面。
还有一个工程上常踩的坑:因果一致不自动给你”单调读”。因为”你上次读到什么版本”这件事需要会话状态来跟踪,而因果一致本身不知道你是谁。你从副本 1 跳到副本 2,副本 2 完全不知道你已经看过 C1 了。所以实践中必须是”因果一致 + 客户端携带版本元数据”才够用——这正是 COPS 那类系统的做法。
最终一致(Eventual Consistency)——什么都不保证,只保证会收敛
只承诺一件事:如果不再有新的写入,所有副本最终会收敛到同一个值。中间过程任意。
第 4 问的答案:最终一致下,D 第二次刷新只剩 4 条,完全合法。这就是所谓的”时光倒流”。
另外三条:不是”全局强度”,是”客户端视角”
这三条跟上面不是一个维度——上面是”全系统范围内的顺序约束”,这三条是单个客户端自己看到的体验。这个区分很重要,很多人把它们混在一起排强弱,是排不出来的。
- 读己之所写(Read-your-writes):我写完之后,我自己一定能读到我写的。别人读不读得到不管。解决的是”改完昵称刷新还是旧的”这种最招投诉的问题。
- 单调读(Monotonic reads):一旦我看到了某个值,后续读不会再看到更旧的值。解决的是”刷新一下数据倒退”的诡异感。
- 单调写(Monotonic writes):同一个客户端的多个写,必须按发出的顺序被系统看到。否则你先”改昵称”后”改头像”,副本可能先应用后者。
- 写跟随读(Writes-follow-reads):我先读到了 X,然后我基于 X 写了 Y,那么 Y 必须排在 X 之后。这条最容易被忽略,但没有它,“读-改-写”会撕裂。
会话一致(Session consistency)= 读己之所写 + 单调读 + 单调写 + 写跟随读,四者打包,作用域限定在一次会话内。会话一断(换设备、换机房、清了 token),保证就没了。
一张对照表,直接拿去用
| 模型 | 保证什么 | 不保证什么 | 典型场景 | 相对成本 |
|---|---|---|---|---|
| 线性一致 | 写入返回后全局立即可见,存在全局实时序 | —(最贵) | 库存扣减、余额、分布式锁、选主 | 极高 |
| 顺序一致 | 存在全局操作序列,不违反各进程程序序 | 不保证”及时”(可整体延迟) | 需要全局序但不要求实时性的协作 | 高 |
| 因果一致 | 有因果的操作全局同序 | 并发操作的顺序 | 社交时间线、评论/回复、协同编辑 | 中 |
| 会话一致 | 单客户端视角的四种保证 | 跨客户端、跨会话什么都没有 | 绝大多数 C 端产品体验 | 低 |
| 读己之所写 | 自己写的自己能读到 | 别人看到的顺序 | 改资料、发内容后的即时反馈 | 很低 |
| 单调读 | 不会读到更旧的值 | 不会读到更新的值 | 分页、列表刷新 | 很低 |
| 最终一致 | 无新写入时终将收敛 | 中间任意状态、任意顺序 | 点赞数、计数、画像、日志 | 最低 |
这一节真正要你带走的一句话
一致性模型的谱系,不是在”强”和”弱”之间排座次,而是在回答三个不同的问题:全局顺序要不要?顺序要不要符合真实时间?保证覆盖谁(全世界,还是你自己)?
看清了这三个问题,你会发现绝大多数业务根本不需要线性一致——它们需要的是”因果 + 会话”。而这两者的成本,比全局强一致低一个数量级。大部分团队的性能问题,不是因为用了强一致,而是因为该用会话一致的地方,误用了全局强一致。
子主题七:Quorum——把”R+W>N”拆开看懂,顺便看穿它的破绽
终于到了那个流传很广的公式。但我希望你不是记住它,而是能证明它,并且知道它在什么地方不成立。
N 个副本。写要拿到 W 个副本的确认才算成功;读要访问 R 个副本,取其中版本最新的那个值。
为什么 R+W>N 就够了?抽屉原理
写集合大小是 W,读集合大小是 R,元素都从同一个 N 里取。如果 W + R > N,那么两个集合必然有交集(否则它们最多只能装下 N 个元素,与 W+R>N 矛盾)。
有交集意味着什么?意味着读到的 R 个副本里,至少有一个副本也参与了最近那次成功的写入。既然参与了,它身上就有最新的值。而你读 R 个副本时会取最新版本——于是你一定拿得到最新已提交的值。
就这么简单,一层抽屉原理。没有时钟,没有协调,没有主节点,纯集合论。 这就是为什么 Quorum 这么优雅。
顺手推一下 N=3 的经典配置:W=2、R=2,2+2=4 > 3,成立,且能容忍 1 个节点挂掉(写只要有 2 个活着,读也只要 2 个活着)。
现在,把它捅穿:Quorum 不保证什么
如果你只知道”R+W>N 就是强一致”,你在工程上会吃亏。至少这四点它管不了:
其一,部分写(partial write)。 R+W>N 依赖的前提是”最近一次写入已经完整成功”。但如果写了 1 个副本就崩了、只写了 W 中的一部分呢?系统里就存在一个”既没成功也没失败”的值。有的读集合碰到了它,有的没碰到——同一个值的读结果会来回跳,线性一致当场破产。真实系统要么在写失败时做清理/回滚,要么靠版本号 + 读修复把这种值”洗”掉。
其二,并发读写的实时序。 读操作开始之后、完成之前,另一个写提交了——这次读该不该看到它?抽屉原理不回答这个问题。R+W>N 保证的是”读到最新已提交的值”,不是”读操作在某个瞬间原子地发生”。 严格的线性一致还需要额外的串行化机制(主节点、租约、或全局时钟)。
其三,read-modify-write。 计数器自增这种”读出来加一再写回去”的操作,在 Quorum 下照样丢更新。因为两次自增可能读了同一个旧值,各自算出 v+1,然后都写回去。要解决这个问题,你需要 CAS(带版本号的条件写)、或者把计数器变成 CRDT(见子主题十一),或者老实放单点。记住:Quorum 给你的是”读得到最新”,不是”改得动原子”。
其四,“哪个副本更新”这件事本身要有依据。 你读 R 个副本,凭什么说这个是新的那个是旧的?靠版本号(逻辑时钟、向量时钟、混合逻辑时钟)或者时间戳。如果用物理时钟的”最后写入者获胜”(LWW),那你在用时钟精度赌正确性——时钟回拨或者两台机器差了几百毫秒,你就会静默丢掉一次写入。这种 bug 极难查,因为它只在特定时序下出现。
调配 W 和 R:在读写之间挪成本
这才是 Quorum 最实用的部分。W 和 R 是一对跷跷板,你可以把延迟成本从读路径挪到写路径,或者反过来。
以 N=3 为例,四种典型配置:
| 配置 | 写 | 读 | 一致强度 | 可用性特征 |
|---|---|---|---|---|
| W=3, R=1 | 慢(等全部,且不容节点故障) | 快(就近找一个) | 强 | 写不容错,读可容 2 节点故障 |
| W=2, R=2 | 中 | 中 | 强(R+W>N) | 读写各容 1 节点故障 |
| W=1, R=3 | 快(但数据安全靠异步复制救) | 慢(要问全部,且不容节点故障) | 强(R+W>N) | 写高可用,读极脆弱 |
| W=1, R=1 | 最快 | 最快 | 基本无保证 | 两边都高可用 |
读多写少怎么办? 把成本压在写路径:抬高 W,压低 R。典型是 W=N(或多数派)、R=1。写的时候疼一次,但读的时候随便找个就近副本就行,还能叠 CDN 和本地缓存。商品详情、用户资料、配置下发这类”写一次读一万次”的数据,就该这么配。
写多读少怎么办? 理论上把成本压在读路径:压低 W,抬高 R。日志采集、埋点上报、IoT 遥测、计数器累加——写入要飞快,读反正少,慢点无所谓,读的时候顺手做读修复。但工程上要小心:R=N 的可用性太差了(任何一个节点不可用,读就整体失败),所以实际很少真用 R=N,更多是 W=1、R=多数派,然后接受”可能读不到最新”。
读写都多怎么办? W = R = 多数派,然后别再纠结参数了——去改架构(见子主题九的局部化)。参数调优救不了读写双高的场景。
被忽略的第二重权衡:R/W 同时在调配”可用性”
这是很多人没意识到的一点:降低 W 提高的是写可用性,降低 R 提高的是读可用性。
N=3、W=3、R=1:写不容错(一个节点挂就写不进去),读能容忍 2 个节点挂。 N=3、W=1、R=3:写随时能写,读不容错。
所以你调配 R/W,表面上是在挪延迟,实际上同时也在挪”哪一侧先不可用”。当你把 W 降到 1 的时候,请问自己一句:写是快了,但”写成功”现在只落在一个副本上——那个副本这会儿挂了,数据就没了,这个风险你扛得住吗?
一个必须知道的坑:Sloppy Quorum
Dynamo 系系统(Cassandra、Riak、Voldemort 的血脉)有个设计叫 sloppy quorum:当目标节点故障时,写操作不拒绝,而是转交给环上的”下一个”临时节点,等原节点恢复后再回传(hinted handoff)。
这个设计换来什么?极高的写可用性——网络分区时照样能写。 代价是什么?它直接破坏了 R+W>N 的数学前提,因为”那 N 个副本”已经不是同一批节点了,读集合和写集合可能落在不同的一批机器上,抽屉原理失效。
所以:在 Dynamo 系里,即使你把 R 和 W 都调到 QUORUM 甚至 ALL,你拿到的依然是最终一致,不是强一致。 这不是 bug,是设计——它明确选择了”永远可写”。
如果你不知道这一点,你会在生产环境里遇到”我明明设了 QUORUM 为什么还是读到了旧值”,然后花三天查不出原因。这类问题的答案往往不在配置里,而在系统的设计取舍里。
子主题八:分布式事务为什么这么贵——把代价拆成”往返”和”锁”两笔账
(本子主题只讲代价的来源和量级,2PC/3PC 的完整细节、隔离级别和并发控制留到第 12 章和第 15 章。)
先问一个扎心的问题:同样是”转账”,单机上你写三行 SQL 包在一个事务里就完事了,跨两台机器怎么就成了业界难题?
因为单机事务的原子性,靠的是一个人说了算:一把锁、一份 WAL、一次 fsync。跨机之后,“说了算”的这个人没了,你得先投票选出一个共识,而投票要一轮一轮地跑网络。
两阶段提交到底在干什么
阶段一(Prepare / 投票)
- 协调者把事务内容发给所有参与者。
- 参与者执行到”该提交但还没提交”的状态:写 undo/redo 日志、fsync 落盘、锁住相关数据行。
- 参与者回复 Yes 或 No。
账目:1 次网络往返 + 1 次 fsync + 从这一刻起开始持有锁。
阶段二(Commit / Abort) 4. 协调者收集完所有投票,把最终决定持久化到自己的日志里(这步不能省,否则协调者重启后不知道该提交还是该回滚)。 5. 协调者把决定广播给所有参与者。 6. 参与者提交、fsync、释放锁,回 ACK。
账目:又 1 次网络往返 + 至少 2 次 fsync。
总计:至少 2 次网络往返 + 至少 3 次 fsync(参与者 prepare、协调者写决定、参与者 commit)。
对比单机事务:0 次网络往返 + 1 次 fsync。数量级差异就是这么来的。
真正的杀手不是往返次数,是锁的持有时间
这是我特别想让你记住的一点。往返次数决定单次延迟,锁持有时间决定吞吐量,而后者才是压垮系统的那根稻草。
分布式事务里,锁从参与者第一次访问数据开始,一直持有到收到协调者的 commit 决定为止:
锁持有时间 ≈ 本地执行时间 + 2 次网络往返 + 2~3 次 fsync
单机事务里,锁持有时间 ≈ 内存操作 + 1 次 fsync。
后果是什么?在热点数据上,吞吐 ≈ 1 / 锁持有时间。 锁持有时间从一个量级跳到另一个量级,热点行的吞吐就按同样的比例掉下去。你加机器没有用——锁是排他的,100 台机器也得排队等那把锁。
这就是为什么**“减少事务参与方”永远比”优化事务协议”更有效**。减少一个参与者,不只是少一次往返,而是少一份锁的持有者、少一份尾延迟的来源、少一份崩溃的概率。
顺带说一个反直觉的性质:2PC 的延迟不随参与者数量线性增长,而是由最慢的那个参与者决定(尾延迟问题)。 参与者越多,撞上慢节点的概率越高,P99 越难看。10 个参与者不会让延迟变成 10 倍,但会让”偶尔很慢”变成”经常很慢”——从用户体验角度看,后者更糟。
协调者:一个单点,但有两种完全不同的崩溃
崩溃点 A:协调者在把决定写进日志之前挂了。 参与者全部卡在 in-doubt 状态——投了 Yes,锁着,不知道该提交还是回滚。只能死等协调者恢复。 这就是 2PC 著名的”阻塞”问题。
崩溃点 B:协调者写完日志、还没来得及广播决定时挂了。 更气人:决定其实已经定下来了(日志里有),但参与者不知道。它们还是得等协调者恢复。
注意这两个点揭示了一件事:协调者的日志必须先落盘再广播,参与者的 prepare 日志必须先落盘再投票。“先落盘,再回答”是 2PC 的铁律。违反它的实现,在崩溃后无法自证状态,会把数据搞坏。
那怎么解决单点?
- ❌ 不是 3PC。 3PC 加了一个 pre-commit 阶段试图解决阻塞,但它在网络分区下依然会脑裂(一部分节点提交,另一部分回滚)。所以工业界基本没人用 3PC,它只活在教科书里。
- ✅ 真正的答案是让协调者本身高可用:把协调者的状态放进一个 Paxos/Raft 复制状态机,让协调者变成无状态的。协调者挂了,Raft 选一个新的出来,从复制日志里读到”上次的决定是什么”,继续推进。这正是 Percolator、TiDB、CockroachDB、Spanner 这类系统的通用做法。
一句话总结本节的代价源头:
分布式事务的代价不是”多做了事”,而是把本地的确定性换成了跨网络的协商。协商每一轮都要等,而等的每一秒,锁都在烧钱。
子主题九:用”局部化”破解权衡——不解决它,绕开它
现在问那个终极问题:有没有办法既强一致又很快?
有。但前提是你得接受一个反常识的前提——你不需要全局强一致,你从来都不需要。
一致性的成本,大致是随两个维度超线性增长的:参与协调的节点数、保证覆盖的数据范围。所以最有效的优化不是让协调变快,而是缩小协调的范围。三条具体路径:
路径一:按数据分区强一致——让事务不出分区
核心洞察极其朴素:单机事务很便宜,跨机事务很贵,那就让相关的数据待在同一台机器上。
- 按
uid分区,那么同一个用户的所有数据(余额、订单、优惠券、收货地址)都在同一分区 → “查我的订单并扣我的余额”是单分区事务,便宜、强一致、没有 2PC。 - 跨用户的操作(A 转给 B)才是分布式事务。
这就是为什么 Spanner 和 CockroachDB 都会做 “single-range / single-partition transaction” 优化,也是早年 Google F1 用 entity group 和层级 schema 的原因:在 schema 设计阶段就为事务局部性让路。
这里有个极其重要的推论:选分片键的第一原则不是”分布均匀”,而是”让事务不出分区”。 分布不均可以通过拆分、搬迁、加盐来补救;跨分区事务一旦形成,你几乎永远甩不掉。
再往上走一层,就是单元化架构:把用户按 uid 切到若干个”单元”,每个单元自带完整的应用、数据库、缓存、MQ,单元内自封闭。绝大多数请求在单元内闭环完成,跨单元调用被限制到极少且走异步。这是”局部化”在架构层面的极值——它不是优化了分布式事务,而是让分布式事务在 99% 的场景里不存在。
反问一句:为什么银行转账偏偏是最难的那个?因为它天生跨两个用户,天然要跨分区。那业界怎么做的?干脆不把它做成分布式事务——见下一节。
路径二:同一用户会话一致——把保证范围缩到”一个人”
再问你一句:你的用户真的关心”全世界的顺序”吗?
他不关心。他只关心”我看到的别出毛病”——我发的帖子我自己得看到,我改的昵称得是我改的那个,我刷新一下状态别往回跳。
保证范围从”全球所有客户端”缩到”一个客户端的一次会话”,成本断崖式下降。做法很成熟:
- 会话粘滞(sticky session):同一个会话优先路由到同一个副本。
- 会话令牌携带版本/位点:客户端每次请求带上”我上次看到的是版本 V”,服务端保证返回的数据不早于 V。副本不够新?换一个副本读,或者等它追上来。
成本有多低? 不需要任何全局协调,只需要”客户端的一个版本号”。这就是为什么会话一致是 C 端产品性价比最高的一档保证——它用几乎为零的成本,消灭了 90% 的用户可感知的不一致。
路径三:关键路径强一致,非关键路径最终一致
一个”下单”请求里,拆开看:
- 关键路径:扣库存、创建订单、扣款。同步执行,强一致。
- 非关键路径:发短信通知、加积分、更新推荐画像、写审计日志、更新搜索索引、给风控送样本。全部异步,最终一致。
实现的标准手法是事务性发件箱(Transactional Outbox):在同一个本地事务里,既改业务数据,又往 outbox 表插一条消息;然后由独立的投递进程异步地读 outbox,把消息发出去。
反问:为什么”写完数据库再发 MQ”这种双写一定会出问题?
因为数据库和 MQ 是两个没有共同原子性的资源。改完库、消息还没发,宕机 → 消息丢了;先发消息、库没改成,宕机 → 消息是假的。中间任意一个时刻宕机,两边就撕裂。
Outbox 的巧妙之处,正是把”跨资源的原子性问题”降级成了”单资源的原子性问题”——outbox 和业务数据在同一个库、同一个事务里,要么都在要么都不在。剩下的只是”投递可能重复”,而重复是可解的(消费端幂等)。
看清楚这一招的本质了吗?它是把不可解的问题(跨资源原子性),转换成了可解的问题(重复投递)。 问题没消失,但被搬到了一个你有武器对付的战场上。
局部化的三条路径,一句话概括
| 路径 | 缩小的是什么 | 典型手段 | 省掉了什么 |
|---|---|---|---|
| 按分区强一致 | 数据的范围 | 分片键设计、entity group、单元化 | 大部分跨机事务 |
| 会话一致 | 客户端的范围 | 粘滞路由 + 版本令牌 | 全局顺序协调 |
| 关键路径分离 | 操作的范围 | 事务性发件箱、异步化 | 关键路径的延迟 |
共同点:它们都没有去优化 2PC。 这本身就是一个方法论——当一个权衡看起来无解时,通常不是要在两端之间找一个更好的平衡点,而是要去质疑产生这个权衡的前提。
子主题十:幂等 + 对账——金融系统为什么干脆放弃了强一致
先回答那个最根本的问题
银行为什么不用分布式事务做跨行转账?
你可以说”因为慢”。但真正的原因更硬:跨行转账的参与方是两家不同的银行,你根本没有、也不可能有”同一个协调者”。
技术上,XA 协议确实能把两个异构数据库纳进同一个事务。但组织和运维上这不可行——你不可能让对方银行的行锁,被你的协调者持有几秒甚至几十秒。 对方凭什么信任你的协调者?你的协调者挂了,对方的锁谁来放?
当”协调”这条路在组织层面被堵死时,金融业走出了另一条路。这条路的关键不是”防止不一致发生”,而是:
承认不一致一定会发生,然后保证它一定能被检测出来、被定位、被修复。
组件一:复式记账 —— 余额是派生量,流水才是事实
这是整套体系的地基。
- 余额不是一个存储的数字,它是所有历史流水的累加结果。 余额是缓存,流水才是事实。
- 每一笔交易产生至少两条分录(借、贷),且借贷必相等。
反问:这个设计为什么这么关键?
因为它给了你一个可以在任意时刻独立验证的不变量。你不需要”相信”余额字段——你可以重放流水把它算出来,然后跟它比对。一个系统的正确性如果只能靠”从不出错”来维持,那它迟早会错;如果正确性随时可以被重新验证,那出错只是个流程问题。
组件二:幂等 —— 让重复操作无害
- 幂等键 + 唯一索引:用业务唯一键(订单号 + 操作类型)或客户端生成的 idempotency key,在流水表上建唯一索引。重复请求直接命中,返回第一次的结果。
- 条件更新(CAS)代替”先查后写”:
UPDATE ... SET status='PAID' WHERE id=? AND status='INIT'。并发下”先 SELECT 判断再 UPDATE”是必然出错的,两条语句之间有个谁都拦不住的窗口。 - 状态机驱动:每一笔账务操作都是一次合法的状态迁移,非法迁移直接被拒。
一个常被说漏的点:幂等 ≠ 去重。幂等 = 去重 + 返回一致的结果。 只去重但第二次返回”未知错误”,客户端会以为失败了,然后去走补偿流程,制造出新的问题。幂等的完整语义是”重复调用的效果与调用一次完全相同,包括返回值”。
组件三:冲正而非删除 —— 错误用反向流水覆盖
账务数据不能改、不能删。错了怎么办?记一笔反向分录(红字冲销),把错误抵消掉。
这个设计的深刻之处在于:正确性不再依赖”没有发生过错误”,而依赖”每一笔错误都被记录了它的相反数”。
它放弃了”时间旅行式的修正”(回到过去假装没发生),换取了”完整可审计的历史”。在金融这个强监管领域,后者值钱得多。
组件四:对账 —— 承认会错,然后系统性地找出来
- 内部账 vs 渠道/银行流水,日终逐笔比对。
- 差错分类:长款(我方多收)、短款(我方少收)、单边账(一方有另一方没有)。
- 挂账(suspense account):对不平的先进”待处理”账户,不让脏数据污染正式账,然后人工或自动跟进。
- 日切(cut-off):划一条明确的时间线,日切前的账务封闭不再变动。
日切这条最容易被非金融系统忽略,但它是”对账能收敛”的前提。 如果你对账的同时数据还在不停地变,你就永远在追一个移动靶——比对结果刚刚生成就已经过期了。有截止线,对账才可能收敛。
这套体系的哲学:把保证从”事前”搬到了”事后”
| 事前预防(强一致) | 事后纠正(幂等 + 对账) | |
|---|---|---|
| 承诺 | 不一致永远不会发生 | 不一致会发生,但可检测、可修复 |
| 代价 | 延迟、可用性、吞吐 | 运营流程、资金占用、人力 |
| 依赖 | 协调机制的正确性 | 可验证的不变量 + 可执行的修复流程 |
什么时候该选后者? 当三个条件同时满足:不一致发生的概率低;强一致的代价高;且错误的后果可以在账面上被精确修复。
金钱恰好完美满足第三条——它是可数的、可加的、可逆的(退钱就是记一笔反向分录)。
那什么业务不适合? 反问你一句:如果副作用不可逆呢?
短信已经发出去了,包裹已经在路上了,库存已经给了别人。这些没法”记一笔反向分录”撤销。
这类业务的唯一解法是在业务层做”预留—确认”两段式:先冻结(预扣库存、预授权额度),确认无误后再真正执行,失败则释放。看出来了吗?你其实是在业务层重新发明了 2PC,只是把”数据库锁”换成了”业务状态的预留”。 形式变了,本质没变——因为不可逆的副作用,除了事前协调,没有第二条路。
子主题十一:业界对照——三条完全不同方向的破局之路
前面讲的所有机制,都在同一个问句下打转:“怎么协调得又快又对?” 下面这三个系统之所以经典,是因为它们根本没有回答这个问题,而是换了问法。
A. Dynamo / Cassandra:把一致性做成每次请求的参数
核心理念:一致性级别不是数据库的属性,是每次请求的参数。 同一张表,这次读用 ALL,下次读用 ONE,随你。
常见的级别(以 Cassandra 的命名为例):ONE / QUORUM / LOCAL_QUORUM / EACH_QUORUM / ALL,写侧还有 ANY(最弱:只要有个节点收下了 hint 就算成功,原节点恢复前根本读不到)。
结合子主题七的 sloppy quorum,你必须建立一个正确的心智模型:
Dynamo 系的正确心智模型是:默认最终一致;调高 R/W 是在提高”读到新值的概率”,而真正的收敛交给读修复(read repair)、反熵(anti-entropy,常用 Merkle 树比对差异)和冲突消解。
那么冲突怎么消?两条路:
- 让冲突不可能发生:单行用 LWW(最后写入者获胜),按时间戳取大的。简单粗暴,但会静默丢弃并发写入。
- 让冲突可以自动合并:保留多个版本(向量时钟),交给应用层按语义合并。原始 Dynamo 论文里的经典例子是购物车——两个副本各自加了不同的商品,合并时取”并集”,两边都不丢。 这就是”应用语义 > 通用机制”的典型。
这套设计的哲学:把冲突解决的责任,从”系统”下放到”数据结构”或者”应用”,换来的是永不阻塞的写入(always-writeable)。网络分区时照样能写购物车,这对电商是直接的营收。
B. Spanner:用 TrueTime 打破”强一致必然慢”的直觉
先确认一下你脑子里的直觉链:强一致需要协调 → 协调需要多轮往返 → 往返受网络延迟支配 → 所以强一致必然慢。
这条链条每一环都对,除了第一环的隐含假设——“协调只能靠互相通信”。
Spanner 问的是:如果所有节点对”现在几点”的看法足够接近,能不能少走几轮?
TrueTime:每个数据中心部署 GPS 接收器和原子钟,提供时间服务。注意它的 API 不返回一个时刻,而是返回一个区间 [earliest, latest],并承诺真实时刻落在区间内。误差被控制在很小的范围(公开论文给出的上限是毫秒量级)。
关键机制:提交等待(commit wait)。 事务选定一个提交时间戳 s,然后必须等到”确定 s 已经过去了”(即 TT.now().earliest > s),才允许真正提交并回复客户端。
为什么等这么一下就够了? 因为它把事务的提交顺序钉死在真实时间轴上:
如果事务 T2 是在 T1 提交返回之后才开始读的,那么按真实时间,T2 的时钟读数一定晚于 T1 的提交时间戳,于是 T2 的快照一定包含 T1。
这就是外部一致性(external consistency)——它等价于线性一致,并且额外保证了”事务的提交顺序与它们在真实世界中发生的先后顺序一致”。这一点线性一致的严格定义里并没有显式要求,但对业务极其重要。
代价是什么? 提交延迟 = 一个时钟不确定区间的长度。你要等那段不确定的时间过去。
现在请体会一下这次交换的本质:
Spanner 把”跨节点协调的若干轮网络往返”,换成了”一次固定长度的等待”。而这个等待的长度,由时钟精度决定,不由网络距离决定。
这是全章最反直觉、也最重要的一点:
“强一致的代价”不是物理定律,它取决于你用什么机制来建立顺序。网络往返是一种顺序机制,高精度时钟是另一种,两者的成本结构完全不同。
但必须诚实地补两句:
- 这需要专用硬件(GPS + 原子钟),不是谁都能抄。这也是后来混合逻辑时钟(HLC)和 CockroachDB 这类另辟蹊径的系统的由来。
- 时钟解决不了距离。 跨大洲部署时,数据还是要花时间跨半个地球传过去,这个下限由光速决定,原子钟帮不了你。TrueTime 优化的是”协调的轮数”,不是”数据搬运的速度”。
C. CRDT:用数据结构设计,直接消除协调
前面所有方案都在问”怎么协调”。CRDT 问了一个完全不同的问题:
能不能让合并的结果与顺序无关?如果结果与到达顺序无关,那就根本不需要协调了。
形式化要求:把状态看成一个集合,定义合并函数 merge(a, b),要求它满足三条:
- 交换律:
merge(a,b) = merge(b,a)—— 顺序无所谓 - 结合律:
merge(merge(a,b),c) = merge(a,merge(b,c))—— 分组无所谓 - 幂等律:
merge(a,a) = a—— 重复无所谓
三条全成立时,无论副本以什么顺序收到更新、收到几次、重复几遍,最终都收敛到同一个值。数学上,这叫并半格(join-semilattice),merge 就是求上确界(join)。
反问:为什么幂等律这一条特别关键? 因为它一举消灭了分布式系统里最烦的两个问题——消息重传和重复投递。重复没关系,合并两次等于合并一次。你不需要”恰好一次”的投递保证,因为”至少一次”就够了。
几个具体例子:
- G-Counter(只增计数器):每个副本只增自己那个槽位,总值 = 各槽位求和;合并 = 各槽位取 max。因为只增,取 max 就是 join。
- PN-Counter:两个 G-Counter 相减,支持增减。
- G-Set(只增集合):合并 = 并集。
- 2P-Set:加删除墓碑,删了就不能再加回来。
- OR-Set:每个元素带唯一 tag,删除只针对”已经见过的 tag”,所以”加→删→再加”是合法的。这就是 add-wins 语义。
- LWW-Register:带时间戳,取时间戳最大的值。最简单也最弱的 CRDT,会丢并发写。
- 序列 CRDT(协同编辑):给每个字符一个唯一标识,用一个全序结构(如 RGA 的树)决定字符位置,从而让并发生效的插入在不同副本上收敛到同一结果。
CRDT 的边界(比会用它更重要)
其一:它只保证收敛,不保证”业务正确”。
经典例子是库存。PN-Counter 可以让所有副本的库存数字最终收敛,但它保证不了不超卖——因为”库存不能减到负数”是一个全局不变量,而维护全局不变量需要的正是协调。CRDT 能免费给你的,只有”交换律能表达的那类不变量”。
记住这条判据:一旦业务里出现”不能低于 0""不能超过 N""必须唯一""总额必须等于 X”这类跨副本约束,CRDT 立刻失效。
其二:状态会膨胀。 墓碑、每副本槽位、每元素 tag 都是元数据。删得越狠的 OR-Set 越长越大,需要垃圾回收——而 GC 本身往往又需要协调,这就讽刺地把你拉回了起点。
其三:语义可能反直觉。 add-wins 还是 remove-wins,是设计者替用户做的选择。用户会困惑:“我明明删了它,怎么又回来了?”
所以什么时候该用 CRDT? 当操作天然可交换、且没有全局不变量要维护时:点赞数、在线人数、协同编辑的文档、购物车合并、多人同时编辑的配置、离线可用的 App 本地状态。当业务里出现”不能超卖""不能透支""不能重复”时,别硬套 CRDT,老老实实回去协调。
三者横向对照
| Dynamo / Cassandra | Spanner | CRDT | |
|---|---|---|---|
| 换了什么前提 | 一致性是每请求参数,冲突交给应用/数据结构 | 用高精度时钟替代部分协调往返 | 让 merge 满足交换/结合/幂等,消除协调 |
| 买到什么 | 永远可写、极低延迟、极高可用 | 全局外部一致 + 不错的延迟 | 零协调、离线可用、天然容忍乱序和重复 |
| 付出什么 | 一致性弱、运维复杂、冲突逻辑要自己写 | 专用硬件、提交等待、跨地域延迟仍在 | 表达力受限、状态膨胀、全局不变量无能为力 |
| 适合 | 购物车、会话、时序、不能拒绝写的场景 | 跨地域核心账务、库存、必须事务的主数据 | 协同编辑、计数、离线优先应用 |
反问最后一句:这三者有什么共同点?
它们都没有去”优化 2PC”。
这才是本章真正希望你带走的东西:当一个权衡看起来无解时,最有力的动作往往不是在两端之间找更好的平衡点,而是去质疑——产生这个权衡的那个前提,本身是不是必需的?
子主题十二:实操——怎么给你的业务划分一致性等级
讲了这么多模型,最后落到手里:一个业务来了,你怎么动手?
七步决策清单
第 1 步:列实体,粒度要到字段级。
别列”订单系统”这种系统级的词,太粗了。要拆到”订单.状态”和”订单.收货地址”这一层——这两个字段的敏感度完全不同:状态错了会引发资损和投诉,收货地址晚几秒没人知道。
第 2 步:对每个实体问三个问题。
- Q1:读到旧值,最坏的后果是什么? 分四档:无感 / 体验差 / 资损 / 安全漏洞。越靠后越往强一致靠。
- Q2:这个后果可逆吗? 钱可以退、积分可以补;短信已发、包裹在途、库存已给别人——不可逆的副作用,必须有”预留—确认”两段式,光靠对账救不回来。
- Q3:读写比的数量级是多少? 9:1 和 1:9 导出的配置完全相反。精确到个位数没意义,数量级就够。
第 3 步:问一句”真的会并发写同一个实体吗?” 很多争用是想象出来的。如果 99.9% 的写落在不同行上,你需要的只是行级锁 + 乐观版本号,根本不是分布式事务。先量再设计,别凭感觉上重武器。
第 4 步:检查能不能收敛成单分区。 回到子主题九:选分片键的第一原则是”让事务不出分区”,不是”分布均匀”。 这一步做对了,后面所有的复杂度都不存在。
第 5 步:选定一致性级别,写进设计文档。 不是口头共识,是文档。因为半年后接手的人不会知道你为什么选它,然后他会”顺手”把它改成异步。
第 6 步:为每一处”非强一致”写出兜底。 幂等键怎么设计?对账周期多长?对不平了怎么修?谁负责?没有兜底的最终一致,不叫权衡,叫赌运气。
第 7 步:补三个几乎所有人都会漏掉的配套工程。
- 可观测性:监控复制延迟/积压的分位数(P99、P999),不是平均值。平均值会撒谎——P50 是几毫秒、P99 是几秒的系统,用户的实际感受是后者,而平均值会把这件事完美掩盖掉。给 P99 设告警。
- 降级预案:强一致不可用时,系统该做什么?拒绝服务 / 降级为最终一致 / 转为只读——必须明确选定。最危险的不是选错,是”从没想过”,因为那样系统在故障时会随机表现出一种没人预料到的行为。
- 故障演练:真的把副本停掉,真的把网络延迟打上去,看看你的不一致窗口有多大、兜底逻辑有没有真的跑通过。没演练过的兜底,等于没有兜底。
一张可以直接抄的等级表模板
| 数据实体 | 场景 | 一致性级别 | 理由 | 兜底 |
|---|---|---|---|---|
| 账户余额 | 扣款 | 线性一致(单分区事务) | 资损不可逆 | 幂等键 + T+1 对账 |
| 账户余额 | App 展示 | 会话一致(读己之所写) | 用户看到自己刚操作的结果即可 | 客户端携带版本号 |
| 商品库存 | 扣减 | 强一致(预留 + 确认) | 超卖代价高 | 对账 + 超卖补偿流程 |
| 商品库存 | 列表页”仅剩 N 件” | 最终一致 | 差几件无感 | 无,允许偏差 |
| 点赞/收藏数 | 计数 | CRDT 或异步聚合 | 无全局约束,操作可交换 | 无,允许短暂偏差 |
| 用户昵称 | 改完立即查看 | 读己之所写 | 改完看不到很怪 | 本地覆盖 / 版本路由 |
| 订单状态 | 下单后查询 | 读己之所写 + 单调读 | 状态倒流会引发投诉 | 会话粘滞 |
| 社交时间线 | 刷 Feed | 因果一致 | 评论不能早于被评论的帖子 | 并发内容顺序不定,可接受 |
| 评论 / 回复 | 展示 | 因果一致 | 回复必须排在它所回复的内容之后 | 同上 |
| 审计日志 / 风控样本 | 写入 | 最终一致(outbox 异步) | 不在关键路径 | 重试 + 幂等消费 |
| 推荐画像 / 搜索索引 | 更新 | 最终一致 | 差几分钟无感 | 全量重建任务 |
收尾的一个反问
清单走完之后,请回到最后这个问题:
你标注的每一处”最终一致”,你真的知道它的不一致窗口有多大吗?P99 是多少?
如果答不上来,这次设计就还没做完。
不知道窗口大小就宣称”最终一致没问题”,和闭上眼睛说”外面没车”是一样的。 你可以选择接受风险,但你不能选择不知道风险——这大概就是”权衡”和”赌运气”之间唯一的区别。
演进脉络
- 早期(单机时代):一致性和性能的冲突不明显,因为只有一个副本,锁和事务就够用。代价模型是”内存 + 一次 fsync”。
- 中期(主从复制 / 读写分离):引入副本换可用性,开始出现”主从延迟”导致的不一致。工程师第一次意识到这个窗口的存在,于是出现了读写分离、读己之所写、会话粘滞这类打补丁式的折中。
- 现代(多副本 + Quorum + PACELC):一致性被抽象成可调旋钮。Quorum 用抽屉原理给出了”R+W>N 即读到最新”的低成本方案,Dynamo 系把一致性级别暴露成每请求参数,PACELC 框架取代 CAP 成为日常主战场的描述语言。同时 2PC 的高昂代价被量化清楚,业界转向”局部化”——让事务不出分区、让保证不出会话、让关键路径之外的东西异步化。
- 近期(换前提):不再优化协调本身,而是质疑协调的必要性。Spanner 用 TrueTime 的高精度时钟替代部分协调往返,证明”强一致必然慢”依赖于”协调只能靠通信”这个隐含假设;CRDT 用满足交换/结合/幂等的合并函数直接消除协调;金融体系则干脆放弃事前预防,转向”幂等 + 复式记账 + 对账”的事后纠正范式。
- 方法论的收敛:回头看这条脉络,真正的分水岭从来不是”选强还是选弱”,而是——你是接受了这道权衡,还是去质疑了产生它的前提。 从读写分离到 Quorum 到局部化,是在权衡内部找更好的点;从 TrueTime 到 CRDT 到对账体系,是换了问法。后者的收益通常比前者大一个数量级。
本章思考题
【思考题】
-
强一致”慢”的本质原因是什么?为什么加机器不能解决它?
-
“最终一致把系统复杂度转移到了应用和运维层”,请举一个你见过的具体例子说明这种转移。
-
什么是”局部强一致”?为什么它比”全局强一致”更实用?
-
为什么 PACELC 比 CAP 更贴近真实工程?
-
顺序一致和因果一致的差别是什么?请以”社交网络时间线”为背景,构造一个满足因果一致但不满足顺序一致的具体执行序列,并说明为什么它同时满足两者之一。
-
请用抽屉原理说明”R+W>N 为什么能保证读到最新已提交的值”。然后回答:它不保证什么?至少说出两点,并各举一个会踩坑的具体场景。
-
一个系统 N=3、读多写少(读写比约 100:1),你会怎么配 W 和 R?为什么?如果反过来是写多读少呢?另外请指出:调配 R/W 除了挪动延迟,还在挪动什么?
-
分布式事务为什么”锁持有时间”特别长?请把 2PC 两个阶段各自做了什么、涉及几次网络往返、几次 fsync 列清楚,并说明锁持有时间由哪些部分构成。为什么在热点数据上这会导致吞吐断崖式下跌?
-
协调者在 2PC 的两个不同时刻崩溃,后果分别是什么?为什么说 3PC 不是答案?工业界真正的答案是什么?
-
什么是”局部化”?请给出三条具体路径,并解释为什么”减少事务的参与方”永远比”优化事务协议实现”更有效。
-
金融系统为什么不用分布式事务做跨行转账?“幂等 + 复式记账 + 对账”这三件套,各自解决的是什么问题?其中”余额是派生量、流水才是事实”这个设计为什么是整个体系的地基?
-
Spanner 的 TrueTime 是怎么打破”强一致必然慢”这个直觉的?提交等待(commit wait)的代价是什么?它优化掉的到底是”协调的轮数”还是”数据搬运的时间”?另外,CRDT 换掉的是什么前提?请举一个CRDT 明确帮不了你的具体业务例子并说明原因。
-
现在让你给一个新业务划分一致性等级。请给出你的决策步骤,并指出其中最容易被团队漏掉的三个配套工程分别是什么。
【参考答案】
-
强一致慢的本质是”等待”——要等多副本确认、要等锁、要等事务提交、要等落盘。这些等待受物理约束支配(网络延迟、磁盘 IO),而加机器只能分摊计算,分摊不了”必须等某个事件完成”这件事。所以强一致的性能瓶颈不在 CPU,在等待时间,加机器解决不了。
-
例如:电商下单用最终一致,主库写入后异步同步到从库,用户下单后立刻查订单列表可能查不到(读从库),于是前端要加本地缓存、后端要加”读己之所写”路由;同步失败还要加对账任务和补偿逻辑。这些兜底逻辑都在业务和运维层,原本是系统该保证的事——复杂度被转移了,不是消失了。
-
局部强一致是指:不需要所有数据在所有场景都强一致,只对”关键部分”或”关键场景”保证强一致,其余用更弱的一致级别。比如余额强一致、昵称最终一致;同用户操作强一致、跨用户最终一致。它更实用,因为绝大多数业务的数据敏感度是不均匀的——用统一的最强保证会为大量不敏感数据付出不必要的延迟代价。
-
因为 CAP 只讨论”网络分区时”的取舍,而真实系统绝大多数时间网络是正常的——此时你面对的不是”要不要一致”,而是”要多少延迟”。PACELC 补全了这一点:正常时在延迟和一致性之间权衡。它更贴近工程,因为它把一致性描述成”和延迟挂钩的连续可调旋钮”,而不是一个二选一的开关。
-
顺序一致要求:所有进程看到的操作能被排成一个单一全局序列,且不违反任何进程自己的程序顺序。因果一致只要求:有 happens-before 关系的操作全局同序,并发操作随意。构造例子(时间线场景,副本为 X 和 Y):
- A 发帖子 P1(无任何前驱)。
- C 发帖子 P2(与 P1 并发,无因果)。
- 副本 X 交付顺序:[P1, P2];副本 Y 交付顺序:[P2, P1]。
这是因果一致的:P1 与 P2 并发,无因果约束,两个副本各自排都合法。但它不满足顺序一致:全局序列只能有一个,不可能同时既包含”P1 在 P2 前”又包含”P2 在 P1 前”。反过来,任何一个满足顺序一致的执行必然也满足因果一致(因为全局序列天然尊重因果顺序),所以顺序一致严格强于因果一致。
-
保证的部分:写集合大小 W、读集合大小 R,都取自同一组 N 个副本。若 R+W>N,两个集合必然相交(否则并集最多 N 个元素,与 R+W>N 矛盾)。交集意味着读到的 R 个副本中至少有一个也参与了最近一次成功写入,于是”取版本号最新者”一定能拿到最新已提交值。全程只用到集合论,不需要时钟或主节点。 不保证的部分(任选两点即可):
- 部分写:写入只落到 W 中的一部分就失败时,系统里存在”既未成功也未失败”的值,不同读集合碰不碰得到它结果不同,线性一致破产。踩坑场景:客户端超时后重试,另一个客户端读到的值来回跳变。
- 并发读写的实时序:读操作进行中另一写提交,这次读该不该看到它?抽屉原理不回答。踩坑场景:你以为设了 QUORUM 就是线性一致,结果在高并发下仍出现”读到了稍旧的版本”。
- read-modify-write 的原子性:并发自增会丢更新(两次读都是 v,各算 v+1 写回)。踩坑场景:点赞计数用
SELECT count再UPDATE,并发下计数永远偏小。必须改用 CAS/版本号/CRDT/单点。 - “谁更新”需要版本号依据:若用物理时钟 LWW,时钟回拨或跨节点时钟偏差会静默丢写。踩坑场景:两台机器差了几百毫秒,一次正常的并发写被无声丢弃,且只在特定时序下复现。
-
读多写少(约 100:1):抬高 W、压低 R,典型 W=N(或 W=多数派)、R=1。写的时候疼一次,读的时候随便找个就近副本,还能叠 CDN 和本地缓存——读是那 100 次,一定要便宜。 写多读少:理论上压低 W、抬高 R(W=1、R=N)。写飞快,读的时候读修复。但工程上要谨慎:R=N 意味着任一节点不可用读就整体失败,可用性极差,所以实际常用 W=1、R=多数派,并接受”可能读不到最新”。另外 W=1 时”写成功”只落在一个副本上,该节点此时故障数据就丢了,必须靠 hinted handoff + 异步复制补救——而这已经不是强一致了。 还在挪动什么:还在挪动可用性。降低 W 提高写可用性,降低 R 提高读可用性。N=3、W=3、R=1 时写不容错而读可容 2 节点故障;W=1、R=3 时完全相反。所以调 R/W 表面挪延迟,实际同时决定了”哪一侧先不可用”。补充一个坑:Dynamo 系的 sloppy quorum 会把写转交给临时节点,导致”那 N 个副本”不再是同一批机器,此时即使 R+W>N 也只拿到最终一致。
-
两阶段账目:阶段一(Prepare):协调者发事务内容 → 参与者写 undo/redo 日志、fsync、锁住相关数据行、投票。1 次网络往返 + 1 次 fsync,并从这里开始持锁。 阶段二(Commit):协调者把决定先持久化到自己的日志 → 广播决定 → 参与者提交、fsync、释放锁、回 ACK。又 1 次网络往返 + 至少 2 次 fsync。 合计至少 2 次网络往返、至少 3 次 fsync。 锁持有时间 ≈ 本地执行时间 + 2 次网络往返 + 2~3 次 fsync,而单机事务只有”内存操作 + 1 次 fsync”。 为什么吞吐断崖:锁是排他的,热点行上的吞吐 ≈ 1 / 锁持有时间。锁持有时间跨一个数量级,热点吞吐就按同比例掉。加机器无用——100 台机器也得排队等同一把锁。另外延迟不随参与者数量线性增长,而是由最慢的参与者决定(尾延迟):参与者越多,撞上慢节点的概率越高,P99 越难看。
-
崩溃点 A(写决定进日志之前挂了):参与者全部卡在 in-doubt——投了 Yes、锁着、不知道该提交还是回滚,只能死等协调者恢复。这就是 2PC 的阻塞问题。 崩溃点 B(写完日志、广播决定之前挂了):更憋屈——决定其实已经定了(日志里有),但参与者不知道,依然只能等。 两点共同揭示一条铁律:协调者的决定必须先落盘再广播,参与者的 prepare 日志必须先落盘再投票,否则崩溃后无法自证状态,会把数据搞坏。 为什么不是 3PC:3PC 加 pre-commit 阶段试图解决阻塞,但在网络分区下依然脑裂(一部分提交、一部分回滚),所以工业界基本不采用。 真正的答案:让协调者本身高可用——把协调者状态放进 Paxos/Raft 复制状态机,协调者变成无状态;它挂了由 Raft 选出新的,从复制日志读到上次的决定继续推进。这是 Percolator、TiDB、CockroachDB、Spanner 的通用做法。
-
三条路径:
- 按数据分区强一致:让相关数据落在同一台机器上,事务不出分区。手段是按业务实体选分片键(如按 uid,同用户数据同分区)、entity group / 层级 schema、单元化架构。选分片键的第一原则是”让事务不出分区”而非”分布均匀”——分布不均可以靠拆分搬迁补救,跨分区事务一旦形成几乎甩不掉。
- 同一用户会话一致:把保证范围从”全球客户端”缩到”一个客户端的一次会话”。手段是会话粘滞 + 会话令牌携带版本号。成本几乎为零(只需一个版本号),却能消灭绝大多数用户可感知的不一致。
- 关键路径强一致、非关键路径最终一致:扣库存/建单/扣款同步强一致;发通知、加积分、更新画像/索引/审计全异步。手段是事务性发件箱——在同一个本地事务里既改业务数据又插 outbox 消息,把”跨资源原子性”降级为”单资源原子性”,剩下”重复投递”交给消费端幂等解决。 为什么减少参与方更有效:2PC 的延迟由最慢参与者决定(尾延迟),而非随数量线性增长;每少一个参与者,就少一份锁的持有者、少一份尾延迟来源、少一份崩溃概率,还可能直接让分布式事务退化成单机事务。而且更重要的是:局部化是在质疑”为什么需要跨节点协调”,而优化协议是在接受这个前提后做得更快——前者改变问题,后者只改变常数。
-
为什么不用分布式事务:跨行转账的参与方是两家不同银行,你根本没有、也不可能有”同一个协调者”。技术上 XA 可行,但组织运维上不可行——没理由让对方银行的行锁被你的协调者持有若干秒;你的协调者挂了,对方的锁谁来放?根本障碍是信任边界,不是技术。 三件套各自解决什么:
- 幂等:让重复操作无害,从而让”至少一次投递”变得可用。注意幂等 = 去重 + 返回一致结果,只去重不返回相同结果会让客户端误判失败并触发补偿,制造新问题。实现靠唯一索引(biz_type + biz_id)和条件更新(CAS)而非”先查后写”。
- 复式记账:每笔交易至少两条分录、借贷必相等,提供一个随时可校验的不变量。
- 对账:承认会错,然后系统性地找出来。靠日终逐笔比对内部账与渠道流水、差错分类(长款/短款/单边账)、挂账账户隔离脏数据。日切是关键前提——有截止线,对账才可能收敛,否则你永远在追一个移动靶。 为什么”余额是派生量、流水是事实”是地基:因为它让正确性不再依赖”系统从不出错”,而依赖”正确性随时可被重新验证”——你可以重放流水把余额算出来并比对。叠加”冲正而非删除”(错误用反向分录抵消),正确性就变成”每一笔错误都被记录了它的相反数”,同时还换来了完整可审计的历史。这是把保证从”事前预防”搬到”事后纠正”的转折点。
-
Spanner / TrueTime:它质疑了”协调只能靠互相通信”这个隐含假设。TrueTime 用 GPS + 原子钟提供时间,返回一个保证包含真实时刻的区间
[earliest, latest]而非单个时刻。事务选定提交时间戳 s 后必须做提交等待:等到TT.now().earliest > s(确定 s 已经过去)才提交并回复客户端。这样事务的提交顺序被钉死在真实时间轴上——T2 若在 T1 提交返回之后才开始读,T2 的时钟读数必晚于 T1 的时间戳,于是 T2 必然看到 T1,这就是外部一致性(等价于线性一致,且额外保证提交顺序与真实先后顺序一致)。代价是提交延迟 = 一个时钟不确定区间:它把”多轮跨节点网络往返”换成了”一次固定长度的等待”,而这个长度由时钟精度决定,不由网络距离决定。 它优化掉的是”协调的轮数”,不是”数据搬运的时间”——跨大洲部署时数据跨半个地球的传输延迟由光速决定,原子钟帮不了你。另外它需要专用硬件,不是谁都能抄,这正是 HLC 和 CockroachDB 另辟蹊径的原因。 CRDT 换掉的前提:它不问”怎么协调”,而问”能不能让合并结果与顺序无关”。要求 merge 满足交换律、结合律、幂等律(数学上即并半格),满足则无论副本以何顺序、收到几次更新都收敛。幂等律尤其关键——它一举消灭消息重传和重复投递,让”至少一次”投递就够了。 CRDT 帮不了的例子:库存防超卖。 CRDT 只能保证副本收敛,保证不了业务正确——“库存不能减到负数”是一个全局不变量,而维护全局不变量需要的正是协调本身。PN-Counter 能让所有副本的库存数字一致收敛,但两个副本同时各减 1 时,两边都合法地减到了 -1,收敛倒是收敛了,超卖已经发生。判据:一旦出现”不能低于 0""不能超过 N""必须唯一”这类跨副本约束,CRDT 立刻失效。 此外 CRDT 还有状态膨胀(墓碑、tag、每副本槽位)和语义反直觉(add-wins/remove-wins)的问题。 -
决策步骤:① 列实体到字段级(“订单.状态”和”订单.收货地址”敏感度完全不同);② 对每实体问三问——读到旧值最坏后果(无感/体验差/资损/安全漏洞)、后果是否可逆(不可逆的必须”预留—确认”两段式)、读写比的数量级;③ 问”真的会并发写同一实体吗”,很多争用是想象出来的,行级锁 + 版本号可能就够了;④ 检查能否收敛成单分区,按”让事务不出分区”选分片键;⑤ 选定一致性级别并写进设计文档,否则半年后会被人”顺手”改掉;⑥ 为每一处非强一致写出兜底(幂等键、对账周期、修复流程、负责人)——没有兜底的最终一致不叫权衡,叫赌运气。 最容易被漏掉的三个配套工程:
- 可观测性:监控复制延迟/积压的分位数(P99/P999)而非平均值。平均值会撒谎——P50 几毫秒、P99 几秒的系统,用户感受是后者,而平均值会完美掩盖它。
- 降级预案:强一致不可用时系统该做什么(拒绝服务 / 降级最终一致 / 转只读)必须明确选定。最危险的不是选错,是从没想过——那样系统在故障时会随机表现出一种没人预料到的行为。
- 故障演练:真的停掉副本、真的把网络延迟打上去,验证不一致窗口有多大、兜底逻辑是否真跑通过。没演练过的兜底等于没有兜底。 最后补一条自检:你标注的每一处”最终一致”,都要能答出它的不一致窗口 P99 是多少。答不上来,设计就还没做完。