一次重复扣款的复盘:分布式锁加错了维度,比不加锁更危险
先说明一下,这篇文章里的金额、时间、单号都做了脱敏,但技术细节和排查过程是真实的。
事故经过
去年年底,我们 ETC 项目的财务同事发现了一个问题:有用户在 12 月 6 号的晚上,同一笔通行费被扣了两次。
金额不大,18 块 5。但性质严重------这是扣用户的钱。
我当时的第一反应是"不可能",因为我们明明加了分布式锁。微信代扣那条链路我亲手写的,RLock 加得规规矩矩。
财务同事把两笔扣款流水截图发过来,我看了时间戳:
| 序号 | 扣款时间 | 金额 | 渠道单号 | 关联账单 |
|---|---|---|---|---|
| 1 | 2026-12-06 21:03:17.482 | ¥18.50 | 42000XXXXX8841 | BILL_20261206001 |
| 2 | 2026-12-06 21:03:17.511 | ¥18.50 | 42000XXXXX8843 | BILL_20261206001 |
同一张账单,29 毫秒内被扣了两次。
锁在 29 毫秒内就失效了?我开始查。
我们的业务模型
先交代背景,不然看不懂后面的锁。
ETC 代扣的场景是这样:用户的车跑了一段高速,产生了通行费,系统生成一条"待扣账单"(bill)。用户的微信绑定了免密代扣,系统在某个时间点发起扣款。
一个用户可能同时有多条待扣账单------比如这个月跑了好几趟。
我们的系统里有两个入口会触发扣款:
- 正常代扣:定时任务扫待扣账单,逐条发起扣款。
- 补扣:用户之前扣款失败(余额不足),系统在后续某个时间点重试。
这两个入口的代码在不同的类里,是两个人(或者说两个时间点)写的。
锁是怎么加的
先看正常代扣这条链路:
java
// WechatWithholdService
public void withholdBill(String billNo) {
Bill bill = billMapper.selectByNo(billNo);
// 按 wallet 加锁
RLock lock = redissonClient.getLock("lock:withhold:wallet:" + bill.getWalletId());
if (!lock.tryLock(3, 30, TimeUnit.SECONDS)) {
log.warn("获取锁失败,跳过 billNo={}", billNo);
return;
}
try {
doWithhold(bill);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
再看补扣这条链路:
java
// WithholdRetryService
public void retryWithhold(String billNo) {
Bill bill = billMapper.selectByNo(billNo);
// 按 bill 加锁
RLock lock = redissonClient.getLock("lock:withhold:bill:" + billNo);
if (!lock.tryLock(3, 30, TimeUnit.SECONDS)) {
log.warn("获取锁失败,跳过 billNo={}", billNo);
return;
}
try {
doWithhold(bill);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
看出来问题了吗。
正常代扣锁的是 wallet,补扣锁的是 bill。
lock:withhold:wallet:W123 和 lock:withhold:bill:BILL_20261206001 是两个完全不同的 key,彼此完全不互斥。
钱是怎么被扣两次的
把两条链路的时间线拼起来就很清楚了:
makefile
21:03:17.430 定时任务扫到 BILL_20261206001,进入补扣链路
21:03:17.435 补扣链路拿到锁 lock:withhold:bill:BILL_20261206001 ✅
21:03:17.440 补扣开始,调用微信代扣 API
21:03:17.460 另一个定时任务(正常代扣)也扫到了这条账单
21:03:17.470 正常代扣去拿锁 lock:withhold:wallet:W123 ✅ 拿到了!
(因为补扣持的是另一个 key,压根不冲突)
21:03:17.482 正常代扣也调用了微信代扣 API ← 第一次扣款
21:03:17.511 补扣的 API 调用返回,扣款成功 ← 第二次扣款
两条链路各自拿着自己的锁,都认为自己"独占"了这张账单。
这就是分布式锁最危险的一种失败模式:锁本身工作正常,但它保护的资源范围错了。
不加锁的话,你知道有风险,会去做幂等。加了锁而锁错了维度,你会以为自己是安全的,反而什么都不做。
为什么会出现两种锁
复盘的时候我去 git blame 看了一下。
正常代扣那条链路是最早写的,当时的需求是"同一个用户不要并发扣款"(因为要控制用户维度的并发),所以按 wallet 加锁。这个设计在当时是合理的。
补扣是半年后加的,需求是"同一条账单不要被补扣两次"(防止补扣任务重试导致重复),所以按 bill 加锁。这个设计单看也是合理的。
两个人的出发点都对,但谁都没有意识到,这两个入口会去操作同一份资源。
这是分布式锁最经典的坑:加锁的维度应该由"被竞争的共享资源"决定,而不是由"我在写这个功能时关心的东西"决定。
正常代扣关心的是用户维度,补扣关心的是账单维度。但真正被竞争的资源,是"这张账单的扣款状态"。两个入口都在改它。
所以正确的锁维度应该是账单------或者更准确地说,所有可能修改同一张账单状态的入口,必须用同一把锁。
修复
修改很简单,把补扣的锁也统一成 wallet(因为 wallet 是更粗的粒度,能覆盖住 bill 维度的并发):
java
// 两个入口统一
String lockKey = "lock:withhold:wallet:" + bill.getWalletId();
RLock lock = redissonClient.getLock(lockKey);
但我没有只改这一处。因为复盘的时候我又发现了一个隐患:如果以后再来第三个入口呢?
所以我做了三件事:
第一,把加锁逻辑抽成一个统一的方法,所有扣款入口必须走它。
java
public <T> T executeWithWalletLock(Long walletId, Supplier<T> action) {
RLock lock = redissonClient.getLock(LOCK_KEY_PREFIX + walletId);
boolean locked = false;
try {
locked = lock.tryLock(3, 30, TimeUnit.SECONDS);
if (!locked) {
throw new BizException("该钱包正在处理中,请稍后重试");
}
return action.get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BizException("获取锁被中断");
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
第二,把锁的 key 前缀定义成常量,禁止在业务代码里手写字符串。
这看起来是小事,但非常有价值。以前两个人各写各的字符串,谁都看不出冲突。现在只有一个常量,谁加新入口都能一眼看到"原来这里已经有锁了"。
第三,加了数据库层面的兜底。
这一点我觉得比锁本身更重要。锁只能减少并发,不能杜绝并发。 任何一个分布式锁方案都可能因为超时、网络分区、Redis 主从切换而"短暂失效"。真正兜底的,还是数据库。
我们在扣款流水表上加了唯一索引:
sql
ALTER TABLE t_withhold_record
ADD UNIQUE INDEX uk_bill_channel (bill_no, channel, deleted);
同一张账单 + 同一个渠道,只能有一条成功记录。(deleted 是为了兼容软删除。)
加了这个索引之后,即使锁失效,第二条扣款请求在写流水的时候也会直接冲突失败,然后回滚。
不过在"调用了微信 API 之后才写流水"这个顺序下,唯一索引只能保证"不重复写库",不能保证"不重复调 API"。所以更严谨的做法是先落一条"处理中"的记录再调 API:
java
// 1. 先插入处理中记录(唯一索引会拦住重复)
// 2. 调微信 API
// 3. 更新记录为成功/失败
这样就变成了一个"预占位"的模型。哪怕锁完全失效,第二条请求在第一步就会因为唯一索引冲突而失败,压根到不了调 API 那一步。
关于锁粒度的一些体会
这个事故之后我把分布式锁的粒度问题重新想了一遍,总结出几条:
1. 锁的对象是"共享资源",不是"业务概念"。
判断方法:问自己"这段代码在读写的那个东西,还有谁会动它?"把所有会动它的入口列出来,锁的 key 必须让这些入口互斥。
2. 粒度不是越细越好。
有人会说"我锁到 bill 级别性能更好"。对,但前提是没有其他入口用更粗的粒度碰它。粒度不一致的锁,等于没锁。
宁可粗一点(wallet),也不要出现两种粒度。
3. 加锁的代码要集中。
同一个资源的锁,只能有一个地方定义 key。散落在各处,早晚出事。
4. 锁不能作为唯一的防线。
分布式锁是"减少并发"的优化手段,不是"保证正确"的兜底。兜底永远在存储层------唯一索引、状态机、乐观锁。
5. 幂等和锁是两件事,都要做。
锁解决的是"同一时刻不要并发",幂等解决的是"重复执行一次也不要出问题"。这两个问题的答案完全不同,不能互相替代。
最后
那次事故之后,我们赔偿了用户的损失(虽然金额很小),并且把整个扣款链路重新做了一遍并发评审。评审的时候我提了一个问题,全员沉默:
"如果现在再让你加一个新的扣款入口,你能保证找到所有该加锁的地方吗?"
答案是不能。所以我们才做了那三件事------把锁收口、把 key 常量化、把兜底放进数据库。
分布式锁的坑,90% 不在 API 怎么用,而在"锁什么"。 这个判断能力,只能靠对业务的完整理解换来,任何一个框架都帮不了你。