模块四:Redis 事务与Lua

模块四: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一气呵成。但这里有个坑面试官最爱问QRedis事务支持回滚吗A不支持。想都别想。你一条命令写错了语法报错那整个事务都废了这个倒是会原子性失败。但如果是运行时错误比如你用INCR去加一个字符串类型的key——bash127.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是干啥的乐观锁了解一下你说不行我得加锁。于是你用了WATCHbashWATCH 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来个实战——库存扣减bashEVAL 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服务端bashSCRIPT LOAD return redis.call(GET, KEYS[1]) # 返回一个sha1值比如b5a8f5b8f5b8f5b8f5b8f5b8f5b8f5b8f5b8f5b8 # 后面用EVALSHA执行省带宽 EVALSHA b5a8f5b8f5b8f5b8f5b8f5b8f5b8f5b8f5b8f5b8 1 key12.3 Java里咋写Redisson一把梭在Java项目里我一般用RedissonjavaString 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 5000ms5秒。你写了个死循环或者特别复杂的逻辑超时了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示例 ListRedisFutureLong futures new ArrayList(); for (int i 0; i 100; i) { futures.add(redisAsyncCommands.incr(counter: i)); } // 一次性提交 for (RedisFutureLong future : futures) { future.get(); // 等待所有结果 }4.2 三个容易搞混的概念我帮你捋清楚很多新手分不清Pipeline、事务、Lua脚本的区别我一句话给你说明白维度Pipeline事务MULTILua脚本原子性❌ 不保证❌ 不保证✅ 保证减少网络开销✅✅✅后一条依赖前一条结果❌ 不行❌ 不行✅ 可以适用场景批量无关命令已过时不建议用复杂原子操作简单说Pipeline 打包发送不保证原子性适合批量写数据事务 打包发送 部分原子性但不回滚鸡肋别用Lua脚本 打包发送 完全原子性 逻辑判断王道五、Spring集成别把Transactional和Redis事务搞混了最后说个容易踩的坑——Spring里集成了Redis事务但别跟数据库的Transactional混为一谈。javaConfiguration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // 开启事务支持 template.setEnableTransactionSupport(true); return template; } } // 使用时 Service public class OrderService { Autowired private RedisTemplateString, 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管不了RedisSpring的Transactional管的是MySQL管不了RedisSpring的Transactional管的是MySQL管不了Redis别指望在方法上贴个TransactionalRedis事务出错了能自动回滚——那是做梦。最后总结面试官最爱听的那段兄弟们干了这么多年我给你们总结一句掏心窝子的话Redis事务MULTI/EXEC这玩意儿知道就行生产环境尽量别用。真要保证原子性上Lua脚本。如果面试官问你们Redis如何实现原子操作你直接回答Redis的Lua脚本可以保证原子性因为整个脚本会被当成一个命令执行期间不会插入其他操作。我们项目里用Lua实现库存扣减、限流、分布式锁释放等场景比原生的MULTI/EXEC事务更可靠。另外Pipeline可以用来批量发送命令减少网络开销但它不保证原子性。