likes
comments
collection
share

别人的坑你来填,事务虐你千百遍,你还待它如初见?

作者站长头像
站长
· 阅读数 22

前言

事务的作用

事务的作用大家背八股文都已经背的差不多了,这里就不在说这些了。 主要站在平时开发的角度,大部分人用事务就那么几个原因: 1.需要事务的原子性(主要原因) 2.保存数据后想要一个数据库的自增id去关联下面的数据 3.不管怎么样,加上总归是好的 以前问过其他同事,大概也就这三种原因,不知道还有没有其他的。

请谨慎的看待问题

首先,事务确实是一种很不错的设计,这点不可否认。 在java开发中也是最最司空见惯的那一类技术点,发展到现在的这些框架上,实际上已经对事务的封装做的很好了,用起来也相对非常舒服。 所以这里带来的第一个错觉就是,事务用起来很简单熟练,上手也非常快。但是,在我看来使用事务应该是非常谨慎的,不能因为简单熟练就忘记了它自身也会存在的问题。 我们需要事务,需要它的原子性,这无可厚非,业务决定了需要它的这种用法。

你加班的原因

一般来说,通过测试阶段的代码,也经常容易在线上因为事务的问题出现事故的比比皆是。 归根原因,测试在测试阶段,很难测出事务使用不规范而导致的问题。因为测试环境数据量少、并发少、业务流程不完整等因素,导致事务的bug极难被发现。 那么,线上找bug的事,不交给你来还能交给谁来。然后找问题的时候,事务的问题又比较隐蔽(原因就是对事务的莫名自信),极容易耗费大量精力。 我曾不止一次见过经验不足的同事找了一星期bug也没找出来原因的,后来总是天天在那念着不可能啊之类的话苦苦思索了很久。 如果你不信,我举几个例子你回忆回忆,不知道你们遇到过没有。

案例一 来吧,相互伤害吧

系统a发送一条消息给系统b,系统b根据消息中的id再去查一下系统a的数据,存到自己的系统。 bug现象: 系统b数据没有更新,同事a质疑同事b数据处理有问题,因为创建的数据能更新,更新的数据偶发不更新。 但是系统a创建和更新时发送消息和查询的逻辑完全一样,一种有问题一种没有问题,而且不是每一个都有问题,那肯定是消费的时候做了什么操作,导致没更新上。 同事b看了,也以为自己消费方法写的有问题,于是检查了半天。报错了?日志被吞了没打印?幂等写的有问题? 找了半天没发现问题,开始怀疑是不是消息丢了,结果消息没那么容易丢。顺便打印了一下消息日志,内容也是对的。 查询接口没对?同事b手动调了下查询接口,发现没问题,返回的就是最新的数据,头发掉了不少。 不管了吧,不行,这数据还挺重要,领导天天来问。最后两人进入撕逼阶段,觉得太吵,我去review了一下他们提交的代码,也看不出个什么问题。 最后翻来翻去,在同事a写的方法外的第三个嵌套调用方法上,发现在使用事务。好吧,来谈谈背锅吧。 没什么好说的,这锅同事a背定了,但是他却略表不服。本来是很简单的需求,但是因为此处涉及到该业务一块公共代码, 所有更新这张表的地方都会走到这里,相关联调用的地方有7、8处,更上层调用还不确定有多少。 照理来说在这里修改完这张表就发送消息通知系统b,但是因为某些更新的业务中用了注解事务,导致事务未提交就被消费完,所以更新的时候有概率更新不上。 创建为什么没问题?答案很简单,创建的入口只有两个,且逻辑都比较单一,并没有用上事务。 而现在的问题是,有些地方用了事务,有些没用,用了事务的还不敢给他去掉,因为开发的人已经不知道换了多少代了,如果去掉不知道会不会引发其他bug。 好了,领导催的急(关键业务),解决方案是,同事a临时在发消息的地方开个线程, 线程里面睡个2s再发消息(本来还有个临时方案是加个时间戳,同事b根据时间戳轮询查询,但同事b死活不干了,好好的查来查去干了一周,结果全白费了,如果这样改了到时候还要改回来)。 最终方案是同事a将发消息的地方改在事务执行之后,但这工作量一下就上来了,他要去每一个调用的地方看一看加在哪里合适。 测试更加痛苦,本来只用简单两个场景(新增和任意的修改)的,也需要根据开发给的修改点来一个个去校验(测试也不知道哪里被改到了)。 好了,看到这肯定会有大聪明会问,为什么不用cannal?为什么不去监听mysql日志?不怎么想回答。

案例二 怎么又OOM了

也是和案例一类似,在不知道(或者没有仔细看)的情况下,在注解事务下发的消息。只是这次问题更大,直接导致服务OOM。 同样的问题,系统a事务里面发了消息,系统a根据订单号去查询(异步处理),结果系统a直接崩了。原因很简单,也是因为事务未提交就消费了。 但是导致OOM的原因是系统b在查询时这样去查的

select  
      字段a  
    ,字段b  
    ...  
from order_detail  
<where>  
    <if test="orderIdList is not null and orderIdList.size() > 0">  
        order_id in  
       <foreach collection="orderIdList" index="index" item="orderId" open="("close=")" separator=",">  
            #{orderId}  
        </foreach>  
    </if>  
...  

为什么这样查?因为是以前就现成的sql,可以复用,只用加一个orderId查询的条件就行。 但是万万没想到,系统a没有提交事务前,因为orderId还没入库,这时候消费消息时理所当然的以为根据id查询了一下order表的order_id一定存在,并将其作为条件带入上面的查询。 由于此时所有参数条件都为null,直接给明细表来了个全表查询,如果不适用标签,语句还能报错中止,但不是敲了不是。看起来确实好笑,更有意思的是,测试是能复现出来的,而且非常容易。 但是由于测试环境数据量少,服务内存够这样霍霍儿。然后下面逻辑会根据orderId去查询出来的集合做匹配, 所以从业务上说,在测试环境只会感觉到某次处理慢了点,结果上没什么区别。

案例三 我的接口都这么快了,还能报死锁?

某一天,系统a线上突然开始报错,Deadlock found when trying to get lock; try restarting transaction。 而且不是偶发,是每隔一段时间就出现几次,十分有规律。没办法找到mysql死锁日志详情,打开看看在干嘛。

2023-05-18 11:23:37 0x7f274b471700  
*** (1) TRANSACTION:  
TRANSACTION 2647332550, ACTIVE 1 sec starting index read  
mysql tables in use 1, locked 1  
LOCK WAIT 4 lock struct(s), heap size 1136, 3 row lock(s), undo log entries 2  
MySQL thread id 4250585, OS thread handle 139806766741248, query id 1252003070 127.0.0.1 user_prod updating  
UPDATE sku_batch_stock SET amount=219.00 WHERE id=1633735677121449986  
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:  
RECORD LOCKS space id 3287 page no 1037 n bits 216 index PRIMARY of table `sku_batch_stock` trx id 2647332550 lock_mode X locks rec but not gap waiting  
Record lock, heap no 78 PHYSICAL RECORD: n_fields 17; compact format; info bits 0  
0: len 8; hex 16ac31f143b4d002; asc 1 C ;;  
1: len 6; hex 00009dcb16e0; asc ;;  
2: len 7; hex 370000400f105d; asc 7 @ ];;  
3: len 8; hex 8000000000000007; asc ;;  
4: len 8; hex 8000000000002f54; asc /T;;  
5: len 6; hex 323330333031; asc 230301;;  
6: len 8; hex 80000000001ef8b3; asc ;;  
7: len 5; hex 800000db00; asc ;;  
8: len 5; hex 800000a300; asc ;;  
9: len 5; hex 8000000600; asc ;;  
10: len 3; hex e8a28b; asc ;;  
11: SQL NULL;  
12: len 4; hex 80000000; asc ;;  
13: len 4; hex 80000001; asc ;;  
14: len 4; hex 80000001; asc ;;  
15: len 5; hex 99af92fb4d; asc M;;  
16: len 5; hex 99b024b5e4; asc $ ;;  
  
*** (2) TRANSACTION:  
TRANSACTION 2647332576, ACTIVE 1 sec starting index read  
mysql tables in use 1, locked 1  
9 lock struct(s), heap size 1136, 8 row lock(s), undo log entries 7  
MySQL thread id 4250535, OS thread handle 139806743402240, query id 1252003086 127.0.01 erp_god updating  
UPDATE sku_batch_stock SET amount=1873.00 WHERE id=1630810181965234177  
*** (2) HOLDS THE LOCK(S):  
RECORD LOCKS space id 3287 page no 1037 n bits 216 index PRIMARY of table `sku_batch_stock` trx id 2647332576 lock_mode X locks rec but not gap  
Record lock, heap no 78 PHYSICAL RECORD: n_fields 17; compact format; info bits 0  
0: len 8; hex 16ac31f143b4d002; asc 1 C ;;  
1: len 6; hex 00009dcb16e0; asc ;;  
2: len 7; hex 370000400f105d; asc 7 @ ];;  
3: len 8; hex 8000000000000007; asc ;;  
4: len 8; hex 8000000000002f54; asc /T;;  
5: len 6; hex 323330333031; asc 230301;;  
6: len 8; hex 80000000001ef8b3; asc ;;  
7: len 5; hex 800000db00; asc ;;  
8: len 5; hex 800000a300; asc ;;  
9: len 5; hex 8000000600; asc ;;  
10: len 3; hex e8a28b; asc ;;  
11: SQL NULL;  
12: len 4; hex 80000000; asc ;;  
13: len 4; hex 80000001; asc ;;  
14: len 4; hex 80000001; asc ;;  
15: len 5; hex 99af92fb4d; asc M;;  
16: len 5; hex 99b024b5e4; asc $ ;;  
  
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:  
RECORD LOCKS space id 3287 page no 1029 n bits 224 index PRIMARY of table `sku_batch_stock` trx id 2647332576 lock_mode X locks rec but not gap waiting  
Record lock, heap no 96 PHYSICAL RECORD: n_fields 17; compact format; info bits 0  
0: len 8; hex 16a1cd385820c001; asc 8X ;;  
1: len 6; hex 00009dcb16c6; asc ;;  
2: len 7; hex 28000015ba2651; asc ( &Q;;  
3: len 8; hex 8000000000000007; asc ;;  
4: len 8; hex 8000000000003467; asc 4g;;  
5: len 8; hex 3232313031313031; asc 22101101;;  
6: len 8; hex 80000000001ef564; asc d;;  
7: len 5; hex 8000075100; asc Q ;;  
8: len 5; hex 8000072e00; asc . ;;  
9: len 5; hex 8000000d32; asc 2;;  
10: len 3; hex e8a28b; asc ;;  
11: SQL NULL;  
12: len 4; hex 80000000; asc ;;  
13: len 4; hex 80000001; asc ;;  
14: len 4; hex 80000001; asc ;;  
15: len 5; hex 99af82e015; asc ;;  
16: len 5; hex 99b024b5e4; asc $ ;;  
  
*** WE ROLL BACK TRANSACTION (1)  
------------  
TRANSACTIONS  
------------  
Trx id counter 2647336188  
Purge done for trx's n:o < 2647336188 undo n:o < 0 state: running but idle  
History list length 52  
LIST OF TRANSACTIONS FOR EACH SESSION:  
---TRANSACTION 421311248761184, not started  
0 lock struct(s), heap size 1136, 0 row lock(s)  

可以看到,事务1执行的语句为:

UPDATE sku_batch_stock SET amount=219.00 WHERE id=1633735677121449986  

事务2执行的语句为:

UPDATE sku_batch_stock SET amount=1873.00 WHERE id=1630810181965234177  

大概意思就是事务2去获取X锁时发现锁被事务1持有,等待后mysql决定回滚事务1。 平平无奇的日志,按理说出现死锁没什么大不了的,偶尔会遇上。但是怪就怪在这个错每天都有,有时候一天五六次。 从运维上分析,这里并不是一个高并发的接口。一秒钟能有两次请求就算不错了,但是基本每次稍微多一点请求,这里就会报错,这就很不正常了。 单独的sql执行是非常快的,会出现死锁那么只有一个原因,哪一个业务执行这条语句时长期持锁。 由于报错回滚的业务从日志上看是很简单的,响应时间也很快。所以为了找到问题,只能全局代码搜存在这条语句更新的业务。 最后找了半天,只能将目光锁定在一个方法上。 方法里面逻辑不算复杂,查询商品信息后,计算出价格并更新。但问题在于这个方法一来就用上了事务,并且查询语句是放在事务里面的, 同时最痛苦的是这里支持批量修改,最大可支持条数在200条,还是for循环一条条更新。看到这,其实已经很无语了。 来吧,让我们来试下查询 + 计算价格的逻辑平均耗时需要多久。嗯,非常快,一条只需要80ms。 但是按最大200条来计算,最坏的情况是,持有第一条更新语句锁的时间为16s!!! 不过运气真好,看了下线上方法平均执行时间在1.2s左右,也就是每次可能也就处理15条左右 很好,都这么玩了,你不死锁谁死锁,问题是影响的不一定是这个业务,也会影响其他的业务。 明明其他的业务接口和并发都处理的很不错了,这样做真的让别人优化接口的感觉自己是小丑。

案例四 崩了,很严重

规范

在这么多年的开发过程中,说实话因为事务问题引发的线上bug数不胜数,每次找到问题的时候都是即好笑又生气。 因为我自己会准寻一些简单的开发规范,看到这种乱加事务的做法确实过不去眼。 接下来,我简单说说我一般遵循的事务的使用规范。

第一 谨慎的使用事务

请谨慎的使用事务。 使用事务时我们应该思考: 1.这块业务是否真的需要事务 2.是否有什么手段可以规避使用事务 3.实在没办法再使用事务 使用时应该是抱着这种想法去做的,而不是【无论如何只要加上总归是好的】的错误思想。 举个例子,保存订单表和订单明细表时,一般来说应该使用事务保持原子性。 但是,我们换个角度,如果先报错明细,再保存总表,两步操作不加事务,通过逻辑转换实现业务体现上的一致呢。 总表和细表保没保存成功,以总表为准,如果总表保存失败,报错。细表虽然入库,但是数据算脏数据,没有用。 这样是不是就没有两个表使用事务的必要了。

第二 注解式事务 @Transactional禁止使用。

是的,是禁止使用。不论你能力多强,领悟多深,多么会使用这个注解甚至玩出花儿来,都禁止使用。 注解式事务第一个常见的问题就是它的失效场景,这些八股文里面又,不再说了。 然而最严重的问题是不论你能力如何,开发这段代码的人大概率不一定是同一个。 怎么用你的方法,或者怎么改造事务下的方法,是后期完全无法预料的。 有些人或者某个时刻,一是偷懒在方法下加一个查询,都会非常致命。 比如某个方法保存的时候,需要从另一个接口查询数据作为入库数据。 但这时的原始保存方法整个在事务里面。 好了,如果改造这个事务的方法,第一种做法是事务方法加一个参数, 然后写一个重载方法,在重载方法查询数据,然后作为参数传入事务方法。这是比较标准的流程。 怕就怕有人直接在事务方法写查询,因为相比标准流程,确实方便不少。

第三 事务应该使用编程式事务

第四 禁止在事务中进行方法调用,无论什么方法

和第一条类似,你不知道你调用的方法在以后会被改成什么样,一眼看不完逻辑,很容易又埋雷。 可以有简单的逻辑,比如if,for等,但原则是事务中从上到下能一眼看明白逻辑。 再比如,上面调审批流的场景,增加一个状态为审批初始化的状态,先入库数据,然后异步消息调审批流,消息处理成功后再修改表单数据字段和状态,也能解决问题。

第五 任何数据的拼接都应在事务之外

结合第一和第三条,事务只能作为入库操作,不能存在任何的数据交互的操作。比如初始化实例,给实例赋值等行为。 这样做,既是怕后来者乱搞,也是减少开启事务的时间。

第六 事务中如何处理id

有人用事务就是为了从数据库拿一个自增id,我一向不建议这样搞,需要id直接使用分布式id就行。 如果觉得网上的分布式id策略复杂,结合自己的业务场景写一个其实也没那么困难。

第七 分清业务主次关系

多表操作甚至多系统操作时,实际的主要业务可能会要求数据的及时性和原子性,其他的业务不一定需要及时。 完全可以通过消息去处理,不一定要写在一堆。只要前期数据校验环节做的完善,在最终数据的呈现上实际都不会有偏差。

总结

请大家更加谨慎的使用事务,为自己和同事,以及未来的同事减少一些不必要的加班时间。 让我们有更多的时间研究摸鱼技术,而不是天天因为线上bug而掉光了头发。 此文,如果你不赞同也没什么关系,你有你的习惯,我有我的准则。

转载自:https://juejin.cn/post/7381997397822455862
评论
请登录