Redis 从入门到精通:事务与 Lua 脚本

Redis 从入门到精通:事务与 Lua 脚本 IT策士 10余年一线大厂经验专注 IT 思维、架构、职场进阶。我会在各个平台持续发布最新文章助你少走弯路。在高并发环境下多个客户端同时读写同一个键很容易出现数据竞态——比如库存扣减成负数、账户余额被并发修改覆盖。Redis 虽然单线程执行命令但多个命令之间仍然可能被其他客户端插队。要保证一系列命令的原子性要么全执行要么全不执行中间不被打断Redis 提供了两套武器事务MULTI/EXEC和Lua 脚本。前者适合简单批处理后者则是原子性的终极方案。本文就从原理到实践把这两把利器彻底讲透。1. Redis 事务MULTI/EXEC 机制1.1 事务是什么Redis 的事务将多个命令打包然后一次性地、顺序地执行。核心命令MULTI开启事务EXEC执行事务中所有命令DISCARD取消事务在MULTI和EXEC之间的命令不会立即执行而是被放入队列。当EXEC被调用时Redis 顺序执行队列中的全部命令期间不会插入其他客户端的命令。动手体验127.0.0.1:6379SET balance100OK127.0.0.1:6379MULTI OK127.0.0.1:6379(TX)DECRBY balance30QUEUED127.0.0.1:6379(TX)INCRBY balance50QUEUED127.0.0.1:6379(TX)GET balance QUEUED127.0.0.1:6379(TX)EXEC1)(integer)702)(integer)1203)120EXEC返回一个数组依次对应每个命令的执行结果。1.2 事务的局限性重要Redis 事务和我们熟知的数据库事务ACID不同有以下关键特性① 不支持回滚如果事务中某条命令执行失败比如对 String 执行LPUSHRedis 不会回滚而是继续执行后续命令。127.0.0.1:6379SET key1helloOK127.0.0.1:6379MULTI OK127.0.0.1:6379(TX)SET key2worldQUEUED127.0.0.1:6379(TX)LPUSH key1x# key1 是 StringLPUSH 会报错QUEUED127.0.0.1:6379(TX)SET key3fooQUEUED127.0.0.1:6379(TX)EXEC1)OK2)(error)WRONGTYPE Operation against a key holding the wrong kind of value3)OKkey2和key3设置成功LPUSH报错但不影响其他命令。② 编译时错误才会中止如果命令本身写错了如拼写错误在MULTI阶段就会失败此时EXEC会返回错误事务不会执行。127.0.0.1:6379MULTI OK127.0.0.1:6379(TX)SET key4aQUEUED127.0.0.1:6379(TX)WRONGCOMMAND# 命令不存在(error)ERR unknowncommandWRONGCOMMAND127.0.0.1:6379(TX)EXEC(error)EXECABORT Transaction discarded because of previous errors.③ 无法读取中间结果事务中的命令不能依赖于之前命令的结果。比如你不能先GET一个值然后用它来做SET。因为GET的结果只有在EXEC后才知道。# 这样是不行的事务内的 GET 不会返回结果供后续命令使用127.0.0.1:6379MULTI OK127.0.0.1:6379(TX)GET counter QUEUED127.0.0.1:6379(TX)SET counter2(GET counter 1)# 无法引用这种需要根据当前值做判断的逻辑必须用 Lua 脚本。④ 无隔离级别概念Redis 单线程执行命令事务执行期间其他客户端命令不会插入天然串行不存在脏读、不可重复读等问题。1.3 事务与 WATCH乐观锁WATCH可以监听一个或多个键如果在EXEC之前这些键被其他客户端修改了事务就会被打断EXEC返回nil。原理类似于乐观锁CAS。经典场景并发安全扣减库存。# 客户端 A127.0.0.1:6379SET stock10OK127.0.0.1:6379WATCH stock OK127.0.0.1:6379MULTI OK127.0.0.1:6379(TX)DECRBY stock1QUEUED127.0.0.1:6379(TX)EXEC1)(integer)9# 成功# 如果在 MULTI 后、EXEC 前另一个客户端修改了 stock# 则 EXEC 返回 (nil)需要重试。WATCH在EXEC或DISCARD、UNWATCH后自动取消。重试逻辑常出现在秒杀、抢红包场景。2. Python 中的事务实战redis-py通过Pipeline(transactionTrue)来封装MULTI/EXEC。2.1 基础事务——转账importredis rredis.Redis(decode_responsesTrue)# 初始化r.set(account:A,100)r.set(account:B,200)def transfer(from_acc, to_acc, amount): piper.pipeline(transactionTrue)pipe.decrby(from_acc, amount)pipe.incrby(to_acc, amount)resultpipe.execute()returnresult print(转账前: A , r.get(account:A),, B , r.get(account:B))transfer(account:A,account:B,50)print(转账后: A , r.get(account:A),, B , r.get(account:B))输出转账前: A100, B200转账后: A50, B2502.2 带 WATCH 的乐观锁转账def safe_transfer(from_acc, to_acc, amount):whileTrue: r.watch(from_acc)balanceint(r.get(from_acc))ifbalanceamount: r.unwatch()raise ValueError(余额不足)piper.pipeline(transactionTrue)pipe.decrby(from_acc, amount)pipe.incrby(to_acc, amount)try: pipe.execute()breakexcept redis.WatchError: print(并发冲突重试...)continuesafe_transfer(account:A,account:B,20)print(最终: A , r.get(account:A),, B , r.get(account:B))如果模拟并发修改from_accWatchError会被触发循环重试保证数据一致。3. Lua 脚本原子性的终极武器由于事务不能做判断、不能读取中间结果复杂业务逻辑如库存不足则拒绝扣减无法实现。Lua 脚本完美解决了这个问题。Redis 内置了 Lua 5.1 解释器你可以把一段 Lua 代码发送到服务端Redis 会原子地执行它期间其他命令全部阻塞。3.1 EVAL 与 EVALSHAEVAL script numkeys key [key ...] arg [arg ...]直接发送脚本。EVALSHA sha1 numkeys key [key ...] arg [arg ...]通过脚本的 SHA1 哈希执行已缓存的脚本避免重复传输脚本内容节省带宽。先用redis-cli体验127.0.0.1:6379EVALreturn hello, .. KEYS[1] .. .. ARGV[1]1world Luahello, world Lua1表示有 1 个键名参数。KEYS[1]接收worldARGV[1]接收Lua。3.2 Lua 脚本原子扣减库存防超卖-- 原子库存扣减localkeyKEYS[1]localdeltatonumber(ARGV[1])localstockredis.call(GET, key)ifstockfalsethenreturn-1-- 键不存在 end stocktonumber(stock)ifstockdeltathenreturn0-- 库存不足 end redis.call(DECRBY, key, delta)return1-- 成功在redis-cli中执行127.0.0.1:6379SET product:100110OK127.0.0.1:6379EVAL...1product:10013(integer)1127.0.0.1:6379GET product:100174. Python 中使用 Lua 脚本redis-py提供了register_script()方法自动完成EVAL→EVALSHA的转换并缓存脚本。4.1 基础调用importredis rredis.Redis(decode_responsesTrue)scriptlocalnameredis.call(GET, KEYS[1])localcountredis.call(INCR, KEYS[2])return{name, count} r.set(username,IT策士)multiplyr.register_script(script)resultmultiply(keys[username,counter],args[])print(result)# [IT策士, 1]register_script返回一个可调用对象传入keys和args即可执行。4.2 原子扣减库存函数inventory_lualocalkeyKEYS[1]localdeltatonumber(ARGV[1])localstockredis.call(GET, key)ifstockfalsethenreturn-1end stocktonumber(stock)ifstockdeltathenreturn0end redis.call(DECRBY, key, delta)return1 inventory_scriptr.register_script(inventory_lua)# 使用r.set(product:1001:stock,10)print(inventory_script(keys[product:1001:stock],args[3]))# 1 成功print(r.get(product:1001:stock))# 7# 超量扣减print(inventory_script(keys[product:1001:stock],args[10]))# 0 失败print(r.get(product:1001:stock))# 7 没变4.3 原子释放分布式锁在第 2 篇中我们提到释放锁需要“判断锁持有者 删除”两步原子化。Lua 脚本是最佳实践release_lock_luaifredis.call(GET, KEYS[1])ARGV[1]thenreturnredis.call(DEL, KEYS[1])elsereturn0end release_lockr.register_script(release_lock_lua)# 获取锁lock_keylock:order:1001lock_valueunique-uuid-123r.set(lock_key, lock_value,nxTrue,ex30)# 释放锁resultrelease_lock(keys[lock_key],args[lock_value])print(result)# 1 删除成功# 另一个客户端尝试释放result2release_lock(keys[lock_key],args[other-uuid])print(result2)# 0 无权释放4.4 复杂业务抢红包原子分配金额模拟红包总金额total_amount剩余个数remain每次抢到随机金额。red_envelope_lualocaltotal_keyKEYS[1]localremain_keyKEYS[2]localtotaltonumber(redis.call(GET, total_key))localremaintonumber(redis.call(GET, remain_key))iftotalnil or remainnilthenreturn-1-- 红包不存在 endifremain0thenreturn0-- 已抢完 endlocalamountifremain1thenamounttotalelseamountmath.random(1, total -(remain -1))end amountmath.min(amount, total)redis.call(DECRBY, total_key, amount)redis.call(DECR, remain_key)returnamount# 初始化红包r.set(red:1001:total,100)r.set(red:1001:remain,5)red_scriptr.register_script(red_envelope_lua)foriinrange(6): amountred_script(keys[red:1001:total,red:1001:remain])ifamount-1: print(红包不存在)elifamount0: print(已抢完)else: print(f第{i1}次抢到: {amount}元)输出示例第1次抢到:47元 第2次抢到:22元 第3次抢到:11元 第4次抢到:19元 第5次抢到:1元 已抢完每次抢到的金额随机且总和为 100并且不会超发。4.5 异步环境中的 Lua在redis.asyncio中register_script同样可用importasyncioimportredis.asyncio as aioredis async def async_lua(): rawait aioredis.from_url(redis://localhost,decode_responsesTrue)scriptr.register_script(returnredis.call(INCR, KEYS[1]))resultawait script(keys[async_counter])print(result)# 1await r.close()asyncio.run(async_lua())script调用需要await因为内部会执行 Redis 命令。5. 事务 vs Lua 脚本如何选择一句话建议凡是需要在命令中间做判断的直接用 Lua简单批处理用事务更简洁。6. 常见误区与最佳实践事务不保证数据安全它只保证一组命令原子执行但没有数据库那样的回滚机制失败命令不会撤销已执行命令。Lua 脚本要轻量脚本执行期间会阻塞整个 Redis 实例避免死循环或耗时计算。建议单个脚本执行时间不超过100ms。不要滥用 Lua能用 Redis 原生命令组合完成的就不用 Lua。Lua 会增加维护成本和调试难度。脚本中避免随机写全局键Lua 脚本中不能使用redis.call(FLUSHALL)等管理命令且所有键应由KEYS显式传入集群环境下必须在同一个哈希槽。务必缓存脚本使用EVALSHA或register_script可避免每次发送脚本正文节省带宽。7. 动手试试模拟并发扣库存用线程池启动 20 个线程同时调用 Lua 库存扣减函数每个线程扣 1 个初始 10 个库存。统计成功次数验证最终库存为 0。抢红包验证修改红包脚本确保最后一次抢的红包金额正好是剩余金额杜绝剩余不可分配的情况。事务 WATCH 并发测试模拟两个进程对同一个键执行WATCHMULTI/EXEC更新观察WatchError和重试次数。预期效果库存扣减 10 次成功、10 次失败红包金额之和等于总金额WATCH 冲突时自动重试成功。8. 总结本文我们深入了 Redis 事务与 Lua 脚本MULTI/EXEC简单原子批处理但不支持条件判断无回滚。WATCH乐观锁实现 CAS适合并发竞争不激烈的场景。Lua 脚本真正的原子逻辑执行引擎能读写判断、计算是实现库存扣减、分布式锁释放、抢红包等复杂逻辑的银弹。Python 实践register_script让 Lua 调用像本地函数一样简单同步/异步无缝支持。掌握了事务和 Lua你就拥有了在高并发下保证数据一致性的核心能力。下一篇我们将走进 Redis 的持久化机制——RDB 与 AOF探秘数据如何从内存落地到磁盘确保重启不丢数据。想了解更多还可以去各个平台搜索「IT策士」一起升级 IT 思维