做后端开发这些年,我见过太多因为并发控制没做好而翻车的现场:库存扣成负数、订单重复创建、余额对不上账,轻则线上告警,重则直接资损。这些问题听起来五花八门,根源却高度一致——对锁的理解和应用没到位。Spring Boot 项目里的锁,翻来覆去就是悲观锁、乐观锁这两大类,真要在业务里用对、用好,其实门道不少。这篇文章我打算把这两种锁的原理、实战写法、选型思路一次讲透,再延伸到分布式场景下 Redis 锁的正确用法,最后把我这些年踩过的坑都翻出来晒一晒。如果你正在用 Spring Boot 写业务系统,又被并发更新、超卖、重复扣款这些问题困扰,这篇内容应该能省下你不少排查时间。
1. 并发场景下的数据竞争:锁到底在解决什么问题
1.1 一个典型的“超卖”事故现场
先还原一个我早年处理过的真实事故。一张商品库存表,字段很简单,id、sku_code、count,用户下单时执行:
sql复制update stock set count = count - 1 where sku_code = 'SKU001';
库存表里 SKU001 只剩 1 件,结果同秒有两个用户同时下单,最后数据库里 count 变成了 -1。用户这边更离谱,两个人同时收到了“下单成功”的提示,但货只有一件。
这种事故就是典型的并发竞争。你说数据库有问题吗?没有,单条 update 其实是原子的,InnoDB 在底层会加行锁保证这条语句执行时不被其他事务干扰。问题出在业务代码往往不是只执行这一条 SQL,而是要先查库存、再校验、再扣减,三段逻辑合在一起才是一个完整业务,而这种“先查后改”的组合动作并不具备原子性。
1.2 并发问题的本质:读改写三步不是一个原子操作
我们看一个最简单的扣库存逻辑,Spring Boot 服务里通常长这样:
java复制Stock stock = stockMapper.selectBySkuCode("SKU001");
if (stock.getCount() <= 0) {
throw new RuntimeException("库存不足");
}
stock.setCount(stock.getCount() - 1);
stockMapper.updateById(stock);
这段代码在单线程下没有任何问题。一旦并发上来,两个请求同时执行到第一行,读到的库存都是 1,都通过了库存校验,然后各自把 1 减成 0,最后后写的那个人把数据库覆盖成了 0。注意,这里不是变成了 -1,而是明明卖了两单,数据库库存只从 1 变成 0,丢失了一次更新。
从数据库角度来看,这个场景的本质是两个事务交错执行了“读-改-写”的全过程。MySQL 默认的隔离级别是REPEATABLE READ,它能防止脏读、不可重复读,但并不能阻止丢失更新。因为丢失更新是应用层逻辑交错导致的,数据库的隔离级别压根管不到这种场景。所以我们必须主动引入锁机制,把“读-改-写”这一个完整业务动作变成串行。
1.3 先从整体上认识三种锁
Spring Boot 项目里提到的“锁”,其实分三个层次,很多人一开始就混在一起了:
| 锁的类型 | 关注维度 | 解决什么问题 |
|---|---|---|
| 悲观锁 | 数据库层面的行锁、表锁 | 在数据库内保证同一条数据的并发修改安全 |
| 乐观锁 | 应用层 + 数据库版本字段 | 通过版本号校验避免并发更新覆盖 |
| 分布式锁 | Redis、ZooKeeper 等外部组件 | 解决多实例部署下本地锁失效的问题 |
我把它们从单机到分布式排了个序,后面的章节会逐个展开。悲观锁和乐观锁解决的是“单库单表下的并发更新”,分布式锁解决的是“多实例多节点下的互斥访问”。很多人一上来就研究 Redis 分布式锁,结果单机场景下的悲观锁、乐观锁都没用明白,属于步子迈大了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 悲观锁实战:让并发更新老老实实排好队
2.1 悲观锁的底层逻辑:先锁后查
悲观锁的核心思想就一句话:我先把这条数据锁住,别人谁也别想动,等我处理完提交事务再放行。说白了,就是默认并发一定会发生冲突,所以用数据库提供的排他锁把并发变成串行。
在 MySQL InnoDB 引擎下,悲观锁的长相就是 select ... for update。执行这条 SQL 后,事务会尝试对命中的行加上排他锁。同一时间,其他事务如果也想对同一行执行 for update 或者 update、delete,会进入阻塞等待,直到持锁事务提交或者回滚。
这里有个关键点得说清楚:for update 锁的粒度取决于 where 条件。如果 where 条件命中索引,锁的是行,这叫行锁;如果条件无法命中索引,InnoDB 会锁住整张表。关于这个我后面专门用一节讲,因为它直接决定你会不会把线上搞挂。
2.2 Spring Data JPA 里用 @Lock 加排他锁
如果你用的是 Spring Data JPA,加悲观锁非常方便,只需要在仓库方法上标注 @Lock 注解。
java复制public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id = :id")
Optional<Stock> findByIdForUpdate(@Param("id") Long id);
}
LockModeType.PESSIMISTIC_WRITE 表示排他写锁,JPA 底层会翻译成 select ... for update。还有个 PESSIMISTIC_READ,表示共享读锁,但 MySQL InnoDB 其实不支持真正的共享读锁语义,实际执行后效果和写锁差不多,所以业务上只用 PESSIMISTIC_WRITE 即可。
调用的时候要注意,这个方法必须在事务里执行,否则锁不会生效。很多朋友在这里踩坑:service 方法加了 @Transactional,但内部调用的是仓库层的 findById 而不是 findByIdForUpdate,锁没加上,代码还看起来没毛病。排查方式就是看日志里的 SQL 是否真的带了 for update。
2.3 MyBatis / MyBatis-Plus 里的 for update 写法
用 MyBatis 的话,SQL 得自己写。我习惯在 mapper 层单独写一个查询方法,语义清晰:
xml复制<select id="selectBySkuCodeForUpdate" resultType="com.example.Stock">
select * from stock
where sku_code = #{skuCode}
for update
</select>
对应的 Mapper 接口方法:
java复制Stock selectBySkuCodeForUpdate(@Param("skuCode") String skuCode);
Service 层使用:
java复制@Transactional(rollbackFor = Exception.class)
public void deductStock(String skuCode, Integer count) {
Stock stock = stockMapper.selectBySkuCodeForUpdate(skuCode);
if (stock.getCount() < count) {
throw new RuntimeException("库存不足");
}
stockMapper.deductCount(skuCode, count);
}
注意最后一句我写了 deductCount,是直接执行的 update:
sql复制update stock set count = count - 1 where sku_code = #{skuCode}
因为前面已经锁住了这行,这里再更新就是安全的。为什么不直接 updateById?因为前面 for update 锁住的版本已经被当前事务读取,后续 update 只是把内存里修改后的值写回去,语义上没问题。但如果中间有人改了数据,用带条件的 update 更保险,也减少一次大字段的无谓更新。
2.4 悲观锁使用中的几个关键细节
悲观锁看着简单,实际用起来有几个细节不注意就会出事故。
第一,必须开启事务。 for update 的锁生命周期跟事务绑定,事务提交或者回滚,锁才释放。如果方法没有 @Transactional,Spring Boot 会以自动提交模式执行 SQL,查询一结束锁就释放了,等于白锁。
第二,锁住的资源范围要尽量小。 事务里尽量不要夹杂远程调用、批量计算这种耗时操作。因为持锁时间越长,其他事务阻塞越久,数据库的连接和线程池被占用越多。我见过一个同事在 for update 之后调了一个第三方接口,接口超时 3 秒,数据库连接被占住,50 个并发直接把连接池打满。
第三,设置合理的锁等待超时。 MySQL 默认的 innodb_lock_wait_timeout 是 50 秒,也就是说一个事务等锁最多等 50 秒才报错。对于在线业务来说,这时间太长了,会让用户端一直转圈。可以在 JDBC 连接参数里加:
text复制jdbc:mysql://localhost:3306/demo?connectTimeout=3000&socketTimeout=5000&lockWaitTimeout=3
或者直接在事务 SQL 里控制逻辑,一旦出现 Lock wait timeout exceeded 就快速失败,把用户请求挡住,而不是让请求在数据库层排队。
3. 乐观锁实战:用版本号挡住并发修改
3.1 乐观锁的底层逻辑:版本号是最后防线
乐观锁的哲学跟悲观锁完全相反:默认并发冲突很少发生,所以不加锁,只在更新时校验一下数据有没有被别人改过。校验的依据就是版本号,或者时间戳。
实现原理很简单,给表加一个 version 字段,每次更新数据时,顺手把 version + 1。更新语句的 where 条件里除了主键,还要带上当前的版本号:
sql复制update stock
set count = count - 1, version = version + 1
where sku_code = #{skuCode} and version = #{version}
这条 SQL 执行后,返回的影响行数就是关键指标。如果影响行数是 1,说明版本号匹配,更新成功;如果是 0,说明在这条数据被当前事务读到之后、更新之前,已经有其他事务改了它,那么本次更新直接失败,业务层需要决定是重试还是提示用户。
3.2 三种主流实现方式对比
方式一:原生 SQL + 手动判断影响行数
在 MyBatis 里手动拼接版本号条件最直观:
java复制int rows = stockMapper.deductStockByVersion("SKU001", 1, 0);
if (rows == 0) {
throw new RuntimeException("操作失败,请重试");
}
xml复制<update id="deductStockByVersion">
update stock
set count = count - #{count},
version = version + 1
where sku_code = #{skuCode}
and version = #{version}
</update>
这种方式最可控,但要求开发者在每一条更新语句里都手动写好版本号条件,漏一个地方就白搭。
方式二:MyBatis-Plus 的 @Version 注解
MyBatis-Plus 把乐观锁封装成了插件。实体类加字段:
java复制@Version
private Integer version;
然后配置乐观锁插件:
java复制@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
这样以后所有通过 updateById 执行更新时,MyBatis-Plus 都会自动在 SQL 里拼接 version = ? 条件,并把返回值作为更新是否成功的依据。你只需要判断影响行数即可,省去了手写条件。但它的前提是:更新时使用实体对象,且实体里带了 version 字段。如果用了自定义 SQL 写 update,插件不会帮你处理,还是得自己写条件。
方式三:JPA 的 @Version 字段
Spring Data JPA 的 @Version 更省心:
java复制@Entity
public class Stock {
@Id
private Long id;
private Integer count;
@Version
private Long version;
}
JPA 在调用 save 方法更新实体时,会自动带上版本号校验。如果版本不匹配,抛 OptimisticLockException,业务层捕获后做重试或提示。
3.3 更新冲突后怎么办:重试机制怎么设计
乐观锁最大的问题是:冲突发生后,更新直接失败,用户看到的是“操作失败”。所以重试机制几乎是必须的。
我的建议是有限次数重试,不要无限自旋。每次重试要重新读取最新数据(包含新版本号),然后再执行一次更新。代码可以参考:
java复制@Transactional(rollbackFor = Exception.class)
public void deductStockWithRetry(String skuCode, int count) {
int retryTimes = 3;
while (retryTimes > 0) {
Stock stock = stockMapper.selectBySkuCode(skuCode);
int rows = stockMapper.deductStockByVersion(
skuCode, count, stock.getVersion()
);
if (rows > 0) {
return;
}
retryTimes--;
}
throw new RuntimeException("系统繁忙,请稍后重试");
}
这里要特别注意重试的事务边界。如果像上面这样把整个重试循环放在一个大事务里,第一次更新失败后,事务并没有提交,第二次重试时读取的数据仍然受当前事务隔离性影响。更稳妥的做法是让每次重试成为独立事务,即在外部用一个非事务方法驱动重试,内部调用带 @Transactional 的业务方法。Spring Boot 里这需要把事务方法放到另一个 Bean 中,或者通过事务模板 TransactionTemplate 手动控制。
4. 怎么选型:两种锁不是对立关系
4.1 一张表看穿两者的差异
很多开发者喜欢问“悲观锁和乐观锁到底选哪个”,我的答案是:先看你的业务场景,再看两者的特性。
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 并发控制方式 | 先锁后操作,数据库直接阻塞并发 | 无锁,更新时用版本号校验 |
| 冲突成本 | 阻塞等待,CPU 不空转 | 更新失败后需要额外重试 |
| 适用冲突率 | 高冲突场景更稳定 | 低冲突场景性能更优 |
| 死锁风险 | 存在,需要控制锁顺序 | 基本无死锁 |
| 实现复杂度 | 低,加注解/for update 即可 | 需要额外处理重试逻辑 |
| 对数据库压力 | 持锁期间会话占用 | 冲突率高时重复 SQL 执行频繁 |
如果业务是写多读少、并发冲突率高,比如秒杀扣库存、抢单,那悲观锁的简单粗暴反而更可靠,因为数据库锁机制已经替你解决了大量并发排队问题。如果业务是读多写少、冲突概率低,比如用户资料的更新、文章点赞数累加,乐观锁更轻量,因为绝大多数情况下更新都能一次成功,省去了加锁、检测死锁的开销。
4.2 按项目场景做选择
我总结了一套判断方法,照着套就行:
- 允许失败重试的业务(比如点赞、收藏、浏览数累计),优先乐观锁。
- 不允许失败、必须成功的强一致业务(比如支付、转账、订单状态流转),优先悲观锁。
- 单次操作涉及的 SQL 少、事务短,可以大胆用悲观锁。
- 单次操作逻辑复杂、事务时间长,谨慎用悲观锁,持锁太久会拖垮连接池,这时候乐观锁 + 有限重试可能更合适。
还有一个容易忽略的点:数据库主从延迟。如果项目用了读写分离,乐观锁读取版本号时走的是从库,从库数据落后主库,可能读到旧版本号,导致更新永远失败。这种场景下要么强制读主库,要么改用悲观锁,别让锁机制和主从架构打架。
4.3 同一项目中的混合使用案例
我在一个订单项目里就同时用到了两种锁。订单支付回调的处理,使用悲观锁:
java复制@Transactional
public void handlePayCallback(Long orderId) {
Order order = orderRepository.findByIdForUpdate(orderId);
if (order.getStatus() == PAID) {
return; // 重复回调,直接返回
}
order.setStatus(PAID);
orderRepository.save(order);
}
这里为什么用悲观锁?因为支付回调可能重复推送,同一订单的并发修改概率极高,而且支付状态一旦错了就是资损事故,必须保证严格串行。
而订单列表里的用户备注更新,使用的是乐观锁:
java复制public void updateRemark(Long orderId, String remark) {
Order order = orderMapper.selectById(orderId);
order.setRemark(remark);
int rows = orderMapper.updateByIdWithVersion(order);
if (rows == 0) {
throw new BusinessException("数据已更新,请刷新后重试");
}
}
备注修改的冲突概率极低,没必要让用户等锁,用乐观锁让用户偶尔重试一次完全可接受。
两种锁不是二选一的关系,它们的本质是:在一致性和并发性能之间,根据业务场景做取舍。
5. 分布式部署场景下,为什么还需要 Redis 锁
5.1 单机锁在微服务场景为什么会失效
悲观锁和乐观锁都建立在“操作同一份数据库数据”的前提上,前提是应用可以访问同一个数据库。但如果你把服务水平扩展成多个节点,问题就来了。
假设两个订单服务实例都收到了下单请求,它们跑的代码一样,都能访问同一个 MySQL。这时候悲观锁依然能工作,因为不管哪个实例发的 for update,数据库都会协调。数据库层的锁天然是跨实例的。
那为什么还需要分布式锁?因为很多互斥资源不在数据库里。典型场景是:多实例定时任务重复执行、多实例调用同一个外部接口、多实例处理同一个 Redis 队列。这些场景下,JVM 层面的 synchronized 或 ReentrantLock 只能锁住当前进程,其他实例根本不认识这把锁。
另外一个更隐蔽的场景:并发不高但必须互斥的数据库操作。比如发优惠券时要求同一用户同一时间只能领一张,即使数据库有唯一索引兜底,你也希望在应用层提前拦住,减少数据库无谓的锁等待和异常日志。这时候分布式锁就能派上用场。
5.2 一个可用的 Redis 分布式锁实现
Redis 分布式锁的经典实现是 SET key value NX PX expireTime,利用 Redis 单线程执行命令的特性,保证同一时刻只有一个客户端能设置成功。
java复制String lockKey = "lock:coupon:" + userId;
String requestId = UUID.randomUUID().toString();
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofSeconds(30));
if (Boolean.TRUE.equals(success)) {
try {
// 领取优惠券的业务逻辑
doIssueCoupon(userId);
} finally {
// 释放锁,必须用 Lua 保证判断和删除的原子性
releaseLock(lockKey, requestId);
}
} else {
// 获取锁失败,快速返回或重试
throw new RuntimeException("操作太频繁,请稍后再试");
}
释放锁的时候,最容易犯的错是直接 delete lockKey。设想这个场景:线程 A 加锁后业务执行超过了 30 秒,锁自动过期了;线程 B 抢到锁开始执行;这时线程 A 终于跑完,执行 delete,把线程 B 的锁删了。线程 B 立刻失去保护,这时线程 C 也能加锁进入,互斥被完全破坏。
正确的释放姿势是“先校验再删除”,而且要保证两步的原子性,用 Lua 脚本:
lua复制if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
java复制// 释放锁的封装
public void releaseLock(String key, String requestId) {
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
redisTemplate.execute(redisScript, Collections.singletonList(key), requestId);
}
requestId 用 UUID 或者雪花 ID 都行,关键是每个线程必须是唯一值,这是防止误删别人锁的凭证。
5.3 小心锁过期:Redisson 的看门狗机制
上面手写的 Redis 锁有一个较难处理的问题:锁的过期时间设多长合适?
设短了,业务没执行完锁就过期,并发又插进来,互斥失效;设长了,万一持有锁的实例宕机,锁要等很久才能自动释放,其他实例干等。为了平衡,我得估算业务最长耗时,然后手写一个保险值,但这始终是个隐患。
更省心的方案是直接用 Redisson。Redisson 的分布式锁自带“看门狗”续期机制:默认锁的租约是 30 秒,如果业务执行超过 30 秒,客户端会每 10 秒自动给锁续期一次,直到业务结束或客户端宕机。这样既保证了业务没跑完锁不会提前过期,又避免了实例宕机后锁无限期持有。
java复制RLock lock = redissonClient.getLock("lock:coupon:" + userId);
try {
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
doIssueCoupon(userId);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
这里说明一下,tryLock(3, 10, TimeUnit.SECONDS) 的参数分别是等待获取锁的时间、锁的租约时间、时间单位。如果你不传租约时间,Redisson 会用默认的 30 秒加看门狗续期。如果你传了租约时间,看门狗就不生效,到期锁直接释放,所以业务时长变化大的场景建议不传或传一个合理的较长值。
5.4 分布式锁的三个边界条件
分布式锁还有几个边界问题必须心里有数:
第一,锁超时释放和业务时长如何匹配。如果用了看门狗,这个问题基本解决。但如果用自研方案,一定要给业务留足够余量,并且在拿到锁后缩短业务执行时间,别在锁内做慢操作。
第二,Redis 主从切换导致锁丢失。当锁写入主节点后,主节点还没同步到从节点就挂了,新主节点上没有了这把锁,另一个客户端就能再次加锁成功。RedLock 算法理论上能缓解,但工程上实现复杂,多数业务并不会遇到这类极端场景。更务实的应对是:让业务逻辑具备幂等性,锁只作为前置拦截,不作为唯一防线。
第三,锁粒度太大等于没有锁。加锁的 key 一定要包含业务标识,比如上面例子里的 userId,而不能直接用全局唯一的常量字符串,否则所有用户领券都串行排队,性能直接报废。
6. 这些年我在生产环境踩过的锁的坑
6.1 事务还没提交,锁就“丢”了
Spring Boot 里事务注解有个经典陷阱:同一个类内部方法自调用时,@Transactional 不会生效。
java复制@Service
public class OrderService {
@Transactional
public void handleOrder(Long orderId) {
// 方法内部自调用,注解失效
this.deductStock(orderId);
orderRepository.findByIdForUpdate(orderId);
}
@Transactional
public void deductStock(Long orderId) {
// 期待这里抛异常后回滚,但 handler 的事务根本没进入
}
}
解决方式有两种:自己调用自己时,注入代理对象,或者把事务方法移到另一个类。很多人排查了大半天,最后发现是自调用导致整个事务都没生效,锁自然形同虚设。判断方法很直接:看数据库里 information_schema.innodb_trx 表的 trx_started,如果事务瞬间就没了,那就是事务没开启。
6.2 where 条件没用索引,行锁直接变表锁
有一次线上压测,服务 TPS 突然掉到接近 0,数据库慢查询一堆,全是 select ... for update 在等待锁。
我第一反应是死锁,结果看 MySQL 状态发现大量的行锁等待。追查下来,for update 的查询条件是一个普通字段,上面没建索引。InnoDB 引擎锁行时如果没有索引可用,就会升级成锁全表。全表被锁之后,所有涉及这张表的写操作全部阻塞,系统自然就瘫了。
排查锁表的 SQL,可以用:
sql复制SELECT * FROM information_schema.innodb_trx;
SELECT * FROM information_schema.innodb_locks;
SELECT * FROM information_schema.innodb_lock_waits;
这三个表能排查当前事务、锁和锁等待的关系。之后给查询条件补上索引,压测立刻恢复正常。这个坑给我的教训是:只要用 for update,就一定先用 EXPLAIN 看一眼 SQL 执行计划,确认 key 列有索引。
6.3 死锁:两个事务互相抬杠
死锁是悲观锁的标配风险。两个事务,A 先锁了订单表,又去锁用户表;B 先锁了用户表,又去锁订单表。结果就是 A 等 B 释放用户表,B 等 A 释放订单表,谁也等不到谁。
MySQL 检测到死锁后会自动选一个事务回滚,另一个继续执行。但问题在于,被回滚的那个事务如果没做好异常处理,用户会收到一个诡异的系统错误。
应对死锁有两条路。第一,业务层面统一锁获取顺序,比如所有事务都先锁订单再锁用户,死锁循环就不会形成。第二,做好兜底异常处理,Spring Boot 里可以捕获 DeadlockLoserDataAccessException,然后做有限次重试,而不是把异常直接抛给前端。
6.4 乐观锁重试风暴:把数据库打趴的一次教训
最后说一个乐观锁翻车的案例。某个活动页面的积分累计功能,我起初用乐观锁实现,想着读多写少。活动上线后流量超出预期,大量用户同时消费积分,版本冲突率飙升。
问题出在重试逻辑上:每个用户冲突后立即重试,重试又去查一次最新数据,接着再更新。数据库的读压力和更新压力在冲突集中时瞬间翻倍,反而比直接加悲观锁还慢。
后续优化做了两件事:一是把单次 SQL 更新从“先查后改”改成“一条 update 直接扣减”,把重试次数从无限降到 2 次;二是使用 Redis 做前置流量削峰,同一用户最多每秒允许一次积分操作请求进入数据库层。
这个案例也让我重新理解了乐观锁的适用边界:乐观锁适合冲突率低于 10% 的场景,一旦冲突率上来,重试成本会快速超过悲观锁的等待成本。遇到高冲突场景,不是硬扛乐观锁,而是要重新评估用悲观锁,或者在前置层做排队。
回到最开始那个扣库存的问题,现在我一般会这样设计:秒杀场景直接上悲观锁 for update,靠数据库严格串行保证不超卖;普通商品下单读多写少,用乐观锁 + 版本号防覆盖;多实例定时任务或者跨服务互斥,再用 Redis 锁。锁这个东西,没有银弹,只有对业务场景理解到位,才能选对、用对。
