Xiaojun 的技术笔记

分布式理论与事务:从 CAP 到对账

分布式约 22 分钟读完
目录

分布式理论与事务是后端面试的高频话题,但常见的回答很浅:CAP 背个“三选二”,分布式事务背个“2PC / TCC / Saga”,一追问就塌。

这篇按「理论 → 共识 → 事务方案 → 幂等 → 分布式 ID → 业务落地」的顺序梳理一遍,重点讲每个点背后的取舍和工程细节,而不是罗列名词。

从 ACID 到 BASE

聊 CAP 之前,先说清楚它在回答什么问题。单机数据库时代我们有 ACID;到了分布式,ACID 撑不住,CAP 划出理论边界;工程上不得不退让,于是有了 BASE。这三个词不是并列的知识点,而是一条被现实逼出来的演进线。

ACID 的边界

字母 含义 单机靠什么实现
A Atomicity 原子性 事务内的操作要么全做,要么全不做 undo log 回滚
C Consistency 一致性 事务把数据库从一个合法状态带到另一个合法状态,完整性约束不被破坏 由 A / I / D 加业务约束共同保证
I Isolation 隔离性 并发事务之间互不干扰 锁 + MVCC
D Durability 持久性 提交之后不丢 redo log + WAL 刷盘

两个容易被忽略的点:

  • C 是目的,不是手段。 A、I、D 是实现机制,C 是它们共同达成的结果。所以 CAP 的 C(线性一致性)和 ACID 的 C 完全不是一回事。
  • I 从来就不是非黑即白。 RU / RC / RR / Serializable 四个隔离级别,本质上就是“一致性强度按需调档,换取并发性能”。这种分级取舍的思路,到了分布式里被放大成 CAP 的 C / A 之争。严格说,退让不是从分布式才开始的,单机时代就已经在退让。

分布式下的困境

上分布式之后,三件事同时变了:

  1. 数据不在一台机器上:原子性要跨节点达成,只能依赖 2PC 这类原子提交协议,于是引入协调者单点和同步阻塞。
  2. 隔离性需要全局视角:要么加全局锁(吞吐崩掉),要么建立全局时序(需要可信的全局时钟,而普通服务器的时钟并不可信)。
  3. 网络会分区,节点会宕机:单机上“要么成功、要么失败”的二元世界,变成了“成功 / 失败 / 不知道“的三元世界。

第三点是分布式系统一切复杂度的真正源头:

超时不等于失败。 请求超时了,对方可能根本没收到,也可能已经执行成功,只是响应丢了。

这一条直接催生了后面所有的幂等设计。ACID 本身没有错,它只是默认了一个在分布式下不成立的前提:所有参与者都在同一台可靠的机器上,故障是原子的。

CAP 的位置

CAP 的历史贡献不在于告诉你该怎么做,而在于证明了有些事做不到:面对必然发生的网络分区,线性一致性和可用性无法同时保住。它把工程师从“再优化一下说不定全都要”的幻想里拉出来,逼着大家做显式的取舍。详见下一章。

BASE 的退让

既然 ACID 在分布式下不可得,那就换一套目标:

  • Basically Available(基本可用):出问题时允许降级,比如关掉推荐、返回兜底数据,但核心链路不能挂。
  • Soft state(软状态):允许系统存在中间状态,比如订单“处理中”、会员“开通中”。
  • Eventually consistent(最终一致):不保证任何时刻都一致,但保证在没有新写入的情况下,最终会收敛。

关键问题是:“最终”是多久?靠什么保证它真的会收敛? 答案是重试 + 幂等 + 对账。所以:

BASE 不是“不管一致性了”,而是把一致性从同步保证改成了异步收敛 + 可观测的补偿机制。 做不到后半句的,不叫 BASE,叫数据不一致。

这是全文的核心。很多团队自称“走 BASE 最终一致”,但既没有重试兜底也没有对账任务,那只是给“不一致”找了个体面的说法。

维度 ACID BASE
一致的时机 事务提交即一致 最终一致,有收敛窗口
冲突时的选择 中止事务,保证正确 尽量响应,允许降级
中间状态 对外不可见 对外可见,前端要能展示“处理中”
实现成本 数据库保证,业务基本无感 业务自己做幂等、重试、对账
典型场景 账户余额、库存扣减、订单主状态 计数、Feed、推荐、通知、画像

最后一行是实操要点:同一个系统里两种模型并存是常态,不要整站选一个。

钟摆摆回

BASE 也不是终点。2012 年之后,Google Spanner 用 TrueTime(GPS + 原子钟,把时钟误差压缩到一个有界区间)配合 Paxos,在全球分布式环境下重新实现了 ACID 事务。CockroachDB 用 HLC(混合逻辑时钟)+ Raft,TiDB 用 PD 集中授时的 TSO + Raft,走的是同一条思路。这类系统统称 NewSQL。

代价当然存在:Spanner 提交时要做 commit-wait,主动等过时钟的不确定区间,用延迟换外部一致性;此外还有硬件和运维成本。但它证明了一件事:ACID 在分布式上并非不可得,只是有价格。

ACID     单机可得,业务无感
  ↓  数据跨节点、网络不可靠
CAP      证明分区面前 C 和 A 不可兼得
  ↓  工程必须继续跑
BASE     退让为最终一致,代价是业务自己兜底
  ↓  可信时钟 + 共识算法成熟
NewSQL   用有界时钟误差 + Raft/Paxos,在有限代价下赎回 ACID

实践意义:BASE 是特定历史条件下的权衡,不是普世真理。今天选型时,“能不能直接上一个分布式 ACID 数据库,把复杂度交给存储层”已经是合理选项,而不是像十年前那样只能默认在业务代码里手写补偿。

CAP 定理

CAP 是定理,没问题;“三选二”的通俗理解才是错的。

三个字母的含义

字母 全称 准确含义
C Consistency 线性一致性。写入成功后,任何后续读都能读到最新值,整个集群表现得像一台单机
A Availability 每个非故障节点收到请求,都必须在有限时间内返回非错误响应(不保证是最新数据)
P Partition Tolerance 网络分区(节点间消息丢失、延迟、不可达)发生时,系统仍能继续工作

第一个坑:CAP 的 C 是线性一致性,不是 ACID 的 C。ACID 的 C 说的是“事务不能破坏数据库的完整性约束”,CAP 的 C 说的是“多副本之间读到的值是不是最新的”。

两节点证明

CAP 是被严格证明过的定理(Gilbert & Lynch, 2002),不是江湖经验。证明很短,值得自己走一遍。

取一个最小系统:两个节点 N1、N2,各持有同一份数据的副本,初始值都是 V0。客户端向 N1 写,从 N2 读。

网络通畅时:

1. 客户端向 N1 写入 V1
2. N1 更新本地副本:V0 → V1
3. N1 把更新同步给 N2,N2 也变成 V1
4. 客户端从 N2 读取 → 得到 V1   C、A、P 同时满足

没有分区的时候,C 和 A 根本不冲突。矛盾只在第 3 步出问题时才出现。

N1 与 N2 之间的链路断了:

sequenceDiagram
    participant C as 客户端
    participant N1
    participant N2
    C->>N1: 写入 V1
    N1-->>C: 成功
    N1--xN2: 同步消息丢失
    C->>N2: 读请求
    alt 返回 V0
        N2-->>C: 旧值,保住 A,违反 C
    else 阻塞或报错
        N2-->>C: 错误,保住 C,违反 A
    end

关键在于:N2 无法区分“N1 压根没发生过写入”和“N1 写了但消息没送到”。分区期间,N2 手上没有任何信息能让它做出一个既正确又及时的响应。所以 C 和 A 在 P 发生时必然二选一。这不是工程能力不够,而是信息层面的硬约束。

那定理和“三选二”是什么关系?

“三者不可能同时满足”是定理,完全正确; “所以你可以任选两个”才是误读。

P 不是摆在桌上供你挑的特性,而是环境强加给你的。放弃 P 等于宣布系统不允许出现网络分区,唯一的做法是退回单机。准确的表述分三层:

  1. 定理层面:C、A、P 三者不可兼得,严格成立。
  2. 工程层面:P 由环境决定,你实际能选的只有 CP 或 AP。
  3. 时间层面:这个选择只在分区期间生效,其余时间的取舍要用 PACELC 描述。

三种常见误读

最典型的来源是网上流传最广的那张 CAP 韦恩图,它有几处不严谨。

第一,CA 在真正的分布式系统里不存在。 只要跨节点通信,网络分区就是客观事实:网线会被拔、交换机会挂、机房会断、跨洋链路会抖。所以真实含义是:

P 是必选项。真正的取舍是:分区发生时,你选 C 还是选 A。

  • CP:分区期间宁可拒绝服务,也要保证数据正确。代表:ZooKeeper、etcd、HBase、TiKV。
  • AP:分区期间继续响应,接受数据暂时不一致。代表:Cassandra、DynamoDB、Eureka、CouchDB。

常见版本的图把 Oracle / MySQL 标在 CA 区域,这本身就是范畴错误:单机数据库不是分布式系统,不在 CAP 的讨论范围内。

第二,Redis 不是 CP。 有的图把 Redis 标成 CP,但 Redis 主从 + Sentinel 是典型的 AP:主从复制是异步的,主挂了做故障切换,还没同步过去的写就丢了。Redis Cluster 同样不保证强一致。Redis 承诺的是低延迟,从来不是线性一致性。

第三,CAP 只在分区期间生效。 没有分区时,系统完全可以同时提供强一致和高可用。CAP 只描述了系统生命周期里很小一段时间的行为。

PACELC 模型

PACELC 补上了 CAP 没说的那一半:

if (P) then A or C; Else L or C

发生分区(P)时,在可用性(A)和一致性(C)之间选; 否则(Else,也就是绝大多数正常运行的时间),在延迟(L)和一致性(C)之间选。

这个 “Else” 才是日常工程里天天遇到的取舍:强一致往往意味着更高延迟,因为要等多数派确认。

系统 分区时 正常时
MySQL 异步复制主从 PA EL(低延迟,读从库可能读到旧数据)
MySQL 半同步复制 PA(超时后退化为异步) EC 倾向(等从库 ACK,换更强的不丢数据保证)
etcd / ZooKeeper PC EC
Cassandra(默认) PA EL
DynamoDB PA EL / EC 可配

半同步复制是拿延迟换一致性的典型:每次提交要等至少一个从库 ACK,响应时间上去了,主库挂掉时丢数据的风险更低。但要注意,超过 rpl_semi_sync_master_timeout 后它会退化成异步,所以它不是严格的强一致。

按业务分级

CAP 是粗粒度的系统级模型,实际工程是按业务重要性分级选择的。同一个系统里两种选择完全可以并存:

  • 用户的订阅状态、账户余额:走 MySQL 主库强一致读(CP 倾向),宁可慢一点,绝不能错。
  • 剧集的播放量、点赞数:走 Redis 计数 + 异步落库(AP 倾向),差几个数没人在意。
  • 推荐列表、热榜:完全可以是分钟级最终一致。

Raft 共识

CAP 告诉你“必须做取舍”,Raft 告诉你“选 C 的那一侧具体怎么实现”。

Raft 比 Paxos 好理解,根本原因是它把共识问题拆成了三个相对独立的子问题:Leader 选举、日志复制、安全性。Paxos 则是一个高度抽象的整体,论文读完往往还是不知道怎么落地。

Leader 选举

三种角色:Follower / Candidate / Leader。时间被划分成递增的 Term(任期),每个 Term 最多一个 Leader。

  1. Follower 在 election timeout(论文建议随机 150–300ms)内没收到 Leader 心跳,转为 Candidate。
  2. Term + 1,给自己投一票,并行向其他节点发 RequestVote。
  3. 拿到**多数派(N/2+1)**选票,成为 Leader,立刻发心跳压制其他候选人。

两个关键细节:

  • 超时时间必须随机化。 如果所有节点用同一个超时值,宕机后会同时发起选举,互相瓜分选票导致平票,不断重试,形成活锁。随机化是 Raft 最简单也最有效的设计之一。
  • 投票约束:只投给日志至少和自己一样新的候选人(先比最后一条日志的 term,相同再比 index)。这保证了新 Leader 一定包含所有已提交的日志,是安全性的基石。

日志复制

客户端请求 → Leader 追加到本地日志(uncommitted)
          → 并行发 AppendEntries 给所有 Follower
          → 多数派确认后标记为 committed
          → 应用到状态机,返回客户端
          → 后续心跳把 commitIndex 告知 Follower

日志匹配特性:AppendEntries 会带上前一条日志的 prevLogIndex 和 prevLogTerm。Follower 发现对不上就拒绝,Leader 把 nextIndex 往回退一格重试,直到找到共同的一致点,再覆盖 Follower 后面所有不一致的日志。

这保证了:如果两个日志在某个 index 上的 term 相同,那么它们从头到这个 index 的所有日志都完全相同。

提交的安全性

这是 Raft 论文里最微妙的一点:Leader 只能直接提交当前任期的日志,之前任期的日志通过“提交当前任期日志时顺带提交”的方式间接提交。

原因是存在一个反例(论文 Figure 8):一条旧任期的日志即使已经复制到多数派,如果直接判定为已提交,后续仍可能被一个拥有更新日志的新 Leader 覆盖。已提交的日志被覆盖,是共识算法绝对不能接受的。

脑裂与节点数

网络分区时,少数派那一侧即使选出了 Leader,也永远拿不到多数派确认,写不进去;多数派那一侧正常工作。分区恢复后,少数派的 Leader 发现更高的 Term,自动退回 Follower,日志被覆盖。

推论:

  • 集群应该用奇数个节点(3 / 5 / 7)。 4 个节点的容错能力和 3 个一样(都只能挂 1 个),却多花一台机器、多一份网络开销。
  • 2 个节点的集群没有意义:挂任何一个都凑不出多数派,可用性还不如单机。

读优化

一个很少被问到的点:直接从 Leader 读也可能读到旧数据。 Leader 可能已经在网络分区中被取代,而它自己还不知道(没收到新 Leader 的心跳),这种“幽灵 Leader”返回的数据就是过期的。两种解法:

  • ReadIndex:读之前先向多数派发一轮心跳确认“我还是 Leader”,再读。多一轮 RTT,但完全正确。
  • Lease Read:基于时钟租约,在租约有效期内 Leader 直接读。更快,但依赖时钟不会大幅漂移这个假设。

etcd、TiKV 都实现了这两种模式。

典型应用

etcd(K8s 的元数据存储)、Consul、TiKV、Kafka 的 KRaft(用来替代 ZooKeeper)、RocketMQ 的 DLedger。

分布式事务四方案

2PC

阶段一 Prepare:协调者问所有参与者"能提交吗" → 参与者锁资源、写 undo/redo、回复 Yes/No
阶段二 Commit :全部 Yes → 发 Commit;任一 No → 发 Rollback

三个致命问题:

  1. 同步阻塞:从 prepare 到 commit 期间,所有参与者的资源锁一直持有。协调者慢一点,整条链路的锁都拖着。
  2. 协调者单点:协调者在阶段二挂掉,参与者会一直阻塞在“已 prepare、不知道该 commit 还是 rollback”的状态。
  3. 数据不一致:commit 阶段部分节点收到、部分没收到,直接不一致。

3PC 加了 CanCommit 阶段和超时机制来缓解阻塞,但消息轮次更多,网络分区下仍可能不一致,实践中几乎没人用。

适用场景:单体应用内跨多个数据库(XA 事务),强一致要求且规模小。微服务架构下基本不用。

TCC

业务层面的 2PC,把资源锁定从数据库层提到业务层:

  • Try:预留资源。比如冻结余额而不是直接扣款,预占库存而不是直接减库存。
  • Confirm:真正执行,把冻结转为扣除。
  • Cancel:释放预留,解冻。

优点:锁的粒度由业务自己控制,不占数据库行锁,性能明显好于 2PC。

代价极大:每个参与服务都要实现三个接口,侵入性极强,还必须处理三个经典问题:

问题 含义 解法
空回滚 Try 因网络问题没执行,Cancel 却先到了 Cancel 识别“没有对应的 Try”,直接返回成功并记录
幂等 Confirm / Cancel 被重复调用 每个操作记录执行状态,重复调用直接返回
悬挂 Try 因网络延迟在 Cancel 之后才到达,预留的资源永远没人释放 Try 执行前检查是否已有 Cancel 记录,有则拒绝

适用场景:资金类等一致性要求极高、且团队愿意付出开发成本的核心链路。不要无脑全站上 TCC。

Saga

把一个长事务拆成一串本地事务,每个本地事务配一个补偿操作。任何一步失败,就反向依次执行前面所有步骤的补偿。

正向:创建订单 → 扣款 → 扣库存 → 创建物流单
补偿:           退款 ← 回滚库存 ← 取消物流单

两种实现方式:

  • 编排式(Choreography):服务之间通过发布 / 订阅事件驱动,去中心化。好处是解耦,坏处是流程散落在各个服务里,没人能说清完整链路,排查困难。
  • 协调式(Orchestration):由一个 Saga 协调器显式控制流程。流程清晰、可观测、易排查,生产环境推荐这种。

优点:全程无锁,性能好,适合长流程、跨多个服务的业务。

致命缺点:没有隔离性。 中间状态对外可见,订单已创建但还没扣款成功时,用户会看到一个“半成品”订单。而且有些操作根本无法补偿:发出去的邮件收不回来,推送的通知撤不掉。这类不可逆操作必须放在 Saga 的最后一步。

适用场景:订单 → 支付 → 库存 → 物流这类长流程业务。

本地消息表

这是我最推荐的方案:简单、可靠、几乎无侵入。

BEGIN;
  UPDATE 业务表 SET ...;                          -- 业务操作
  INSERT INTO local_message (...) VALUES (...);   -- 待发送的消息
COMMIT;  -- 两者在同一个本地事务里,要么都成功要么都失败

-- 后台任务扫描 local_message → 投递到 MQ → 下游幂等消费 → 标记已发送

核心思想:把“业务操作”和“我要发一条消息”写进同一个本地事务。本地事务的原子性天然保证“业务成功了,消息一定在”,剩下只是“把消息可靠地投出去”,用后台重试就能解决。

优点:只依赖本地事务 + MQ,不需要分布式事务框架,没有协调者单点。 缺点:多一张消息表和一个扫描任务;只能做到最终一致。

两个演进变体:

  • 事务消息(RocketMQ 原生支持):发半消息 → 执行本地事务 → 提交 / 回滚半消息;Broker 长时间没收到结果会回查业务方“这个事务到底成没成”。省掉消息表,但绑定特定 MQ。
  • CDC / binlog 订阅(Debezium、Canal):用 CDC 替代“扫描表 + 投递”的后台任务。Debezium 推荐的 Outbox 模式仍然写 outbox 表,只是不用自己写扫描投递;也可以直接订阅业务表的变更,但会把下游和表结构耦合起来。

选型原则

这是真正考察判断力的地方,优先级应该是:

  1. 先问:能不能不用? 强关联的数据为什么会被拆到两个库?很多时候分布式事务是服务边界划错了的症状。把它们放回同一个服务同一个库,问题就消失了。
  2. 其次:最终一致 + 幂等 + 对账。 本地消息表 / CDC,成本最低,可靠性最高。
  3. 最后:TCC / Saga。 实在绕不过去的核心链路才上。

有对账的最终一致,比没有对账的“强一致方案”更可靠。

无论选哪个方案,都要有定时对账任务去发现不一致、告警、自动或人工修复。对账是最后一道保障。

顺带一提 Seata:AT / TCC / Saga / XA 四种模式都支持。AT 模式靠解析 SQL 自动生成反向补偿,接入简单,但限制不少(要求表有主键、不支持复杂 SQL、全局锁有性能影响),用之前先搞清楚这些限制。

幂等设计

为什么必需

在分布式系统里,重复请求是常态而非异常:

  • 客户端网络超时后重试(超时不代表失败,请求可能已经执行成功)
  • MQ 的 at-least-once 语义导致重复投递
  • 用户手抖重复点击
  • 网关 / 负载均衡层的自动重试

只要有网络,就有“我不知道对方到底执行没执行”的问题。幂等是解决它的唯一办法。

五种实现

方式 做法 评价
唯一索引 业务唯一键建唯一约束,重复插入直接失败 最可靠,由数据库保证,无并发窗口
状态机 + 乐观锁 UPDATE ... WHERE id=? AND status='pending',看影响行数 适合状态流转,天然防止非法流转
幂等 token 客户端携带 UUID,服务端 SET token NX EX 校验 通用,但依赖 Redis 可用性
去重表 单独一张表记录已处理的请求 ID 和唯一索引本质一样,只是与业务表解耦
天然幂等 覆盖式写入(SET status = 'paid' 而非 count = count + 1) 能设计成这样最好

订单接口实例

以「提交订阅订单」接口为例:

1. 客户端生成 requestID(UUID)
   每次"点击购买"生成一次,重试时必须复用同一个

2. 服务端直接插入,不要先查再插:
   INSERT INTO orders (request_id, user_id, sku, status, ...)
   VALUES (?, ?, ?, 'pending', ...)
   -- request_id 上建唯一索引

3. 如果 INSERT 冲突(duplicate key):
   说明是重复请求,查出已有订单,返回它的当前状态(而不是报错)

4. 支付回调用 transaction_id 做第二层幂等

5. 状态流转用乐观锁:
   UPDATE orders SET status='paid' WHERE id=? AND status='pending'

五个细节

这几点决定了“是不是真做过”:

  1. 重复请求要返回和首次相同的成功结果,而不是报错。 客户端重试时收到“订单已存在”的错误,会以为下单失败,然后换个 requestID 重下,幂等等于没做,还会重复下单。幂等的目标是让重试变得安全,不是让重试报错。
  2. 幂等键要对同一次操作保持稳定。 对客户端发起的请求,由客户端生成并在所有重试中复用;服务端每次生成新 ID,重试时两次请求的 ID 不同,幂等就失去了意义。支付回调这类场景则用平台给的 transaction_id 等业务天然键。
  3. 幂等键要有合理的有效期。 太短会失效(用户网络差,断断续续重试了 10 分钟),太长占存储。订单类建议至少 24 小时。
  4. 绝对不要“先 SELECT 再 INSERT”。 中间有并发窗口,两个请求可能都查到“不存在”然后都插入。把并发控制交给数据库的唯一索引,让其中一个失败,这正是我们要的。
  5. 分库分表会让唯一索引失效。 唯一索引只在单个分片内有效。要么把 requestID 作为分片键的一部分,要么用 Redis 做全局去重。这个坑很隐蔽,往往上线后才爆。

幂等与并发控制

两个常被混淆的概念:

  • 幂等解决”同一个请求执行多次,结果应该一样”。
  • 并发控制解决”不同请求同时修改同一份数据”。

两者都要有,不能互相替代。做了幂等,两个不同用户同时抢最后一件库存照样超卖。

做成中间件

更成熟的做法是把幂等沉淀成基础设施,比如一个 Gin 中间件,自动读取 header 里的 Idempotency-Key,命中则直接返回缓存的首次响应,业务代码完全无感知。Stripe 的 API 就是这么设计的,Idempotency-Key 已经是支付类 API 的业界惯例。

分布式 ID

Snowflake 结构

| 1 bit 符号位(恒0) | 41 bit 时间戳(毫秒) | 10 bit 机器ID | 12 bit 序列号 |
  • 41 bit 毫秒 ≈ 69 年(从自定义纪元起算,不是 1970)
  • 10 bit 机器 ID = 1024 个节点(常拆成 5 bit 机房 + 5 bit 机器)
  • 12 bit 序列号 = 单节点每毫秒 4096 个 ID,约 400 万 / 秒

优点:本地生成无网络开销、趋势递增(对 B+ 树聚簇索引友好)、可以反解出生成时间。

三个风险

  1. 强依赖时钟。 NTP 校时、虚拟机快照恢复都可能导致时钟回拨,生成重复 ID。
  2. 机器 ID 需要外部协调。 ZooKeeper、etcd、配置中心、K8s StatefulSet 的有序序号都可以,但必须有。
  3. ID 可被推测。 连续下两单就能算出日订单量,对外暴露是信息泄漏。对外应另外生成不可推测的短码做映射。

时钟回拨

回拨幅度 处理策略
很小(几十毫秒内) 等待时钟追上来再生成,阻塞一小会儿
较大 拒绝服务并告警(宁可不可用,也不能生成重复 ID),或切换到备用 workerID(准备一组 ID 轮换,回拨时换一个,保证 ID 空间不重叠)
根本预防 关闭 NTP 的步进式校时,改用 slew 模式渐进调整;同时监控时钟偏移

美团 Leaf 的做法值得一提:启动时向 ZK 上报当前时间戳,并检查是否小于上次记录的值,小于则直接拒绝启动。把问题挡在启动阶段,而不是运行时。

其他方案

方案 优点 缺点
数据库号段(segment) 不依赖时钟、DB 压力小、可双 buffer 预取避免取号毛刺 ID 不连续(重启丢一段)、依赖 DB 可用性
Redis INCR 简单 引入对 Redis 的强依赖,要处理持久化丢失
UUID v4 完全无依赖 随机无序,做 InnoDB 主键会导致大量随机写和页分裂,写入性能明显下降(幅度取决于数据量与 buffer pool);128 位占空间
UUID v7 / ULID 前 48 bit 是毫秒时间戳 + 后面随机,既全局唯一又趋势递增,无需任何协调 仍是 128 位,比 int64 占空间

美团 Leaf、滴滴 TinyID 走的都是号段路线,实践中很稳。UUID v7 是现代推荐方案,兼顾了 UUID 的零协调和 Snowflake 的有序性。Go 里可以用 github.com/google/uuid(v1.6.0 起支持 v7)或 oklog/ulid。

选型建议

  • 需要有序且量大:Snowflake 或号段。
  • 不想维护额外基础设施:UUID v7。
  • 对外暴露的 ID:内部用上面任意一种,对外另外生成不可推测的短码做映射。
  • 多区域部署:机器 ID 段按区域预分配避免冲突,或直接用 UUID v7 免去协调。

最后一条硬性要求:ID 作为 MySQL 主键时必须趋势递增,否则 B+ 树聚簇索引会频繁页分裂。UUID v4 做主键是经典错误,量大之后写入性能断崖式下跌。

订阅支付落地

前面都是零件,这一章把它们装起来。场景:海外影视 APP,用户在 App Store 支付 → 服务端验证 → 开通会员 → 发放权益 → 记录流水 → 通知用户。

一致性切分

这是整个设计的起点:

必须强一致(放在同一个本地事务里):

  • 创建订单
  • 更新会员状态
  • 记录资金流水

这三件事必须原子,有订单没会员、或有会员没流水,都是生产事故。它们本来就在同一个库里,一个本地事务就够了,不需要任何分布式事务框架。

可以最终一致(异步):

  • 发放权益(赠送观影券等)
  • 发送推送通知
  • 更新用户画像
  • 数据上报

这些失败了可以重试,不影响核心状态。

完整流程

flowchart TD
    A[客户端完成支付并上报 transactionID] --> B[服务端调 App Store API 验证收据]
    B --> C{本地事务}
    C --> C1[INSERT 订单 transaction_id 唯一索引]
    C1 --> C2[UPDATE 会员状态 乐观锁]
    C2 --> C3[INSERT 资金流水]
    C3 --> C4[INSERT 本地消息表]
    C4 --> D[返回客户端成功]
    C4 -.-> E[任务或 CDC 投递 MQ]
    E -.-> F[下游幂等消费 权益 推送]

几个要点:

  • 必须服务端验证收据,绝不能信客户端传上来的结果。
  • 订单插入冲突说明已处理过,查出来直接返回成功。
  • 会员过期时间只能延长,不能缩短。

六类异常

这才是这个场景的重点:

  1. Apple 通知重复 / 乱序到达。 用 notificationUUID 做幂等;用 transaction_id + 状态机保证不会把已退款的订单改回已支付。状态机只允许合法流转。
  2. 通知丢失。 Apple / Google 的 Server Notification 不保证送达,必须有定时对账任务主动调 API 拉取订阅状态,和本地数据对比修复。这是海外订阅业务的必备组件,不是可选项。
  3. 客户端上报失败但支付已成功。 用户重启 APP 后 SDK 会重新提供未完成的交易,客户端要重新上报;服务端也能通过 Apple 的通知兜底。两条路径都要有。
  4. 自动续订。 每期续费都会发通知,要能正确延长会员期限:是幂等 + 期限累加,不是覆盖。重复收到同一期的续订通知不能加两次。
  5. 退款 / 取消。 收到退款通知要撤销权益。已消费部分怎么算是产品决策,技术上通常是立即终止会员资格。
  6. 跨区域。 用户可能在不同区域购买,价格和货币都不同。流水必须记录原始货币、金额、汇率、结算金额,否则财务对不上账。

对账体系

  • 每日订单对账:本地订单 vs Apple / Google 的交易报表,差异告警,能自动修复的自动修复。
  • 状态一致性检查:会员状态表 vs 订单表,扫描“有订单没会员”“有会员没订单”两类异常。
  • 金额对账:本地流水总额 vs 支付平台结算金额,这是财务要的。

为何不用 TCC

核心状态全在同一个库里,本地事务已经足够;异步部分用“消息表 + 幂等 + 对账”,是成本最低且最可靠的组合。引入分布式事务框架只会增加复杂度和新的故障点,还要额外维护一套协调器。克制是一种能力。

两条原则

支付类业务的第一原则:宁可慢,不可错。 所有异步路径都要有对账兜底,所有状态变更都要有审计日志。

审计轨迹尤其重要:会员状态的每一次变更(谁、什么时候、因为哪个订单)都要记录。这既是排查依据,也是客诉和退款纠纷时的证据,海外业务还涉及合规审计要求。另外,支付链路的发布要特别谨慎:新逻辑先对小比例用户灰度,且必须能快速回滚。

决策路径

把前面的内容压缩成一张决策图:

flowchart TD
    A[需要跨服务或跨库保证一致性] --> B{能不能不跨}
    B -->|能| C[调整服务边界 用本地事务]
    B -->|不能| D{接受最终一致吗}
    D -->|接受| E[本地消息表或 CDC 加幂等加对账]
    D -->|不接受| F{长流程且要求无锁吗}
    F -->|是| G[Saga 协调式 不可逆操作放最后]
    F -->|否| H[TCC 处理空回滚 幂等 悬挂]

无论选哪条路:幂等、对账、审计日志,一个都不能少。

常见误区

1. 把 CAP 说成“三选二”。 正确表述是:P 是必然存在的前提,真正的选择是分区发生时保 C 还是保 A。再补上 PACELC,说明你知道正常运行时也要在延迟和一致性之间权衡。

2. 一上来就套 TCC / Seata。 该展现的是判断力,不是方案储备量。顺序应该是:先问“能不能不用分布式事务”,再选“最终一致 + 幂等 + 对账”,TCC 是最后手段。上来就上重方案,反而暴露了没在生产环境踩过坑。

3. 幂等只会说“加个唯一索引”。 实战细节包括:重复请求要返回和首次相同的成功结果;幂等键要对同一次操作保持稳定;分库分表时唯一索引会失效;绝不能先 SELECT 再 INSERT。

延伸阅读

  • 《数据密集型应用系统设计》(DDIA):第 5 章复制、第 7 章事务、第 9 章一致性与共识,是本文大部分内容的理论源头。
  • Raft 论文与可视化动画:https://raft.github.io/,动画一定要自己拖着跑一遍。
  • Martin Kleppmann:How to do distributed locking:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html,Redlock 争议的经典文章。
  • Gilbert & Lynch:Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services(2002):CAP 的原始证明,两节点反证即出自此文。
  • Jim Gray & Andreas Reuter:《Transaction Processing: Concepts and Techniques》:ACID 的经典出处。
  • Spanner: Google’s Globally-Distributed Database(OSDI 2012):TrueTime 与 commit-wait 的原始论文。
  • Eric Brewer:CAP Twelve Years Later: How the “Rules” Have Changed:CAP 提出者本人对“三选二”的澄清。
  • Daniel Abadi:Consistency Tradeoffs in Modern Distributed Database System Design:PACELC 的出处。
  • App Store Server Notifications / Google Play RTDN 官方文档:做海外订阅业务绕不开,尤其要看“通知不保证送达”那几段。