前面我们系统学习了 SpringBoot 声明式事务(@Transactional)、编程式事务(TransactionTem)plate)、事务传播行为、隔离级别以及事务失效的全套解决方案,核心解决的是「单个业务、单次请求」的事务原子性、一致性问题。
但、项目上线后,真正的线上隐患,往往不是单线程的事务问题,而是高并发场景下,多事务同时操作同一条数据引发的各种数据安全问题。
比如这些高频线上场景,你一定遇到过或听过:
• 电商秒杀:1000人同时抢购100件商品,最终超卖50件,库存显示负数;
• 支付场景:用户同时发起两次支付,余额被重复扣减,出现负数;
• 积分兑换:多线程同时兑换积分,积分被重复扣减,或兑换到超出库存的商品;
• 订单状态修改:多线程同时修改订单状态(比如“待支付”→“已支付”、“待支付”→“已取消”),导致订单状态错乱。
很多同学会陷入一个致命误区:只要加了 @Transactional 事务,数据就绝对安全。
事实恰恰相反:Spring 事务的 ACID 特性,只能保证「单个事务内部」的原子性、一致性,无法解决「多个事务并发修改同一条数据」的问题。想要解决高并发下的多事务数据安全,必须依靠数据库层面的并发控制方案——悲观锁与乐观锁。
为什么 @Transactional 解决不了并发修改问题?
在讲锁之前,我们必须先搞懂核心问题:为什么加了事务,还是会出现超卖、重复扣减?结合最经典的「库存超卖案例」,拆解底层原因,一看就懂。
1. 超卖案例
场景:商品库存初始值为 10,两个用户同时抢购(线程A、线程B),每个用户抢购1件,核心业务代码(仅加事务,未加锁):
@Service public class StockService { @Autowired private StockMapper stockMapper; // 仅加事务,未加锁 @Transactional(rollbackFor = Exception.class) public void decreaseStock(Long productId) { // 1. 查询当前库存 Stock stock = stockMapper.selectByProductId(productId); if (stock == null || stock.getStock() <= 0) { throw new BusinessException("库存不足"); } // 2. 扣减库存(stock = stock - 1) int rows = stockMapper.decreaseStock(productId); if (rows == 0) { throw new BusinessException("库存扣减失败"); } } }高并发下的执行流程(线程A、线程B同时执行):
1. 线程A、线程B同时进入事务,执行「查询库存」操作,此时数据库隔离级别为 READ COMMITTED(默认),两个线程都读到库存 = 10;
2. 线程A先执行扣减操作:stock = 10 - 1 = 9,执行完毕,等待提交;
3. 线程B紧接着执行扣减操作:基于读到的旧库存 10,执行 stock = 10 - 1 = 9,执行完毕,等待提交;
4. 线程A、线程B先后提交事务,最终库存变为 9,而非 8。
最终结果:两个用户都抢购成功,但库存只扣减了1次,出现超卖问题,数据严重不一致。
2. 核心原因
出现这个问题的核心原因有两个,缺一不可:
• ① 事务隔离级别的局限性:MySQL 默认隔离级别是 READ COMMITTED(读已提交),只能解决「脏读」,无法解决「不可重复读」和「并发更新覆盖」。多个事务同时读取同一条数据的旧值,基于旧值做修改,提交后互相覆盖,导致数据错误;
• ② 事务执行的“非原子性”(相对于多事务):单个事务内部是原子性的,但多个事务并发执行时,「查询库存」和「扣减库存」这两个操作,对于多事务来说,并不是原子操作——两个事务可以同时执行“查询”,再先后执行“扣减”,导致基于旧数据修改。
补充说明:即使把事务隔离级别提升到 REPEATABLE_READ(可重复读),也无法彻底解决这个问题。因为可重复读解决的是“同一个事务内多次读取数据一致”,但多个事务之间,依然可以同时读取旧数据,执行修改操作,还是会出现并发覆盖。
3. 结论
Spring 事务的核心作用是保证「单个事务内部」的原子性、一致性;而锁机制的核心作用是保证「多个事务并发操作同一条数据」的一致性。两者缺一不可,事务是基础,锁是高并发下的补充。
二、锁的核心概念:悲观锁 vs 乐观锁
解决多事务并发修改问题,核心是「阻止多个事务同时修改同一条数据」,或者「让多个事务有序修改同一条数据」。根据“是否提前阻止并发”,分为两种设计思想:悲观锁、乐观锁。
用通俗的比喻理解,非常好记:
• 悲观锁:假设一定会发生并发冲突,提前加锁,阻止其他事务修改数据,直到当前事务执行完毕,释放锁。(比如:抢东西时,先把东西抱在怀里,别人拿不到,直到自己用完);
• 乐观锁:假设不会发生并发冲突,不提前加锁,而是在修改数据时,检查数据是否被其他事务修改过,如果没有被修改,就执行修改;如果被修改,就拒绝修改或重试。(比如:抢东西时,不先拿,直到要付钱时,才检查东西是否还在,还在就拿走,不在就放弃)。
1. 悲观锁(Pessimistic Lock)
核心思想:悲观锁,锁的是“数据”,提前锁定目标数据,禁止其他事务对其进行修改、删除操作,直到当前事务提交或回滚,释放锁。
核心特点:
• 优点:并发冲突时,直接阻止,不会出现数据不一致,安全性高;
• 缺点:提前加锁,会阻塞其他事务,降低并发性能;如果锁的粒度太大(如表锁),会导致大量事务阻塞,引发性能瓶颈;
• 底层实现:依赖数据库自身的锁机制(行锁、表锁),由数据库层面控制。
常见应用场景:写操作频繁、并发冲突概率高的场景(如下单支付、余额扣减、库存扣减)。
2. 乐观锁(Optimistic Lock)
核心思想:乐观锁,锁的是“版本”,不提前加锁,而是给数据添加一个“版本标识”(如版本号、时间戳),修改数据时,检查版本标识是否和自己查询时一致,一致则修改,不一致则拒绝或重试。
核心特点:
• 优点:不阻塞其他事务,并发性能高;无需依赖数据库锁机制,实现简单;
• 缺点:存在“ABA问题”(后面详细讲);并发冲突概率高时,会出现大量重试,反而降低性能;
• 底层实现:由开发者手动实现(版本号、时间戳),不依赖数据库锁。
常见应用场景:读操作频繁、写操作少、并发冲突概率低的场景(如商品详情查询、积分查询、订单详情修改)。
三、悲观锁实战:3种实现方式(SpringBoot + MySQL)
悲观锁的实现,依赖 MySQL 自身的锁机制,SpringBoot 中无需额外导入依赖,只需在 SQL 或代码中配置锁逻辑即可。根据锁的粒度,分为「行锁」和「表锁」,实际开发中优先使用「行锁」(粒度细,并发性能高),表锁仅用于特殊场景。
先准备环境(直接复用前面的库存表,可直接复制):
-- 库存表(核心字段:商品ID、库存数量) CREATE TABLE `stock` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `product_id` bigint NOT NULL COMMENT '商品ID', `stock` int NOT NULL DEFAULT 0 COMMENT '库存数量', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `idx_product_id` (`product_id`) COMMENT '商品ID唯一索引' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表'; -- 插入测试数据(商品ID=1,库存=10) INSERT INTO `stock` (`product_id`, `stock`) VALUES (1, 10);SpringBoot 环境准备(依赖、配置):和前面事务实战一致,导入 mybatis-spring-boot-starter、mysql-connector-j 依赖,配置 application.yml 数据源,此处省略(可直接复用)。
方式1:MySQL 行锁—— select ... for update
这是企业开发中最常用的悲观锁实现方式,属于「行锁」,只锁定当前查询的行数据,不影响其他行,并发性能高。
核心原理:在查询数据时,加上for update关键字,MySQL 会对查询到的行数据加行锁,其他事务想要修改、删除该行吗数据,会被阻塞,直到当前事务提交或回滚,释放锁。
✅ 注意事项:
•
select ... for update必须在事务内执行(否则锁会立即释放,无效);• 必须通过「索引」查询(如 product_id 唯一索引),否则会升级为表锁,阻塞所有行的操作;
• 锁的释放时机:事务提交(commit)或回滚(rollback)时,自动释放锁。
✅ 完整代码:
// 1. Mapper 接口(添加带 for update 的查询方法) public interface StockMapper { // 带行锁的查询:select ... for update Stock selectByProductIdForUpdate(@Param("productId") Long productId); // 库存扣减 int decreaseStock(@Param("productId") Long productId); } // 2. Mapper XML(核心 SQL) <select id="selectByProductIdForUpdate" resultType="com.example.concurrency.entity.Stock"> SELECT id, product_id, stock FROM stock WHERE product_id = #{productId} FOR UPDATE -- 加行锁 </select> <update id="decreaseStock"> UPDATE stock SET stock = stock - 1 WHERE product_id = #{productId} AND stock > 0 </update> // 3. Service 层(事务 + 行锁) @Service @Slf4j public class StockService { @Autowired private StockMapper stockMapper; // 必须加事务,否则 for update 锁会立即释放 @Transactional(rollbackFor = Exception.class) public void decreaseStockWithPessimisticLock(Long productId) { // 1. 查询库存并加行锁(其他事务会被阻塞,直到当前事务结束) Stock stock = stockMapper.selectByProductIdForUpdate(productId); if (stock == null || stock.getStock() <= 0) { throw new BusinessException("库存不足"); } // 2. 扣减库存(此时该数据已被锁定,其他事务无法修改) int rows = stockMapper.decreaseStock(productId); if (rows == 0) { throw new BusinessException("库存扣减失败"); } log.info("库存扣减成功,商品ID:{},剩余库存:{}", productId, stock.getStock() - 1); } } // 4. Controller 层(接口测试) @RestController @RequestMapping("/stock") public class StockController { @Autowired private StockService stockService; @PostMapping("/decrease/{productId}") public ResultVO decreaseStock(@PathVariable Long productId) { try { stockService.decreaseStockWithPessimisticLock(productId); return ResultVO.success("库存扣减成功"); } catch (BusinessException e) { return ResultVO.fail(e.getMessage()); } catch (Exception e) { log.error("库存扣减异常", e); return ResultVO.fail("系统异常,请稍后重试"); } } }✅ 高并发验证(压测):
用 JMeter 压测:1000个并发请求,商品ID=1,初始库存=10,最终库存=0,无超卖、无重复扣减,验证成功。
执行流程:多个并发请求同时进入事务,第一个请求查询库存并加行锁,其他请求会阻塞在「select ... for update」这一步,直到第一个事务提交,释放锁,下一个请求才能获取锁、执行操作,依次类推,实现有序扣减。
方式2:MySQL 表锁—— lock table
表锁是粒度最大的悲观锁,锁定整个表,其他事务无法对该表执行任何 insert、update、delete 操作,并发性能极差,仅用于「全表更新、批量操作」等特殊场景。
✅ 实战代码:
@Service public class StockService { @Autowired private JdbcTemplate jdbcTemplate; @Autowired private StockMapper stockMapper; @Transactional(rollbackFor = Exception.class) public void decreaseStockWithTableLock(Long productId) { try { // 1. 加表锁(锁定整个 stock 表) jdbcTemplate.execute("LOCK TABLE stock WRITE"); // 2. 查询库存 Stock stock = stockMapper.selectByProductId(productId); if (stock == null || stock.getStock() <= 0) { throw new BusinessException("库存不足"); } // 3. 扣减库存 int rows = stockMapper.decreaseStock(productId); if (rows == 0) { throw new BusinessException("库存扣减失败"); } } finally { // 4. 释放表锁(必须在 finally 中释放,防止事务异常导致锁未释放) jdbcTemplate.execute("UNLOCK TABLES"); } } }❌ 缺点:锁定整个表,并发性能极差,1000个并发请求会排队执行,导致接口响应时间过长,甚至超时,日常开发中严禁使用。
方式3:Spring 声明式锁—— @Lock(结合 Spring Data JPA)
如果项目中使用 Spring Data JPA,无需写原生 SQL,可通过 @Lock 注解快速实现悲观锁,本质也是底层调用 MySQL 的 select ... for update。
✅ 实战代码(JPA 场景):
// 1. Repository 接口(添加 @Lock 注解) public interface StockRepository extends JpaRepository<Stock, Long> { // @Lock(LockModeType.PESSIMISTIC_WRITE) 对应 select ... for update(行锁) @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT s FROM Stock s WHERE s.productId = :productId") Optional<Stock> findByProductIdWithLock(@Param("productId") Long productId); } // 2. Service 层 @Service public class StockService { @Autowired private StockRepository stockRepository; @Transactional(rollbackFor = Exception.class) public void decreaseStockWithJpaLock(Long productId) { // 查询库存并加行锁 Stock stock = stockRepository.findByProductIdWithLock(productId) .orElseThrow(() -> new BusinessException("商品不存在")); if (stock.getStock() <= 0) { throw new BusinessException("库存不足"); } // 扣减库存 stock.setStock(stock.getStock() - 1); stockRepository.save(stock); } }✅ 说明:@Lock(LockModeType.PESSIMISTIC_WRITE) 是写锁(行锁),适合修改操作;如果是查询操作,可使用 @Lock(LockModeType.PESSIMISTIC_READ)(读锁,共享锁)。
四、乐观锁实战:2种实现方式
乐观锁不依赖数据库锁机制,由开发者手动实现,核心是「版本标识」。常用的两种实现方式:版本号机制(最通用)、时间戳机制(简化版),实际开发中优先使用版本号机制。
先修改库存表,添加版本号字段(乐观锁的关键):
-- 给 stock 表添加版本号字段(乐观锁核心) ALTER TABLE `stock` ADD COLUMN `version` int NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁用)'; -- 同步测试数据(版本号默认1) UPDATE `stock` SET `version` = 1 WHERE product_id = 1;方式1:版本号机制
核心原理:给数据添加一个版本号(version),初始值为1;
• 查询数据时,同时查询版本号;
• 修改数据时,在 update 语句中添加条件:version = 查到的版本号;
• 如果修改成功(影响行数=1),说明数据未被其他事务修改,同时将版本号+1;
• 如果修改失败(影响行数=0),说明数据已被其他事务修改,拒绝修改或重试。
✅ 完整代码:
// 1. 实体类(添加 version 字段) @Data @TableName("stock") public class Stock { private Long id; private Long productId; private Integer stock; private Integer version; // 乐观锁版本号 private Date createTime; private Date updateTime; } // 2. Mapper 接口 public interface StockMapper { // 查询库存(同时查询版本号) Stock selectByProductId(@Param("productId") Long productId); // 乐观锁扣减库存(核心:where 条件添加 version = #{version}) int decreaseStockWithVersion(@Param("productId") Long productId, @Param("version") Integer version); } // 3. Mapper XML(核心 SQL) <select id="selectByProductId" resultType="com.example.concurrency.entity.Stock"> SELECT id, product_id, stock, version FROM stock WHERE product_id = #{productId} </select> <update id="decreaseStockWithVersion"> UPDATE stock SET stock = stock - 1, version = version + 1 -- 版本号+1 WHERE product_id = #{productId} AND stock > 0 AND version = #{version} -- 核心条件:版本号匹配 </update> // 4. Service 层(核心:重试机制,可选) @Service @Slf4j public class StockService { @Autowired private StockMapper stockMapper; // 无需加锁,事务可选(根据业务需求添加) @Transactional(rollbackFor = Exception.class) public void decreaseStockWithOptimisticLock(Long productId) { // 循环重试(可选,解决并发冲突时修改失败的问题) int retryCount = 3; // 最多重试3次 while (retryCount > 0) { // 1. 查询库存和版本号 Stock stock = stockMapper.selectByProductId(productId); if (stock == null || stock.getStock() <= 0) { throw new BusinessException("库存不足"); } // 2. 乐观锁扣减库存(版本号匹配才修改) int rows = stockMapper.decreaseStockWithVersion(productId, stock.getVersion()); if (rows > 0) { // 修改成功,退出循环 log.info("库存扣减成功,商品ID:{},剩余库存:{},版本号:{}", productId, stock.getStock() - 1, stock.getVersion() + 1); return; } // 修改失败,重试(重试次数减1) retryCount--; log.warn("库存扣减失败,重试次数:{},商品ID:{}", retryCount, productId); // 可选:重试间隔(避免频繁重试,减轻数据库压力) try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } // 重试3次仍失败,抛出异常 throw new BusinessException("库存扣减失败,请稍后重试"); } } // 5. Controller 层(和悲观锁一致,省略)✅ 高并发验证:
1000个并发请求,商品ID=1,初始库存=10,最终库存=0,无超卖;部分请求会因版本号不匹配重试,最终都能成功扣减(重试次数控制在3次内,避免无限重试)。
✅ 关键细节:重试机制是可选的,根据业务场景决定——如果是秒杀场景,可拒绝重试,直接返回“抢购失败”;如果是普通库存扣减,可添加重试机制,提升成功率。
方式2:时间戳机制
核心原理:用数据的 update_time(更新时间)作为版本标识,替代版本号,原理和版本号机制一致——查询数据时获取 update_time,修改时判断 update_time 是否和查询时一致。
✅ 实战代码(核心 SQL):
// Mapper XML 核心 update 语句 <update id="decreaseStockWithTimestamp"> UPDATE stock SET stock = stock - 1, update_time = CURRENT_TIMESTAMP WHERE product_id = #{productId} AND stock > 0 AND update_time = #{updateTime} -- 用更新时间作为版本标识 </update>❌ 缺点:时间戳精度问题(如 MySQL 时间戳精确到秒),如果多个事务在同一秒内修改数据,会出现误判(认为数据未被修改);且无法直观看到数据被修改的次数,排查问题不便,日常开发中不推荐。
乐观锁的 ABA 问题
✅ 什么是 ABA 问题?
场景:线程A查询数据(stock=10,version=1),线程B修改数据(stock=9,version=2),然后线程B又修改数据(stock=10,version=3),此时线程A执行修改,发现 version=1 != 3,修改失败——这是正常情况。
但如果线程C在线程A查询后,修改数据(stock=9,version=2),再修改回(stock=10,version=3),线程A执行修改时,虽然数据最终值和查询时一致,但数据已经被修改过,线程A却不知道,这就是「ABA问题」。
✅ 解决方案(2种):
• 方式1:使用「版本号+时间戳」双重校验(推荐),既判断版本号,也判断更新时间,避免ABA问题;
• 方式2:使用「原子引用(AtomicReference)」,记录数据的修改记录,不仅仅判断最终值,还判断修改过程(适合Java内存中的乐观锁,数据库层面不常用)。
✅ 数据库层面解决方案(实战代码):
-- 修改 update 语句,添加双重校验 <update id="decreaseStockWithDoubleCheck"> UPDATE stock SET stock = stock - 1, version = version + 1, update_time = CURRENT_TIMESTAMP WHERE product_id = #{productId} AND stock > 0 AND version = #{version} AND update_time = #{updateTime} -- 双重校验:版本号+更新时间 </update>五、悲观锁 vs 乐观锁 深度对比
这是本篇核心重点,结合企业实战场景,从10个核心维度做全面对比,帮你快速选型,避免踩坑。
对比维度 | 悲观锁(行锁为主) | 乐观锁(版本号为主) |
设计思想 | 假设并发冲突一定会发生,提前加锁 | 假设并发冲突不会发生,修改时校验 |
底层依赖 | 依赖数据库自身锁机制(行锁、表锁) | 开发者手动实现(版本号、时间戳),不依赖数据库锁 |
并发性能 | 较低:会阻塞其他事务,锁竞争激烈时性能下降明显 | 较高:不阻塞其他事务,并发冲突时仅重试,无阻塞开销 |
数据安全性 | 高:直接阻止并发修改,不会出现数据不一致 | 较高:存在ABA问题(可解决),并发冲突高时可能出现重试失败 |
实现复杂度 | 低:只需在SQL中添加 for update,无需额外代码 | 中:需添加版本号字段,编写校验逻辑,可选重试机制 |
锁粒度 | 细(行锁)或粗(表锁),可灵活选择 | 无锁,仅通过版本号校验,粒度极细 |
适用场景 | 写操作频繁、并发冲突概率高(下单、支付、余额扣减) | 读操作频繁、写操作少、并发冲突概率低(查询、详情修改) |
常见问题 | 死锁、锁升级、阻塞超时 | ABA问题、重试失败、并发冲突高时性能下降 |
性能开销 | 锁的获取、释放开销,阻塞时的线程切换开销 | 重试开销(并发冲突时),无锁获取开销 |
SpringBoot 整合难度 | 低:无需额外依赖,SQL中添加 for update 即可 | 中:需修改表结构(添加版本号),编写校验逻辑 |
选型核心原则(必记):根据并发冲突概率和读写比例选型——写多读少、冲突高,用悲观锁;读多写少、冲突低,用乐观锁。没有绝对的优劣,只有是否适配业务场景。
六、文末小结
多事务并发控制,是企业级高并发项目的必备知识点,核心是「锁机制」,悲观锁和乐观锁没有绝对的优劣,关键是适配业务场景。
总结核心要点:
•
1. 事务解决「单个事务内部」的数据一致性,锁解决「多事务并发」的数据一致性,两者缺一不可;
•
2. 悲观锁:提前加锁,阻塞事务,安全高,适合写多读少、冲突高的场景,常用 select ... for update 行锁;
•
3. 乐观锁:修改校验,不阻塞事务,性能高,适合读多写少、冲突低的场景,常用版本号机制;
•
4. 高并发优化:锁粒度优化、缓存优化、重试机制、分布式锁,避免踩死锁、锁升级、ABA问题等坑;
•
5. 选型原则:根据读写比例和并发冲突概率,选择合适的锁方案,不要盲目追求高性能。
本篇文章的所有代码均可直接复制上线使用,建议结合实际业务场景,灵活调整锁方案和优化技巧。如果在项目中遇到并发锁相关的问题,或者有其他疑问,欢迎在评论区留言交流,一起避坑、一起进步!
别忘了点赞+在看+收藏三连,关注我,解锁更多 SpringBoot 高并发实战干货,不见不散❤️