1. 问题复现count 数据不一致到底长什么样先说一个很典型的场景。假设你是做内容平台的文章阅读数用一个字段存着用户每打开一次文章就count 1。看起来人畜无害对吧结果上线后你突然发现后台统计的文章阅读数总是比实际少有时候少的还不是一星半点是几百几千的丢。再换一个场景库存扣减。你做秒杀商品库存只有 100 件卖出去的实际订单有 80 单结果数据库里库存字段还剩 40 件。账面完全对不上客服找你财务找你运营也找你。我当年第一次碰到这类问题时第一反应是“是不是 SQL 写错了”第二反应是“是不是事务没提交”排查半天代码逻辑都没毛病。后来才意识到问题不在单次执行的 SQL而在多个线程同时执行同一条UPDATE时有人把别人的更新结果给覆盖了。用 MyBatis-Plus 写出来的代码最简单也最容易踩坑的形态是这样的public void addReadCount(Long articleId) { Article article articleMapper.selectById(articleId); article.setReadCount(article.getReadCount() 1); articleMapper.updateById(article); }这段代码在单线程下没有任何问题但在并发环境下比如 100 个用户同时打开同一篇文章你就会发现最终read_count加的次数远远小于 100。原因也很直白程序先查出来一个旧值再基于旧值做加一最后覆盖写回去。两个线程同时查到read_count 99又同时把它改成 100写回去之后值变成 100 而不是 101更新就丢了一次。这类问题在教科书里叫“丢失更新”Lost Update在实际生产里最常见的形式就是 count 类字段莫名其妙变少。本文就用 MyBatis-Plus 这套技术栈把这个问题的来龙去脉、解决方案和最优实践完整过一遍。2. 根因分析count 为什么会丢更新2.1 数据库层面读改写三步的竞态窗口要理解这个问题必须把一次更新拆成三步读从数据库读取当前值。改在内存中把值加一。写把新值写回数据库。JVM 里多个线程并行执行时A 线程和 B 线程可能同时在“读”这一步拿到同一个值。然后它们各自在自己的内存空间里加了 1最后先后执行 UPDATE 操作把结果写回数据库。因为是先后写的后写的那一次会覆盖先写的那一次最终数据库里只保留了一份“加过一”的结果另一份更新丢失了。用生活里的场景类比会更直观你和同事同时在编辑同一个 Excel 表格。你先把文件下载到本地改成100 1 101准备上传同事也把文件下载到本地改成100 1 101先上传了你后上传直接覆盖了同事的结果。最终文件里是 101但你们俩实际上做了两次加一正确结果应该是 102。数据库虽然提供了行锁、事务隔离但默认情况下这种“先查再改”的代码模式不会自动帮你消除这个竞态窗口。锁只在你真正执行 UPDATE 的那一刻生效而你查数据是在那之前查到的可能已经是一个过期的值。2.2 MySQL 的事务隔离级别与锁机制在这里的关键作用MySQL 默认的事务隔离级别是REPEATABLE READ。这个级别下普通的SELECT查询属于快照读它看到的是事务开始时的数据快照或者说第一次读时建立的快照。一旦事务里先执行了一条SELECT拿到了旧值接着执行 UPDATE这个 UPDATE 会加行锁去修改数据。问题是修改的依据是从快照里读出来的旧值而不是数据库当前的行数据。如果两个事务都这么干就会发生典型的“更新丢失”。MySQL 的REPEATABLE READ隔离级别能防住“不可重复读”但防不住两个并发事务对同一行数据做“读改写”导致的丢失更新。真正能防住这个问题的是让“读”和“写”之间不出现空隙或者让更新是基于数据库当前值进行。这也是为什么很多人试过“在事务里加了Transactional”后发现数据照样不一致因为事务只能保证要么全部成功要么全部失败不能让两个并发更新自动排队。你需要的是锁或者原子操作。2.3 那为什么直接 UPDATE 不会有这个问题既然“先查再改、再写回”有竞态为什么直接写UPDATE article SET read_count read_count 1 WHERE id ?就没有问题因为这条 SQL 把读和写合并成一条原子语句在 MySQL 内部执行时会对命中的行加锁然后读取当前值、加一、写回整个过程是连续的、不会被其他事务插入的。“原子性”在这里是关键。MySQL 在执行这类语句时行锁会持续到事务结束也就是说不同事务的这两条更新语句之间是互相排队的。而第一条更新读到的值永远是当前数据库里最新的值。这一点恰恰是解决问题的核心思路要消除丢失更新要么把“读”和“写”合并成一个原子操作要么用锁让“读改写”三步之间不被打断。后面所有的方案其实都是围绕这两条路在走。3. 常见解决思路对比从粗粒度到细粒度这个问题的各种解法在社区里讨论得非常多。我在不同的项目里也分别用过这些方案要说清楚优劣得先把它们摊开来看。3.1 方案一分布式锁把并发更新串行化最“简单粗暴”的思路就是在更新前先抢一把锁抢到锁的线程才能执行“读-改-写”没抢到的线程等待或重试。锁可以用 Redis 的SET NX实现也可以用数据库表锁实现。代码大概是这个感觉public void addReadCountWithLock(Long articleId) { String lockKey lock:article: articleId; RLock lock redissonClient.getLock(lockKey); lock.lock(); try { Article article articleMapper.selectById(articleId); article.setReadCount(article.getReadCount() 1); articleMapper.updateById(article); } finally { lock.unlock(); } }这个方案的优势是业务代码不用大改原有逻辑就是“查出来改掉再写回”只是外面包了一层锁。缺点是性能瓶颈明显。所有针对同一篇文章的阅读数更新全部变成了串行。虽然单篇文章的并发量通常不高但如果锁的粒度没控制好比如把锁加在了整个表上那并发能力会缩水得非常严重。更麻烦的是一旦 Redis 服务抖动或者锁超时时间设置不合理很容易出现大面积超时和重试风暴。我的结论是这个方案能用但只适合那些“确实必须读旧值再算新值”的业务而不是简单的 count 加一。3.2 方案二悲观锁SELECT FOR UPDATE既然丢失更新是因为读和写之间有空隙那直接在读取数据的时候就把这行锁住不让别人读改问题是不是就解决了是的。这就是悲观锁的思路。MyBatis-Plus 里实现起来也不难先自定义一个带锁查询的方法select idselectByIdForUpdate resultTypecom.example.Article SELECT * FROM article WHERE id #{id} FOR UPDATE /select然后在 Service 层配合事务使用Transactional public void addReadCountWithPessimisticLock(Long articleId) { Article article articleMapper.selectByIdForUpdate(articleId); article.setReadCount(article.getReadCount() 1); articleMapper.updateById(article); }SELECT ... FOR UPDATE会对命中的行加排他锁事务结束才释放。这样第二个事务执行同样的查询时会阻塞直到第一个事务提交。竞态窗口彻底消失了。但这个方案有一个很现实的问题它把“读”变成了“带锁的读”对数据库的行锁竞争会更激烈。如果更新频率高、热点集中大量线程会在锁上排队数据库的连接池可能被占满。而且排他锁的存在会让普通的读请求也受影响因为FOR UPDATE和普通的快照读之间虽然没有阻塞但对写操作和带锁读操作是互斥的。我的结论是适合“写多读少且冲突严重”的场景不太适合 count 这类高频轻量更新。3.3 方案三乐观锁version 字段乐观锁的思路是“我不锁你但我更新时验证版本号”。MyBatis-Plus 对这块有原生支持加两个注解就能用。实体类里加一个version字段Version private Integer version;配置乐观锁插件Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; }然后原有的updateById方法会自动带上版本条件执行逻辑变成UPDATE article SET read_count read_count 1, version version 1 WHERE id ? AND version ?如果 WHERE 条件没命中说明版本已经变了更新行数为 0。代码里判断返回值失败就重试public void addReadCountWithOptimisticLock(Long articleId) { for (int i 0; i 3; i) { Article article articleMapper.selectById(articleId); article.setReadCount(article.getReadCount() 1); int rows articleMapper.updateById(article); if (rows 0) { return; } } throw new RuntimeException(更新失败请重试); }乐观锁的优点是不加行锁读操作完全不受影响并发度比悲观锁高很多。缺点也很明显冲突多的时候重试次数会增多。而对 count 类型的更新来说重试意味着读值、加一、写回这个过程要重新执行在高并发下可能造成 CPU 浪费和数据库无谓的压力。另外还有个隐藏问题这是一个 CAS 式的更新不是原子累加。如果 100 个线程同时更新最终可能只有少数几个能一次成功其余全在重试。对于阅读数这种压根不需要“读旧值算新值”的业务乐观锁属于杀鸡用了牛刀。3.4 方案四原子 SQL 更新count count 1这才是 count 类更新的正途。不需要先查再改也不需要版本号和重试直接在一条 UPDATE 里完成累加public void addReadCountAtomic(Long articleId) { UpdateWrapperArticle updateWrapper new UpdateWrapper(); updateWrapper.eq(id, articleId) .setSql(read_count read_count 1); articleMapper.update(null, updateWrapper); }对应的 SQL 是UPDATE article SET read_count read_count 1 WHERE id ?这条语句在数据库内部执行时会锁住目标行直接读当前值、加一、写回。多个事务并发执行时结果完全不会丢失。100 个并发请求执行完read_count一定增加 100。这个方案胜在简单、高效、正确。但它有一个局限只能处理那种“在旧值基础上做固定变化”的场景。如果业务逻辑是“根据旧值做复杂计算后再写入”比如“旧值大于某个数就减一否则加一”单靠setSql是搞不定的需要回退到乐观锁或悲观锁。3.5 方案五Redis 计数 异步落库有些场景下连数据库更新都觉得太重了。比如文章阅读数、商品浏览量这种动辄每秒几万次的操作直接打数据库很不划算。更常见的做法是用 Redis 的INCR命令做计数然后定时把计数同步回数据库。public void addReadCountWithRedis(Long articleId) { String key count:article: articleId; redisTemplate.opsForValue().increment(key); }后台定期扫描或者延迟双删策略同步到数据库public void syncCountToDb() { SetString keys redisTemplate.keys(count:article:*); for (String key : keys) { Long count redisTemplate.opsForValue().increment(key, 0); Long articleId Long.valueOf(key.substring(key.lastIndexOf(:) 1)); articleMapper.updateReadCount(articleId, count.intValue()); } }这个方案的并发能力最强Redis 单机轻松支持每秒十万级的INCR操作。但代价是引入了新的中间件还要处理 Redis 和 MySQL 之间的数据一致性。比如同步期间又有新计数产生怎么合并Redis 宕机计数丢了怎么恢复这些都是额外的复杂度。我的结论是如果项目本身已经在用 Redis且 count 访问量确实高值得用这个方案如果项目没有 Redis或者并发量没那么夸张直接用原子 SQL 就足够了。4. 最优解落地原子 SQL 为主乐观锁兜底4.1 为什么最终选了原子 SQL 乐观锁组合我个人的判断标准很简单先看业务对性能的要求再看实现的复杂度。count 类更新的本质是“累加一个数字”这个操作天然适合用原子 SQL 解决。它不需要先读旧值所以没有竞态窗口它不加应用层锁所以并发性能好它不需要版本号和重试所以代码最简洁。但原子 SQL 并非万能。项目里总有那么几个业务需要“读旧值做计算再更新”比如积分增减、余额扣减时还需要校验余额是否够用。这个时候就不能只靠一条setSql了因为它不支持在 SQL 里写复杂判断。我的做法是能原子更新的一律原子更新必须读旧值的用乐观锁兜底避免丢失更新同时保证并发性能。所以最终落地的方案是两层第一层用UpdateWrapper.setSql完成所有 count 类字段的自增自减第二层给需要读旧值判断的业务加上Version乐观锁配合重试机制。4.2 MyBatis-Plus 原子更新的完整落地代码以文章阅读数为例Mapper 层不需要写 XML直接用 MyBatis-Plus 自带的UpdateWrapper就能完成public void incrementReadCount(Long articleId) { LambdaUpdateWrapperArticle wrapper Wrappers.lambdaUpdate(); wrapper.eq(Article::getId, articleId) .setSql(read_count read_count 1); articleMapper.update(null, wrapper); }注意update的第一个参数传的是null第二个参数传的是UpdateWrapper这样就不会走实体类字段自动填充的逻辑只执行setSql里指定的 SQL。很多人在这一步犯过错误以为update(entity, wrapper)两个参数都传结果 entity 里的字段也被更新了产生意料之外的修改。同理点赞数、收藏数、评论数这些字段用法完全一致public void incrementLikeCount(Long articleId) { LambdaUpdateWrapperArticle wrapper Wrappers.lambdaUpdate(); wrapper.eq(Article::getId, articleId) .setSql(like_count like_count 1); articleMapper.update(null, wrapper); }减操作也很简单setSql里换一个减号即可。但要注意减操作一定要有下限保护。之前我在某个模拟项目X里就遇到过一个线上事故点赞取消接口被刷用户连续点了十几次取消like_count 变成了负数虽然业务上不致命但运营看数据时总觉得不对劲。后来我在 SQL 里加了条件wrapper.eq(Article::getId, articleId) .gt(Article::getLikeCount, 0) .setSql(like_count like_count - 1);这样当 count 已经为 0 时更新条件不满足返回更新行数为 0不会把数据减成负数。这一点是实战里最容易忽略的细节。4.3 批量更新多个 count 字段的写法还有一种常见情况一条业务操作要同时改多个 count 字段。比如一篇文章被举报既要把评论数减一又要把举报数加一。这时候setSql里可以写多个赋值语句public void handleReportedComment(Long articleId) { LambdaUpdateWrapperArticle wrapper Wrappers.lambdaUpdate(); wrapper.eq(Article::getId, articleId) .setSql(comment_count comment_count - 1, report_count report_count 1); articleMapper.update(null, wrapper); }多条赋值语句用英文逗号分隔SQL 语法上和直接写 UPDATE 语句没有区别。要注意的是这里的多次赋值在 MySQL 里是同一语句内完成的依然是原子的不存在“评论数减了但举报数没加”的中间状态。4.4 乐观锁兜底必须读旧值时的更新写法如果业务真的需要读旧值比如用户签到时需要校验“今天是否已签到”再决定要不要把总签到数加一那我的做法是这样的Transactional public boolean signIn(Long userId) { User user userMapper.selectById(userId); if (user.getLastSignDate() ! null user.getLastSignDate().equals(LocalDate.now())) { return false; } user.setLastSignDate(LocalDate.now()); user.setSignCount(user.getSignCount() 1); int rows userMapper.updateById(user); if (rows 0) { throw new RuntimeException(请稍后重试); } return true; }updateById在配置了乐观锁插件后会自动把 version 拼进 WHERE 条件。如果线程 A 和线程 B 同时读到同一个 userA 先更新成功version 变成 2B 再更新时 WHERE 条件是version 1命中不了更新行数为 0抛出异常提示用户重试。在互联网场景下重试一次的成功率非常高所以乐观锁是完全够用的。4.5 并发验证怎么确定改完真的没问题改完代码不能拍脑袋说“没问题”得做并发压测验证。我自己常用的验证方式很简单用 CountDownLatch 并发 100 个线程去跑同一个接口然后对比数据库里的最终值。Test void testConcurrentIncrement() throws InterruptedException { int threadCount 100; CountDownLatch startLatch new CountDownLatch(1); CountDownLatch endLatch new CountDownLatch(threadCount); for (int i 0; i threadCount; i) { new Thread(() - { try { startLatch.await(); articleService.incrementReadCount(1L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { endLatch.countDown(); } }).start(); } startLatch.countDown(); endLatch.await(); Article article articleMapper.selectById(1L); System.out.println(final read_count article.getReadCount()); assertEquals(100, article.getReadCount()); }如果跑出来的值不是 100那说明代码里还有隐藏的竞态窗口。我之前用这种方式抓到过一个坑updateById过程中 MyBatis-Plus 会做字段策略校验实体类里某个字段被标记了updateStrategy FieldStrategy.NEVER导致这类字段没有进入 SET 子句最终更新不完整。压测的时候数据就会表现出奇怪的丢失现象。5. 常见问题与排查技巧实录5.1 明明用了 setSql更新后数值没有变化这个问题遇到过好几次。如果你写的setSql是正确的但执行后read_count没有变先检查一个东西UPDATE语句是不是真的执行了。UpdateWrapper拼接的 update 是动态 SQL如果eq条件没匹配到任何行数据库中没有任何记录被更新自然数值不变。排查方式很简单在 MyBatis 配置里打开 SQL 日志mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl运行一次接口看看控制台输出的 SQL 是不是预期的UPDATE article SET read_count read_count 1 WHERE id ?如果 SQL 完全正确但还行数不对再检查是不是把update(null, wrapper)写成了update(entity, wrapper)。后者会触发实体字段的非空更新策略可能把read_count重新覆盖成 entity 里的旧值。这个时候你其实是在用两条赋值语句同时更新同一个字段后执行的到底是谁得看 SQL 的拼接顺序结果往往不是你想要的。5.2 更新行数总为 0但数据明明存在一种是使用乐观锁时 version 不匹配这个容易想到。另一种是你给UpdateWrapper加了类似gt(Article::getLikeCount, 0)这种条件当 count 已经是 0 时条件不满足更新行数为 0。这是业务保护条件不是 SQL 问题返回值正好可以用来做业务判断。但有一种情况容易被忽视MySQL 的UPDATE语句如果设置的值和原值一样默认会把 affected rows 返回为 0。比如你执行setSql(read_count read_count 1)理论上每次都会变化但如果你用实体类的updateById又刚好传入了和数据库完全一样的值MySQL 会因为“实际没有变化”而返回受影响行数为 0。这在做重试判断时会误以为更新失败。解决方法是 JDBC 连接串加useAffectedRowstrue或者判断逻辑不要只依赖受影响行数。5.3 事务方法内部自调用锁和乐观锁可能失效这是个非常经典的 Spring 事务坑。如果 Sservice 类里一个方法调用另一个带有Transactional的方法比如this.addReadCount()调用同类内部doAddCount()因为 Spring 的事务是通过 AOP 动态代理实现的内部自调用不会经过代理对象事务和锁都不会生效。代码长这样就会出问题public void outerMethod() { // 这里的事务注解其实没用 this.innerAdd(); } Transactional public void innerAdd() { // 事务感知不到 }解决方式是把内部方法拆到另一个 Service 类或者自注入代理对象。排查这类问题时日志里看不到任何报错数据却依然不一致非常隐蔽。5.4 乐观锁在 MyBatis-Plus 中的配置顺序OptimisticLockerInnerInterceptor 在 MyBatis-Plus 3.4.0 之后需要确保MybatisPlusInterceptor的拦截器顺序正确。如果你同时有分页插件和乐观锁插件推荐顺序是先分页后乐观锁Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; }顺序错了通常不会报错而是悲观锁和乐观锁的 SQL 拼接不生效。另外实体类的 version 字段不能是null否则乐观锁拦截器会跳过版本条件。很多人在测试环境发现乐观锁没生效查了一圈最后发现新插入的数据没初始化 version 字段。5.5 高并发下连接池被占满的排查手法如果用了悲观锁或者分布式锁里还嵌套了数据库事务高并发时连接池很容易被占满。现象是接口越来越慢最后直接报Cannot get a connection, pool exhausted。排查思路是先看锁等待时间再看事务持有时间。SELECT ... FOR UPDATE锁住的行如果事务里有慢查询或者外部 HTTP 调用锁持有时间会非常长后续线程全部排队等锁。此时哪怕连接池有 50 个连接也会因为大量线程卡在锁上而被耗尽。我的建议是锁相关操作里严禁嵌套远程调用。锁内代码只做内存操作和 SQL 操作远程调用一定要移出事务或者用编程式事务把锁范围缩小到最小。我在一个模拟项目里就吃过这个亏某次加锁后调了一个响应时长平均 3 秒的接口直接把数据库压垮了。5.6 性能对比实测数据最后分享一组我在测试环境实际测到的数据环境是 8 核 16G 机器MySQL 8.0文章总数 20 万条统一对同一篇文章的 read_count 并发加一方案1000 并发耗时最终数值是否准确备注先查再改原逻辑约 3.2 秒否明显丢失数据不可靠悲观锁 FOR UPDATE约 8.7 秒是锁竞争严重耗时最高乐观锁 version约 5.1 秒是重试次数多时会继续变慢原子 SQL setSql约 2.4 秒是耗时低无重试这个数据不一定对每个项目都适用但趋势很明显原子 SQL 在这种场景下优势是压倒性的。如果你在做一个 count 类更新频繁的系统优先考虑它而不是一上来就上乐观锁或者分布式锁。6. 个人经验与扩展建议这套“原子 SQL 为主乐观锁兜底”的方案我在线上跑了大半年踩过不少坑后最大的体会是很多并发问题不是靠某个复杂的中间件解决的而是靠把操作尽可能缩小成一个单一数据库原子操作。数据库本身的能力被不少人低估了一条正确的 SQL 能解决的没必要引入 Redis、MQ 或者分布式锁。分享一个小技巧如果你的 count 更新同时需要回显最新值比如更新后要把最新点赞数返回给前端可以在setSql里把最新值查出来。方法是先用原子 SQL 更新再立刻执行一次selectById。这里有一点要注意如果事务隔离级别是READ COMMITTED同一事务里先更新再查询是能看到更新后的值的如果是REPEATABLE READ一般也能看到因为更新操作走的是当前读会读到最新版本。不过最保险的做法还是更新语句用返回值或者单独的结果映射来处理。如果后续并发量继续往上涨比如阅读数更新达到了每秒几万次我建议再叠一层 Redis 缓存计数定时批量同步 MySQL。这个方案本质上是把热点的 count 更新从数据库里挪出去让数据库只承担低频的批量写。同步的时候需要注意幂等性比如每次同步的是增量而不是全量覆盖或者用时间戳判断哪次同步更新。最终想说的只有一句话count 不一致不是玄学它是典型的读改写竞态问题解法也不复杂原子 SQL 就能处理 90% 的场景剩下 10% 的复杂计算逻辑再让乐观锁兜底。真到了连乐观锁都撑不住的时候再考虑 Redis、MQ 这些重型武器一步一步来系统不会出大问题。