锁在工作,唯一索引在工作,订单状态也确实只流转了一次。券还是发了两遍,积分还是加了两遍。
一个五年后端在面试里被问"接口幂等怎么保证",答的是"唯一主键约束 + 前端传幂等号 + 分布式锁"------挑不出错的标准答案。面试官随后甩出一个真实支付事故:两次成功回调间隔几十毫秒,订单状态只更新一次,优惠券和积分各落了两遍,财务对不上账。
病根不在方案选型。是锁释放的时机早于事务提交,叠加下游副作用(发券、加积分)没有自己粒度的幂等键。幂等从来不是"在某处加把锁",它是一条从接入层贯穿到 DB 的时序契约。
这张图就是"幽灵"本尊。锁在生效,唯一索引在生效,状态也确实只翻转了一次,第二笔请求照样把两个副作用完整跑了一遍。
标准答案只覆盖了一种场景
面试官的问题是"接口幂等性怎么保证"。候选人的回答没错,但它只覆盖了并发同时到达这一种形态。线上真正跑出来的重复请求比这野得多:
- 第三方支付平台的重试推送,间隔几十毫秒到几秒------不是并发,是串行错位
- 网关层超时重放,客户端没收到响应就重发
- MQ 的 at-least-once 投递,同一条消息消费两次
面试官继续往下压:锁加了、唯一索引建了、订单状态也更新成功了,重复业务为什么还能执行?候选人猜"锁没生效"。这是最典型的错误归因方向------事故现场里,锁恰恰是生效的。
问题出在时序,不在组件。
根因:finally 里的 unlock 抢在 commit 前面
看这段代码。它几乎是每个人第一次写支付回调时都会写出来的形状:
java
// ❌ 锁在事务内部释放
@Transactional(rollbackFor = Exception.class)
public void onPaySuccess(PayCallbackDto dto) {
RLock lock = redisson.getLock("lock:order:" + dto.getOrderId());
lock.lock();
try {
Order order = orderMapper.selectById(dto.getOrderId());
if (order.getStatus() != OrderStatus.PENDING) {
return; // 检查点 1:内存态判断
}
orderMapper.updateStatus(dto.getOrderId(), OrderStatus.PAID); // 不关心 affected rows
couponService.issue(dto.getOrderId()); // 副作用 A
pointService.add(dto.getOrderId(), 100); // 副作用 B
} finally {
lock.unlock(); // ← 这里执行完,方法才返回,代理才去 commit
}
}
Spring 的事务代理包在方法外面:tx.begin() → 执行业务方法体(含 finally 块 )→ tx.commit()。所以 lock.unlock() 铁定发生在 commit 之前,中间空出一个几十毫秒的窗口。
第二笔回调钻的就是这个窗口:
- 拿到了已经释放的锁
selectById读到的是已提交数据 ------A 的事务还没提交,状态仍然是PENDING- 检查点 1 通过,进入业务逻辑
UPDATE ... WHERE status = PENDING被 A 持有的行锁挡住,等 A 提交后返回 0 行受影响- 代码没校验受影响行数,继续往下发券、加积分
订单状态只变了一次,因为第二步的 CAS 更新被行锁挡住了;副作用跑了两遍。这就是"订单状态更新成功了,可重复业务还是执行了"的完整解释。
修法有两处,缺一不可。
第一,把锁挪到事务外面,让提交先于解锁:
java
// ✅ 锁包住整个事务,提交完成后才释放
public void onPaySuccess(PayCallbackDto dto) {
RLock lock = redisson.getLock("lock:pay:" + dto.getPayNo());
lock.lock();
try {
transactionTemplate.execute(status -> doHandle(dto));
} finally {
lock.unlock();
}
}
private Boolean doHandle(PayCallbackDto dto) {
int affected = orderMapper.casToPaid(dto.getOrderId()); // UPDATE ... AND status = PENDING
if (affected == 0) {
log.info("订单已流转,忽略重复回调, payNo={}", dto.getPayNo());
return false;
}
couponService.issue(dto.getOrderId());
pointService.add(dto.getOrderId(), 100);
return true;
}
第二,状态流转必须用 CAS 并校验 affected rows,而不是先 select 再判断。
sql
UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE id = 1001
AND status = 'PENDING';
-- affected = 0 → 说明状态已经流转过,直接短路返回
select 后判断是"读-判断-写",中间有 gap;UPDATE ... WHERE status = 旧值 把判断下沉到数据库的行锁里,天然原子。两者在单机低并发下表现一模一样,在分布式并发下差一个资损。
第二个幽灵:幂等键的粒度对不上
时序修好了,还有一层问题:幂等只做在订单状态上,副作用各自没有幂等键。
订单状态用 order_id 做幂等,那发券呢?加积分呢?它们如果位于另一个事务、另一段代码,共用同一个幂等键,或者干脆没有幂等键------只要链路里任何一处被绕过,副作用就会重复落库。
正确做法是按业务动作拆开幂等键,每个副作用在自己的落库点建唯一约束:
| 业务动作 | 幂等键 | 落点 |
|---|---|---|
| 支付回调受理 | pay_no(支付平台流水号) |
pay_callback_log.uk_pay_no |
| 订单状态流转 | order_id + 状态机 CAS |
orders 表条件更新 |
| 发优惠券 | order_id + coupon_template_id |
user_coupon.uk_order_tpl |
| 加积分 | order_id + point_type |
point_log.uk_order_type |
sql
CREATE TABLE pay_callback_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
pay_no VARCHAR(64) NOT NULL COMMENT '支付平台流水号',
order_id BIGINT UNSIGNED NOT NULL,
raw_body JSON NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_pay_no (pay_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
锁的 key 也得跟着换:用 pay_no,不用 order_id。同一个订单可能先走微信支付失败、再走支付宝成功,两张流水号落在同一个 order_id 上。拿订单号当锁键,会把两笔彼此独立的支付事件错误地串行化。
状态机:把不可逆写进代码
订单状态流转必须有明确的合法边。前置状态对不上,直接拒绝,不依赖调用方传什么参数。
状态不可逆,是从根源上锁死重复执行最便宜的手段。代价是每加一个状态都得想清楚边从哪来、到哪去。支付链路上,这点设计成本远比资损便宜。
三层防御,不是三层装饰
视频里那个面试官最后总结的三层防御,我按自己的理解重画了一遍:
第一层:业务分级。 资金强相关的操作(支付、库存扣减)用 DB 唯一约束 + 状态机双重校验;非核心操作(消息通知、日志记录)允许短暂重复,靠消费端去重兜底。别给所有接口套同一个模板,它们的成本根本不是一个量级。
第二层:时序防护。 先加锁、再开事务、提交完再释放。这条规则说起来一句话,写错的人一抓一大把------因为 @Transactional 和业务代码挂在同一个方法上时,光看代码根本意识不到 finally 跑在 commit 前面。
第三层:架构层。 接入层先做一次幂等拦截,相同幂等号的请求在网关直接返回缓存结果,落不到业务层。大促场景开强校验模式,全链路强制走 DB 唯一约束兜底------宁可牺牲一点性能,也不允许资金类业务重复执行。
几个方案的边界,值得单独摆出来看:
| 方案 | 挡得住 | 挡不住 |
|---|---|---|
| 前端幂等号 + Redis SETNX | 用户重复点击、网关重放 | 第三方平台自己重推、Redis 击穿 |
| 分布式锁 | 并发同时到达 | 时间错位的重复请求、锁先于事务释放 |
| DB 唯一索引 | 已落库的重复记录 | 业务代码不写这条记录就白搭 |
| 状态机 CAS + 校验 affected rows | 状态已流转的重复请求 | 不依赖状态的副作用(发券/积分) |
没有银弹,所以是"三层",不是"一层"。
出事了:先止血,再找根因
面试官问"线上出现资损怎么快速止损和排查",候选人卡住了。这题的姿势是先止血,再定位,顺序反了,损失会持续扩大:
-
入口降级。 回调接口先切成"只落盘不处理",把
pay_no和原始报文写进pay_callback_log,业务处理转异步补偿。重复请求在这一步就被唯一索引挡住。 -
拉重复数据。 按业务维度 group 一遍,几分钟内就能圈定影响面:
sqlSELECT order_id, COUNT(*) AS cnt FROM user_coupon WHERE created_at >= '2025-08-14 00:00:00' GROUP BY order_id HAVING cnt > 1; -
补偿要留痕,不要 DELETE。 重复发放的券做冻结、积分做负数冲正,每条补偿都落审计日志。直接物理删除,T+1 对账会把你对到怀疑人生。
-
跟支付平台对账。 用
pay_no全量比对当日流水,确认没有漏单和多单。 -
上巡检。 定时核对核心业务的操作次数与订单数量,发现重复自动告警,而不是等财务来问。
写在最后
幂等不是"在某个方法上加把锁",而是一条从网关到 DB 的时序契约------锁必须在事务提交之后释放,每个副作用必须有自己粒度的幂等键,状态流转必须靠 CAS 的 affected rows 说话。
普通开发和高级工程师的分水岭就在这:前者以为加了唯一索引就搞定了幂等,后者知道在分布式并发下,一个几十毫秒的时序缝隙就能换来真金白银的资损。