Java 分布式锁误用导致库存超卖的生产事故复盘:从错用到端到端治理

Java 分布式锁误用导致库存超卖的生产事故复盘:从错用到端到端治理

技术主题:Java 编程语言
内容方向:生产环境事故的解决过程(故障现象、根因分析、解决方案、预防措施)

引言

一次促销活动期间,我们的下单服务在高并发场景出现“偶发超卖”。最初怀疑是数据库隔离级别问题,深入排查后发现是分布式锁错误使用:锁未有效覆盖临界区、续期缺失导致锁误释放、以及针对跨服务写入缺少防重放的“围栏令牌”(Fencing Token)。本文复盘事故全过程,给出可直接落地的修复方案与代码骨架。

一、故障现象与影响

  • 现象:
    • 秒杀窗口内,库存数出现负值;
    • 订单表与库存表不一致,重放补偿任务异常增多;
    • 日志显示偶发“锁获取失败后仍继续下单”与“锁提前过期”。
  • 影响:
    • 业务侧投诉;
    • 事后对账成本高,风控与补偿系统压力上升。

二、排查步骤

  1. 指标对齐:对比下单 QPS、锁获取失败率、平均持锁时长、数据库行锁等待;
  2. 采样日志:抽取同一商品在 1s 内的多实例日志,定位冲突批次;
  3. 代码走查:核对加锁与临界区边界是否一致,是否存在“加锁后调用远端再回写”的跨边界;
  4. Redis 观测:采样 key TTL 与持锁实例 ID,是否存在 watchdog 未续期;
  5. DB 版本:检查库存更新是否有乐观锁/版本号,是否能抵挡重放。

三、根因分析

  • 锁与临界区不一致:加锁仅包裹“扣减库存”前的一小段逻辑,实际写入分散在多个 RPC 之后;
  • 固定过期时间:使用固定 leaseTime(如 3s),遇到 JVM 停顿/下游抖动导致持锁超时被误释放;
  • 无围栏令牌:下游写入没有“单调递增的令牌校验”,被误释放的旧持锁者仍可能覆盖新写入;
  • 锁失败策略不当:tryLock 失败后未快速失败,而是继续业务流程。

四、修复方案(架构与工程双管齐下)

  • 锁的“边界与归属”
    • 临界区要与“对库存状态产生最终写入”的边界一致;
    • 优先把扣减库存收敛为一个原子操作(本地事务或单服务内完成)。
  • 续期与超时
    • 使用 Redisson 的 watchdog 自动续期(不设置固定 leaseTime),或按最长 P99 处理时长评估合理超时;
  • 围栏令牌(Fencing Token)
    • 在获取锁的同时生成单调递增的 token,所有写路径携带 token,由下游以“令牌更大者为准”进行校验;
  • 双重防线
    • 应用层锁 + 数据层乐观锁(version/updated_at)共同保障;
  • 失败即快返
    • tryLock 获取不到立即失败,避免排队放大延迟;
  • 观测与压测
    • 采集持锁时长、续期次数、锁竞争度、库存版本冲突率;在压测环境覆盖 GC 停顿与下游抖动场景。

五、关键代码(Redisson + Fencing Token)

5.1 正确的 Redisson 加锁用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// Maven 依赖:org.redisson:redisson
import org.redisson.api.RedissonClient;
import org.redisson.api.RLock;
import java.util.concurrent.TimeUnit;

public class StockService {
private final RedissonClient redisson;
private final StockRepository stockRepo;
private final FencingTokenService tokenService;

public StockService(RedissonClient redisson, StockRepository repo, FencingTokenService tokenService) {
this.redisson = redisson;
this.stockRepo = repo;
this.tokenService = tokenService;
}

public void deduction(String skuId, int qty, String orderId) {
String lockKey = "lock:stock:" + skuId;
RLock lock = redisson.getLock(lockKey);
boolean locked = false;
long token = -1L;
try {
// 不指定 leaseTime,交给 watchdog 自动续期,避免固定过期
locked = lock.tryLock(200, TimeUnit.MILLISECONDS);
if (!locked) {
throw new IllegalStateException("busy, please retry");
}
// 在持锁后获取 fencing token(全局单调递增)
token = tokenService.nextToken(skuId);

// 临界区:在本地事务内完成库存扣减与订单记录,携带 token
boolean ok = stockRepo.tryDeductWithToken(skuId, qty, orderId, token);
if (!ok) {
throw new IllegalStateException("deduct failed by version/token check");
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}

说明:

  • tryLock 等待时间很短(如 200ms),拿不到就让上游退避/排队限流;
  • 不设置固定 leaseTime,交给 watchdog;若你的处理存在长阻塞,仍需压测确认续期是否可靠;
  • 临界区尽可能短,并在本地完成“读-判-写”。

5.2 Fencing Token 与乐观锁示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 令牌服务:可用 Redis INCR 或数据库序列,保证单调递增
public interface FencingTokenService {
long nextToken(String resourceKey);
}

// Repository 层伪代码:基于 version/token 的双重校验
public class StockRepository {

// 表结构示意:
// stock(sku_id PK, available, version BIGINT, last_token BIGINT)

public boolean tryDeductWithToken(String skuId, int qty, String orderId, long token) {
// 读取当前版本与 last_token
StockRow row = selectForUpdate(skuId); // 行锁 or MVCC + version

if (row.getAvailable() < qty) {
return false;
}

// Fencing:如果传入 token 小于等于 last_token,则拒绝(旧持锁者重放)
if (token <= row.getLastToken()) {
return false;
}

// 乐观锁 or 悲观锁
int updated = updateStockIfVersionMatch(
skuId,
row.getVersion(),
row.getVersion() + 1,
row.getAvailable() - qty,
token // 写入 last_token
);

if (updated == 1) {
insertOrder(orderId, skuId, qty, token);
return true;
}
return false;
}
}

说明:

  • Fencing Token 防止“旧锁持有者”在锁被误释放后继续写入;
  • 乐观锁保证并发写入的线性化,避免并发覆盖;
  • 在跨服务写入(例如库存服务 + 订单服务)时,所有写路径都需要携带并校验 token。

5.3 观测与告警建议

  • Redisson 指标:持锁时长、续期次数、锁竞争失败率;
  • 业务指标:每 SKU 的版本冲突率、令牌拒绝率、库存负值防线触发次数;
  • 告警阈值:
    • 续期异常比率 > 0.5%(1 分钟窗口)
    • tryLock 超时率 > 5% 且下单 QPS 上升
    • 版本冲突率 P95 > 2%

六、效果与验证

  • 压测场景:
    • 注入 100-300ms 抖动与 1-2s 尖刺;
    • 模拟 GC 停顿(手工触发或压测机制造压力);
    • 异常断电/进程崩溃,验证锁与 token 的一致性约束仍生效。
  • 结果(示意):
    • 促销峰值超卖降至 0;
    • tryLock 失败率 < 3%,平均持锁时长 P95 < 40ms;
    • 版本冲突率 < 1%,无库存负值。

七、防复发清单

  • 设计层面:
    • 确认临界区边界;
    • 评估是否可用单机原子操作(如 DB 原子扣减)替代分布式锁。
  • 工程层面:
    • Redisson 统一封装:tryLock 短等待、watchdog、finally 解锁;
    • 引入 Fencing Token 与版本号,在所有写路径校验;
    • 失败快返 + 上游退避限流;
    • 完整可观测性:锁指标、版本/令牌指标、数据库冲突;
    • 压测用例纳入回归(GC/网络抖动/下游慢)。

总结

这次事故的关键不在“有没有加锁”,而在“锁是否覆盖正确的临界区、能否在异常条件下维持顺序”。通过 Redisson 的正确使用(短等待+续期+finally 解锁)、围栏令牌与乐观锁双重校验、以及全面的观测与压测,我们将库存链路从“偶发失序”拉回到“可验证的线性化”。以上代码骨架可直接迁移并作为团队基线,在此之上再做容量与性能优化。