消息队列之后,我们来看 Redis 在「事务与原子性」上的设计。关系型数据库里,事务通常意味着 ACID:原子性、一致性、隔离性、持久性;Redis 也提供了 MULTI/EXEC 将多条命令打包执行,但语义与 MySQL 等差异很大: 它不保证「失败回滚」,也不保证「隔离级别」,只保证「事务块内的命令按序执行、期间不被其他客户端插入」。
我们从 MULTI/EXEC 的语义说起,再谈原子操作的意义,最后说明为什么 Redis 不支持回滚,以及何时用 WATCH、何时用 Lua。
Redis 的「事务」由 MULTI、EXEC、DISCARD、WATCH 组成。客户端发送 MULTI 后,后续命令会被放入队列而非立即执行;发送 EXEC 时,服务端按序执行队列中的全部命令,并将每个命令的返回值按顺序组成数组返回。在这段时间内,其他客户端的命令不会插在事务块中间执行,因此从「串行化」角度讲,事务块内的命令是「一起执行、中间无穿插」的。若在提交前不想执行已入队的命令,可发送 DISCARD 清空事务队列并退出事务,连接恢复为正常状态。
错误处理需要区分入队阶段与执行阶段。入队阶段(MULTI 之后、EXEC 之前):若某条命令无法入队(例如命令不存在、参数个数错误、内存不足),Redis 会记录错误,等到 EXEC 时拒绝执行整个事务,返回 EXECABORT Transaction discarded because of previous errors,队列中的命令都不会执行(Redis 2.6.5+)。执行阶段(EXEC 之后):命令已入队则会被依次执行;若某条命令执行时失败(例如对字符串类型的 Key 执行 SADD、类型不匹配),Redis 不会回滚已执行的命令,该命令返回错误对象,其余命令继续执行。因此,「命令不存在」属于入队错误,整事务被拒绝;「类型错误」属于执行错误,只影响该条,后续照常执行。
因此,MULTI/EXEC 提供的是「命令打包 + 串行执行」,而不是「失败即回滚」的原子事务。适合的场景是「需要连续执行多条命令、且希望中间不被其他客户端打断」,例如先 INCR 再 EXPIRE;若某条命令依赖前一条的结果且可能失败,需要在应用层根据 EXEC 返回的数组做补偿或重试,不能指望 Redis 自动回滚。
|MULTI INCR counter EXPIRE counter 60 EXEC

在 Redis 中,「原子」有两个层次。一是单条命令的原子性:INCR、SET、ZADD 等在执行期间不会被其他命令打断,因此「读-改-写」若能用一条命令完成(如 INCR、HINCRBY),就天然原子。二是多条命令的原子性:若逻辑需要「先判断再更新」或「先读 A 再写 B」,单条命令无法表达,就需要 MULTI/EXEC 或 Lua 脚本把多条命令打包成一段不可分割的执行序列。MULTI/EXEC 保证的是「这段序列按序执行、中间无穿插」,但不保证「某条失败则前面全部撤销」;Lua 脚本则保证「整段脚本执行期间独占、要么全执行要么全不执行」(脚本本身无语法错误时)。
原子操作的意义在于避免并发下的竞态。例如限流:先 GET 当前计数、再判断是否超限、再 INCR,三步若分开执行,多个客户端可能同时读到「未超限」然后都 INCR,导致实际超限。用 INCR 的返回值判断(INCR 后若返回值大于阈值则 DECR 回退或拒绝)或把「判断+INCR+EXPIRE」放进 Lua,才能保证「同一时刻只有一个逻辑在更新」。因此,能单命令就单命令,单命令不够就用 Lua 或 WATCH+MULTI/EXEC,根据业务对「原子」和「回滚」的需求选择。

Redis 官方明确表示:事务中的命令即使执行失败,也不会回滚前面已执行的命令。设计理由主要有两点。一是实现回滚成本高:要回滚就需要记录「事务开始前的状态」或「每条命令的逆操作」,在内存型、单线程的模型下会显著增加复杂度和开销,与 Redis 追求简单、高性能的定位不符。二是回滚无法覆盖所有错误:很多「错误」是业务逻辑错误(如对错误类型的 Key 执行了错误命令、或误删了不该删的数据),这类问题即使用回滚恢复了数据,逻辑错误仍然存在,正确的做法是在开发阶段保证命令与数据匹配,而不是依赖数据库回滚兜底。
因此,使用 Redis 事务时,应尽量保证事务块内的命令「不会因类型或约束失败」;若必须做「条件执行」(如仅当某 Key 未被修改时才执行),可用 WATCH 实现乐观锁:在 MULTI 之前对要依赖的 Key 执行 WATCH,若在 WATCH 与 EXEC 之间这些 Key 被其他客户端修改,EXEC 会返回 nil 表示事务被取消,应用层可重试;EXEC 被调用后,无论成功与否,所有被 WATCH 的 Key 都会自动解除监视(相当于 UNWATCH)。注意 WATCH 必须在 MULTI 之前调用,事务块内的命令不会触发 WATCH 条件。对于更复杂的「读-判断-写」逻辑,Lua 脚本更合适:脚本在服务端原子执行,可包含分支与循环,且无需乐观锁重试。下一讲我们会专门看 Lua 脚本与复杂逻辑,包括限流与库存扣减的实现。
