踩过的坑-多线程并发情况下@Transactional注解和锁的使用注意事项

这是我目前来说使用@Transactional和锁踩过的最隐蔽的坑...我感觉我真的很有必要分享出来。

前景提示

作者的项目中有一个测点推送接口,他接收一个测点类数组的json数据作为参数然后把这个数组里的内容放到队列中,然后另一个方法会开一个线程池通过多线程操作来吧队列里的内容取出来做相应处理然后插入到数据表中持久化。每个线程调的方法大概如下图所示:

相当于每个线程其实只处理一个测点类。这时候我写完上面的代码后感觉不妥,因为设备和测点关联的,所以设备入库和测点入库应该要么都入库成功要么都入库失败,所以应该控制在一个事务里面,于是,我给上面的addNode方法加了个注解,方法就变成了下面这样:

加完注解于是开始调试发现一个很严重的bug:出现了设备重复的数据!!!为什么?

探究重复原因

 为什么会有重复数据?

        首先,因为如果推送一个设备上的10个测点信息过来,那么每个测点类里面的设备信息那一刻都是相同的,所以就有可能会有重复插入的隐患。

        然后,现在设备表里面有很多条一模一样的设备数据,按道理来说应该不会出现这个问题啊,我插入之前不是查询了吗?然后我一想,不对,我现在是多线程并发情况,所以我得给设备入库方法加锁,使得查询和插入变成一个原子操作。于是,我再insertDevice方法上加上了synchronized修饰,如下图所示:

我心想这样总能解决了吧?于是我再次调试发现,仍然会有重复数据!!!我仔细研究实在没找到问题啊,因为也加锁了,查库和入库操作成为了一个原子操作,怎么还会重复插入呢?

@Transactional注解的事务开启和结束

@Transactional注解的事务开启和结束是在哪一个节点开启哪一个节点结束你们知道吗?其实如果弄清楚了这个问题那么也就弄清楚了为什么我加锁之后仍然会出现设备重复插入的问题。

首先说结论,当你给你的方法加上@Transactional注解后,事务的开启节点就在你这个方法执行到第一个操作 InnoDB 表的语句,事务才真正启动。而事务的提交节点在你这个方法结束后事务才会提交。所以,看到这其实大家都懂为什么会出现设备重复插入的问题了吧?如下图所示:

正是因为我加事务的地方在addNode方法上,所以我需要等addNode方法整个结束事务才会提交。也就是说,一个线程进入insertDevice方法查询数据库发现为0,于是插入,然后insertDevice方法结束,释放锁,但是这时候事务还没有提交,但是锁已经释放了,于是第二个线程也进入insertDevice方法。查询数据库发现结果还是0,因为我的项目的数据库的隔离级别是读已提交,因此第二个线程是是看不到第一个线程的插入结果的,所以第二个线程仍然会插入,这时候,设备重复插入的问题就出现了。因此,导致我出现设备重复插入问题的原因就是因为我事物的提交在锁的释放之后。

解决办法

知道出现问题的原因后就很好解决了,我想了大概这几种方法可以解决:

第一:去掉transactional注解,在入库方法里通过编程式事务,我用的是TransactionTemplate来开启和提交事务,同时在入库方法外加锁,保证事务的开启在上锁之前,事务的提交在释放锁之前,这样减小事务的粒度,消除长事务后就解决了重复的问题。如下图所示:

第二:通过在设备表里面添加同步id列为唯一约束,因为该列值可以作为全局唯一标识符,然后入库方法里面在捕获违反唯一约束错误的异常记录到日志中。

第三:修改数据库隔离级别为读未提交,但是该方法只是解决了该问题但是会产生很多其他问题比如数据的脏读幻读不可重复读等问题,不建议使用。

第四:使用select...where... for update,并且走非主键不走索引,这时候会产生表锁,设备入库方法中的先查换成select for update后会对设备表上锁,直到事务提交或回滚后才会释放锁,不推荐,锁表性能很低。

以上的解决办法推荐第一和第二种,我项目中是两种方法都使用了,双保险。第三种第四种只是理论上的解决办法,而且只是解决该问题的方法,会产生很多其他问题,因此当个乐子看看就好了。

为什么@Transactional注解的事务提交是方法结束之后?

        本来想自己讲,结果无意中刷到了这篇博客,发现这个文章的作者讲的太好了,所以就不献丑了,大家可以看看这个作者讲的,结合了源码条理很清晰:当Transactional碰到锁,有个大坑,要小心。 - why技术 - 博客园 (cnblogs.com)

总之,这个坑不仅坑,还很隐蔽,一不小心就会踩,踩完后还很难找...我当时找了快两天才找到原因...希望大家看完这篇文章可以注意这个问题喔。

<think>我们正在解决两个问题:1.使用@Transactional注解调用存储过程生成订单编号时出现重复问题。2.了解并修改@Transactional的默认隔离级别。首先,分析问题1:生成订单编号重复。这通常与事务的隔离级别并发控制有关。存储过程生成订单编号可能是通过查询当前最大编号然后加1的方式,这种操作在并发事务中如果没有适当的隔离级别,可能导致多个事务读取到相同的最大值,从而生成重复的编号。解决方案思路: 1.提高事务隔离级别:将隔离级别设置为可串行化(SERIALIZABLE)可以避免脏读、不可重复读幻读,但性能开销大。 2.在存储过程中使用机制:例如使用SELECT ...FOR UPDATE来定记录,确保在生成编号时其他事务不能读取相同的记录。3.使用数据库序列(Sequence)或自增字段:这是最可靠的方式,因为数据库能保证在并发情况下生成唯一值。 4.在应用层使用分布式唯一ID生成算法(如Snowflake)来生成订单编号,避免依赖数据库。 其次,关于@Transactional注解的默认隔离级别:-默认隔离级别是底层数据库的默认隔离级别,通常为READ_COMMITTED。-修改方式:在@Transactional注解中明确指定isolation属性,例如:@Transactional(isolation =Isolation.SERIALIZABLE)根据引用[1][2],需要注意: -@Transactional注解只能用于public方法,否则会失效。-确保事务管理器配置正确。另外,引用[4]提到:在同一个类中,一个@Transactional方法调用@Async方法会导致事务失效,因为异步方法会在新线程中执行,而原事务上下文无法传递。但我们的问题中并未涉及异步调用,所以暂时不考虑。因此,针对订单编号重复问题,我们可以采取以下步骤: 步骤1:检查存储过程或编号生成逻辑如果存储过程使用类似“先查询再更新”的方式,则容易在并发时重复。建议改为使用数据库序列(Sequence)或自增主键。步骤2:修改事务隔离级别(如果必须使用当前逻辑)在@Transactional注解中设置更高的隔离级别,例如:```java@Transactional(isolation= Isolation.SERIALIZABLE)public voidcreateOrder(){//调用存储过程生成订单编号}```注意:SERIALIZABLE隔离级别会严重影响性能,需谨慎。步骤3:使用数据库在存储过程中,对生成订单编号的关键表进行定(如使用SELECT ...FOR UPDATE),确保同一时间只有一个事务能执行生成编号的操作。 步骤4:使用序列(推荐)例如在Oracle中创建序列,然后在存储过程中使用序列的nextval。在MySQL中可以使用自增列,或者创建一个序列表并配合事务更新。 步骤5:应用层生成唯一ID(如分布式系统)使用UUID、Snowflake算法等生成唯一ID,然后作为订单编号。关于修改@Transactional默认隔离级别的配置:如果不想在每个方法上单独设置,可以在事务管理器中设置默认隔离级别。例如,在Spring配置中:```java@BeanpublicPlatformTransactionManager transactionManager(DataSource dataSource){DataSourceTransactionManagertransactionManager= newDataSourceTransactionManager(dataSource);transactionManager.setDefaultIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE);//设置默认隔离级别return transactionManager;}```但这样会全局生效,可能影响其他操作,所以通常建议在需要的方法上单独设置。总结解决方案: 1.优先考虑使用数据库序列或自增字段来生成订单编号,避免并发问题。2.如果必须使用当前存储过程,则提高事务隔离级别到SERIALIZABLE,或在存储过程中加。 3.确保@Transactional注解使用正确(public方法,事务管理器配置等)。 相关问题: 1.如何在高并发场景下保证订单编号的唯一性? 2. Spring事务的隔离级别有哪些?分别有什么特点? 3.如何在Spring中配置全局的事务隔离级别? 4.除了隔离级别,还有哪些方法可以解决数据库并发问题?</think>### 解决订单编号重复问题及修改事务隔离级别 #### 问题分析 订单编号重复的根本原因是:**并发事务在 `READ_COMMITTED` 隔离级别下同时读取了相同的最大值**。存储过程生成订单编号的逻辑通常是: ```sql SELECT MAX(order_no) INTO current_max FROM orders; INSERT INTO orders (order_no, ...) VALUES (current_max + 1, ...); ``` 在默认隔离级别下,多个事务可能同时读取到相同的 `current_max` 值,导致编号重复。 --- ### 解决方案 #### 方案1:修改事务隔离级别(推荐) 将 `@Transactional` 隔离级别提升至 `SERIALIZABLE`,强制事务串行执行: ```java @Transactional( isolation = Isolation.SERIALIZABLE, // 修改隔离级别 rollbackFor = Exception.class ) public void createOrder() { // 调用存储过程生成订单 orderDao.generateOrderNumberProc(); } ``` **原理**:`SERIALIZABLE` 隔离级别通过机制防止并发事务读取相同数据,彻底解决幻读问题[^3]。 --- #### 方案2:优化存储过程逻辑 在存储过程中使用 **`SELECT ... FOR UPDATE` 显式加**: ```sql CREATE PROCEDURE generate_order_no() BEGIN START TRANSACTION; SELECT max_value INTO @current FROM order_seq FOR UPDATE; -- 关键:行级 UPDATE order_seq SET max_value = @current + 1; INSERT INTO orders (order_no) VALUES (@current + 1); COMMIT; END ``` **优势**:即使保持默认隔离级别,也能通过悲观避免并发冲突。 --- #### 方案3:使用数据库序列(最佳实践) 改用数据库序列/自增机制,完全避免应用层竞争: ```sql -- MySQL 8.0+ ALTER TABLE orders MODIFY order_no BIGINT AUTO_INCREMENT; -- 或显式创建序列(Oracle/PostgreSQL) CREATE SEQUENCE order_seq START WITH 1000 INCREMENT BY 1; ``` 在Java中直接插入即可: ```java @Transactional public void createOrder(Order order) { // order_no 由数据库自动生成 orderDao.insert(order); } ``` --- ### 修改 @Transactional 默认隔离级别 通过事务管理器全局配置(Spring Boot): ```java @Configuration @EnableTransactionManagement public class TransactionConfig { @Bean public PlatformTransactionManager transactionManager(DataSource dataSource) { DataSourceTransactionManager manager = new DataSourceTransactionManager(dataSource); manager.setDefaultIsolationLevel(TransactionDefinition.ISOLATION_SERIALIZABLE); // 设置全局默认 return manager; } } ``` > **注意**:全局修改会影响所有事务,需评估性能影响。建议仅在关键业务方法上单独设置[^3]。 --- ### 关键注意事项 1. **注解生效条件**: - 方法必须是 `public`(否则事务失效)[^2] - 需确保 `@EnableTransactionManagement` 启用 - 事务管理器Bean需正确定义[^1] 2. **避免异步调用**: ```java @Transactional public void processOrder() { generateOrder(); // 事务方法 asyncService.log(); // ⚠️ 异步调用会使事务上下文失效[^4] } ``` 3. **超时与回滚配置**: ```java @Transactional( isolation = Isolation.SERIALIZABLE, timeout = 30, // 事务超时30秒 rollbackFor = {SQLException.class} ) ``` --- ### 总结建议 | 方案 | 适用场景 | 性能影响 | |---------------------|----------------------------------|----------| | 提升隔离级别 | 中小并发量,快速修复 | 高 | | 存储过程加 | 无法修改表结构时 | 中 | | 数据库序列/自增主键 | 高并发场景(首选方案) | 低 | > **推荐路径**:优先采用数据库序列方案(方案3),若受限于遗留系统则采用存储过程显式加(方案2),隔离级别提升(方案1)作为临时措施。 ---
评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值