嗨,我是东哥!今天聊点“血泪经验”:当 Redis 遇上 @Transactional,真的是个隐藏大坑,稍有不慎就会踩进去。
这个事儿我是真经历过,每次出了问题,都是一通排查,结果发现问题根本不是写代码能解决的,这坑到底有多深,咱们一起来挖掘看看!
最近我负责的一个项目,生产环境突然出现了一个诡异的情况。
客服人员每天早上想创建客服事件时,总是失败。但奇怪的是,重启服务后居然就好了,可到第二天早上又会出现同样的错误——非重启不能解决,循环往复。这类问题真是头疼得很,程序员的梦魇没跑了。
于是开始排查,发现代码里使用了 Redis 的递增操作来生成唯一的分布式 ID,代码如下:
return redisTemplate.opsForValue().increment("count", 1);
按理说,这个递增操作应该每次都返回一个整数。但偏偏到早上这段代码就返回 null,导致后续一系列逻辑全部卡壳,事件无法保存。
最骚的是,重启之后又正常了!这“玄学”特性可真让人抓狂。
既然问题在 Redis 操作上,我们的排查方向也就定了下来,为什么 Redis 操作会返回 null?而且重启后为什么又好使了?到底问题出在哪?
排查过程:疑云渐散,锁定“锅”在 @Transactional
先说结论,问题出在 @Transactional 和 Redis 的组合上。讲真,起初也没想到是它,咱们一步步来还原这过程:
监控 Redis 连接:刚开始我以为是 Redis 本身的问题,于是开启 Redis 监控,看看是不是 Redis 连接超时或者连接数不足。结果 Redis 正常得很,连接也很稳定。
日志排查:既然 Redis 本身没问题,那就看看服务端的日志。于是我在递增操作周围加了各种日志,查看每一步的返回值。发现出错的早上,这段代码里 increment 操作返回了 null,但重启后又正常返回整数值,这让我开始怀疑是不是某种环境问题。
锁定 @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 一起使用时。通过这次经历,我总结了几条经验:
Redis 操作尽量放在事务外部,让 Redis 操作与事务解耦。
必要时使用 Lua 脚本,确保操作的原子性。
ID 生成尽量使用分布式算法,减少对 Redis 的依赖。
总之,@Transactional 和 Redis 的组合要格外小心,否则就会像我这样掉进坑里,天天 debug 到天亮
领取专属 10元无门槛券
私享最新 技术干货