当 Redis 碰上 @Transactional,有大坑!

科技   2024-11-11 11:43   山西  
嗨,我是东哥!今天聊点“血泪经验”:当 Redis 遇上 @Transactional,真的是个隐藏大坑,稍有不慎就会踩进去。
这个事儿我是真经历过,每次出了问题,都是一通排查,结果发现问题根本不是写代码能解决的,这坑到底有多深,咱们一起来挖掘看看!
最近我负责的一个项目,生产环境突然出现了一个诡异的情况。
客服人员每天早上想创建客服事件时,总是失败。但奇怪的是,重启服务后居然就好了,可到第二天早上又会出现同样的错误——非重启不能解决,循环往复。这类问题真是头疼得很,程序员的梦魇没跑了。
于是开始排查,发现代码里使用了 Redis 的递增操作来生成唯一的分布式 ID,代码如下:
return redisTemplate.opsForValue().increment("count"1);
按理说,这个递增操作应该每次都返回一个整数。但偏偏到早上这段代码就返回 null,导致后续一系列逻辑全部卡壳,事件无法保存。
最骚的是,重启之后又正常了!这“玄学”特性可真让人抓狂。
既然问题在 Redis 操作上,我们的排查方向也就定了下来,为什么 Redis 操作会返回 null?而且重启后为什么又好使了?到底问题出在哪?

排查过程:疑云渐散,锁定“锅”在 @Transactional

先说结论,问题出在 @Transactional 和 Redis 的组合上。讲真,起初也没想到是它,咱们一步步来还原这过程:
  1. 监控 Redis 连接:刚开始我以为是 Redis 本身的问题,于是开启 Redis 监控,看看是不是 Redis 连接超时或者连接数不足。结果 Redis 正常得很,连接也很稳定。
  2. 日志排查:既然 Redis 本身没问题,那就看看服务端的日志。于是我在递增操作周围加了各种日志,查看每一步的返回值。发现出错的早上,这段代码里 increment 操作返回了 null,但重启后又正常返回整数值,这让我开始怀疑是不是某种环境问题。
  3. 锁定 @Transactional这时我留意到,递增操作是在一个 @Transactional 方法中进行的,而这可能就是问题的核心。回忆起来,@Transactional 这个注解和 Redis 可不怎么对付,特别是在某些场景下会导致 Redis 操作异常。
经过分析,这里出现问题的根本原因其实是:Spring 的事务机制和 Redis 操作在某些情况下并不兼容,特别是事务回滚时会干扰 Redis 操作,导致 Redis 返回 null

了解 @Transactional 如何“惹祸”

知道问题在 @Transactional 上后,我们深入了解一下 @Transactional 到底如何“惹祸”的。
首先要知道,@Transactional 是 Spring 管理事务的注解。事务开启后,Spring 会创建一个新的数据库连接来执行 SQL 操作,事务结束时则决定是否提交或回滚。
在 Spring 的事务回滚机制中,如果某个操作需要回滚,事务管理器会自动清除当前事务中执行的操作(包括 Redis 的操作)。然而 Redis 操作通常是不带事务的,当 @Transactional 要求回滚时,Redis 的连接偶尔会受到影响,比如被释放或者被清空,从而导致后续 Redis 操作返回 null
具体来说,当 Redis 在事务管理中执行时,事务的回滚可能会使 Redis 操作变得不稳定,导致返回 null。而一旦服务重启,连接重置,Redis 恢复正常,直到事务再次触发。
为了加深理解,看看源码可以更清晰:
@Transactional
public void createCustomerEvent() {
    Long id = redisTemplate.opsForValue().increment("count"1); 
    if (id == null) {
        throw new RuntimeException("Redis increment returned null!");
    }
    // 其他数据库操作...
}
在事务中,Redis 的递增操作被干扰了,最终返回 null,导致异常。而在 Spring 事务管理中,Redis 连接和数据库连接一旦发生交叉,就可能触发这类问题。

修复方案

既然找到了问题根源,修复方法也就清晰了。以下几种方案可以避免 @Transactional 和 Redis 冲突:

方案一:Redis 操作移出事务

最直接有效的方式就是把 Redis 操作移出事务。Redis 本身是一个内存型数据库,性能非常高,一般不需要事务管理,因此可以单独处理它的操作:
public void createCustomerEvent() {
    Long id = redisTemplate.opsForValue().increment("count"1); 
    if (id == null) {
        throw new RuntimeException("Redis increment returned null!");
    }
    
    // Redis 操作完毕,开始事务
    saveEventWithTransaction(id);
}

@Transactional
private void saveEventWithTransaction(Long id) {
    // 数据库操作...
}
这样一来,Redis 操作与数据库事务分开,避免了 Redis 操作被事务回滚影响的情况。

方案二:使用 Redis 的 Lua 脚本

如果业务逻辑复杂,必须在事务中使用 Redis,可以考虑用 Lua 脚本来保证 Redis 操作的原子性。Lua 脚本在 Redis 中是原子执行的,可以确保递增操作的稳定性:
// Lua 脚本
String script = "return redis.call('INCRBY', KEYS[1], ARGV[1])";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);

Long id = redisTemplate.execute(redisScript, Collections.singletonList("count"), 1);
这种方式避免了 @Transactional 带来的回滚问题,因为 Lua 脚本本身是一个整体,即使 Redis 内部有小问题,也不会导致 null 返回。

方案三:使用分布式 ID 生成器

如果 Redis 的递增操作不可靠,还可以使用分布式 ID 生成器,比如雪花算法(Snowflake)或者 UUID。这类算法不依赖 Redis,可以单独生成唯一 ID,避免了 Redis 的不确定性。
public Long generateUniqueId() {
    return System.currentTimeMillis(); // 简单的 ID 生成示例
}
当然,雪花算法更推荐,它能生成长整型 ID,不容易冲突。
@Transactional 是个强大的注解,但用不好就会带来意想不到的麻烦,特别是与 Redis 一起使用时。通过这次经历,我总结了几条经验:
  1. Redis 操作尽量放在事务外部,让 Redis 操作与事务解耦。
  2. 必要时使用 Lua 脚本,确保操作的原子性。
  3. ID 生成尽量使用分布式算法,减少对 Redis 的依赖。
总之,@Transactional 和 Redis 的组合要格外小心,否则就会像我这样掉进坑里,天天 debug 到天亮 
对编程、职场感兴趣的同学,可以链接我,微信:coder301 拉你进入“程序员交流群”。
🔥东哥私藏精品 热门推荐🔥
东哥作为一名超级老码农,整理了全网最全《Java高级架构师资料合集》
资料包含了《IDEA视频教程》《最全Java面试题库》、最全项目实战源码及视频》及《毕业设计系统源码》总量高达 650GB 。全部免费领取!全面满足各个阶段程序员的学习需求。

Java面试那些事儿
回复 java ,领取Java面试题。分享AI编程,Java教程,Java面试辅导,Java编程视频,Java下载,Java技术栈,AI工具,Java开源项目,Java简历模板,Java招聘,Java实战,Java面试经验,IDEA教程。
 最新文章