
1. 从一次扣款失败的线上事故说起去年我们团队遇到一个挺典型的线上问题一个用户余额扣减的场景在高并发请求下偶尔会出现余额扣减了两次但商品库存只减少了一次的诡异情况。排查代码发现业务逻辑是先查余额判断足够后扣减余额然后再去扣减库存。这个“先查后改”的操作在Redis这种单线程内存数据库里如果没有额外的保护遇到并发请求时两个操作之间的间隙就足以让另一个请求读到未更新的旧数据从而导致超扣。当时我们尝试用Redis事务MULTI/EXEC来解决但发现它并非严格意义上的原子性它只是将命令打包顺序执行期间不保证不被其他客户端命令插入而且没有回滚机制。最终我们引入了Redis Lua脚本才彻底解决了这个原子性问题。今天我们就来深挖一下为什么在Redis中一段用Lua写的脚本就能保证原子性这背后是Redis怎样的设计哲学以及除了解决并发扣减Lua脚本还能在哪些场景下大显身手成为我们手中的利器如果你也在为Redis下的数据一致性头疼或者想更高效地利用Redis那这篇从实战踩坑中总结出来的经验应该能给你一些直接的启发。2. 原子性的本质与Redis的单线程模型要理解Lua脚本的原子性首先得抛开对“事务”的固有认知。在传统数据库中原子性通常与“事务”绑定通过锁、日志WAL、MVCC等复杂机制来保证一系列操作的“要么全做要么全不做”。但Redis走了一条更简洁、更高效的路。2.1 Redis的“单线程”指的是什么很多人误解Redis是单线程的所以慢。这里必须澄清Redis在处理网络I/O和键值对读写命令时确实是单线程的。这个主线程会顺序处理所有客户端发来的命令。你可以把它想象成一个极其高效的流水线工人他一次只处理一个零件命令但手速极快且没有任何切换上下文的开销。这种设计避免了多线程编程中令人头疼的锁竞争和上下文切换使得Redis在绝大多数场景下性能惊人。2.2 单线程如何保证单个命令的原子性这是关键。因为命令是顺序执行的所以对于Redis的每一个内置命令如SET、GET、INCR、HSET其执行过程对于其他客户端命令来说就是原子的。当一个INCR key命令在执行时直到它完成并将结果返回给客户端之前不会有任何其他命令能接触到同一个key。这是Redis原子性的基石。2.3 多命令组合的困境问题来了。我们业务中的逻辑比如“检查并扣减”Check-And-Set通常需要多个命令组合GET- 判断 -SET。在单线程模型中虽然每个命令自身是原子的但客户端发送多个命令时它们之间是可能被其他客户端的命令插入的。这就是我们开头遇到问题的根源。客户端A: GET balance - (返回100) 客户端B: GET balance - (也返回100) 客户端A: DECRBY balance 50 - (余额变为50) 客户端B: DECRBY balance 30 - (余额变为20实际应为70)看到了吗B在A的GET和DECRBY之间执行了自己的GET读到了脏数据。这就是“原子性”被破坏的经典场景。Redis自带的WATCH/MULTI/EXEC乐观锁可以缓解但它是一种“乐观”的并发控制在冲突频繁时性能下降明显且语法相对复杂。3. Lua脚本如何成为Redis的“原子操作单元”既然单个Redis命令是原子的那能不能把多个命令打包成一个“超级命令”呢Lua脚本就是这个“超级命令”的载体。3.1 脚本的加载与执行一个不可分割的整体当你通过EVAL或SCRIPT LOADEVALSHA向Redis发送一段Lua脚本时Redis的处理流程是严格序列化的解析与加载Redis主线程接收并解析整个脚本。执行在同一个线程上下文中连续执行脚本中的所有Redis命令调用通过redis.call()或redis.pcall()。返回结果将脚本最后一个命令的结果或脚本中return语句的值返回给客户端。最关键的一点在于在整个脚本执行期间Redis服务器不会处理任何其他客户端命令也不会进行任何线程切换。脚本中的所有Redis操作在效果上等同于一个单一的、扩展的Redis命令。这就好比给那个流水线工人下达了一个包含多个步骤的“复合指令包”工人必须完整执行完这个包里的所有动作后才能去处理下一个客户的请求。这个“复合指令包”的执行过程对外部世界来说就是原子的。3.2 与事务MULTI/EXEC的本质区别这里必须划清界限因为很多人会混淆。Redis事务MULTI只是开启一个命令队列客户端后续的命令会被缓存起来直到EXEC时一次性发送到服务器执行。但在MULTI开始后、EXEC执行前其他客户端的命令是可以正常执行的。它保证的是批量命令的顺序性和隔离性但不保证原子性因为中间可能被其他命令影响且没有回滚。Lua脚本从EVAL命令被服务器接收的那一刻起到脚本执行完毕返回结果这期间服务器是独占的脚本内的逻辑对外是完全隔离且不可中断的。它天生就具备了原子性。用一个类比事务像是把几封信塞进同一个邮筒MULTI邮递员Redis一次取走EXEC并按顺序投递。而Lua脚本则像是一封内容详尽的挂号信邮递员必须完整处理完这封信里的所有要求读信、盖章、登记、回执后才能去处理下一封信。3.3 脚本执行中的错误处理原子性还意味着“要么全做要么全不做”。在Lua脚本中如果某个redis.call()命令执行出错比如对字符串执行HINCRBY默认情况下错误会抛出Lua异常导致整个脚本的执行被停止并且之前已经执行成功的命令也不会被回滚。等等这听起来不满足“全不做”是的这是Redis Lua脚本原子性的一个特殊之处它不提供传统数据库的“回滚”Rollback语义。因为Redis的设计目标是简单和快实现回滚需要维护复杂的日志这与它的哲学不符。那么如何保证一致性呢答案是将脚本设计成幂等的、或是在脚本内部进行充分的错误检查。例如在扣减余额前先用GET检查数值如果不够直接return不执行后续的DECRBY。这样要么所有检查通过并执行成功要么在错误发生前就提前终止避免了部分执行的状态。虽然已执行的命令无法撤销但通过逻辑设计我们避免了进入一个不一致的中间状态。注意使用redis.pcall()可以在Lua内部捕获Redis命令错误并继续执行脚本后续逻辑。但这通常用于非关键性的、可容忍失败的操作。对于核心业务逻辑更推荐用redis.call()让错误直接终止脚本避免产生更复杂的不一致状态。4. Lua脚本的典型使用场景与实战代码理解了原理我们来看看Lua脚本在哪些地方能真正解决痛点。下面我会结合具体代码示例来说明。4.1 场景一分布式锁与复杂锁逻辑这是最经典的场景。虽然可以用SET key value NX PX来实现一个简单的分布式锁但锁的释放需要判断值是否为自己设置的避免误删就需要GET和DEL的组合操作。不用Lua脚本这个过程就不是原子的。-- 释放锁的脚本只有锁的值匹配当前客户端标识时才删除 -- KEYS[1] 锁的key -- ARGV[1] 当前客户端标识如UUID if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个脚本保证了“判断所有权”和“删除锁”是一个不可分割的操作。在Redisson等成熟的客户端中其分布式锁的实现核心就是依赖了类似的Lua脚本。更进一步你可以实现更复杂的锁比如可重入锁、读写锁、红锁RedLock的取锁逻辑这些都需要多个Redis命令的原子组合Lua脚本是不二之选。4.2 场景二库存扣减、余额检查等需要“读取-判断-写入”的场景开篇提到的扣款问题用Lua脚本可以完美解决-- 原子性地扣减商品库存并记录用户购买记录 -- KEYS[1] 商品库存key (item:stock:1001) -- KEYS[2] 用户购买记录key (user:purchase:123) -- ARGV[1] 购买数量 -- ARGV[2] 用户ID -- ARGV[3] 商品ID local stock tonumber(redis.call(get, KEYS[1])) if stock nil then return {-1, 商品不存在} -- 错误码和消息 end if stock tonumber(ARGV[1]) then return {-2, 库存不足} end -- 扣减库存 redis.call(decrby, KEYS[1], ARGV[1]) -- 记录购买流水使用Sorted Set分数用时间戳 local timestamp redis.call(time)[1] -- 获取Redis服务器时间 redis.call(zadd, KEYS[2], timestamp, ARGV[3]..:..ARGV[1]..:..timestamp) return {0, 成功} -- 成功码和消息这个脚本一次性完成了库存检查、扣减和记录操作在高并发秒杀活动中至关重要。我曾在一个电商大促中将核心商品的扣减逻辑从“应用层计算Redis事务”改为纯Lua脚本超卖问题直接归零TPS还提升了近30%因为网络往返次数大大减少。4.3 场景三实现复杂的原子计数器或统计比如你需要统计一个视频的播放次数但同时要记录最近100个播放用户的ID用于去重或展示。-- 原子性增加播放量并更新最近播放用户列表 -- KEYS[1] 播放量key (video:play:888) -- KEYS[2] 最近播放用户列表key (video:recent:888) -- ARGV[1] 用户ID local currentCount redis.call(incr, KEYS[1]) -- 将用户ID插入列表头部并修剪列表只保留最近100个 redis.call(lpush, KEYS[2], ARGV[1]) redis.call(ltrim, KEYS[2], 0, 99) return currentCount如果没有Lua脚本你先INCR再LPUSH和LTRIM中间这个播放量数值和列表就可能出现短暂的不一致虽然对业务影响可能不大但不够完美。4.4 场景四自定义的原子性数据结构操作Redis提供了丰富的数据结构但有时我们需要组合操作。例如用一个Hash来存储用户信息同时用一个Sorted Set来按用户积分排名。当用户积分更新时需要原子性地更新Hash和Sorted Set。-- 更新用户积分并刷新排名 -- KEYS[1] 用户信息Hash key (user:info) -- KEYS[2] 用户积分排名Sorted Set key (user:score:rank) -- ARGV[1] 用户ID -- ARGV[2] 新的积分 -- 更新Hash中的积分字段 redis.call(hset, KEYS[1], ARGV[1] .. :score, ARGV[2]) -- 更新Sorted Set中的分数 redis.call(zadd, KEYS[2], ARGV[2], ARGV[1]) return 15. 使用Lua脚本的实战心得与避坑指南用好了是神器用不好也会带来麻烦。下面是我在大量使用Lua脚本后总结的一些经验。5.1 性能考量脚本不宜过重过长虽然脚本执行是原子的但它是阻塞式的。一个执行缓慢的脚本比如里面包含循环遍历大量Key会阻塞整个Redis服务器导致所有其他客户端超时。这是使用Lua脚本最大的风险。黄金法则Lua脚本应该是轻量级的、确定性的。避免在脚本中使用耗时的Lua计算如复杂的字符串处理、循环更不要执行KEYS *这样的命令。脚本的核心作用应该是“协调多个Redis命令”而不是做复杂的业务计算。监控务必通过Redis的SLOWLOG命令监控慢查询。任何执行时间超过预设阈值如10毫秒的脚本都需要被审查和优化。拆分如果逻辑确实复杂考虑能否拆分成多个更小的、原子性的脚本或者将部分计算移到客户端。5.2 注意脚本的缓存与复用每次使用EVAL都会导致Redis服务器对脚本进行解析和编译虽然编译结果会缓存。对于频繁执行的脚本应该使用SCRIPT LOAD命令将其加载到服务器缓存获取一个SHA1校验和然后用EVALSHA来执行。这可以节省网络带宽和服务器CPU。# 首次加载 $ redis-cli SCRIPT LOAD return redis.call(get, KEYS[1]) 4e6d8fc8bb01276962cce5371fa795a7763657ae # 后续执行 $ redis-cli EVALSHA 4e6d8fc8bb01276962cce5371fa795a7763657ae 1 mykey在客户端如Jedis、Lettuce中通常有封装好的接口来自动管理这个“加载-执行SHA”的过程。5.3 参数传递与数据序列化Lua脚本通过KEYS和ARGV数组接收参数。这里有个重要规范所有被脚本操作的Redis键都必须通过KEYS数组传递。这是为了支持Redis集群模式。在集群中Redis需要根据KEYS来计算脚本应该被路由到哪个节点执行。如果你在脚本里用字符串拼接动态生成Key在集群模式下会出错。ARGV则用于传递非键的参数比如数值、标识符等。Lua和Redis的数据类型需要转换从Redis到Lua数字会变成Lua number字符串变成Lua string。从Lua到RedisLua number会被转换成整数如3.14变成3如果需要浮点数或保留精度要以字符串形式传递并在Lua中用tonumber()转换。布尔值true/false和nil在转换时也要特别注意。5.4 调试与日志调试Lua脚本不像调试应用代码那么方便。我常用的方法是在开发环境先用redis-cli --eval直接执行脚本文件快速验证逻辑。在脚本中关键位置使用redis.log(redis.LOG_NOTICE, ...)函数输出日志到Redis日志文件。注意日志级别生产环境慎用DEBUG级别。对于复杂逻辑先在本地用单独的Lua环境模拟测试核心算法部分。5.5 版本兼容性与沙盒安全不同版本的Redis其内置的Lua解释器版本或可用库可能不同。如果你的脚本用到了特定的Lua库函数如cjson需要在部署前确认目标Redis版本是否支持。此外Redis的Lua环境是一个沙盒移除了很多危险函数如文件I/O、网络访问但依然要警惕。永远不要执行来自不可信客户端的脚本内容以防脚本中包含死循环耗尽服务器资源。6. 进阶Lua脚本与Redis模块、Stream的配合当业务越来越复杂单纯的Lua脚本可能也会力不从心。这时可以了解一些进阶组合。6.1 在Lua脚本中调用Redis模块命令从Redis 4.0开始支持了模块化扩展。像RedisBloom布隆过滤器、RedisJSON、RedisSearch等模块提供了新的命令。好消息是在Lua脚本中你可以通过redis.call()直接调用这些模块注册的命令就像调用原生命令一样。这极大地扩展了Lua脚本的能力边界。例如你可以在一个原子脚本中先检查布隆过滤器BF.EXISTS如果存在再执行后续的数据库查询和更新操作。6.2 利用Lua脚本处理Stream消息Redis Stream是5.0引入的持久化消息队列。在处理Stream消息时消费组Consumer Group的XREADGROUP和消息确认XACK是两个独立操作。为了确保“消费”和“处理成功并确认”的原子性可以借助Lua脚本。虽然Stream没有直接提供原子性的“读取-处理-确认”命令但你可以设计这样的模式消费者用XREADGROUP读到消息后将消息ID和待处理状态写入一个Hash然后开始处理。处理成功后执行一个Lua脚本这个脚本会从Hash中移除该消息ID并执行XACK。如果处理失败或超时另一个监控脚本可以扫描Hash中超时的消息ID将其重新放回Pending列表。这样通过Lua脚本保证了状态更新和XACK的原子性避免了消息丢失或重复确认。7. 总结何时该用何时不该用经过上面的剖析我们可以给Lua脚本的使用画个清晰的边界应该使用Lua脚本的场景需要原子性执行多个Redis命令且这些命令强相关中间状态不一致会导致业务问题如扣减库存、分布式锁。对性能有极高要求希望减少客户端与Redis服务器之间的网络往返次数RTT。逻辑相对简单、确定、轻量执行时间可预测且短。应避免或谨慎使用Lua脚本的场景脚本逻辑非常复杂、耗时过长超过1毫秒就需要警惕有阻塞Redis的风险。脚本中需要访问大量不同的Key特别是在集群模式下会导致脚本无法执行。业务逻辑频繁变化导致脚本需要经常更新和重新加载。团队对Lua语言不熟悉维护和调试成本高。在这种情况下也许将逻辑放在应用层配合Redis事务或更上层的分布式事务框架是更可维护的选择。我个人在实际项目中Lua脚本就像一把精准的手术刀用于解决那些明确的、局部的、对原子性有苛刻要求的“点状”问题。而对于复杂的、涉及多个数据源或长流程的业务我依然会将其放在应用层用更成熟的事务或补偿机制来保证最终一致性。工具没有好坏只有是否适用。理解了Redis Lua脚本原子性的根源在于其单线程的顺序执行模型你就能更准确地判断在什么情况下该请出这把“原子性利器”让它为你的系统稳定性和性能保驾护航。