Redis 事务:MULTI/EXEC
Redis 提供了"事务"机制,但它的语义和 MySQL 的事务很不一样——没有 ACID 中的"I(隔离)"和传统的"R(回滚)"。本篇讲清 Redis 事务到底做了什么、WATCH 怎么实现乐观锁,以及为什么很多场景下用 Lua 脚本更好。
1. MULTI / EXEC / DISCARD
Redis 事务的核心三个命令:
# MULTI:开启事务,后续命令进入"队列"而非立即执行
127.0.0.1:6379> MULTI
OK
# 之后输入的命令都被缓存到队列,返回 QUEUED
127.0.0.1:6379(TX)> SET name "小明"
QUEUED
127.0.0.1:6379(TX)> SET age 20
QUEUED
127.0.0.1:6379(TX)> INCR counter
QUEUED
# EXEC:一次性执行队列里的所有命令
127.0.0.1:6379(TX)> EXEC
1) OK
2) OK
3) (integer) 1
# 返回值是数组,顺序对应每条命令的结果
# 一旦 EXEC,事务结束,返回正常模式
# DISCARD:取消事务,清空命令队列,退出事务模式
127.0.0.1:6379(TX)> DISCARD
OKMULTI 开启事务后,输入的命令不会立即执行,而是返回 QUEUED 表示已加入队列。直到 EXEC 才一次性顺序执行所有命令。DISCARD 取消事务,清空队列。
事务期间,Redis 保证这些命令作为整体顺序执行——执行期间不会被其他客户端的命令插入。这是 Redis 事务的核心价值。
2. Redis 事务的"原子性"特点
这里有个反直觉的细节,一定要记住:Redis 事务不支持运行时回滚。
# Redis 事务的"原子性"特点:
# 1. 顺序性:EXEC 之前命令不会插队执行
# 其他客户端的命令在事务期间不会插入到中间
# 2. 不支持回滚:某条命令运行时出错(如类型错误),
# 后续命令仍然继续执行
# 演示"出错不回滚"的例子
127.0.0.1:6379> SET k1 "hello"
OK
127.0.0.1:6379> SET k2 10
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> INCR k2 # 这条会成功,k2 -> 11
QUEUED
127.0.0.1:6379(TX)> INCR k1 # 这条会失败(k1 是文本,不能 INCR)
QUEUED
127.0.0.1:6379(TX)> INCR k2 # 这条仍然执行,k2 -> 12
QUEUED
127.0.0.1:6379(TX)> EXEC
1) (integer) 11
2) (error) ERR value is not an integer or out of range
3) (integer) 12
# 注意:即使第 2 条报错,第 3 条仍然执行!
# 这和 MySQL 事务完全不同(MySQL 会整体回滚)为什么 Redis 不支持回滚?官方文档的解释是:Redis 命令错误通常是程序 bug(用错命令、类型不匹配),不应在运行时发生——发版前测试就能发现。支持回滚会让 Redis 的实现复杂得多(且影响性能)。所以 Redis 选择了"不支持回滚、保持简单"的设计哲学。这要求开发者在用事务时,提前确保命令不会运行时出错。
注意区分两类错误:语法错误(命令不存在、参数错)会让整个事务在 EXEC 时直接失败,所有命令都不执行;运行时错误(如对字符串 INCR)只影响那一条,其他正常执行。
3. WATCH:乐观锁
事务解决的只是"原子执行",但实际业务里更常见的是"读出来 → 计算 → 写回"这种 CAS 场景。比如转账:先读余额、判断够不够、扣款。如果中间别人改了余额,你的判断就失效。Redis 用 WATCH 解决:
# WATCH:乐观锁,监控一个或多个 key
# 如果 EXEC 之前被监控的 key 被其他客户端修改过,整个事务失败
# 终端 A:
WATCH balance
# 假设 balance 当前是 100
MULTI
INCRBY balance -50 # 想扣 50
EXEC
# 如果期间 balance 没被改 -> 返回数组,扣款成功
# 如果期间被改了 -> 返回 (nil),事务被放弃
# 终端 B(在 A 的 EXEC 之前):
SET balance 200 # 修改了被监控的 key
# 此时终端 A 的 EXEC 会返回 (nil),扣款不会执行
# 应用代码需要重新读取余额并重试
# 取消所有监控
UNWATCH
# 注意:EXEC 会自动取消 WATCH,无论事务是否成功
# DISCARD 也会自动取消 WATCHWATCH 是乐观锁:监控某些 key,如果 EXEC 之前这些 key 被任何客户端修改过(包括自己),整个事务自动放弃,返回 nil。应用代码需要重新读取数据并重试。这是 Redis 实现 CAS 的标准模式。
4. 实战:用 WATCH 实现原子转账
# 转账实例:用 WATCH + MULTI 实现原子转账
# 应用代码(python 伪代码):
import redis
r = redis.Redis()
def transfer(from_user, to_user, amount):
while True:
# 监视两个账户余额
r.watch(f"balance:{from_user}", f"balance:{to_user}")
from_bal = int(r.get(f"balance:{from_user}") or 0)
to_bal = int(r.get(f"balance:{to_user}") or 0)
if from_bal < amount:
r.unwatch()
raise Exception("余额不足")
# 开启事务
pipe = r.multi()
pipe.decrby(f"balance:{from_user}", amount)
pipe.incrby(f"balance:{to_user}", amount)
try:
pipe.execute() # 提交事务
return True
except redis.WatchError:
# 期间有人改了余额,重试
continue
# 这种"读-改-写"循环就是经典的 CAS(Compare-And-Swap)模式
# 高并发下重试可能频繁,但避免了悲观锁带来的阻塞这个"读 → 检查 → 写"的循环是所有需要 CAS 的场景的通用模板。低并发下几乎一次成功,高并发下重试是正常代价——比起悲观锁带来的阻塞,乐观锁在大部分场景下吞吐更高。
注意几个易错点:WATCH 必须在 MULTI 之前调用;EXEC/DISCARD 都会自动 UNWATCH;连接断开后所有 WATCH 失效。
5. 更优雅的方案:Lua 脚本
虽然 WATCH+MULTI 能实现 CAS,但"重试循环"写起来啰嗦。Redis 提供更优雅的方案——Lua 脚本:整个脚本在 Redis 里原子执行,无需重试。
# Lua 脚本:Redis 的"原子操作"更优雅方案
# 整个脚本在 Redis 单线程里原子执行,期间不会被其他命令插入
# 例:原子转账(比 WATCH+MULTI 更简洁)
EVAL "
local from = KEYS[1]
local to = KEYS[2]
local amount = tonumber(ARGV[1])
local from_bal = tonumber(redis.call('GET', from) or 0)
if from_bal < amount then
return {err='INSUFFICIENT_BALANCE'}
end
redis.call('DECRBY', from, amount)
redis.call('INCRBY', to, amount)
return {ok='OK'}
" 2 balance:A balance:B 50
# 参数说明:
# "脚本内容" : Lua 代码
# 2 : 后面有几个 KEY
# balance:A balance:B : KEYS[1], KEYS[2]
# 50 : ARGV[1]
# 优势(对比 MULTI):
# 1. 一次网络往返(脚本和参数一次性发送)
# 2. 真正的"读-改-写"原子,不需要重试
# 3. 可以做条件判断、循环等复杂逻辑
# 4. 脚本会被缓存(SCRIPT LOAD + EVALSHA)
# 加载脚本得到 SHA1 摘要
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# 返回 "a5260dd66ce..." (40 字符)
# 之后用 EVALSHA 执行(只传摘要,省带宽)
EVALSHA "a5260dd66ce..." 1 some_keyLua 脚本的核心优势:真正原子的"读-改-写",不需要客户端重试。因为脚本在 Redis 服务端执行,中间无法被任何命令插入。脚本会被 Redis 缓存(用 SHA1 摘要索引),后续调用只需传摘要(SCRIPT LOAD + EVALSHA),节省网络带宽。
复杂业务(分布式锁释放、限流的滑动窗口、原子转账、库存扣减)都强烈推荐用 Lua 脚本。各大客户端库都内置了脚本支持(ioredis 的 eval()、redis-py 的 register_script()、go-redis 的 Script)。
6. PIPELINE:批量但非事务
新手常把 PIPELINE 和 MULTI 混为一谈,它们是两个正交的概念:
# PIPELINE:批量命令(不是事务,但常被混淆)
# 把多条命令一次性发给 Redis,一次性接收所有响应
# 应用代码(Node.js 示例):
const pipeline = client.multi(); // ioredis 的 multi() 实际是 pipeline
pipeline.set("k1", "v1");
pipeline.set("k2", "v2");
pipeline.incr("counter");
const results = await pipeline.exec();
// 一次网络往返,3 条命令执行完毕
# PIPELINE vs MULTI 的区别:
# PIPELINE:批量发命令,但不保证原子性(中间可能插入其他客户端命令)
# MULTI :保证原子性(命令作为整体顺序执行)
#
# 性能上 PIPELINE 更快(无需队列+确认),
# 但凡需要"多条命令要么全执行要么都不执行",必须用 MULTI
#
# 实际上 ioredis/redis-py 的 multi() 默认是 MULTI+PIPELINE 的结合:
# 既保证原子,又用 pipeline 方式发送,兼具两者优点简言之:PIPELINE 解决"多次网络往返"的性能问题(批量发),MULTI 解决"原子性"问题(整体执行)。实际客户端库的 multi() API 通常是"MULTI + PIPELINE"的结合,兼具两者优点。如果你只是想批量发命令(不要求原子),用纯 PIPELINE 就行;需要原子性必须 MULTI。
7. Redis 事务 vs MySQL 事务
最后强调两者的关键差异,避免误用:
- 隔离性:MySQL 提供 4 级隔离(读未提交、读已提交、可重复读、串行化);Redis 事务没有隔离级别概念——单线程顺序执行,天然没有并发冲突。
- 回滚:MySQL 出错整体回滚;Redis 出错不回滚,继续执行后续命令。
- 持久化:MySQL 通过 redo log/binlog 保证;Redis 持久化和事务无关,看 RDB/AOF 配置。
- 用途:MySQL 事务保证数据一致性;Redis 事务主要用于"多条命令原子执行"和配合 WATCH 实现 CAS。
所以不要把 Redis 事务当成"MySQL 事务的替代",它们解决的是不同问题。需要强一致性的复杂事务请用 MySQL,Redis 只用它"原子执行"这一面。
小结
Redis 事务(MULTI/EXEC)的核心是原子顺序执行,但不支持回滚。配合 WATCH 实现乐观锁(CAS),或用 Lua 脚本做更优雅的"读-改-写"原子操作。PIPELINE 是性能优化(批量),与事务正交。下一篇讲 Redis 实战场景,把所学组合到真实业务里。
← 上一篇 Redis 发布订阅
下一篇 Redis 实战场景 →