模块四:Redis 事务与Lua

📅 2026/7/22 13:55:31 👁️ 阅读次数 📝 编程学习
模块四:Redis 事务与Lua

注意: 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那行报错了,但key2key3照写不误。

这就是我那天晚上超卖的元凶——Redis事务不具备原子性,没有回滚机制。

官方文档原话:"如果事务中一条命令失败,其他命令仍然会继续执行。"

1.2 那WATCH是干啥的?乐观锁了解一下

你说不行,我得加锁。于是你用了WATCH

bash

WATCH balance GET balance # 假设拿到100 MULTI SET balance 120 EXEC

如果在你WATCH之后、EXEC之前,有其他客户端把balance给改了,那EXEC会返回(nil),整个事务取消。

这其实就是乐观锁(CAS)的实现思路。

但说实话,工作中用WATCH的兄弟多吗?我反正见得不多。为啥?

  1. 你得自己处理重试逻辑,代码里写一堆循环,烦不烦?

  2. 高并发下冲突频繁,性能堪忧

  3. 只能监视key有没有被改,没法做复杂判断

所以你看,Redis事务这玩意儿吧——能用,但不好用。


二、Lua脚本:真·原子操作救世主

2.1 为啥要用Lua?一个字:稳

那天晚上翻车之后,我就把库存扣减改成了Lua脚本。为啥?

因为Lua脚本在执行的时候,整个脚本是一气呵成的,Redis单线程在执行期间不会插入任何其他命令。

这才是真正的原子性。

而且你想想,原来你要扣库存,得三步走:

  1. GET查库存

  2. Java里判断够不够

  3. 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 key1

2.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会怎么做?

  1. 脚本继续跑,但Redis开始拒绝其他命令

  2. 5秒后还没结束,Redis会记录日志,但不会主动kill脚本

  3. 只有执行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可以用来批量发送命令减少网络开销,但它不保证原子性。"