没想到,Redis 遇到 @Transactional注解,有这么大个坑
大家好,我是小富~
最近项目的生产环境遇到一个奇怪的问题:
现象:每天早上客服人员在后台创建客服事件时,都会创建失败。当我们重启这个微服务后,后台就可以正常创建了客服事件了。到第二天早上又会创建失败,又得重启这个微服务才行。
初步排查:创建一个客服事件时,会用到 Redis 的递增操作来生成一个唯一的分布式 ID 作为事件 id。代码如下所示:
return redisTemplate.opsForValue().increment("count", 1);
而恰巧每天早上这个递增操作都会返回 null
,进而导致后面的一系列逻辑出错,保存客服事件失败。当重启微服务后,这个递增操作又正常了。
那么排查的方向就是 Redis 的操作为什么会返回 null 了,以及为什么重启就又恢复正常了。
二、排查
根据上面的信息,我们先来看看 Redis 的自增操作在什么情况下会返回 null。
2.1 推测一
根据重启后就恢复正常,我们推测晚上执行了大量的 job,大量 Redis 连接未释放,当早上再来执行 Redis 操作时,执行失败。重启后,连接自动释放了。
但是其他有使用到 Redis 的业务功能又是正常的,所以推测一的方向有问题,排除。
2.2 推测二
可能是 Redis 事务造成的问题。这个推测的依据是根据下面的代码来排查的。
直接看 redisTemplate
递增的方法 increment
,如下所示:
官方注释已经说明什么情况下会返回 null:
- 当在 pipeline(管道)中使用这个 increment 方法时会返回 null。
- 当在 transaction(事务)中使用这个 increment 方法时会返回 null。
事务提供了一种将多个命令打包,然后一次性、有序地执行机制.
多个命令会被入列到事务队列中,然后按先进先出(FIFO)的顺序执行。
事务在执行过程中不会被中断,当事务队列中的所有命令都被执行完毕之后,事务才会结束。(内容来自 Redis 设计与实现)
继续看代码,发现在操作 Redis 的 ServiceImpl 实现类的上面添加了一个 @Transactional 注解,推测是不是这个注解影响了 Redis 的操作结果。
2.3 验证推测二
如下面的表格所示,第二行中没有添加 Spring 的事务注解 @Transactional
时,执行 Redis 的递增命令肯定是正常的,而接下来要验证的是表格中的第一行:加了 @Transactional
是否对 Redis 的命令有影响。
为了验证上面的推论,我写了一个 Demo 程序。
Controller 类,定义了一个 API,用来模拟前端发起的请求:
Service 实现类,定义了一个方法,用来递增 Redis 中的 count 键,每次递增 1,然后返回命令执行后的结果。而且这个 Service 方法加了@Transactional 注解。
Postman 测试下,发现每发一次请求,count 都会递增 1,并没有返回 null。
然后到 Redis 中查看数据,count 的值也是递增后的值 38,也不是 null。
通过这个实验说明在 @Transactional 注解的方法里面执行 Redis 的操作并不会返回 null,结论我记录到了表格中。
所以说上面的推论不成立(加了 @Transactional 注解并不影响),到这里线索似乎断了。
2.4 推测三
然后跟当时做这块功能的开发人员说明了情况,告诉他可能是 Redis 事务造成的,然后问有没有其他同学在凌晨执行过 Redis 事务相关的 Job。
他说最近有同事加过 Redis 的事务功能,在凌晨执行 Job 的时候用到事务。我将这位同事加的代码简化后如下所示:
下面是针对这段代码的解释,简单来说就是开启事务,将 Redis 命令顺序放到一个队列中,然后最后一起执行,且保证原子性。
setEnableTransactionSupport
表示是否开启事务支持,默认不开启。
难道开启了 Redis 事务,还能影响 Spring 事务中的 Redis 操作?
2.5 验证推测三
如下表,序号 3 和 序号 4 的场景都是开启了 Redis 的事务支持,两个场景的区别是是否加了 @Transactional 注解。
为了验证上面的场景,我们来做个实验:
- 先开启 Redis 事务支持,然后执行 Redis 的事务命令 multi 和 exec 。
- 验证场景 3:在 @Transactional 注解的方法中执行 Redis 的递增操作。
- 验证场景 4:在非 @Transactional 注解的方法中执行 Redis 的递增操作
2.5.1 执行 Redis 事务
首先就用 Redis 的 multi 和 exec 命令来设置两个 key 的值。
如下图所示,设置成功了。
2.5.2 @Transactional 中执行 Redis 命令
接下来在标注有 @Transactional 注解的方法中执行 Redis 的递增操作。
多次执行这个命令返回的结果都是 null,这不就正好重现了!
再来看 Redis 中 count 的值,发现每执行一次 API 请求调用,都会递增 1,所以虽然命令返回的是 null,但最后 Redis 中存放的还是递增后的结果。
接下来我们验证下场景 4,先执行 Redis 事务操作,然后在不添加 @Transactional 注解的方法中执行 Redis 递增操作。
用 Postman 调用这个接口后,正常返回自增后的结果,并不是返回 null。说明在非 @Transactional 中执行 Redis 操作并没有受到 Redis 事务的影响。
四个场景的结论如下所示,只有第三个场景下,Redis 的递增操作才会返回 null。
问题原因找到了,说明 RedisTemplete 开启了 Redis 事务支持后,在 @Transactional 中执行的 Redis 命令也会被认为是在 Redis 事务中执行的,要执行的递增命令会被放到队列中,不会立即返回执行后的结果,返回的是一个 null,需要等待事务提交时,队列中的命令才会顺序执行,最后 Redis 数据库的键值才会递增。
三、源码解析
那我们就看下为什么开启了 Redis 事务支持,效果就不一样了。
找到 Redis 执行命令的核心方法, execute 方法。
然后一步一步点进去看,关键代码就是 211 行到 216 行,有一个逻辑判断,当开启了 Redis 事务支持后,就会去绑定一个连接(bindConnection
),否则就去获取新的 Redis 连接(getConnection
)。这里我们是开启了的,所以再到 bindConnection
方法中查看如何绑定连接的。
接着往下看,关键代码如下所示,当开启了 Redis 事务支持,且添加了 @Transactional 注解时,就会执行 Redis 的 mutil 命令。
关键代码:conn.multi();
Redis Multi 命令用于标记一个事务块的开始,事务块内的多条命令会按照先后顺序被放进一个队列当中,最后由 EXEC 命令原子性(atomic)地执行。
真相大白,开启 Redis 事务支持 + @Transactional 注解后,最后其实是标记了一个 Redis 事务块,后续的操作命令是在这个事务块中执行的。
比如下面的的递增命令并不会返回递增后的结果,而是返回 null。
stringRedisTemplate.opsForValue().increment("count", 1);
而我们的生产环境重启服务后,开启的 Redis 事务支持又被重置为默认值了,所以后续的 Redis 递增操作都能正常执行。
四、修复方案
目前想到了两种解决方案:
- 方案一:每次 Redis 的事务操作完成后,关闭 Redis 事务支持,然后再执行 @Transactional 中的 Redis 命令。(有弊端)
- 方案二:创建两个 StringRedisTemplate,一个专门用来执行 Redis 事务,一个用来执行普通的 Redis 命令。
4.1 方案一
方案一的写法如下,先开启事务支持,事务执行之后,再关闭事务支持。
但是这种写法有个弊端,如果在执行 Redis 事务期间,在 @Transactional 注解的方法里面执行 Redis 命令,则还是会造成返回结果为 null。
4.2 方案二
弄两个 RedisTemplate Bean,一个是用来执行 Redis 事务的,一个是用来执行普通 Redis 命令的(不支持事务)。不同的地方引入不同的 Bean 就可以了。
先创建一个 RedisConfig 文件,自动装配两个 Bean。一个 Bean 名为 stringRedisTemplate
代表不支持事务的,执行命令后立即返回实际的执行结果。另外一个 Bean 名为 stringRedisTemplateTransaction
,代表开启 Redis 事务支持的。
代码如下所示:
接下来在测试的 Service 类中注入两个不同的 StringRedisTemplate 实例,代码如下所示:
Redis 事务的操作改写成这样,且不需要手动开启 Redis 事务支持了。用到的 StringRedisTemplate 是支持事务的那个实例。
在 Spring 的 @Tranactional 中执行的 Redis 命令如下所示,用到的 StringRedisTemplate 是不支持事务的那个实例。
然后还是按照上面场景 3 的测试步骤,先执行 testRedisMutil 方法,再执行 testTransactionAnnotations 方法。
验证结果:Redis 递增操作正常返回 count 的值,修复完成。
另外关于 Redis 事务使用还有一个坑,就是 Redis 连接未释放,导致获取不到连接了,这是下一个话题了~
参考资料:https://blog.csdn.net/qq_34021712/article/details/79606551
- END -
说在最后
一年一度的消费日双十一即将到来!
为了让尽可能多的喜欢Java的人学习 到有趣和有用知识, 我们现在推出年度套餐!
在11月13日前,年度订阅Code Gym
可获得 5折以上 的优惠 哦!
除了打折价,还有一份个人奖金在等着你。
机不可失,先到先得!
C odeGym 背后的小故 事
Code Gym 由充满激情的 Java开发人员于 2018年创立。2019年, Code Gym 在全球拥有十 万名用户。 从那时起, 我们的团队不断 创新 Code Gym 课程,使我们的用户人数达到 95万人。我们的任务是为大家提供最新的 Java学习体验。
Java是一种最常用也最容易学习的编程语言,掌握了它,不仅能够轻松炫技,更打开了高薪的大门。Java 语言程序员的平均薪资为 7.5万美元。
Code Gym 是一门面向实践的交互式编程课程,其中80%的内容为实践,20%的内容为基本Java理论。这才是成为一名真正的Java开发人员所需要的东西。本学习课程非常适合初学者以及想要扩展技能的软件开发人员。
我们相信,只要用正确的方法,每个人都能掌握编码技能。学习代码应该是以实践为重点。事实上,任何人都可以学习编程——你不需要成为数学天才,只 要有学习的 欲望就足够学会编程,程序员并不是天生的。
加入 Code Gym , 懂 点代 码!
-
大量的练习让你为真正的工作做好准备
要学习 Java 语言并成为一名程序员,你需 要大量编写代码。采用实践第一的方法是 Code Gym 的显著特点。有超过1200个不同难度的任务,你可以对所学的每个主要Java主题进行大量的练习。这些数量足以让你获得足够的经验找到一份工作。Code Gym 还为大家带来许多妙趣横生的Java实践体验——聊天应用程序、自动化餐厅应用程序、HTML 编辑器、ATM模拟器等。和我们一起学 Java,别跟丢了~!
-
边玩游戏边学习
学习不应该是一个无聊的事情!Code Gym 教程使用最新技术让你的学习更轻松、更有趣和更富有成效:可视化的课程进展、有趣的编程故事、激励机制、玩代码游戏……听起来很有趣,是不是?但 在这里不都是编码游戏,而是一个很酷的工具来创建你自己的游戏,或者更准确地说,创建自己的一些老派经典游戏的版本。
-
即使解决方法验证
普通课堂上,你需要等很长时间老师才有空检查你的作业。 不要再浪费时间等待了! 我们的全能虚拟导师会在眨眼间来检查你所有的解决方法并将给出针对此代码的评论列表,能精确地告诉你哪个地方不满足要求。
-
不再使学习Java一件孤单的事情
如果你在学习中遇到了瓶颈和困难,可以在Code Gym 社区中发帖交流,大家会热心地帮助你。在社区中,你也可以和来自全世界的学习者一起分享你的学习收获。在这里不仅有机会学习Java编程技术,还能锻炼自己的英语能力,相辅相成。
Code Gym
想换个新工作或者开启新副业?
想成为家族群里最亮眼的明星?
想成为一名 Java 开发人员?
事不宜迟,你知道该怎么做。
抓住机会开始学习 Java!
用最低的价格购最优的课程,这样福利怎能错过!
即日起 至 11 月13日
CodeGym超低限时折扣
一年期高级版订阅服务
仅需 ¥391 登陆即享
未注册过的新⽤户,先注册,再订阅
1. 点击链接注册
https://codegym.cc/zh/
2. 点击 “ 课程 ” 寻找菜单
3. 点击菜单享受折扣优惠
已经注册的 ⽤ 户 ,
在登陆状态下打开链接直接订阅
https://codegym.cc/zh/sale
如有问题可以通过微信公众号后台发私信沟通,或者到官⽹社区留⾔。
点击阅读原文 即刻获取课程!