从乐观锁被冲烂到原子扣减稳如磐石:高并发防超卖方案的三次迭代

#Java #高并发 #Redis #避坑指南

摘要:本地跑得好好的扣库存代码,一上并发压测就超卖炸库?本文记录了我在耗材管理系统中防超卖方案的真实演进历程:从 Mybatis-Plus 乐观锁的无底洞重试,到引入 Redis 预扣减 + Spring Retry 导致的"超额扣除"血案,最终靠一条原子 SQL 消除并发间隙,Redis 负责挡流量、MySQL 保一致性。不讲空洞理论,带你直击压测现场,扒一扒那些教科书里不教的隐蔽大坑。

压测面板上的红色数字

上周在做一个耗材管理系统的库存模块,本地跑得好好的,一上并发压测就炸了。

测试条件很直白:40个线程,通过 CountDownLatch 发令枪同时发起领用请求,目标耗材库存50,每人领2个。按理说最多25个人成功,剩下15个收到"库存不足"就完事了。

实际结果:

makefile 复制代码
成功: 5, 失败: 35, 数据库库存: 40

5个人成功,扣了10个,库存从50变成40。数值守恒没问题,但吞吐量崩了------25个本该成功的请求被乐观锁误杀,只有5个漏网。 35个失败里有20个是被版本竞争冤死的合法请求,只有15个才是真正的"库存不够"。

更诡异的是,如果把线程数降到5,全部成功,零超卖。问题只在高并发时出现。

这篇文章记录了我从发现问题、踩坑重试、到最终用三层方案解决的全过程。不讲教科书理论,只讲生产里真正会遇到的坑。


第一版方案:MySQL 乐观锁------看起来没问题

最开始的扣减逻辑很朴素,MyBatis-Plus 的 @Version 乐观锁:

java 复制代码
// 1. 查出来
Material material = materialMapper.selectById(materialId);

// 2. 应用层计算
material.setStock(material.getStock() - quantity);

// 3. 乐观锁更新(version 不匹配就返回 0 行)
int rows = materialMapper.updateById(material); // @Version 自动拼 WHERE version=?

if (rows == 0) {
    throw new BusinessException("并发冲突,请重试");
}

单线程跑完全没问题。40个线程一冲,updatedRows == 0 出现了35次。

原因很简单:40个线程几乎同时 selectById,读到同一个 version=1。应用层各算各的,都得出 stock=48。然后40个 UPDATE ... WHERE version=1 一起打过去,数据库只认第一个拿到行锁的事务,后面39个 version 已经过期,全部返回0。

但实际压测中线程调度有时序差------部分线程在第一个事务提交后才去读库,读到了新 version,所以40个里有5个成功、35个失败。极端情况下(毫秒级完全重叠),可能只有1个成功。

乐观锁的本质是"先到先得,后到重试"。但重试在高并发下是个无底洞------越重试越碰撞。


第二版方案:加 Redis 预扣减 + Spring Retry------更炸了

为了解决"大部分请求打到 MySQL 白跑一趟"的问题,我在 MySQL 前面加了一层 Redis 预扣减:

java 复制代码
// 1. Redis 原子预扣减(快速拦截)
Long remain = redisTemplate.opsForValue().decrement(redisKey, quantity);
if (remain < 0) {
    // 库存不够,补偿回去,别碰 MySQL
    redisTemplate.opsForValue().increment(redisKey, quantity);
    throw new BusinessException("库存不足");
}

// 2. MySQL 乐观锁落盘
Material material = materialMapper.selectById(materialId);
material.setStock(material.getStock() - quantity);
int rows = materialMapper.updateById(material);
if (rows == 0) {
    // 乐观锁冲突,补偿 Redis,抛异常让重试
    redisTemplate.opsForValue().increment(redisKey, quantity);
    throw new BusinessException("并发冲突,请重试");
}

然后加了 Spring Retry:

java 复制代码
@Retryable(value = BusinessException.class, maxAttempts = 5,
           backoff = @Backoff(delay = 10, multiplier = 1))
@Transactional(rollbackFor = Exception.class)
public void applyBatchMaterial(BatchApplyDTO batchDTO) { ... }

压测结果:

makefile 复制代码
成功: 10, 失败: 30, Redis 库存: -20

Redis 库存变成了负数。

debug 了半小时才找到根因:@Retryable + @Transactional + Redis 三者组合产生了"悬挂扣减"。

具体过程------假设方法里循环处理两个 item:

java 复制代码
第一次调用:
  item1: Redis decrement(2) ✅ → MySQL 乐观锁 ✅ (在事务中)
  item2: Redis decrement(2) ✅ → MySQL 乐观锁 ❌ → Redis increment(2) 补偿 → 抛异常
  → @Transactional 回滚整个事务(item1 的 MySQL 扣减被撤销)
  → 但 item1 的 Redis decrement 不会回滚(Redis 不在事务里)
  → Redis 多扣了 2,这笔扣减悬挂了------没有对应事务支撑,也没有人补偿

重试(第二次调用):
  item1: Redis decrement(2) ✅ → MySQL 乐观锁 ✅
  → item1 又扣了一次,但上一轮的悬挂扣减没人管

多次重试叠加,悬挂扣减累积,Redis 库存被扣穿。

Redis 不认识事务回滚。 MySQL rollback 只管数据库,不会帮你把 Redis 的 decrement 撤回来。这是跨数据源操作套 @Retryable 最隐蔽的坑。


最终方案:Redis 预扣 + MySQL 原子扣减

反复折腾之后,问题的根因就一句话:"先查后改"在高并发下天然有间隙,不管用乐观锁还是悲观锁,SELECT 和 UPDATE 之间都有窗口期。

那就不查了。直接一条 SQL:

java 复制代码
@Update("UPDATE material SET stock = stock - #{quantity}, " +
        "version = version + 1, update_time = NOW() " +
        "WHERE id = #{id} AND stock >= #{quantity} AND is_deleted = 0")
int deductStock(@Param("id") Long id, @Param("quantity") int quantity);
java 复制代码
public void applyBatchMaterial(BatchApplyDTO batchDTO) {
    for (ApplyItemDTO item : batchDTO.getItems()) {

        // 1. Redis 预扣减(快速拦截)
        Long remain = redisTemplate.opsForValue()
                .decrement(redisKey, item.getQuantity());
        if (remain < 0) {
            redisTemplate.opsForValue().increment(redisKey, item.getQuantity());
            throw new BusinessException("库存不足");
        }

        // 2. MySQL 原子扣减(一行搞定,不需要重试)
        int rows = materialMapper.deductStock(item.getMaterialId(), item.getQuantity());
        if (rows == 0) {
            // 扣减失败,补偿 Redis
            redisTemplate.opsForValue().increment(redisKey, item.getQuantity());
            throw new BusinessException("库存不足");
        }
        // ... 插入记录、触发风控
    }
}
ini 复制代码
压测结果:

成功: 25, 失败: 15, 数据库库存: 0

50 == 0 + 25 × 2  数据守恒√
0 >= 0            零超卖√

40个线程,25个抢到库存,15个被 stock >= quantity 拦住返回0行,没有重试、没有冲突、没有负数。方案一里那20个被乐观锁冤死的请求,在方案三里终于不再被误杀了------没有 version 竞争,只要库存够就一定能扣成功。


为什么这条 SQL 能扛住并发

关键在 stock >= quantity 这个 WHERE 条件。

sql 复制代码
线程 A: UPDATE ... SET stock = stock - 2 WHERE id=1 AND stock >= 2
线程 B: UPDATE ... SET stock = stock - 2 WHERE id=1 AND stock >= 2
线程 C: UPDATE ... SET stock = stock - 2 WHERE id=1 AND stock >= 2

MySQL InnoDB 的行锁机制保证了同一时刻只有一个事务能修改这行。三个线程排队执行:

yaml 复制代码
线程 A → stock: 50 → 48  返回 1 行
线程 B → stock: 48 → 46  返回 1 行
线程 C → stock: 46 → 44  返回 1 行

当 stock 扣到 0 时,stock >= 2 不满足,后面的请求全部返回 0 行,直接拒绝。

不存在"先查后改"的间隙,不存在 version 冲突,不需要重试。 这条 SQL 天然就是串行的。

三种方案横向对比

方案 并发冲突率 是否需要重试 Redis 与 MySQL 一致性 实现复杂度 适用场景
乐观锁 极高,取决于线程调度时序 是,冲突后重试 不涉及 读多写少、竞争极低的后台更新
Redis 预扣 + 乐观锁 + Retry 降低,但乐观锁冲突仍在 是,且重试触发 Redis 重复扣减 事务回滚不补偿 Redis,产生悬挂扣减 高,三者交织 不推荐------坑多收益少
Redis 预扣 + 原子扣减 0,stock >= quantity 兜底 失败时显式补偿 Redis 中,一条原子 SQL + 显式补偿 高并发扣减、秒杀防超卖

结论: 方案二看似"加了缓存层更先进",实际上把两个数据源的一致性问题和 Spring 代理链的执行顺序问题搅在了一起,是三个方案里最容易翻车的。方案三用一条 SQL 消除了"先查后改"的间隙,根本不需要重试,复杂度反而比方案二更低。


测试里踩的另一个坑:Redis 没初始化

方案改完后跑压测,结果还是不对:

makefile 复制代码
成功: 10, 失败: 30, 数据库库存: 30

理论上 25 个成功才对。debug 发现失败的线程全部是"库存不足",但数据库明明有 50 个。

回头看测试代码:

java 复制代码
@BeforeEach
void setUp() {
    // 只初始化了 MySQL
    Material mat = materialMapper.selectById(TARGET_MATERIAL_ID);
    mat.setStock(50);
    materialMapper.updateById(mat);
    // Redis 里是上次测试剩下的脏数据,可能是 0
}

Redis 里存的是上次测试的残留值。 第一个线程 decrement 就把库存扣成负数了,后面的线程全部被拦截。

加上 Redis 初始化:

java 复制代码
String redisKey = "dcp:material:stock:" + TARGET_MATERIAL_ID;
redisTemplate.delete(redisKey);                    // 清掉脏数据
redisTemplate.opsForValue().set(redisKey, 50);     // 同步初始化

改完再跑:

makefile 复制代码
成功: 25, 失败: 0, 数据库库存: 0

测试环境的脏数据比生产 bug 更隐蔽。你改了方案、调了参数、加了重试,折腾两天,结果是 Redis 里一个旧值在捣乱。


Redis 预扣减的意义:不是为了扣库存,是为了挡子弹

有人会问:既然 MySQL 原子扣减已经能防超卖了,为什么还要加 Redis 这一层?

因为 Redis 的作用不是"扣库存",是"快速拦截"

复制代码
请求进来
  │
  ▼
Redis.decrement  ← 微秒级,库存不够直接拒,不碰数据库
  │
  ▼  库存足够
MySQL.deductStock ← 毫秒级,原子扣减,数据库行锁兜底
  │
  ▼  扣减成功
插入记录 + 触发风控

如果 1000 个请求同时打过来,没有 Redis 层,1000 个请求全部打到 MySQL。有了 Redis,可能 900 个在 Redis 层就被拦住了,只有 100 个真正到数据库。

Redis 是流量的第一道闸门,MySQL 是最后的安全底线。 即使 Redis 宕机,业务可以降级到纯数据库模式,数据依然安全。


面试防御

面试官问:"你这个方案和直接扣数据库有什么区别?"

直接扣数据库也能防超卖,stock >= quantity 在数据库层面保证了不会扣成负数。但 1000 个并发请求全部打到数据库竞争同一把行锁,连接池和 DB CPU 扛不住。Redis 的 decrement 是单线程原子操作,微秒级返回,能在应用层快速拦截掉大部分不合法请求,真正到数据库的只有少数。这是流量分层的设计------Redis 挡流量,MySQL 保一致性。

面试官问:"为什么不用分布式锁?"

分布式锁(Redisson)能解决,但对这个场景过重了。原子扣减用的也是数据库行锁------和分布式锁一样是串行执行,但少了一次加锁和一次解锁的 Redis 网络往返。分布式锁至少需要三次网络 IO(加锁 + 业务操作 + 解锁),原子 SQL 一次数据库交互就搞定。而且分布式锁还要处理锁超时续期、客户端宕机后锁泄漏这些额外问题,复杂度更高。如果未来是微服务多实例部署且需要跨服务协调,再考虑 Redisson。


避坑指南

  1. 不要用 Spring Retry 套含 Redis 操作的方法。 事务回滚不会补偿 Redis,每次重试还会产生新的"悬挂扣减"。如果非要重试,把 Redis 操作拆到重试范围之外,内层只做 MySQL 操作。

  2. 不要 SELECT → 应用层计算 → UPDATE。 高并发下这是灾难。能一条 SQL 原子完成的,不要拆成两步。

  3. 压测前同时初始化 MySQL 和 Redis。 Redis 的残留数据会让你怀疑人生。

  4. @Transactional@Retryable 放在一起时,注意代理链顺序。 Spring 默认 Retry 在外层、Transaction 在内层,每次重试会开新事务。上一次事务回滚了,但事务外的操作(Redis decrement)不会跟着回滚。如果方法里同时有 Redis 和数据库操作,要么把 Redis 移到重试范围外,要么像最终方案一样用原子 SQL 消除重试需求。

  5. MySQL 扣减失败时,必须补偿 Redis。 最终方案代码里 rows == 0 分支的 increment 不是可选的------漏掉它,Redis 和 MySQL 的数据就会永久偏差。建议再加一个定时对账任务,以 MySQL 真实库存为准定期校准 Redis。

相关推荐
pixcarp11 小时前
Redis ZSet:底层设计与实践
数据库·redis·后端·学习·golang·web
小橙编码日志11 小时前
MCP(Model Context Protocol)详解
后端
落木萧萧82511 小时前
自动生成 SQL 会拖慢性能吗?实测 MyBatisGX、MyBatis、MyBatis-Plus、MyBatis-Flex
java·orm
PythonAI实战君11 小时前
若依后台管理系统 - Docker Compose 阿里云部署指南
后端·docker
lnnvv_im11 小时前
Spring Boot
后端
我是一颗柠檬11 小时前
【MySQL全面教学】MySQL多表查询与JOIN Day6(2026年)
数据库·后端·sql·mysql
Full Stack Developme11 小时前
Spring Boot 状态机 与 com.alibaba.cola 中的状态机
java·spring boot·后端
MacroZheng11 小时前
让 Claude Code 成本爆降 89%,这个开源工具有点猛...
java·人工智能·后端
likerhood11 小时前
Java 异常处理:从 try-catch-finally 到项目最佳实践
java·开发语言·php