资讯动态

多线程环境下count更新的并发问题与解决方案

发布时间:2026/9/12 8:02:16 来源:尧图企业网站定制
1. 问题场景还原多线程更新count的典型困境上周排查一个生产环境问题时发现合同统计模块的月度汇总数据频繁出现异常。具体表现为系统每天凌晨会启动10个线程并行处理各分公司的合同数据每个线程负责更新对应分公司的当月合同总数。按业务逻辑北京分公司12月应该显示352份合同但数据库里却记录了327——少了整整25条记录。这种count统计不一致的问题在涉及以下场景时几乎必然出现统计型数据的实时更新如订单数、库存量定时任务并行处理分区数据高并发下的计数器场景关键现象无论重复运行多少次最终结果总是小于预期值且差额随机2. 根因分析你以为的原子操作其实不原子2.1 伪原子操作的真相大多数开发者会这样实现count更新MyBatis-Plus示例// 错误示范1先查后改 Contract contract contractMapper.selectById(contractId); contract.setCount(contract.getCount() 1); contractMapper.updateById(contract); // 错误示范2直接运算更新 contractMapper.update( new UpdateWrapperContract() .setSql(count count 1) .eq(id, contractId) );看似第二种方式用了SQL表达式应该没问题其实在MyBatis-Plus 3.5.x版本中这两种方式在多线程环境下都会出问题。原因在于事务隔离级别陷阱默认的REPEATABLE_READ隔离级别下两个线程可能同时读到相同的count值SQL执行间隙setSql生成的UPDATE table SET countcount1语句在数据库层面确实是原子的但MyBatis-Plus的插件机制可能会将其拆分为多个步骤版本控制缺失没有使用乐观锁等并发控制机制2.2 并发测试验证通过以下测试代码可以稳定复现问题SpringBootTest class ConcurrentUpdateTest { Autowired private ContractService contractService; Test void testConcurrentUpdate() throws InterruptedException { // 初始count0 Contract contract new Contract().setCount(0); contractMapper.insert(contract); // 启动100个线程并发1 CountDownLatch latch new CountDownLatch(100); for (int i 0; i 100; i) { new Thread(() - { contractService.incrementCount(contract.getId()); latch.countDown(); }).start(); } latch.await(); // 结果通常小于100 Contract result contractMapper.selectById(contract.getId()); System.out.println(Final count: result.getCount()); } }3. 解决方案对比从临时补丁到终极方案3.1 方案1同步锁不推荐public synchronized void incrementCount(Long id) { // 更新逻辑 }缺点性能杀手完全丧失多线程优势分布式环境失效3.2 方案2数据库悲观锁适用特定场景Transactional public void incrementCount(Long id) { Contract contract contractMapper.selectById(id, new QueryWrapperContract().last(FOR UPDATE)); contract.setCount(contract.getCount() 1); contractMapper.updateById(contract); }适用场景写多读少允许较高延迟3.3 方案3MyBatis-Plus乐观锁推荐基础方案实体类添加Version注解public class Contract { Version private Integer version; // 其他字段 }更新时自动校验版本public void incrementCount(Long id) { Contract contract contractMapper.selectById(id); contract.setCount(contract.getCount() 1); contractMapper.updateById(contract); // 自动校验version }优势轻量级并发控制失败后自动重试3.4 方案4CAS重试机制高并发最优解public void safeIncrement(Long id, int maxRetry) { for (int i 0; i maxRetry; i) { Contract contract contractMapper.selectById(id); int oldCount contract.getCount(); int newCount oldCount 1; boolean updated contractMapper.update( new UpdateWrapperContract() .eq(id, id) .eq(count, oldCount) .set(count, newCount) ) 0; if (updated) break; } }核心原理Compare And Swap原子操作类似Java中的AtomicInteger实现4. MyBatis-Plus专项优化技巧4.1 批量更新的性能陷阱即使使用乐观锁这样的批量更新仍然有问题list.forEach(item - { item.setCount(item.getCount() 1); mapper.updateById(item); });正确姿势ListLong ids list.stream().map(Contract::getId).collect(Collectors.toList()); mapper.update( new UpdateWrapperContract() .setSql(count count 1) .in(id, ids) );4.2 版本号管理的隐藏坑发现很多团队这样用乐观锁// 反例手动设置version contract.setVersion(contract.getVersion() 1); mapper.updateById(contract);正确做法永远让MyBatis-Plus自动管理version在实体类中version字段添加Version注解即可4.3 分库分表下的特殊处理当数据分布在多个库时需要额外处理DS(sharding) // 动态数据源注解 public void distributedIncrement(Long id) { // 使用分布式锁或Seata等方案 }5. 生产环境真实案例复盘某电商平台促销活动期间库存扣减出现超卖。排查发现其已售数量统计用的是普通updateUPDATE product SET soldsold1 WHERE id?最终解决方案短期应急改用SELECT FOR UPDATE长期方案商品表添加version字段更新SQL改为UPDATE product SET soldsold1, versionversion1 WHERE id? AND version?架构升级热点商品采用Redis原子计数器异步同步到数据库性能对比方案TPS错误率原生update12008.7%乐观锁8500%Redis异步56000%6. 扩展思考不同场景的选型建议低频更新如点赞数直接使用方案3的乐观锁配合Retryable注解实现自动重试高频更新如秒杀库存Redis原子操作 定时持久化考虑Redisson的分布式锁财务级精确统计事务日志补偿机制采用TCC模式实际项目中我在处理物流订单状态统计时最终采用了组合方案实时显示Redis INCR每日对账跑批修复数据库统计值关键报表独立统计引擎这种设计使得日均200万次的更新请求数据库压力几乎为零且统计误差控制在万分之一内。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价