对账巡检那天,同一笔渠道交易在账面上出现了两次。
不是必然事故,只是一次擦肩:渠道回调重试撞上了一次不巧的时序。但支付链路的铁律是——回调一定会重试,网络一定会抖,你的服务一定会重启。重复通知不是异常场景,是常态输入。
背景约束
我们的 POS 收银聚合了微信、支付宝、收钱吧三个渠道,记账发生在回调处理里:收到支付成功通知 → 标记渠道订单 PAID → 走结算链落账。多实例部署,所以任何进程内状态都不可信;Redis 是加速件,不是账本。
方案推演:为什么不用锁
第一版思路通常是 Redis SET NX 分布式锁。问题是锁会失效:锁过期了、Redis 抖了一下、客户端 GC 停顿了一瞬——锁一丢,第二个回调就进来了。进程内锁更不必说,多实例下形同虚设。也有人做”先查是否已处理,没处理再记账”——check-then-act 在两个请求同时到达时必然竞态。
想清楚一件事就不纠结了:锁只能提高效率(挡掉大多数重复),保证不了正确性(漏一个就是资损)。真正不可欺骗的裁判只有一个——数据库唯一索引。两个事务不可能同时插入同一把唯一键,这是存储引擎用锁保证的物理事实。
实现
第一步,幂等键的设计。三段式:渠道编码 + 渠道交易号 + 事件类型:
public static String build(String channelCode, String bizNo, String eventSuffix) {
return channelCode + ":" + bizNo + ":" + eventSuffix;
}
// 例:SQB:7894002680123456789:PAYMENT_RECEIVED
第三段不可省。同一个渠道单号会伴随多种事件(创建、支付、退款),只用”渠道:单号”两段,支付事件会把退款事件的首次处理误吞掉——不同事件类型必须互不干扰。
第二步,存储层落点。幂等表一个唯一索引说话:
CREATE TABLE ss_channel_idempotent (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
idempotent_key VARCHAR(191) NOT NULL,
event_type VARCHAR(64) NOT NULL,
tenant_id BIGINT NOT NULL,
...
UNIQUE KEY uk_idem (tenant_id, idempotent_key)
) ENGINE=InnoDB;
第三步,占位事务必须独立。tryAcquire 用 REQUIRES_NEW 把占位行的 INSERT 提交在业务事务之外:
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW)
public boolean tryAcquire(Long tenantId, String idempotentKey, String eventType) {
ChannelIdempotentEntity entity = new ChannelIdempotentEntity();
entity.setIdempotentKey(idempotentKey);
entity.setEventType(eventType);
entity.setHandledDate(new Date());
try {
return save(entity); // 占位先行提交
} catch (DuplicateKeyException dke) {
return false; // 翻译成业务语义:已处理,幂等命中
}
}
这里的传播行为是整套设计的灵魂:业务事务回滚,不释放占位。回调方拿到 false 就直接按”已消费”处理,返回成功应答;万一业务处理到一半崩了,渠道重发的下一次回调会再次撞上唯一索引——宁可让对账去追一笔”渠道成功、我方未记账”的孤儿单(可补),绝不放行第二次记账(资损)。两个方向的代价不对称,设计就要偏向代价小的那一边。
踩坑
一是退款的窗口变体。退款预受理时渠道已经受理了退款请求,如果幂等键跟着主事务回滚,“渠道退了、我方没记录”的重复退款窗口就打开了——退款预登记同样要独立事务先行提交,与回调幂等是同一个原则的两个落点。二是异常语义要保守:tryAcquire 只把 DuplicateKeyException 翻译成 false(已占位);其余异常原样抛出,让本次处理失败、由 MQ / 渠道重投兜底——没有占位成功就是没有处理过,绝不吞成”已消费”(返回 false 却 ack 消息,等于把重投的路也堵死了)。
一句原则
锁是效率层,唯一索引是正确性层;幂等设计要把裁决权交给存储层——占位事务独立于业务事务提交,让 DuplicateKeyException 变成一句业务语义,而不是一个 500。