最近在金融级交易系统中处理实时统计需求时,遇到了一个典型的多线程更新计数场景:20个并发线程同时执行"阅读量+1"操作,理论上最终计数应增加20,但实际结果随机分布在15-18之间。这种计数丢失现象在电商库存扣减、社交平台点赞计数等场景同样高频出现。
通过Arthas监控发现,当线程A读取count=100后,线程B也读取到100,两者各自+1后都写入101,导致其中一次更新被覆盖。这就是并发编程中臭名昭著的"丢失更新"问题。
在REPEATABLE READ隔离级别下(MySQL默认),普通的UPDATE语句实际执行流程如下:
这种混合读取机制导致并发更新时出现判断盲区。更致命的是,MyBatis-Plus的update(Wrapper<T> updateWrapper)方法生成的SQL语句形如:
sql复制UPDATE article SET count=count+1 WHERE id=1
表面看是原子操作,但在Java层面拆分为"查询->计算->更新"三步,失去数据库原生原子性保证。
常见的乐观锁实现方案:
java复制@Version
private Integer version;
在并发量超过50TPS时,会出现大量OptimisticLockException。我们的压测显示:
这种指数级上升的失败率在高并发场景不可接受。
搭建百万级压测环境,对以下方案进行对比测试:
| 方案 | 100并发成功率 | 平均耗时(ms) | 适用场景 |
|---|---|---|---|
| 原生SQL原子更新 | 100% | 12 | 简单计数场景 |
| 分布式锁(Redisson) | 100% | 45 | 分布式环境 |
| 乐观锁重试3次 | 89.7% | 37 | 低并发业务 |
| 悲观锁(for update) | 100% | 62 | 强一致性场景 |
| Redis增量+定时同步 | 100% | 8 | 允许最终一致 |
| 消息队列顺序消费 | 100% | 105 | 异步统计场景 |
| 分段计数(ConcurrentHashMap) | 100% | 5 | 超高并发计数 |
java复制// 方案1:基于UpdateWrapper的原子更新
UpdateWrapper<Article> wrapper = new UpdateWrapper<>();
wrapper.setSql("count = count + 1")
.eq("id", 1);
articleMapper.update(null, wrapper);
// 方案2:自定义SQL(XML或注解方式)
@Update("UPDATE article SET count=count+1 WHERE id=#{id}")
void incrementCount(@Param("id") Long id);
关键点:
对于需要更新多个字段的场景:
java复制// 实体类添加版本字段
@Version
private Integer version;
// 更新时带上版本条件
LambdaUpdateWrapper<Article> wrapper = Wrappers.lambdaUpdate();
wrapper.set(Article::getCount, entity.getCount() + 1)
.set(Article::getTitle, "新标题")
.eq(Article::getId, entity.getId())
.eq(Article::getVersion, entity.getVersion());
配合Spring Retry实现自动重试:
java复制@Retryable(value = OptimisticLockException.class, maxAttempts = 3)
public void updateWithRetry(Article entity) {
// 更新逻辑
}
索引失效陷阱:UpdateWrapper的eq条件必须命中索引,否则锁表
java复制// 错误示范(content字段无索引)
wrapper.eq("content", "abc");
批量更新优化:使用executeBatch替代循环update
java复制SqlSession batchSession = sqlSessionFactory.openSession(ExecutorType.BATCH);
ArticleMapper batchMapper = batchSession.getMapper(ArticleMapper.class);
连接池配置:高并发下需要调整连接池参数
yaml复制spring:
datasource:
hikari:
maximum-pool-size: 50
connection-timeout: 30000
监控指标:必须监控的关键指标
当服务部署多个实例时,推荐组合方案:
java复制Long redisCount = redisTemplate.opsForValue().increment("count:1");
boolean success = articleMapper.casUpdate(1, redisCount);
CAS的SQL实现:
sql复制UPDATE article
SET count = #{newCount}
WHERE id = #{id} AND count = #{oldCount}
这种方案在千万级PV的资讯平台实测,计数误差小于0.001%。