每篇都出现的两个字
回头数一数前九篇:本地消息表要「消费幂等」,事务消息要「消费幂等」,TCC 的 Confirm/Cancel 要幂等,Saga 补偿要幂等,最大努力通知要防重复处理——幂等是分布式事务的地基,地基不牢,上面盖什么都塌。为什么重复如此普遍?MQ 至少一次投递、框架超时重试、用户双击提交、网关重发请求——在分布式世界,「只执行一次」是要花大价钱构造的幻觉,「至少一次 + 幂等消费」才是便宜又可靠的组合。
先分清天然幂等与天然不幂等
幂等的定义不绕弯:同一个操作执行一次和执行多次,效果相同。按这个标准给写操作分两类:天然幂等——把状态设为固定值(status 改成 PAID)、按 ID 删除、把余额更新为指定值;天然不幂等——insert 一条新记录(重复插两行)、相对值变更(余额减 100,执行两遍减 200)、发送一条新消息。设计幂等的功夫,就是把不幂等的写法改造成幂等的写法,五大武器各有战场。
武器一:唯一索引,最后的墙
给业务唯一键(订单号、支付流水号)建唯一索引,重复插入直接报错——这是数据库层面最后的防线。它的珍贵之处在于原子性:应用层的一切「先查后写」都有竞态窗口(两个请求同时查「没处理过」,然后都去处理),唯一索引的插入冲突是数据库在锁层面保证的,没有竞态。凡是能找到业务唯一键的场景,无脑上唯一索引。
武器二:去重表,消费端标配
没有天然唯一键的操作(比如「给用户加积分」),建一张去重表,以消息 ID 或业务键为主键:消费前先插去重记录,插入成功才执行业务,插入冲突说明处理过,直接幂等返回。进阶版把去重记录和业务变更放进同一个本地事务——业务做了,去重记录在;业务没做,去重记录也不在,天然避免「标记了没做」的半提交。
武器三:状态机,防乱序防重复一箭双雕
业务对象的一生是状态的流转:待支付 → 已支付 → 已发货 → 已完成,流转规则写死:只有当前状态匹配才允许流转。update orders set status=PAID where id=? and status=PENDING——重复的支付回调第二次执行时状态已不是 PENDING,影响行数为零,天然被拒。状态机还能防乱序(发货请求先于支付到达时被拒绝),是订单类业务的首选骨架。
武器四:Token 机制,防用户手抖
前端防重复提交的标准解:进入下单页先领一个一次性 token,提交订单时带上;服务端用 Redis 的原子删除(DEL 返回 1 才是抢到)校验——删成功说明是第一次提交,执行业务;删不到说明 token 已被用过,拒绝。适合没有业务唯一键的场景(用户可能真的要下两单,只能靠 token 区分「这一次提交」和「上一次提交」)。
武器五:乐观锁版本号
update account set balance=?, version=version+1 where id=? and version=?——带版本号的更新天然幂等(同一版本只能成功一次),顺带解决并发丢失更新。适合状态流转不明显的资源类数据(配置、库存快照),代价是冲突时需要重试逻辑。
选型一张表
| 武器 | 适用 | 局限 |
|---|---|---|
| 唯一索引 | 有业务唯一键的插入 | 要求键可定义 |
| 去重表 | MQ 消费、回调处理 | 多一张表的维护 |
| 状态机 | 订单等有生命周期的对象 | 无状态对象用不上 |
| Token | 前端防重复提交 | 依赖 Redis 可用性 |
| 乐观锁 | 资源类并发更新 | 冲突需重试 |
两条纪律
一:幂等键要用业务语义键。用「支付单号」做幂等键是对的;用随机生成的请求 ID,重试时生成个新的,幂等形同虚设。幂等键的选取和第 12 篇(分布式 ID)同理——它是另一个「十年合约」级别的决策。
二:别用先查后写冒充幂等。先 select 判断再 insert/update,两个并发请求都能通过检查——竞态窗口里没有幂等。检查逻辑可以有,但兜底必须是原子的:唯一索引、原子命令、带条件的 update。
小结
幂等篇一句话:重复是分布式的常态,五大武器按场景选择;唯一索引是最后的墙,业务语义键是幂等的魂,先查后写不是幂等。手握幂等这块压舱石,前面所有方案才算真正闭环。接下来进入框架篇——Seata 四模式一张图选型,看老牌开源框架怎么把前九篇的方案工程化。
评论 (0)