分布式理论与事务是后端面试的高频话题,但常见的回答很浅: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 之争。严格说,退让不是从分布式才开始的,单机时代就已经在退让。
分布式下的困境
上分布式之后,三件事同时变了:
- 数据不在一台机器上:原子性要跨节点达成,只能依赖 2PC 这类原子提交协议,于是引入协调者单点和同步阻塞。
- 隔离性需要全局视角:要么加全局锁(吞吐崩掉),要么建立全局时序(需要可信的全局时钟,而普通服务器的时钟并不可信)。
- 网络会分区,节点会宕机:单机上“要么成功、要么失败”的二元世界,变成了“成功 / 失败 / 不知道“的三元世界。
第三点是分布式系统一切复杂度的真正源头:
超时不等于失败。 请求超时了,对方可能根本没收到,也可能已经执行成功,只是响应丢了。
这一条直接催生了后面所有的幂等设计。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 等于宣布系统不允许出现网络分区,唯一的做法是退回单机。准确的表述分三层:
- 定理层面:C、A、P 三者不可兼得,严格成立。
- 工程层面:P 由环境决定,你实际能选的只有 CP 或 AP。
- 时间层面:这个选择只在分区期间生效,其余时间的取舍要用 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。
- Follower 在 election timeout(论文建议随机 150–300ms)内没收到 Leader 心跳,转为 Candidate。
- Term + 1,给自己投一票,并行向其他节点发
RequestVote。 - 拿到**多数派(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
三个致命问题:
- 同步阻塞:从 prepare 到 commit 期间,所有参与者的资源锁一直持有。协调者慢一点,整条链路的锁都拖着。
- 协调者单点:协调者在阶段二挂掉,参与者会一直阻塞在“已 prepare、不知道该 commit 还是 rollback”的状态。
- 数据不一致: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 表,只是不用自己写扫描投递;也可以直接订阅业务表的变更,但会把下游和表结构耦合起来。
选型原则
这是真正考察判断力的地方,优先级应该是:
- 先问:能不能不用? 强关联的数据为什么会被拆到两个库?很多时候分布式事务是服务边界划错了的症状。把它们放回同一个服务同一个库,问题就消失了。
- 其次:最终一致 + 幂等 + 对账。 本地消息表 / CDC,成本最低,可靠性最高。
- 最后: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'
五个细节
这几点决定了“是不是真做过”:
- 重复请求要返回和首次相同的成功结果,而不是报错。 客户端重试时收到“订单已存在”的错误,会以为下单失败,然后换个 requestID 重下,幂等等于没做,还会重复下单。幂等的目标是让重试变得安全,不是让重试报错。
- 幂等键要对同一次操作保持稳定。 对客户端发起的请求,由客户端生成并在所有重试中复用;服务端每次生成新 ID,重试时两次请求的 ID 不同,幂等就失去了意义。支付回调这类场景则用平台给的
transaction_id等业务天然键。 - 幂等键要有合理的有效期。 太短会失效(用户网络差,断断续续重试了 10 分钟),太长占存储。订单类建议至少 24 小时。
- 绝对不要“先 SELECT 再 INSERT”。 中间有并发窗口,两个请求可能都查到“不存在”然后都插入。把并发控制交给数据库的唯一索引,让其中一个失败,这正是我们要的。
- 分库分表会让唯一索引失效。 唯一索引只在单个分片内有效。要么把 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+ 树聚簇索引友好)、可以反解出生成时间。
三个风险
- 强依赖时钟。 NTP 校时、虚拟机快照恢复都可能导致时钟回拨,生成重复 ID。
- 机器 ID 需要外部协调。 ZooKeeper、etcd、配置中心、K8s StatefulSet 的有序序号都可以,但必须有。
- 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[下游幂等消费 权益 推送]
几个要点:
- 必须服务端验证收据,绝不能信客户端传上来的结果。
- 订单插入冲突说明已处理过,查出来直接返回成功。
- 会员过期时间只能延长,不能缩短。
六类异常
这才是这个场景的重点:
- Apple 通知重复 / 乱序到达。 用
notificationUUID做幂等;用transaction_id+ 状态机保证不会把已退款的订单改回已支付。状态机只允许合法流转。 - 通知丢失。 Apple / Google 的 Server Notification 不保证送达,必须有定时对账任务主动调 API 拉取订阅状态,和本地数据对比修复。这是海外订阅业务的必备组件,不是可选项。
- 客户端上报失败但支付已成功。 用户重启 APP 后 SDK 会重新提供未完成的交易,客户端要重新上报;服务端也能通过 Apple 的通知兜底。两条路径都要有。
- 自动续订。 每期续费都会发通知,要能正确延长会员期限:是幂等 + 期限累加,不是覆盖。重复收到同一期的续订通知不能加两次。
- 退款 / 取消。 收到退款通知要撤销权益。已消费部分怎么算是产品决策,技术上通常是立即终止会员资格。
- 跨区域。 用户可能在不同区域购买,价格和货币都不同。流水必须记录原始货币、金额、汇率、结算金额,否则财务对不上账。
对账体系
- 每日订单对账:本地订单 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 官方文档:做海外订阅业务绕不开,尤其要看“通知不保证送达”那几段。