尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Redis事务与Lua脚本:从命令队列到ACID边界的深度解析

Redis事务与Lua脚本:从命令队列到ACID边界的深度解析 我之前在帮团队做技术面试复盘时注意到一个现象一说起 Redis 事务几乎没有候选人不认识MULTI和EXEC但问到“Redis 明明不支持回滚为什么面试题里还要叫它事务”“事务和 Lua 脚本到底什么时候该选谁”大部分人就卡住了。这篇攻略面经就是要把 Redis 事务这件事从命令层面一直聊到 ACID 边界把面试官真正想听的底层机制、易错点、选型逻辑都摊开讲清楚。1. 面试官问“Redis 事务”时内心在等什么答案1.1 Redis 事务的官方定位把多个命令打包成“准原子”执行Redis 事务并不是我们理解的那种数据库事务。它做的事情很朴素先把一组命令放进队列然后一次性、按顺序地执行。核心命令就四个MULTI标记一个事务块的开始后续收到的命令不会立刻执行而是进入队列。EXEC触发事务按入队顺序依次执行队列里的命令。DISCARD清空事务队列退出事务状态。WATCH监视一个或多个 key在EXEC之前如果这些 key 被其他客户端改动则整个事务被中断。这个机制解决的核心问题其实是避免多个命令在并发场景下被其他客户端的操作“插队”。我举一个项目里最常见的场景——转账。A 账户要给 B 账户转 100 块你至少需要两步DECR A 100INCR B 100。如果中间来个并发请求A 的余额又被人扣了一次账就算不清了。Redis 事务把这两条命令按顺序打包执行中间不会插入其他命令这就保证了这组命令逻辑上的整体性。1.2 面试官真正在意的三个层次我把面试中的回答质量分成三层你可以对照一下自己停在哪儿。第一层能背出MULTI/EXEC/DISCARD/WATCH的用法知道“命令入队后统一执行”。这层能过但只能算是及格线。第二层能讲清楚 Redis 事务的“失败模型”——入队时的语法错误和运行时的执行错误是两种完全不同的表现事务中的命令执行失败不会自动回滚已执行的命令。这层已经能区分出大部分人了。第三层能解释“Redis 事务到底在 ACID 上缺了什么、多了什么”并且能结合项目实际情况说明“为什么我不用事务而是用 Lua 脚本”或“为什么我在这个场景下必须用 WATCH 做乐观锁”。这层是资深工程师的理解方式。如果你正准备面试建议目标至少定在第二层第三层才是拉开差距的地方。下面逐层拆。2. 命令队列机制与“不回滚”这句话的真正含义2.1 入队阶段发生的错误连执行资格都没有要理解 Redis 事务的失败模型必须从命令进入队列的瞬间开始。当你输入MULTI之后Redis 会给当前客户端标记一个REDIS_MULTI状态。之后收到的命令都会走一条独立路径检查命令是否存在、参数个数是否正确、命令是否允许在事务中使用。如果检查全部通过命令对象会被包装进一个multiCmd结构追加到当前客户端的mstate命令队列里然后返回QUEUED。这里有个关键点入队时的语法错误不会立刻中断整个事务但它会把事务标记成一个“脏”状态。比如127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET name redis QUEUED 127.0.0.1:6379 GETS name (error) ERR unknown command GETS 127.0.0.1:6379 SET age 18 QUEUED 127.0.0.1:6379 EXEC (error) EXECABORT Transaction discarded because of previous errors.EXEC被拒绝整个事务被丢弃SET name和SET age都不会执行。这是从 Redis 2.6.5 之后的行为这么设计是为了避免“入队时已经知道会错还要稀里糊涂执行其他命令”的尴尬情况。我在实际排障时看到过不少误用有人以为入队时报错的命令不会执行其他命令可以继续结果被EXECABORT整段丢弃。如果你在事务里有一个命令拼错了参数整组业务操作都会失效这个点在生产中很容易被忽视尤其是在用客户端封装工具时错误信息层层包装之后很容易被当成“超时”或“网络异常”处理。2.2 运行时错误已执行的命令不会回滚入队阶段最大的特点是一切命令都“只检查不运行”但到了EXEC阶段命令会真正开始执行。如果某个命令在运行时出错了呢举个例子127.0.0.1:6379 MULTI OK 127.0.0.1:6379 SET key1 hello QUEUED 127.0.0.1:6379 LPUSH key1 world QUEUED 127.0.0.1:6379 INCR key2 QUEUED 127.0.0.1:6379 EXEC 1) OK 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value 3) (error) ERR value is not an integer or out of range注意看SET key1 hello执行成功了LPUSH key1 world报错key1 是字符串类型INCR key2也报错。但整个EXEC返回了三个结果第一个是OK后面两个是 error。已经成功的第一个命令不会回滚后面的命令也不会因为前面出错而停止。这就是 Redis 官方文档里“Redis 事务不支持回滚”的真正含义执行阶段的错误是运行时错误Redis 既不会中断事务也不会撤销已经完成的操作。面试官问到这里往往会追加一句“那为什么 Redis 不设计回滚”我比较认可的解答思路是回滚能力会显著增加 Redis 实现的复杂度而且运行时错误绝大多数情况下是由 bug 导致的回滚并不能修复 bug反而会让数据处于一个更不可预测的状态。更重要的是Redis 的强项是性能和简单的内存操作模型为了极低频的编程错误引入回滚机制性价比太低。2.3 生产环境里如何处理“伪回滚”既然 Redis 不会帮我们回滚那需要业务一致性的时候怎么办我的经验是分成三个层面去处理。第一入队前做尽量多的“预检查”。把可能引发运行时错误的操作提前用只读命令验证掉例如在执行INCR前先确认 key 的类型用TYPE key判断确保不会对字符串执行列表操作。第二事务执行前先做数据校验执行后检查返回结果。在应用代码里EXEC返回的结果数组里包含了每条命令的执行状态。只要发现某条结果是 error就说明事务里出现了部分成功这时候需要在业务层做补偿。第三如果补偿逻辑太复杂那就不建议用原生事务直接上 Lua 脚本。Lua 脚本可以通过redis.pcall捕获错误并中断后续命令配合返回值来主动控制执行流程比纯事务更加可控。这个后面会详细展开。3. WATCH 乐观锁Redis 实现 CAS 的关键3.1 WATCH 的底层机制版本变化检测WATCH是我认为 Redis 事务里最值得深挖的命令也是面试官最喜欢往下追问的点。它的作用是在MULTI之前监视一个或多个 key如果这些 key 在事务执行前被其他客户端修改EXEC直接返回空结果事务不执行。从源码角度看WATCH做的事情是把当前客户端注册到被监视 key 的监听列表里。Redis 内部维护了一个watched_keys字典key 是“数据库号 键名”value 是一个链表存放所有监听了这个 key 的客户端。当某个 key 被修改比如SET、LPUSH、DEL时touchWatchedKey函数会把所有监听该 key 的客户端标记为CLIENT_DIRTY_CAS。等到EXEC执行时客户端只要带着CLIENT_DIRTY_CAS标志事务就会被直接放弃。这个过程很像乐观锁里的版本号机制只是 Redis 替我们把“版本号”维护好了。3.2 一个扣库存的完整范例库存扣减是 WATCH 最经典的应用场景。假设商品库存存储在stock:1001这个 key 上用户要买 1 件。用 WATCH 实现的伪代码逻辑是这样的WATCH stock:1001 GET stock:1001 # 读出当前库存假设是 5 MULTI DECR stock:1001 EXEC如果整个流程执行期间没有其他人修改过stock:1001EXEC返回 1扣减成功。如果另一个请求在这期间把库存改成了 4当前客户端的CLIENT_DIRTY_CAS被置位EXEC返回空说明扣减失败需要重试。应用代码里推荐的模式是“循环重试”while True: with redis.pipeline() as pipe: try: pipe.watch(stock:1001) current int(pipe.get(stock:1001)) if current 1: return 库存不足 pipe.multi() pipe.decr(stock:1001) pipe.execute() return 扣减成功 except redis.WatchError: continue注意这里WATCH必须放在MULTI之前而且WATCH状态在EXEC或DISCARD之后会被清除。失败重试时需要重新WATCH不能只重新执行EXEC。3.3 WATCH 的三个坑我踩过的教训第一个坑在同一连接里WATCH之后自己又改了那个 key。很多人以为“我自己改自己的 key总不会影响乐观锁吧”但事实上 Redis 并不区分修改方是谁只要被监视的 key 发生写操作就会触发脏标记。我自己就遇到过排障半天的情况用同一个连接先WATCH了一个 key随后在事务外SET了它回头EXEC永远返回空最后才发现是“自己脏了自己”。第二个坑WATCH能监视的 key 数量是有限的但实际场景里不用太担心。Redis 对单个客户端监视 key 的数量上限由watched_keys数据结构本身决定如果你WATCH了太多 key内存占用会上升而且每次写操作都要去遍历监听链表写性能会被拖慢。我的建议是只监视真正参与事务逻辑的 key不要图省事把所有相关 key 全部WATCH。第三个坑WATCH和连接池搭配时要小心。如果在连接池里取出了一个连接执行了WATCH然后忘了在业务结束时调用UNWATCH或DISCARD就归还连接下一个拿到这条连接的人就会带着上一轮的监视状态轻则性能损耗重则整个事务执行结果不符合预期。生产环境里用完WATCH之后要么EXEC要么UNWATCH这是必须养成的习惯。4. 用 ACID 对照Redis 事务与传统数据库事务的边界4.1 原子性原子执行不等于可回滚很多面试资料里说“Redis 事务具备原子性”这个说法很容易误导人。原子性在数据库理论里强调的是“要么全成功要么全失败”但 Redis 事务在执行阶段的错误不会回滚已执行的命令。所以更准确的说法是Redis 事务保证的是“命令不会被并发插入、完整地按顺序执行”但不保证“执行失败时整体回滚”。用一个表格来区分维度传统数据库事务Redis 事务执行方式SQL 逐条解析并执行支持回滚命令入队后一次性执行不回滚失败中断任一语句失败可回滚所有变更运行时错误只影响出错命令其他继续原子性事务整体提交或回滚只保证“有序整体执行”不保证失败回滚隔离性通过锁/MVCC 实现多种隔离级别单线程天然串行执行持久性通过 WAL/redo log 等保证依赖 AOF/RDB与事务无关理解了这一点你就能接住面试官的经典追问“Redis 事务能保证原子性吗”正确答案不是“能”或“不能”而是先区分“原子执行”和“可回滚”这两个概念再根据语境给出结论。4.2 一致性数据结构一致业务一致性靠应用层一致性在数据库里有很严格的数学定义但在工程实践里通常可以理解为“数据始终满足某种约束”。Redis 事务能保证的“一致性”非常有限它不会让某种结构性的数据损坏比如字符串 key 不会莫名变成列表类型。但如果你在事务里把 A 账户扣了 100B 账户因为某种原因比如 key 不存在、类型错误没有加上 100Redis 不会管这个业务约束是否被破坏。所以在实际项目中我用 Redis 事务时一定会明确一点Redis 事务只负责命令序列的原子性业务上的一致性必须由调用方自己保证。比如在转账场景里执行完EXEC之后必须检查B 账户 INCR的返回结果如果它报错了A 账户的扣减结果需要单独做补偿。有些同学会问那为什么不把“判断 INCR 是否成功”也放进事务里很遗憾Redis 事务命令队列里的命令在EXEC之前不会计算结果所以无法实现“前一条命令的结果决定后一条命令是否执行”这种动态逻辑。这恰恰是 Lua 脚本的用武之地。4.3 隔离性单线程模型下的天然串行隔离性是 Redis 事务最省心的一环。Redis 采用单线程模型忽略 6.0 之后多线程 IO 对命令执行的影响所有命令在服务端都是一个接一个执行的不存在并发执行命令的问题。这意味着你不需要像传统数据库那样去配置隔离级别也不需要担心脏读、不可重复读。但注意Redis 的“天然串行”是针对命令执行而言并不是说事务之间不存在竞争。举个例子两个客户端同时以WATCH MULTI EXEC的方式扣减同一个库存 key它们是可能发生竞争冲突的只不过这种冲突通过 WATCH 在事务执行前就被发现了而不是在事务执行中才出现锁等待。所以面试时谈到隔离性最好的说法是Redis 事务的隔离性是“执行期隔离”不是“事务期隔离”。事务在执行的那一刻独占命令执行权但事务从开始到提交之间其他客户端可以正常读写其他 key不会像数据库那样锁住表或行。4.4 持久性事务和持久化是两个独立的话题Redis 的事务本身不提供持久性保证这一点和数据库事务很不一样。Redis 是否丢数据完全取决于你的持久化配置。如果只开了 RDB那么从上次快照到故障发生之间的所有数据都可能丢失事务也不例外。如果开了 AOFappendfsync配置为always时每个写命令都会同步刷盘持久性最好everysec时最多丢 1 秒数据no时由操作系统决定刷盘时机。混合持久化RDB AOF是 Redis 4.0 之后的推荐配置但依然不能做到数据库那样的事级崩溃恢复保证。有一次线上环境出现过 Redis 实例异常重启因为 AOF 配置是everysec导致最近 1 秒内的几笔事务数据丢失。这个案例告诉我们如果业务对数据安全要求高不要依赖 Redis 事务来保证不丢数据要么用appendfsync always代价是吞吐下降要么在应用层做数据补偿。5. 事务与 Lua 脚本选型时要想清楚的事5.1 Lua 脚本能解决事务解决不了的动态逻辑聊完 ACID进入选型环节。很多人在项目里纠结“到底用事务还是 Lua 脚本”我的结论很直接如果只是把几个互不依赖的命令打包执行用MULTI/EXEC就够了如果命令之间存在逻辑判断或者后一个命令要根据前一个命令的结果来决定是否执行必须用 Lua。举个例子还是库存扣减。普通的事务写法是这样的它把所有命令一股脑丢给 RedisMULTI DECR stock:1001 INCR sold:1001 EXEC问题在于如果stock:1001已经是 0DECR之后它变成了 -1相当于卖超了。你需要先读库存、判断、再扣减这种“读 条件判断 写”的模式原生事务做不到但 Lua 脚本可以local stock tonumber(redis.call(GET, KEYS[1])) if not stock or stock tonumber(ARGV[1]) then return -1 end redis.call(DECRBY, KEYS[1], ARGV[1]) redis.call(INCRBY, KEYS[2], ARGV[1]) return 0调用方式EVAL 脚本内容 2 stock:1001 sold:1001 1脚本执行期间Redis 不会执行任何其他命令因此不需要 WATCH也不会出现并发扣减超卖的问题。而且脚本返回值可以清楚地告诉调用方是“库存不足”还是“扣减成功”这是原生事务无法提供的能力。5.2 一份选型对比与我的取舍建议维度Redis 事务Lua 脚本原子性命令串行执行运行期不回滚脚本整体执行不会被插入命令但也不回滚动态条件不支持支持可读可写可判断复杂度低四个命令即可需要维护脚本字符串较复杂错误处理EXEC 返回结果数组需逐个检查pcall 可捕获错误并中断流程适用场景简单批量写、转账、计数器复杂业务逻辑、条件更新、库存扣减性能一条条命令依次执行脚本缓存后开销较低我个人的实践标准是这样如果一组命令超过三条而且中间有“读一个值根据这个值决定下一步”的逻辑直接写 Lua别再用事务硬撑。反过来如果只是绑定执行两个INCR事务足够简单干净。5.3 面试里关于 Lua 脚本的套路追问面试官在聊完事务之后大概率会顺带问 Lua 脚本常见套路有这么几个“Lua 脚本和 Redis 事务有什么区别”最核心的区别是 Lua 脚本能在执行过程中读取中间结果并做条件判断而事务的命令队列是一个没有反馈的机械执行过程。此外 Lua 脚本天然具备原子性不需要 WATCH。“Lua 脚本执行期间会不会阻塞 Redis”会。如果一个脚本里有死循环整个 Redis 实例都会卡住。线上要特别注意脚本的运行时长官方建议脚本执行时间要短最好控制在毫秒级。如果脚本运行超过lua-time-limit默认 5000msRedis 会向其他客户端发送“脚本繁忙”的提示但脚本不会主动终止。所以发布前一定要压测脚本耗时。“能不能在 Lua 脚本里执行MULTI”不能。Lua 脚本本身已经处于一个类似“原子事务”的执行环境下再嵌套事务会破坏语义。“Lua 脚本和事务能一起用吗”可以但要清楚脚本内部不需要MULTI脚本外部的MULTI也没法包裹EVAL来获得额外收益。现实中我用 Lua 脚本时会直接在脚本内部完成所有读写外部不加事务。我把 Redis 事务相关问题的回答要点列成了一个速记清单面试前看一眼很有帮助事务是什么MULTI 入队、EXEC 执行、DISCARD 清空、WATCH 乐观锁。事务的边界入队错误会导致 EXECABORT运行时错误不回滚已执行命令保留。ACID 怎么答原子性仅限“有序执行”一致性靠应用层隔离性靠单线程持久性靠 AOF/RDB。和 Lua 对比动态逻辑用 Lua简单打包用事务复杂补偿方案优先 Lua。项目落地WATCH 使用完毕要 UNWATCH连接池场景尤其注意避免脏状态被复用。最后说一个我在线上真实遇到过的教训。有一次业务方反馈“库存偶尔会多扣”排查下来发现代码里确实用了WATCH MULTI EXEC看起来没有问题。但查看日志后发现这个客户端在并发量升高时频繁抛出WatchError重试逻辑写成了“重新 GET 库存但没重新 WATCH”。结果旧事务失败后紧接着的新事务在没有任何监视的情况下直接执行了DECR库存就被多扣了。所以 WATCH 相关代码的重试逻辑不是简单循环而是整段“WATCH → GET → MULTI → 组装命令 → EXEC”都要重新完整执行漏掉任何一步都会破坏乐观锁的语义。这种细节面试官未必会写在题面上但如果你能主动讲出来印象分会明显不一样。
返回列表