【Redis篇】Redis 事务:原子性与脚本执行机制

【Redis篇】Redis 事务:原子性与脚本执行机制 文章目录Redis 事务原子性与脚本执行机制一、前言二、Redis 事务的基本概念2.1 为什么需要 Redis 事务2.2 Redis 事务的三个命令三、事务的完整流程3.1 基本使用示例3.2 放弃事务四、事务中的错误处理4.1 第一类错误命令语法错误编译期错误4.2 第二类错误命令运行时错误执行期错误4.3 Redis 事务的弱原子性五、WATCH 命令与乐观锁5.1 事务存在的竞态问题5.2 WATCH 命令5.3 基于 WATCH 实现乐观锁的完整示例5.4 UNWATCH 命令六、Lua 脚本6.1 Redis 事务的局限性6.2 Lua 脚本的核心优势6.3 EVAL 命令语法6.4 使用示例6.5 SCRIPT LOAD 与 EVALSHA6.6 Lua 脚本的注意事项七、事务 vs Lua 脚本八、总结Redis 事务原子性与脚本执行机制一、前言这一篇讲什么Redis 的事务机制与 Lua 脚本核心内容Redis 事务的基本流程MULTI / EXEC / DISCARD事务中出现错误怎么处理Redis 事务的弱原子性是什么意思WATCH 命令如何实现乐观锁Redis 事务的局限性是什么为什么 Lua 脚本能弥补事务的不足Lua 脚本的使用方式上一篇讲完了 Redis 持久化这一篇来看 Redis 的事务机制。很多同学从 MySQL 事务的认知出发去理解 Redis 事务会踩不少坑——Redis 事务和 MySQL 事务差别很大它并不支持回滚。搞清楚这些差异才能正确地在业务中使用 Redis 事务。二、Redis 事务的基本概念2.1 为什么需要 Redis 事务Redis 是单线程处理命令的单条命令的执行是原子的。但很多业务场景需要把多条命令组合在一起原子地执行中间不能被其他客户端的命令打断这就是 Redis 事务要解决的问题。经典例子转账操作需要同时减少 A 的余额、增加 B 的余额这两步要么都成功要么都不执行。2.2 Redis 事务的三个命令Redis 事务用三个命令控制命令作用MULTI开启事务进入事务模式EXEC提交事务顺序执行事务中排队的所有命令DISCARD丢弃事务清空命令队列退出事务模式相当于回滚三、事务的完整流程3.1 基本使用示例127.0.0.1:6379MULTI# 开启事务OK127.0.0.1:6379SET k1 v1# 命令进入队列不立即执行QUEUED127.0.0.1:6379SET k2 v2 QUEUED127.0.0.1:6379INCR counter QUEUED127.0.0.1:6379EXEC# 提交事务顺序执行所有命令1)OK2)OK3)(integer)1执行MULTI后Redis 会返回QUEUED而不是立即执行命令所有命令都放入一个命令队列中。调用EXEC后Redis 顺序执行队列中的所有命令并返回结果数组。3.2 放弃事务执行DISCARD可以放弃当前事务清空命令队列127.0.0.1:6379MULTI OK127.0.0.1:6379SET k1 v1 QUEUED127.0.0.1:6379SET k2 v2 QUEUED127.0.0.1:6379DISCARD# 放弃事务OK# 事务中的命令全部取消k1 和 k2 均未被修改四、事务中的错误处理这是理解 Redis 事务最重要的部分。Redis 事务对错误的处理方式与 MySQL 完全不同且两类错误的处理方式也不一样。4.1 第一类错误命令语法错误编译期错误如果在 MULTI 和 EXEC 之间输入了一条语法错误的命令比如命令名写错了Redis 会立即返回错误并在执行 EXEC 时放弃整个事务127.0.0.1:6379MULTI OK127.0.0.1:6379SET k1 v1 QUEUED127.0.0.1:6379SETXXX k2 v2# 不存在的命令语法错误(error)ERR unknowncommandSETXXX127.0.0.1:6379SET k3 v3 QUEUED127.0.0.1:6379EXEC(error)EXECABORT Transaction discarded because of previous errors.# 整个事务被放弃k1 和 k3 均未被修改这类错误整个事务不执行。表现和我们预期的事务回滚接近。4.2 第二类错误命令运行时错误执行期错误如果命令语法正确但执行时因为数据类型不匹配等原因失败Redis 会继续执行其他命令不会回滚127.0.0.1:6379SET mystrhello# mystr 是字符串类型OK127.0.0.1:6379MULTI OK127.0.0.1:6379SET k1 v1 QUEUED127.0.0.1:6379INCR mystr# 对字符串执行自增运行时才会报错QUEUED127.0.0.1:6379SET k2 v2 QUEUED127.0.0.1:6379EXEC1)OK# SET k1 v1 执行成功2)(error)ERR value...# INCR mystr 执行失败但不影响其他命令3)OK# SET k2 v2 执行成功这类错误只有出错的那条命令失败其他命令继续正常执行。4.3 Redis 事务的弱原子性从上面两种错误处理可以看出Redis 事务不支持回滚Rollback。在 MySQL 中只要事务中任意一步出错整个事务都会回滚保证要么全成功要么全失败。Redis 的事务做不到这一点一旦 EXEC 开始执行即使中途某条命令失败其他命令也会继续执行已经成功的命令不会被撤销。因此Redis 事务被称为**“弱原子性”**✅ 保证了事务中的命令不会被其他客户端的命令穿插打断隔离性✅ 保证了命令一定会被顺序执行有序性❌ 不保证全部成功或全部失败不支持回滚为什么 Redis 不支持回滚Redis 官方给出的理由是运行时错误通常是编程错误比如把字符串当数字用正确编写的程序不应该出现这类错误。不支持回滚使得 Redis 的实现更简单性能更好。五、WATCH 命令与乐观锁5.1 事务存在的竞态问题假设要实现一个功能只有当账户余额balance 100时才执行扣款操作减去 100。用事务写出来可能是GET balance# 读取余额假设返回 200MULTI DECR balance100EXEC问题在于GET balance和MULTI...EXEC之间存在时间窗口另一个客户端可能在这个窗口内也读到了 balance200也发起了扣款两个客户端都成功执行最终 balance 变成了 0而不是预期的 100。这就是**Check-Then-Act先检查后操作**的竞态条件普通事务无法解决。5.2 WATCH 命令WATCH命令为 Redis 事务提供了乐观锁机制WATCH key[key...]WATCH监视一个或多个 key。在 MULTI 之前执行 WATCH如果在 MULTI 到 EXEC 期间被监视的 key 被任何其他客户端修改执行 EXEC 时整个事务会自动失败返回 nil而不是命令结果数组。# 客户端 A127.0.0.1:6379WATCH balance# 监视 balanceOK127.0.0.1:6379GET balance# 读取余额 200200127.0.0.1:6379MULTI OK127.0.0.1:6379DECRBY balance100QUEUED# 此时客户端 B 修改了 balance # 客户端 B: SET balance 50127.0.0.1:6379EXEC# 回到客户端 A 提交事务(nil)# 返回 nil事务失败balance 未被修改5.3 基于 WATCH 实现乐观锁的完整示例实际业务中配合重试逻辑使用 WATCHimportredis rredis.Redis()whileTrue:withr.pipeline()aspipe:try:pipe.watch(balance)# 监视 balancebalanceint(pipe.get(balance))ifbalance100:print(余额不足取消操作)pipe.unwatch()# 取消监视breakpipe.multi()# 开启事务pipe.decrby(balance,100)pipe.execute()# 提交事务print(扣款成功)breakexceptredis.WatchError:# 监视的 key 被其他客户端修改重试print(数据被修改重试中...)continueWATCH 的工作原理乐观锁假设并发冲突不常见。不上锁只在提交时检查是否有冲突有冲突就放弃并重试没冲突就直接提交。性能比悲观锁每次操作都加锁好得多适合冲突概率低的场景。5.4 UNWATCH 命令UNWATCH取消当前客户端对所有 key 的监视。执行 EXEC 或 DISCARD 后WATCH 监视会自动取消无需手动 UNWATCH。六、Lua 脚本6.1 Redis 事务的局限性虽然 WATCH 事务可以实现乐观锁但 Redis 原生事务有一个根本的局限性无法在事务执行期间根据命令结果来决定下一步操作。比如如果 key 不存在就 SET如果已存在就 INCR。用事务写不了因为 MULTI 进入队列阶段无法读取命令结果无法做条件判断。Lua 脚本完美解决了这个问题。6.2 Lua 脚本的核心优势Redis 内置了 Lua 解释器通过EVAL命令可以执行 Lua 脚本。Lua 脚本在 Redis 中具有以下关键特性原子性Lua 脚本在 Redis 中是原子执行的。执行期间其他客户端的命令不会被执行不存在并发问题。这是比原生事务更强的保证。支持条件判断Lua 脚本是完整的编程语言可以在脚本中读取 Redis 数据然后根据数据决定执行什么操作原生事务做不到这一点。减少网络往返多条命令打包在一个 Lua 脚本中只需要一次网络请求比多次 COMMAND 调用效率更高。6.3 EVAL 命令语法EVAL script numkeys key[key...]arg[arg...]参数说明scriptLua 脚本内容numkeys传入的 key 数量key [key ...]传给脚本的 key 名称在脚本中通过KEYS[1]、KEYS[2]访问下标从 1 开始arg [arg ...]传给脚本的额外参数在脚本中通过ARGV[1]、ARGV[2]访问在 Lua 脚本中调用 Redis 命令的方式redis.call(命令名,参数1,参数2,...)redis.pcall(命令名,参数1,参数2,...)-- 出错时不抛异常返回错误信息6.4 使用示例示例一基本使用# 设置一个 key值为传入的参数127.0.0.1:6379EVALreturn redis.call(SET, KEYS[1], ARGV[1])1mykey hello OK127.0.0.1:6379GET mykeyhello示例二条件判断事务做不到的事# 如果 key 不存在则设置值如果已存在则自增# 这在原生事务中无法实现但 Lua 脚本可以轻松做到127.0.0.1:6379EVAL if redis.call(EXISTS, KEYS[1]) 0 then return redis.call(SET, KEYS[1], ARGV[1]) else return redis.call(INCR, KEYS[1]) end 1counter100OK127.0.0.1:6379EVAL if redis.call(EXISTS, KEYS[1]) 0 then return redis.call(SET, KEYS[1], ARGV[1]) else return redis.call(INCR, KEYS[1]) end 1counter100(integer)101示例三用 Lua 实现原子性的库存扣减127.0.0.1:6379EVAL local stock tonumber(redis.call(GET, KEYS[1])) if stock nil or stock 0 then return 0 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 1product:1001:stock1(integer)1-- 返回1表示扣减成功返回0表示库存不足6.5 SCRIPT LOAD 与 EVALSHA如果一个 Lua 脚本需要频繁执行每次都传输完整的脚本内容会浪费带宽。可以先加载脚本得到一个 SHA1 摘要后续用摘要来执行# 加载脚本到 Redis返回 SHA1 摘要127.0.0.1:6379SCRIPT LOADreturn redis.call(GET, KEYS[1])e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b5# 用 SHA1 执行脚本传输数据量更小127.0.0.1:6379EVALSHA e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b51mykeyhello# 检查某个脚本是否已加载127.0.0.1:6379SCRIPT EXISTS e0e1f9fabfa9d353e4f3df77bf9d21d32dbb72b51)(integer)1# 清空所有已缓存的脚本127.0.0.1:6379SCRIPT FLUSH OK6.6 Lua 脚本的注意事项不要在 Lua 脚本中执行耗时操作。Lua 脚本执行期间Redis 主线程被阻塞其他所有客户端命令都要等待。如果脚本执行了耗时的循环计算会严重影响 Redis 的响应时间。Redis 会超时强制终止 Lua 脚本。通过lua-time-limit配置默认 5000ms超过此时间还未完成的脚本会被强制停止。Lua 脚本在 Redis 集群中有限制。脚本中涉及的所有 key 必须在同一个 slot哈希槽中否则会执行失败。在集群模式下建议使用 Hash Tags 把相关 key 固定到同一个 slot。七、事务 vs Lua 脚本对比维度Redis 事务Lua 脚本原子性弱不支持回滚强执行期间完全阻塞其他命令支持条件判断❌ 不支持✅ 支持完整的编程逻辑错误处理运行时错误不回滚可用 pcall 捕获错误并发安全需要配合 WATCH 实现乐观锁天然原子无需额外处理网络开销多次命令 MULTI/EXEC一次请求适用场景简单的多命令原子批量执行复杂的需要条件判断的原子操作结论简单的多命令批量执行且不需要条件判断 →事务MULTI/EXEC需要在 Redis 端做条件判断、逻辑运算 →Lua 脚本需要乐观锁保护共享数据 →WATCH 事务或直接用Lua 脚本八、总结✅事务三件套MULTI开启 → 命令入队列 →EXEC提交 /DISCARD放弃✅两类错误不同处理语法错误 → 整个事务放弃运行时错误 → 仅该命令失败其他命令继续执行✅弱原子性Redis 事务不支持回滚这是与 MySQL 事务最大的区别✅WATCH 乐观锁监视 key如果在 MULTI~EXEC 期间被其他客户端修改EXEC 自动失败✅Lua 脚本原子执行支持条件判断弥补了原生事务的最大局限✅EVALSHA预加载脚本 SHA1 摘要执行减少重复传输开销