MySQL 乐观锁的实际落地:避免并发更新冲突的 3 种实现方式

  • 在高并发业务场景(如秒杀库存扣减、订单状态更新)中,数据库并发更新冲突是核心痛点------例如秒杀时"库存超卖"、订单"同时支付和取消"导致状态异常。悲观锁(SELECT FOR UPDATE)虽能解决冲突,但会导致线程阻塞、性能下降,甚至引发死锁;而乐观锁基于"无锁假设",通过"更新时校验条件"实现冲突控制,适合读多写少、高并发的业务场景。本文将详解乐观锁的 3 种落地方式(版本号字段、时间戳字段、CAS 原生对比),结合秒杀、订单场景提供可直接落地的代码案例,并分析高并发下的性能差异与死锁规避技巧。

一、乐观锁核心原理与适用场景

核心原理

乐观锁假设"并发更新冲突概率低",核心逻辑是:

  • 读取数据时不加锁,仅记录数据的"版本/状态标识";
  • 更新数据时,通过 WHERE条件校验"标识是否未被修改";
  • 若校验通过则更新,若失败则说明数据已被其他线程修改,触发应用层重试。

适用场景

  • 高并发读、低并发写的场景(如秒杀库存扣减、商品库存更新);
  • 避免长事务阻塞的场景(如订单状态更新,无需锁定整个记录);
  • 对性能要求高、可接受"少量重试"的场景(冲突率 < 10%)。

前置测试环境

为复现并发场景,先创建 2 张核心表并插入测试数据:

二、三种乐观锁实现方式实战

方式 1:版本号字段实现(最稳定、推荐)

核心原理

在表中新增 version 整数字段,初始值为 1:

  • 读取数据时,同步获取 version值;
  • 更新数据时,WHERE条件必须包含"当前读取的 version",且更新时将 version + 1;
  • 若更新影响行数为 0,说明数据已被修改,触发重试。

场景 1:秒杀库存扣减(防超卖)

核心 SQL(数据库层)

应用层落地代码(Java + MyBatis 示例)

复制代码
   // 1. 库存扣减服务接口
   public interface SeckillStockService {
       /**
        * 秒杀库存扣减(乐观锁+重试)
        * @param goodsId 商品ID
        * @param retryTimes 最大重试次数
        * @return 是否扣减成功
        */
       boolean deductStock(Long goodsId, int retryTimes);
   }

   // 2. 实现类
   @Service
   @Transactional(rollbackFor = Exception.class)
   public class SeckillStockServiceImpl implements SeckillStockService {
       @Resource
       private SeckillStockMapper stockMapper;

       @Override
       public boolean deductStock(Long goodsId, int retryTimes) {
           // 递归终止条件:重试次数为0
           if (retryTimes <= 0) {
               return false;
           }

           // 步骤1:读取当前库存和版本号(无锁)
           SeckillStock stock = stockMapper.selectByGoodsId(goodsId);
           if (stock == null || stock.getStockNum() <= 0) {
               return false; // 库存为空,直接失败
           }

           // 步骤2:执行乐观锁更新
           int updateRows = stockMapper.deductStockByVersion(
               goodsId, 
               stock.getVersion()
           );

           // 步骤3:判断更新结果
           if (updateRows > 0) {
               return true; // 扣减成功
           } else {
               // 冲突,重试(休眠10ms避免高频重试)
               try {
                   Thread.sleep(10);
               } catch (InterruptedException e) {
                   Thread.currentThread().interrupt();
               }
               return deductStock(goodsId, retryTimes - 1); // 递归重试
           }
       }
   }

   // 3. MyBatis Mapper 接口
   public interface SeckillStockMapper {
       // 根据商品ID查询库存(无锁)
       @Select("SELECT id, goods_id, stock_num, version FROM seckill_stock WHERE goods_id = #{goodsId}")
       SeckillStock selectByGoodsId(@Param("goodsId") Long goodsId);

       // 版本号乐观锁扣减库存
       @Update("UPDATE seckill_stock SET stock_num = stock_num - 1, version = version + 1 " +
               "WHERE goods_id = #{goodsId} AND version = #{version} AND stock_num > 0")
       int deductStockByVersion(@Param("goodsId") Long goodsId, @Param("version") Integer version);
   }

场景 2:订单状态更新(防并发修改)

复制代码
-- 订单状态更新 SQL(版本号乐观锁)
UPDATE order_info
SET status = 2, version = version + 1  -- 2=已支付
WHERE order_id = 9001 AND version = #{version} AND status = 1; -- 1=待支付

版本号实现优缺点

方式 2:时间戳字段实现(轻量化)

核心原理

利用 update_time 时间戳字段替代版本号:

  • 读取数据时记录 update_time;
  • 更新时 WHERE条件包含"读取的 update_time",同时更新 update_time 为当前时间;
  • 依赖时间戳的唯一性判断数据是否被修改。

场景:订单状态并发更新(轻量化场景)

核心 SQL

复制代码
-- 时间戳乐观锁更新订单状态
UPDATE order_info
SET status = 3, update_time = NOW()  -- 3=已取消
WHERE order_id = 9001 
  AND update_time = #{updateTime}  -- 读取时的更新时间
  AND status = 1; -- 仅允许修改待支付订单
  1. 应用层代码(关键片段)
  2. 时间戳实现优缺点

方式 3:CAS(Compare and Swap)原生实现(极简)

核心原理

无需额外字段,直接对比"更新前的核心字段值"(如库存数、状态值),本质是"值对比"的乐观锁:

  • 更新时 WHERE条件包含"更新前的字段值";
  • 适合简单场景(如仅校验库存数、状态值)。

场景:秒杀库存扣减(极简场景)

核心 SQL

应用层代码(关键片段)

CAS 实现优缺点

三、高并发性能对比与分析

为验证三种方式的性能,模拟 1000/10000/100000 并发请求(秒杀库存扣减,初始库存 1000),测试指标包括成功扣减率平均耗时冲突重试次数 ,结果如下:

性能结论

  1. 版本号方式:稳定性和成功率最高,是核心业务的首选;
  2. CAS 方式:执行效率略高,但冲突率最高,仅适合极简场景;
  3. 时间戳方式:折中方案,轻量化但受精度影响,适合非核心业务。

四、死锁与冲突规避技巧

乐观锁本身基于"无锁"设计,不会直接引发死锁,但高并发下的"重试+事务"仍可能导致性能问题或隐性冲突,以下是核心规避技巧:

合理设置重试机制(避免无限重试)

  • 重试次数:核心业务建议 3-5 次,非核心业务 1-3 次;
  • 重试间隔:采用"指数退避"策略(如首次 10ms,第二次 20ms,第三次 40ms),避免高频重试加剧数据库压力;
  • 代码示例(指数退避重试):

缩小事务范围(避免长事务)

  • 乐观锁更新操作应作为"最小事务"执行,避免事务中包含查询、远程调用等耗时操作;
  • 反例(错误):

正例(正确):

控制并发量级(避免数据库过载)

  • 应用层限流:通过 Redis 分布式锁、令牌桶算法限制并发请求数(如秒杀接口限制每秒 1000 次请求);
  • 数据库层限流:设置 max_connections、innodb_thread_concurrency等参数,避免数据库连接池耗尽;

避免"幻读"(合理设置事务隔离级别)

  • 乐观锁建议使用 READ COMMITTED隔离级别(MySQL 默认是 REPEATABLE READ),减少 MVCC 版本链压力,降低冲突概率;
  • 设置方式:

核心字段加唯一索引(防重复提交)

  • 秒杀场景:为 goods_id + user_id添加唯一索引,防止同一用户重复秒杀;
  • 订单场景:为 order_id添加主键索引,避免重复更新;

失败兜底策略(避免用户体验差)

  • 乐观锁重试失败后,提供兜底方案(如"当前抢购人数过多,请稍后再试"),而非直接返回"失败";
  • 核心业务可结合消息队列(如 RocketMQ)实现"异步重试",确保最终一致性。

五、最佳实践总结

核心原则

优先版本号方式:稳定性最高,适配所有高并发核心业务;

拒绝过度设计:简单场景无需引入版本号,CAS 或时间戳即可满足需求;

重试≠万能:乐观锁的核心是"低冲突假设",若冲突率 > 20%,应考虑拆分业务(如分库分表)而非增加重试次数;

监控大于优化:通过慢查询日志、Prometheus 监控乐观锁的"重试次数""失败率",及时发现并发瓶颈。

乐观锁的落地核心是"扬长避短"------利用其无锁特性提升高并发性能,同时通过合理的重试、事务、限流策略规避冲突,最终在"性能"与"数据一致性"之间找到最佳平衡点。

相关推荐
倔强的石头_9 分钟前
关系数据库替换用金仓:数据迁移过程中的完整性与一致性风险
数据库
Elastic 中国社区官方博客15 分钟前
使用 Groq 与 Elasticsearch 进行智能查询
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
穿过锁扣的风32 分钟前
一文搞懂 SQL 五大分类:DQL/DML/DDL/DCL/TCL
数据库·microsoft·oracle
l1t33 分钟前
DeepSeek总结的SNKV — 无查询处理器的 SQLite 键值存储
数据库·sqlite·kvstore
洛豳枭薰35 分钟前
MySQL 梳理
数据库·mysql
九.九1 小时前
CANN 算子生态的底层安全与驱动依赖:固件校验与算子安全边界的强化
大数据·数据库·安全
蓝帆傲亦1 小时前
代码革命!我用Claude Code 3个月完成1年工作量,这些实战经验全给你
jvm·数据库·oracle
亓才孓1 小时前
[JDBC]事务
java·开发语言·数据库
PD我是你的真爱粉1 小时前
FastAPI使用tortoiseORM
数据库·fastapi
剩下了什么9 小时前
MySQL JSON_SET() 函数
数据库·mysql·json