注意: Spring的@Transactional和Redis事务是两回事,不要混淆。
哥们,你的Redis事务翻车了吗?——从一次库存超卖事故聊起
看我把Redis事务、Lua脚本、Pipeline那点破事给你捋明白
开场白:那个加班的夜晚
兄弟们,先给大家讲个真实发生在我身上的事儿。
那是去年双11前夜,我们系统要做库存扣减。我寻思着,Redis单线程、速度快,用MULTI/EXEC包一下不就完事儿了?原子性?那不手拿把攥吗!
结果呢?超卖了。
凌晨两点,运营小姐姐一个电话把我从被窝里薅起来:"哥,库存变负数了..."
我当时脑子"嗡"的一下,赶紧爬起来看日志。你猜怎么着?MULTI里面套了三个命令,第二个命令报错了,第一个和第三个照常执行了。
那一刻我才明白——Redis事务,它压根就不是你想的那种"事务"。
今天咱不背八股文,就着这个翻车现场,把Redis事务、Lua脚本、Pipeline这些玩意儿的底裤扒干净。
一、Redis事务:披着事务外衣的"假事务"
1.1 命令长啥样?看一眼就懂
bash
# 开整 127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET key1 value1 QUEUED 127.0.0.1:6379> INCR key2 QUEUED 127.0.0.1:6379> EXEC 1) OK 2) (integer) 1
看着挺像那么回事儿对吧?MULTI开启,命令排队,EXEC一气呵成。
但这里有个坑,面试官最爱问:
Q:Redis事务支持回滚吗?
A:不支持。想都别想。
你一条命令写错了,语法报错?那整个事务都废了,这个倒是会"原子性"失败。
但如果是运行时错误,比如你用INCR去加一个字符串类型的key——
bash
127.0.0.1:6379> SET key1 "abc" OK 127.0.0.1:6379> MULTI OK 127.0.0.1:6379> SET key2 value2 QUEUED 127.0.0.1:6379> INCR key1 QUEUED 127.0.0.1:6379> SET key3 value3 QUEUED 127.0.0.1:6379> EXEC 1) OK 2) (error) ERR value is not an integer or out of range 3) OK
看到了吗?key1那行报错了,但key2和key3照写不误。
这就是我那天晚上超卖的元凶——Redis事务不具备原子性,没有回滚机制。
官方文档原话:"如果事务中一条命令失败,其他命令仍然会继续执行。"
1.2 那WATCH是干啥的?乐观锁了解一下
你说不行,我得加锁。于是你用了WATCH:
bash
WATCH balance GET balance # 假设拿到100 MULTI SET balance 120 EXEC
如果在你WATCH之后、EXEC之前,有其他客户端把balance给改了,那EXEC会返回(nil),整个事务取消。
这其实就是乐观锁(CAS)的实现思路。
但说实话,工作中用WATCH的兄弟多吗?我反正见得不多。为啥?
你得自己处理重试逻辑,代码里写一堆循环,烦不烦?
高并发下冲突频繁,性能堪忧
只能监视key有没有被改,没法做复杂判断
所以你看,Redis事务这玩意儿吧——能用,但不好用。
二、Lua脚本:真·原子操作救世主
2.1 为啥要用Lua?一个字:稳
那天晚上翻车之后,我就把库存扣减改成了Lua脚本。为啥?
因为Lua脚本在执行的时候,整个脚本是一气呵成的,Redis单线程在执行期间不会插入任何其他命令。
这才是真正的原子性。
而且你想想,原来你要扣库存,得三步走:
GET查库存Java里判断够不够
DECR扣减
这三步分开走,中间万一有并发请求插进来,数据就乱了。
现在用Lua,三步合一,一次发给Redis,中间谁也插不了队。
2.2 命令咋写?三分钟上手
最简单的Lua脚本长这样:
bash
# EVAL "脚本内容" 键数量 key列表 arg列表 EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 key1 value1来个实战——库存扣减:
bash
EVAL " local stock = redis.call('GET', KEYS[1]) if stock and tonumber(stock) > 0 then redis.call('DECR', KEYS[1]) return 1 else return 0 end " 1 stock:123这段脚本的逻辑是:
查库存
如果大于0,扣减,返回1(成功)
否则返回0(失败)
一个请求,一次网络交互,原子完成。
你要是觉得每次发整个脚本太浪费带宽,可以先用SCRIPT LOAD把脚本缓存到Redis服务端:
bash
SCRIPT LOAD "return redis.call('GET', KEYS[1])" # 返回一个sha1值,比如:b5a8f5b8f5b8f5b8f5b8f5b8f5b8f5b8f5b8f5b8 # 后面用EVALSHA执行,省带宽 EVALSHA b5a8f5b8f5b8f5b8f5b8f5b8f5b8f5b8f5b8f5b8 1 key12.3 Java里咋写?Redisson一把梭
在Java项目里,我一般用Redisson:
java
String script = "local stock = redis.call('GET', KEYS[1]) " + "if stock and tonumber(stock) > 0 then " + " redis.call('DECR', KEYS[1]) " + " return 1 " + "else " + " return 0 " + "end"; Boolean result = redisson.getScript().eval( RScript.Mode.READ_WRITE, script, RScript.ReturnType.BOOLEAN, Collections.singletonList("stock:123"), Collections.emptyList() ); if (result) { // 扣减成功 } else { // 库存不足 }就这么简单。一行脚本,原子扣库存,再也不用担心超卖了。
2.4 踩坑提醒:脚本超时别忽视
有个事得提醒兄弟们——Lua脚本是有超时时间的,默认配置是lua-time-limit 5000ms(5秒)。
你写了个死循环或者特别复杂的逻辑,超时了,Redis会怎么做?
脚本继续跑,但Redis开始拒绝其他命令
5秒后还没结束,Redis会记录日志,但不会主动kill脚本
只有执行
SCRIPT KILL才能停(但如果是写操作,SCRIPT KILL都不好使,只能用SHUTDOWN NOSAVE重启)
所以,Lua脚本里别写循环!别写复杂逻辑!就做纯粹的Redis原子操作,复杂业务逻辑放Java层。
三、Lua脚本 vs 事务:别纠结,直接用Lua
我知道你们面试时肯定被问过这个问题,我直接给你个对比表,背就完了:
| 维度 | Lua脚本 | 事务(MULTI) |
|---|---|---|
| 原子性 | ✅ 整个脚本原子执行 | ❌ 命令间可能失败,无回滚 |
| 条件判断 | ✅ 支持if/else,随心所欲 | ❌ 只能WATCH,不能做逻辑判断 |
| 网络开销 | 小(一次发送) | 小(一次发送) |
| 适用场景 | 复杂原子操作 | 简单命令队列(已过时) |
| 推荐程度 | ⭐⭐⭐⭐⭐ 优先使用 | ⚠️ 建议放弃 |
面试话术来一个:
"我们项目中用Redis Lua脚本实现库存扣减、接口限流等场景,因为它能保证原子性,支持条件判断,比原生的MULTI/EXEC事务更灵活可靠。"
四、Pipeline:我要的是速度,不是原子性
4.1 Pipeline是干啥的?
兄弟们,你们有没有遇到过这种情况:要批量设置100个key,挨个发命令,来回100次网络请求,慢得要死。
Pipeline就是干这个的——把一堆命令打包,一次发过去,一次收回来。省去了每次请求的网络往返时间(RTT)。
java
// Lettuce的Pipeline示例 List<RedisFuture<Long>> futures = new ArrayList<>(); for (int i = 0; i < 100; i++) { futures.add(redisAsyncCommands.incr("counter:" + i)); } // 一次性提交 for (RedisFuture<Long> future : futures) { future.get(); // 等待所有结果 }4.2 三个容易搞混的概念,我帮你捋清楚
很多新手分不清Pipeline、事务、Lua脚本的区别,我一句话给你说明白:
| 维度 | Pipeline | 事务(MULTI) | Lua脚本 |
|---|---|---|---|
| 原子性 | ❌ 不保证 | ❌ 不保证 | ✅ 保证 |
| 减少网络开销 | ✅ | ✅ | ✅ |
| 后一条依赖前一条结果 | ❌ 不行 | ❌ 不行 | ✅ 可以 |
| 适用场景 | 批量无关命令 | 已过时,不建议用 | 复杂原子操作 |
简单说:
Pipeline= 打包发送,不保证原子性,适合批量写数据
事务= 打包发送 + 部分原子性(但不回滚),鸡肋,别用
Lua脚本= 打包发送 + 完全原子性 + 逻辑判断,王道
五、Spring集成:别把@Transactional和Redis事务搞混了
最后说个容易踩的坑——Spring里集成了Redis事务,但别跟数据库的@Transactional混为一谈。
java
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // 开启事务支持 template.setEnableTransactionSupport(true); return template; } } // 使用时 @Service public class OrderService { @Autowired private RedisTemplate<String, Object> redisTemplate; public void test() { redisTemplate.multi(); // 开始Redis事务 try { redisTemplate.opsForValue().set("key1", "value1"); redisTemplate.opsForValue().set("key2", "value2"); // 注意!这里的异常不会触发Redis回滚! redisTemplate.exec(); // 执行事务 } catch (Exception e) { redisTemplate.discard(); // 手动丢弃 } } }重要的事情说三遍:
Spring的@Transactional管的是MySQL,管不了Redis!
Spring的@Transactional管的是MySQL,管不了Redis!
Spring的@Transactional管的是MySQL,管不了Redis!
别指望在方法上贴个@Transactional,Redis事务出错了能自动回滚——那是做梦。
最后总结(面试官最爱听的那段)
兄弟们,干了这么多年,我给你们总结一句掏心窝子的话:
Redis事务(MULTI/EXEC)这玩意儿,知道就行,生产环境尽量别用。真要保证原子性,上Lua脚本。
如果面试官问你们"Redis如何实现原子操作",你直接回答:
"Redis的Lua脚本可以保证原子性,因为整个脚本会被当成一个命令执行,期间不会插入其他操作。我们项目里用Lua实现库存扣减、限流、分布式锁释放等场景,比原生的MULTI/EXEC事务更可靠。另外Pipeline可以用来批量发送命令减少网络开销,但它不保证原子性。"