一锁、二判、三更新:Redisson 锁、MySQL 乐观锁、状态机别再混淆了!一文讲清它们到底解决什么问题

88 阅读6分钟

一个在并发场景下中特别容易混淆的话题:

Redisson 的分布式锁、MySQL 的乐观锁(version 字段)、还有状态机——这仨到底该用在哪儿?能不能互相替代?

很多同学一遇到“重复提交”或“并发冲突”,就一股脑全上:加个 Redis 锁、搞个 version 字段、再套个 if 判断状态……结果代码又重又慢,还可能漏掉关键问题。

其实,这三种机制根本不是一类东西,它们解决的是不同层面的问题。用错了,轻则性能浪费,重则数据出错。

下面我就结合我们真实的业务场景,把它们的区别说清楚,并告诉你怎么组合才最安全、最高效。


一、先说结论:它们各管一摊事

机制解决什么问题?本质
Redisson 分布式锁控制“谁在操作”悲观锁:假设会冲突,先锁住(适用于同步请求)
MySQL 乐观锁(version)防止“基于旧数据的更新覆盖新数据”无锁并发控制:先改,失败再重试
状态机防止“非法的状态跳转”必须放在 UPDATE 的 WHERE 中,作为写时兜底

状态机的兜底和乐观锁一样,放进 SQL 的 WHERE 条件里。


二、Redisson 分布式锁:同步请求的主力防线

Redisson 锁的核心思想很简单:

同一时间,只让一个人干这件事。

典型写法:

RLock lock = redisson.getLock("user_rights_update:" + userId);
if (lock.tryLock(0, TimeUnit.SECONDS)) { // 快速失败:拿不到立刻返回
    try {
        // 更新用户权益
    } finally {
        lock.unlock();
    }
} else {
    throw new BusinessException("操作太频繁,请稍后再试");
}

这种 “拿不到锁就直接失败” 的行为,就是典型的 悲观锁策略

适合场景

  • 用户实时操作(如充值、消费、修改配置)
  • 需要严格串行的高频写入
  • 大部分日常同步业务流量

💡 在我们系统里,99% 的同步实时请求靠 Redisson 锁就能稳稳兜住

但它无法覆盖所有场景——尤其是那些异步、延迟、重试的操作,比如:

  • 支付/退款的第三方回调;
  • MQ 消息的重复投递或延迟消费;
  • 定时任务的补偿逻辑;
  • 人工后台重推操作。

这些请求到来时,Redisson 锁早已释放,内存状态也无从谈起。
唯一可信的,只有数据库里持久化的状态和版本号。

所以,状态机 + 乐观锁 的双重 WHERE 条件,不仅是锁失效的兜底,更是整个异步系统的安全基石。


三、MySQL 乐观锁:批量导入、重试场景的救星

这里分享一个我们真实的业务场景:

我们要把一批老系统的客户迁移到新系统,初始化他们的用户权益(积分、等级、资格等)。

这个过程有几个关键特点:

  • 数据量大(几十万甚至上百万用户);
  • 允许多次重试(比如某次导入中途失败,后续可重新跑);
  • 导入期间,用户可能已经在新系统活跃(比如自己注册、充值、消费),导致权益数据已被修改。

如果对每个用户都加 Redisson 锁?

  • 锁数量巨大,Redis 压力剧增;
  • 导入速度极慢,无法接受;
  • 更重要的是——没必要,因为这不是高并发实时请求,而是后台异步任务。

于是我们选择:给用户权益表加上 version 字段,用乐观锁安全更新

具体做法是:

  1. 先查询当前用户权益的最新状态和 version

    UserRights current = userRightsMapper.selectById(userId);
    
  2. 尝试基于当前 version 更新

    UPDATE user_rights 
    SET points = #{newPoints}, level = #{newLevel}, version = version + 1 
    WHERE user_id = #{userId} AND version = #{currentVersion};
    
  • 如果用户从未在新系统操作过 → 更新成功;
  • 如果用户已经充值或修改过权益(version 已变更)→ WHERE 条件不满足,更新失败,自动跳过该用户
  • 后续重试时,依然会读取最新的 version,不会覆盖用户的真实操作。

这种设计的优势非常明显

  • 无需加任何锁,性能极高,适合大批量处理;
  • 天然支持幂等重试:跑多少次都不怕;
  • 保护用户已有数据:绝不覆盖用户在新系统产生的真实行为。

乐观锁的是“基于当前版本号更新”


四、状态机:真正的兜底是在 UPDATE 的 WHERE 里(尤其为 MQ 而生)

很多人以为状态机就是写个 if 判断:

if (order.getStatus() != OrderStatus.CREATED) {
    throw new Exception("不能重复支付");
}

但这种做法在高并发或异步场景下是无效的——因为判断和更新不是原子的。更糟的是,在 MQ 回调、定时任务等场景中,这个 if 根本没有上下文可言。

正确做法:状态条件必须放进 SQL 的 WHERE 子句

UPDATE orders 
SET status = 'PAID', version = version + 1 
WHERE id = 123 
  AND status = 'CREATED'   -- ← 状态机兜底:只允许从 CREATED 变 PAID
  AND version = 5;         -- ← 乐观锁兜底:基于最新版本

只有当当前状态确实是 CREATED 且 version 匹配时,更新才会成功。

这个设计,正是为了应对 MQ、回调、补偿等异步场景而存在的

🌰 案例:支付回调晚到

  1. 用户下单 → 系统加锁 → 创建订单(status=CREATED);
  2. 3 分钟后,MQ 检测到未支付,自动取消订单(status=CANCELLED);
  3. 5 分钟后,微信支付回调终于到达,尝试将订单设为 PAID。

如果没有状态机兜底:

  • 回调代码只查订单是否存在,就直接更新 → 已取消的订单被错误激活,资损!

如果有状态机在 WHERE 中:

  • status = 'CREATED' 条件不满足 → 更新失败 → 安全退出。

这个场景里,Redisson 锁早在下单完成后就释放了,根本覆盖不到回调时刻。真正守住底线的,是状态机 + 乐观锁。


五、正确姿势:一锁、二判、三更新

真正健壮的高并发逻辑,往往是三者按顺序配合:

✅ 第一步:加锁(Redisson)

控制同步请求的并发入口,99% 的实时操作靠它就够了。

✅ 第二步:判断(可选内存预检)

快速拦截明显非法请求(提升用户体验),但不能依赖它做安全校验

✅ 第三步:更新(乐观锁 + 状态机 双重兜底)

把 version 和 status 都放进 WHERE,让数据库原子地保证一致性。

场景1:用户实时充值(同步)

RLock lock = getLock(userId);
lock.lock();
try {
    UserRights rights = loadRights(userId);
    if (rights.status == FROZEN) throw error("冻结账户");
    
    boolean success = updateWithVersionAndStatus(
        userId, rights.version, RightsStatus.ACTIVE, newBalance
    );
    if (!success) throw error("请重试");
} finally {
    lock.unlock();
}

场景2:MQ 消费支付回调(异步)

// 无锁!直接双重兜底更新
boolean success = updateOrderStatus(
    orderId,
    expectedVersion,      // 可从消息体或 DB 查询
    OrderStatus.CREATED,  // 只允许从 CREATED 变 PAID
    OrderStatus.PAID
);
if (!success) {
    log.warn("订单 {} 状态已变更,跳过回调", orderId);
}

六、总结:什么时候用什么?

场景推荐方案
用户实时操作(充值、下单)Redisson 锁 +(可选预检)+ 乐观锁 + 状态机(WHERE 中)
批量导入、数据迁移乐观锁 + 状态机(WHERE 中),无需加锁
MQ 消费、回调、补偿任务无需锁!直接双重兜底更新
极端异常兜底(锁失效)数据库层的 WHERE 条件是最后防线

同步靠锁,异步靠状态;
安全不在 if,而在 WHERE。

在这里插入图片描述