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 UpdateWrapper<Contract>() .setSql("count = count + 1") .eq("id", contractId) );看似第二种方式用了SQL表达式应该没问题?其实在MyBatis-Plus 3.5.x版本中,这两种方式在多线程环境下都会出问题。原因在于:
- 事务隔离级别陷阱:默认的REPEATABLE_READ隔离级别下,两个线程可能同时读到相同的count值
- SQL执行间隙:setSql生成的
UPDATE table SET count=count+1语句在数据库层面确实是原子的,但MyBatis-Plus的插件机制可能会将其拆分为多个步骤 - 版本控制缺失:没有使用乐观锁等并发控制机制
2.2 并发测试验证
通过以下测试代码可以稳定复现问题:
@SpringBootTest class ConcurrentUpdateTest { @Autowired private ContractService contractService; @Test void testConcurrentUpdate() throws InterruptedException { // 初始count=0 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 QueryWrapper<Contract>().last("FOR UPDATE")); contract.setCount(contract.getCount() + 1); contractMapper.updateById(contract); }适用场景:
- 写多读少
- 允许较高延迟
3.3 方案3:MyBatis-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 方案4:CAS+重试机制(高并发最优解)
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 UpdateWrapper<Contract>() .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); });正确姿势:
List<Long> ids = list.stream().map(Contract::getId).collect(Collectors.toList()); mapper.update( new UpdateWrapper<Contract>() .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. 生产环境真实案例复盘
某电商平台促销活动期间,库存扣减出现超卖。排查发现其"已售数量"统计用的是普通update:
UPDATE product SET sold=sold+1 WHERE id=?最终解决方案:
- 短期应急:改用SELECT FOR UPDATE
- 长期方案:
- 商品表添加version字段
- 更新SQL改为:
UPDATE product SET sold=sold+1, version=version+1 WHERE id=? AND version=?
- 架构升级:
- 热点商品采用Redis原子计数器
- 异步同步到数据库
性能对比:
| 方案 | TPS | 错误率 |
|---|---|---|
| 原生update | 1200 | 8.7% |
| 乐观锁 | 850 | 0% |
| Redis+异步 | 5600 | 0% |
6. 扩展思考:不同场景的选型建议
低频更新(如点赞数):
- 直接使用方案3的乐观锁
- 配合@Retryable注解实现自动重试
高频更新(如秒杀库存):
- Redis原子操作 + 定时持久化
- 考虑Redisson的分布式锁
财务级精确统计:
- 事务日志+补偿机制
- 采用TCC模式
实际项目中,我在处理物流订单状态统计时,最终采用了组合方案:
- 实时显示:Redis INCR
- 每日对账:跑批修复数据库统计值
- 关键报表:独立统计引擎
这种设计使得日均200万次的更新请求,数据库压力几乎为零,且统计误差控制在万分之一内。