一个用户被扣了两笔钱:关于幂等我踩过的坑
10 月 12 号下午,客服转来投诉:用户买了一双 899 的鞋,银行卡扣了两次款,订单表里却只有一条订单。
查下来是这样的:用户点"立即支付"时网络抖了一下,前端没收到响应,自动重试了一次。两次请求都打到了支付回调接口,回调里没有做幂等,于是扣款发生了两次。
我一开始觉得"加个 Redis 锁就行了",真动手才发现这事儿没那么简单。前后改了三版,把四种方案的取舍都过了一遍。
先明确什么叫幂等
数学上的定义是 f(f(x)) = f(x)。放到接口上:同一个请求执行一次和执行 N 次,对系统状态的影响是一样的。
要注意"同一个请求"这个前提,怎么定义"同一个"是设计的核心。我们的做法是由客户端生成一个幂等号(idempotent key),通常和业务主键绑定:
- 支付场景:订单号 + 支付流水号
- 下单场景:用户 ID + 前端生成的 requestId(UUID)
- 消息消费:MQ 的 msgId 或业务主键
另外要区分幂等和防重:查询、删除天然幂等(删一次和删多次结果一样);INSERT、金额累加、状态流转需要额外处理。我们这次出问题的就是金额扣减。
方案一:数据库唯一索引
最简单粗暴,也最可靠。建一张防重表,或者直接在业务表上加唯一索引。
CREATE TABLE payment_record (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
pay_no VARCHAR(64) NOT NULL COMMENT '支付流水号',
order_no VARCHAR(64) NOT NULL COMMENT '订单号',
amount DECIMAL(12,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_pay_no (pay_no) -- 关键在这一行
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
代码里直接 insert,靠数据库的唯一约束挡住重复:
@Transactional(rollbackFor = Exception.class)
public PayResult pay(PayRequest req) {
PaymentRecord record = buildRecord(req);
try {
paymentRecordMapper.insert(record); // 唯一的插入点
} catch (DuplicateKeyException e) {
// 已经处理过,直接查出来返回
log.warn("duplicate payment, payNo={}", req.getPayNo());
PaymentRecord exist = paymentRecordMapper.selectByPayNo(req.getPayNo());
return PayResult.success(exist.getPayNo());
}
// 走到这里才是第一次处理,执行真正的扣款
boolean ok = bankClient.deduct(req);
if (!ok) {
throw new BizException("扣款失败"); // 抛异常让事务回滚,记录一并回滚
}
paymentRecordMapper.updateStatus(record.getId(), PAID);
return PayResult.success(req.getPayNo());
}
这个方案的可靠性来自数据库,不依赖任何中间件,是我们最终采用的主方案。
几个必须注意的点:
- catch 的一定是
DuplicateKeyException,不要 catchException。否则扣款失败、字段超长这些异常也被当成"重复请求"吞掉,问题会被掩盖。 - insert 和后续的业务操作必须在同一个事务里。如果扣款失败抛异常,那条防重记录要跟着回滚,否则下次重试会被误判成重复。
- MySQL 8.0 的
INSERT ... ON DUPLICATE KEY UPDATE也能做,但它有个副作用:即使没有实际更新,affectedRows也可能返回 1 或 2(取决于有没有变化),判断"是不是第一次插入"要小心。我更喜欢直接 insert 然后 catch 异常,语义更清晰。
缺点是:唯一索引会让写入多一次唯一性检查,我们压测下来单表插入 QPS 从 8400 降到 7100,降了 15%。另外分库分表之后,唯一索引只能保证单库单表唯一,跨库的幂等要另想办法(比如用 gen 出来的全局 ID 做路由,保证同一个 key 落到同一个分片)。
方案二:Token 机制
适合前端能配合的写操作,比如下单、提交表单。流程是两段式:
- 进入页面时,前端先调
/token/apply拿一个 token,服务端存进 Redis 并设过期时间 - 提交表单时带上这个 token,服务端校验并删除,删除成功才处理业务
// 第一步:申请
@GetMapping("/token/apply")
public String applyToken() {
String token = UUID.randomUUID().toString().replace("-", "");
String key = "idem:token:" + token;
redisTemplate.opsForValue().set(key, "1", 5, TimeUnit.MINUTES);
return token;
}
第二步的校验必须用原子操作。我第一版写成"先 GET 再 DEL",压测时 100 并发下放过去了 7 个请求:两个线程同时 GET 到 token 存在,都去删,都删成功。
// 错误写法:GET 和 DEL 之间有窗口
if (redisTemplate.hasKey(key)) {
redisTemplate.delete(key);
// 并发下多个线程都能走到这里
}
正确做法是用 Lua 脚本,把"判断 + 删除"变成一次原子操作:
private static final String CONSUME_TOKEN_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
private final RedisScript<Long> consumeTokenScript =
RedisScript.of(CONSUME_TOKEN_LUA, Long.class);
public boolean consumeToken(String token) {
String key = "idem:token:" + token;
Long result = redisTemplate.execute(consumeTokenScript,
Collections.singletonList(key), "1");
return Long.valueOf(1).equals(result); // 返回 1 表示删除成功,是首次请求
}
业务代码里这么用:
@PostMapping("/order/create")
public Result createOrder(@RequestHeader("Idempotent-Token") String token,
@RequestBody OrderRequest req) {
if (!consumeToken(token)) {
// 拿不到 token:要么用过了,要么过期了
return Result.fail("请勿重复提交");
}
return orderService.create(req);
}
这个方案的缺点是强依赖前端配合。如果调用方是别的系统(我们这次的支付回调就是银行调的),根本没法要求对方先申请 token。而且 Redis 挂了或者 token 过期,业务就走不通了,需要降级。
方案三:状态机 + 乐观锁
这是我个人最喜欢的方案,因为它把幂等和业务状态绑定在一起,不需要额外的存储。
核心是那句带条件的 UPDATE:
<!-- OrderMapper.xml -->
<update id="updateStatusWithCheck">
UPDATE orders
SET status = #{targetStatus},
update_time = NOW()
WHERE order_no = #{orderNo}
AND status = #{expectStatus}
</update>
@Transactional(rollbackFor = Exception.class)
public void payCallback(String orderNo, String payNo) {
// CAS 式更新:只有当前状态是 WAIT_PAY 才更新成 PAID
int rows = orderMapper.updateStatusWithCheck(orderNo,
OrderStatus.WAIT_PAY.getCode(), OrderStatus.PAID.getCode());
if (rows == 0) {
// 没更新到:要么订单不存在,要么已经支付过了
Order order = orderMapper.selectByOrderNo(orderNo);
if (order == null) {
throw new BizException("订单不存在");
}
log.info("order already handled, orderNo={}, status={}",
orderNo, order.getStatus());
return; // 幂等返回成功,让 MQ 别再重试
}
// rows == 1,说明是我把状态改掉的,执行后续逻辑
stockService.deduct(orderNo);
couponService.markUsed(orderNo);
notifyService.sendPaidMsg(orderNo);
}
MySQL 的 UPDATE 本身是加锁的(当前读 + 行锁),所以并发的多个 UPDATE 会串行执行,只有第一个能匹配到 status = WAIT_PAY,后面的 rows 都是 0。天然幂等,零额外成本。
状态机的定义要写清楚,我们在 OrderStatus 枚举里维护了流转规则:
public enum OrderStatus {
WAIT_PAY(1, "待支付"),
PAID(2, "已支付"),
SHIPPED(3, "已发货"),
FINISHED(4, "已完成"),
CANCELED(9, "已取消");
// 允许的流转路径
private static final Map<Integer, Set<Integer>> TRANSITIONS = new HashMap<>();
static {
TRANSITIONS.put(1, Sets.newHashSet(2, 9)); // 待支付 → 已支付 / 已取消
TRANSITIONS.put(2, Sets.newHashSet(3, 9)); // 已支付 → 已发货 / 已取消
TRANSITIONS.put(3, Sets.newHashSet(4)); // 已发货 → 已完成
TRANSITIONS.put(4, Sets.newHashSet());
TRANSITIONS.put(9, Sets.newHashSet());
}
public static boolean canTransfer(int from, int to) {
return TRANSITIONS.getOrDefault(from, Collections.emptySet()).contains(to);
}
}
在 update 之前先校验一次,非法的流转直接拒绝并告警,不用打到数据库。
这个方案的局限:只适用于有状态的实体,且状态是单向流转的。像"给用户加积分"这种累加操作,没有状态可判断,就不适用。而且如果状态需要回滚(比如已支付退回待支付),CAS 就失效了,得配合流水表。
方案四:分布式锁
这是最"通用"也最容易写错的方案。我们的第一版就是它,后来被我换掉了。
public PayResult payWithLock(PayRequest req) {
String lockKey = "idem:pay:" + req.getPayNo();
String requestId = UUID.randomUUID().toString();
try {
// SET key value NX PX 30000,一条命令完成加锁和设过期时间
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(locked)) {
return PayResult.fail("操作正在处理中,请勿重复提交");
}
// 执行业务
return doPay(req);
} finally {
// 只能删自己加的锁,所以要比对 value
String releaseLua =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(RedisScript.of(releaseLua, Long.class),
Collections.singletonList(lockKey), requestId);
}
}
三个必须遵守的规则:
- 加锁和设过期时间必须是一条命令。
SETNX之后再EXPIRE,中间进程崩溃就是死锁。Spring Data Redis 2.1 的setIfAbsent(key, value, timeout, unit)就是封装好的一条命令。 - value 必须是唯一值,释放时比对。否则可能删掉别人的锁(A 的锁超时了,B 加锁成功,A 醒过来把 B 的锁删了)。
- 过期时间要大于业务最大耗时。我们压测时支付最长耗时 3.2 秒,锁设 30 秒,留了 10 倍余量。
但我最终没用分布式锁做幂等,原因是它有两个解不掉的问题:
一、锁只能挡住并发,挡不住先后。第一次请求处理完了、锁释放了,第二次相同请求进来,锁是空的,它会正常执行一遍。分布式锁保证的是"同一时刻只有一个线程处理",不是"这个请求只处理一次"。要做幂等还得配合其他方案。
二、锁过期时间是个两难题。设短了,业务没跑完锁就失效,别人趁虚而入;设长了,客户端要等很久才拿到"操作处理中"的返回,体验差。我们压测时试过 5 秒,结果 P99 有 1.2% 的请求因为业务超时(下游抖动到 6 秒)被穿透,出现了 3 笔重复。
所以分布式锁的正确定位是:防并发,不防重复。它适合用在"不允许同时操作"的场景(比如防止库存超卖的瞬时并发),而不是幂等。
四种方案对比
| 方案 | 可靠性 | 性能开销 | 依赖 | 适用场景 | 主要缺点 |
|---|---|---|---|---|---|
| 唯一索引 | 最高 | 写 QPS -15% | 数据库 | 支付、下单等所有写操作 | 分库分表后只能保证单表唯一 |
| Token 机制 | 中 | 一次 Redis 往返(约 0.4 ms) | Redis + 前端配合 | 表单提交、防止重复点击 | 第三方回调场景用不了 |
| 状态机乐观锁 | 高 | 零额外开销 | 无 | 有单向状态流转的实体 | 累加类操作不适用 |
| 分布式锁 | 低(只防并发) | 一次 Redis 往返 | Redis | 防瞬时并发,如库存超卖 | 挡不住先后重复;过期时间难定 |
我们最终的方案
按场景分开用,不是一刀切:
- 支付回调(第三方调用,必须可靠):唯一索引 + 状态机双保险。先 insert 防重表(唯一索引兜底),再 CAS 更新订单状态(状态机)。两条防线,任意一条生效就幂等。
- 下单接口(前端可控):Token 机制 + 唯一索引。Token 挡住用户重复点击(体验好,能返回"请勿重复提交"),唯一索引保证极端情况下也不出错。
- 订单状态流转:纯状态机,不额外加东西。
- 库存扣减:分布式锁(防并发超卖)+ 数据库层面的
UPDATE stock SET num = num - 1 WHERE num > 0(防超卖兜底)。
上线三个月,重复扣款从每月 3 到 5 笔降到 0。压测数据(100 并发重复请求同一个支付流水号 1000 次):
| 方案 | 实际扣款次数 | 平均耗时 | P99 |
|---|---|---|---|
| 修复前(无幂等) | 847 | 312 ms | 1,840 ms |
| 仅分布式锁 | 12 | 318 ms | 1,910 ms |
| 唯一索引 + 状态机 | 1 | 324 ms | 1,960 ms |
性能几乎没损失(多一次 insert,+12 毫秒),因为那次插入的耗时远小于银行接口的调用。
先到这
《接口幂等性设计的几种方案》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。