news 2026/7/2 0:11:19

MyBatisPlus乐观锁:防止多个线程同时修改同一Token余额

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatisPlus乐观锁:防止多个线程同时修改同一Token余额

MyBatisPlus 乐观锁:防止多个线程同时修改同一 Token 余额

在高并发系统中,用户资产类数据的更新始终是开发中的“雷区”。比如一个常见的场景:用户参与活动需要扣除 10 个 Token,但若多个请求几乎同时发起,且服务端没有做好并发控制,就可能出现余额被超扣的问题——原本只有 15 个 Token 的用户,连续两次操作后余额变成 -5,这显然不可接受。

传统做法是使用数据库行锁(SELECT FOR UPDATE)或 Java 层面的synchronized来串行化访问,但这意味着性能牺牲。尤其在微服务架构下,单点同步无法跨实例生效,而数据库锁又容易成为瓶颈。有没有一种既能保障一致性、又不牺牲吞吐量的方案?MyBatisPlus 的乐观锁机制给出了优雅的答案。


从一个真实问题说起:为什么余额会变负?

设想这样一个流程:

  1. 用户 A 当前有 10 个 Token;
  2. 同时触发两个任务:签到奖励消耗 5 个,抽奖活动也消耗 5 个;
  3. 两个线程几乎同时查询到余额为 10;
  4. 都基于此值计算出新余额为 5,并尝试更新;
  5. 数据库无冲突地接受了两次更新,最终结果看似正常。

但再进一步:如果这两个操作都是“减 8”呢?

  • 线程1读取余额 = 10,判断够扣 → 准备更新为 2;
  • 线程2也读取余额 = 10,判断够扣 → 准备更新为 2;
  • 两者先后执行UPDATE user_token SET token_balance = 2 WHERE user_id = ?
  • 最终余额确实是 2,但实际应只允许一次成功,另一次应当失败。

更危险的是,若系统未做兜底校验,甚至可能让余额变为负数。这种“竞态条件”正是并发编程中最隐蔽却最致命的问题之一。

要解决它,核心在于确保“读取—修改—写入”这一系列动作的原子性。而乐观锁,正是实现这一点的轻量化手段。


什么是乐观锁?MyBatisPlus 是怎么做的?

乐观锁的基本思想很像 Git 的合并逻辑:你在本地修改文件时并不加锁,但在提交时会检查目标分支是否已被他人改动。如果有冲突,你就得先拉取最新代码再重试。

映射到数据库操作中,就是:

  • 查询时带上版本号(如version=1);
  • 更新时附加条件WHERE version = 1
  • 如果期间其他事务已将该记录版本升至 2,则当前更新影响行数为 0,说明发生了冲突;
  • 此时由应用决定是否重试。

MyBatisPlus 将这套机制封装得极为简洁。你只需在实体类中标记一个字段为@Version,框架便会自动拦截所有updateById类型的操作,在生成的 SQL 中加入版本比对和自增逻辑。

举个例子,当你调用mapper.updateById(entity)时,MyBatisPlus 实际执行的 SQL 是这样的:

UPDATE user_token SET token_balance = #{tokenBalance}, version = version + 1, updated_at = NOW() WHERE id = #{id} AND version = #{version}

注意最后的AND version = #{version}—— 这就是乐观锁的核心所在。如果此时数据库中的version已经不是 entity 原始读取的那个值,这条语句就不会更新任何行,返回影响行数为 0。MyBatisPlus 检测到这种情况后,会抛出OptimisticLockException,提醒开发者处理冲突。

整个过程无需手动拼接 SQL,也不依赖复杂的事务控制,真正做到了“声明即生效”。


如何快速接入?三步搞定

第一步:定义实体类,标记版本字段

@Data @TableName("user_token") public class UserToken { private Long id; private Long userId; private Integer tokenBalance; @Version private Integer version; // 版本号字段 }

⚠️ 注意事项:
-@Version字段类型推荐使用IntegerLong
- 初始值建议设为1,便于追踪首次更新;
- 不支持浮点类型或字符串类型。

第二步:注册乐观锁插件

从 MyBatisPlus 3.4.0 开始,推荐通过MybatisPlusInterceptor注册内置拦截器:

@Configuration @MapperScan("com.example.mapper") public class MyBatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }

这个配置一旦完成,所有符合规则的更新操作都会自动启用乐观锁保护,完全透明无感。

第三步:编写业务逻辑,加入重试机制

光有乐观锁还不够,你还得应对冲突发生后的场景。毕竟不能因为一次失败就直接返回错误给用户。合理的做法是进行有限次重试。

@Service @Transactional public class TokenService { @Autowired private UserTokenMapper userTokenMapper; public boolean deductToken(Long userId, int amount) { int maxRetries = 3; for (int i = 0; i < maxRetries; i++) { try { // 查询当前余额与版本 UserToken token = userTokenMapper.selectByUserId(userId); if (token == null || token.getTokenBalance() < amount) { throw new IllegalArgumentException("Token不足"); } // 计算新余额 token.setTokenBalance(token.getTokenBalance() - amount); // 执行更新(自动带 version 条件) int updated = userTokenMapper.updateById(token); if (updated > 0) { return true; // 成功退出 } } catch (OptimisticLockException e) { // 可选:记录日志或增加退避时间 if (i == maxRetries - 1) { throw new RuntimeException("操作频繁,请稍后再试", e); } try { Thread.sleep(50 + new Random().nextInt(50)); // 随机退避 } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } } return false; } }

这里有几个关键设计点值得强调:

  • 重试次数不宜过多:一般 2~5 次足够,避免造成响应延迟;
  • 引入随机退避:减少多个线程同时重试导致再次碰撞的概率;
  • 尽早抛出明确异常:当重试耗尽时,应返回对用户友好的提示,而非堆栈信息;
  • 事务边界要短:不要把整个循环包在一个大事务里,否则容易引发锁等待。

表结构设计与最佳实践

对应的 MySQL 表结构如下:

CREATE TABLE user_token ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNIQUE NOT NULL, token_balance INT DEFAULT 0, version INT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );

几点建议:

  • user_id加唯一索引,方便按用户查询;
  • version默认值设为1,避免NULL带来的判断复杂度;
  • updated_at自动更新,便于追踪最后修改时间;
  • 若存在高频查询,可结合 Redis 缓存userId -> balance/version映射,减少数据库压力。

不过要注意缓存一致性问题。推荐策略是:

先更新数据库,再删除缓存

这样能最大程度保证强一致性。即使缓存未及时重建,下次查询也会回源到数据库获取最新值。


和悲观锁相比,到底好在哪?

维度悲观锁乐观锁(MyBatisPlus)
加锁时机读取即锁定资源更新时才检测冲突
性能表现低并发下稳定,高并发易阻塞无锁运行,仅冲突时重试
适用场景写密集、冲突频繁读多写少、偶尔更新
实现成本需显式写FOR UPDATE,易遗漏注解驱动,零侵入

对于 Token、积分、优惠券这类“更新频率不高但一致性要求极高”的字段,乐观锁几乎是首选方案。它既避免了悲观锁带来的性能瓶颈,又能有效防止超卖和负余额问题。


实际应用中的注意事项

尽管乐观锁强大,但在落地过程中仍有一些陷阱需要注意:

1. 不适合高频写入的热点账户

如果你的服务中有“头部用户”,比如某个主播收到海量打赏,其 Token 记录被持续更新,那么乐观锁的冲突率会急剧上升,导致大量重试甚至失败。这时可以考虑:

  • 分段设计:将单一余额拆分为多个子账户(如 bucket1~bucket5),分散写压力;
  • 异步队列:将扣减请求放入 Kafka/RocketMQ,串行消费处理;
  • 使用 CAS 型指令:如 Redis 的 Lua 脚本做预检。

2. 避免在事务中长时间停留

以下代码是典型反例:

@Transactional public void badDeduct(Long userId, int amount) { UserToken token = mapper.selectById(userId); // 事务开始 Thread.sleep(5000); // 其他耗时操作 token.setTokenBalance(token.getTokenBalance() - amount); mapper.updateById(token); // 此时 version 很可能已过期 }

由于事务跨度太长,中间可能发生其他更新,导致最后updateById必然失败。正确做法是缩短事务范围,只包裹真正的写操作。

3. 监控重试率,及时发现问题

可以在日志中记录乐观锁失败次数:

log.warn("乐观锁冲突,用户ID={},第{}次重试", userId, i + 1);

然后通过 ELK 或 Prometheus 收集统计指标。如果发现某类操作重试率超过 5%,就要警惕是否存在热点数据或逻辑缺陷。

4. 前端体验优化

当用户因“操作太频繁”被拒绝时,不要返回500 Internal Error,而是给出清晰提示:

{ "code": 409, "message": "操作过于频繁,请稍后重试" }

必要时可在前端添加防抖机制,限制短时间内重复提交。


它还能用在哪些地方?

除了 Token 余额,乐观锁同样适用于:

  • 商品库存扣减(低并发场景)
    尤其适合秒杀后补货、限量兑换等非超高频写入场景。

  • 配置项版本管理
    多人编辑后台配置时,防止覆盖彼此更改。

  • 订单状态流转
    例如从“待支付”到“已取消”,需防止重复关闭。

  • 任务调度去重
    分布式环境下多个节点抢任务,可通过乐观锁更新任务状态实现抢占。

只要涉及“先查后改”的复合操作,且希望避免加锁开销,都可以考虑引入乐观锁。


结语

MyBatisPlus 的乐观锁不是一个炫技功能,而是一个真正解决实际问题的工程利器。它用极简的方式实现了“无锁并发控制”,让我们在追求高性能的同时,不必以牺牲数据一致性为代价。

更重要的是,它的接入成本极低:一个注解 + 一行配置 + 几次重试,就能构建出可靠的并发更新逻辑。对于大多数中小型项目而言,这已经足够应对绝大多数竞争场景。

当然,技术没有银弹。面对极端高并发或热点数据问题,仍需结合缓存、消息队列、分片等手段综合治理。但至少在多数日常场景下,MyBatisPlus 乐观锁已经为我们筑起了一道坚实的数据安全防线

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/1 0:41:52

ComfyUI快捷键设置:提升操作DDColor工作流的效率

ComfyUI快捷键设置&#xff1a;提升操作DDColor工作流的效率 在处理老照片修复项目时&#xff0c;你是否曾因反复点击“运行”按钮而感到疲惫&#xff1f;尤其是在面对上百张黑白影像需要逐一张上色的场景下&#xff0c;每一次鼠标移动、定位、点击都在无形中消耗着时间和精力…

作者头像 李华
网站建设 2026/7/1 2:28:21

聚合前先查:ES教程中filter与query的应用对比

聚合前先查&#xff1a;Elasticsearch中filter与query的本质区别与实战优化你有没有遇到过这样的场景&#xff1f;在 Kibana 里写了个聚合查询&#xff0c;想统计最近一周订单按省份的分布。DSL 写完一运行&#xff0c;响应时间竟然要3秒以上&#xff0c;而数据总量其实也就几百…

作者头像 李华
网站建设 2026/7/1 13:05:08

微PE集成小型Web服务器:在无网络环境下运行DDColor服务

微PE集成小型Web服务器&#xff1a;在无网络环境下运行DDColor服务 你有没有遇到过这样的场景&#xff1f;一台老旧电脑躺在角落积灰&#xff0c;系统早已无法启动&#xff0c;但里面还存着几张家族的老照片——黑白泛黄、模糊不清。你想修复它们&#xff0c;却又担心上传到云端…

作者头像 李华
网站建设 2026/6/29 6:28:16

百度贴吧话题运营:发起‘最感人老照片修复’征集活动

百度贴吧话题运营&#xff1a;发起“最感人老照片修复”征集活动 —— 基于DDColor的黑白老照片智能修复技术解析 在一张泛黄、斑驳的老照片前驻足&#xff0c;许多人会忍不住想象&#xff1a;那时的人穿着什么颜色的衣服&#xff1f;街边的招牌是红是蓝&#xff1f;祖辈年轻时…

作者头像 李华
网站建设 2026/6/26 17:13:06

百度SEO策略:抢占‘老照片上色软件’长尾关键词排名

百度SEO策略&#xff1a;抢占“老照片上色软件”长尾关键词排名 在家庭影像数字化浪潮席卷之下&#xff0c;越来越多用户开始翻出尘封已久的黑白老照片&#xff0c;试图让祖辈的容颜、老屋的模样重新焕发生机。然而&#xff0c;传统修复方式费时费力&#xff0c;而市面上多数“…

作者头像 李华
网站建设 2026/6/26 17:15:21

JavaScript前端控制Python Flask后端执行DDColor图像处理任务

JavaScript前端控制Python Flask后端执行DDColor图像处理任务 在数字时代&#xff0c;一张泛黄的老照片可能承载着几代人的记忆。然而&#xff0c;传统的人工修复方式耗时费力&#xff0c;难以满足大众需求。如今&#xff0c;借助AI技术&#xff0c;我们可以在几分钟内将模糊的…

作者头像 李华