赵杰 · zhaojie.tech
一次联调,渠道把同一笔支付通知发了三遍(应答超时触发重试),我的服务第一遍记了账、第二三遍被 Redis 分布式锁挡住了。看起来防线工作正常——直到有人问:如果服务在持锁期间重启呢?如果 Redis 主从切换、锁丢了呢?
我答不上来。那一刻我明白:基于”先检查再执行”的幂等,不管检查做得多重,都只是概率上的挡,不是裁决。 幂等需要一道崩不掉的门,而数据库的唯一索引就是这道门。
三段式幂等键:维度怎么选
门要拦得准,先把”什么算重复”定义清楚。我用的是三段式幂等键:
渠道编码 : 业务单号 : 事件类型
SQB:7894002680123456789:PAYMENT_RECEIVED
三个维度各有存在的理由:
- 渠道编码:不同渠道的单号空间互不相干,微信的交易号和收钱吧的交易号撞在一起不是重复,不加渠道段就会误拦;
- 业务单号:用渠道侧的流水号而不是我方单号——重复的判定基准必须出自”重复的来源方”,渠道说这是同一笔,它才是同一笔;
- 事件类型:同一张订单会先后产生
ORDER_CREATED、ORDER_PAID两种事件,共用单号。只按单号拦,支付事件会被创建事件的占位吞掉。多租户再加一层:唯一索引是uk_idem(tenant_id, idempotent_key),租户隔离进键的一部分。
还有一个容易漏的维度:版本。商品同步事件的键是 goodsId#version——同一商品不同版本是不同事件,不加版本段,第二次改价会被第一次的占位拦掉。选维度的原则就一条:把”天然不同”的每一维都放进键里,剩下的才交给索引去判重。
实现:让 DuplicateKeyException 说人话
占位逻辑收敛在一个方法里(独立事务提交的细节上一篇写过,此处不展开):
try {
return save(entity); // INSERT,撞 uk_idem
} catch (DuplicateKeyException dke) {
log.debug("[ChannelIdempotent] duplicate skipped: key={}", idempotentKey);
return false;
}
值得说的是异常翻译。DuplicateKeyException 如果原样抛到上层,用户看到的是 500;回调方看到 500,会按失败处理、更猛烈地重试。它应该被翻译成业务语义:重复的券码核销请求返回”券码已被核销”,重复的支付通知直接应答成功让渠道收兵。技术异常和业务结论之间隔着一层翻译,这层翻译就是接口友好度的全部。我的回调入口拿到 false 后返回 R.ok(),就是明确告诉渠道:“这笔我处理过了,别再发了。”
另外两个存储层细节:捕获要收窄到 DuplicateKeyException,别的异常按”占位失败”处理(让消息重投,宁可慢不可错);InnoDB 并发插同一键值时后到者在 S 锁上排队,与插入意向锁成环可能偶发死锁,占位路径要能承受死锁重试。
和 Redis setnx 的分工:效率层 vs 正确性层
“那要 Redis 干嘛?“——分工不同:
| Redis setnx | 唯一索引 | |
|---|---|---|
| 层次 | 效率层 | 正确性层 |
| 失败模式 | 锁超时误放、主从切换丢锁、持锁进程重启 | 除非数据库本身不可用 |
| 代价 | 挡住大部分并发,减少无效请求 | 少量 1062 冲突要捕获翻译 |
只靠锁:锁一丢,重复请求长驱直入,且没有任何事后证据。只靠索引:每个重复请求都要打穿到数据库、消耗一次异常,高峰期无谓的损耗。组合拳是锁在前做第一道闸、唯一索引兜底裁决:锁挡量,索引定性。丢了锁,最坏结果是一次 DuplicateKeyException 被干净地翻译掉;数据库里的占位行本身就是处理凭证,事后审计有据可查。
一句收尾
应用层的每一道幂等检查都可以失效,唯一索引不能——把最终裁决放在最后写入的那张表上,让它连崩溃都拦得住重复。