【Redis从入门到精通】第55篇:Redis事务——MULTI/EXEC/DISCARD/WATCH详解
上一篇【第54篇】发布订阅实战——实时消息推送、聊天室、事件通知
下一篇【第56篇】Redis事务的ACID分析——它到底算不算ACID事务
如果你用过MySQL,一定很熟悉BEGIN ... COMMIT ROLLBACK。Redis也有事务,但它的"性格"跟MySQL完全不同——它更像是一个批量命令执行器,而不是传统意义上的数据库事务。
今天我们就来详细拆解Redis事务的四个命令(MULTI、EXEC、DISCARD、WATCH),看看它的实现原理和使用场景。
Redis事务的四个命令
Redis事务由以下四个命令组成:
| 命令 | 功能 | 类比SQL |
|---|---|---|
| MULTI | 标记事务开始,之后命令进入队列 | BEGIN TRANSACTION |
| EXEC | 执行事务队列中的所有命令 | COMMIT |
| DISCARD | 取消事务,清空队列 | ROLLBACK |
| WATCH | 监视一个或多个key,实现乐观锁 | SELECT … FOR UPDATE(但机制不同) |
事务的工作流程
Redis 事务执行时序图 Client Redis Server │ │ │── MULTI ───────────────────────>│ 开启事务,返回OK │<── OK ──────────────────────────│ │ │ (命令入队,不执行) │── SET key1 value1 ─────────────>│ 入队 ✓ │<── QUEUED ──────────────────────│ │ │ │── SET key2 value2 ─────────────>│ 入队 ✓ │<── QUEUED ──────────────────────│ │ │ │── INCR counter ────────────────>│ 入队 ✓ │<── QUEUED ──────────────────────│ │ │ │── GET key1 ────────────────────>│ 入队 ✓ │<── QUEUED ──────────────────────│ │ │ │── EXEC ────────────────────────>│ 批量执行队列中的所有命令 │<── [OK, OK, 1, "value1"] ─────│ 返回所有命令的结果 │ │关键点:MULTI之后、EXEC之前,所有命令都只是入队,不会实际执行。Redis也不会校验命令的合法性(比如类型错误)。只有当EXEC被调用时,才按顺序批量执行。
命令入队阶段
当客户端执行MULTI后,客户端的状态会切换为事务模式。在此模式下,每个命令都会被放入事务队列中。
事务队列的实现
在Redis的客户端结构中,事务队列存储在client.mstate中:
// client 结构中的事务状态typedefstructclient{multiState mstate;// 事务状态// ...}client;typedefstructmultiState{multiCmd*commands;// 命令数组(FIFO队列)intcount;// 命令数量// ...}multiState;typedefstructmultiCmd{robj**argv;// 命令参数数组intargc;// 参数个数structredisCommand*cmd;// 命令结构指针}multiCmd;事务队列内存布局 client.mstate ┌──────────────────────────────────────────────────┐ │ count: 4 │ │ │ │ commands[0]: SET key1 value1 │ │ argv: ["SET", "key1", "value1"] │ │ cmd: setCommand │ │ │ │ commands[1]: SET key2 value2 │ │ argv: ["SET", "key2", "value2"] │ │ cmd: setCommand │ │ │ │ commands[2]: INCR counter │ │ argv: ["INCR", "counter"] │ │ cmd: incrCommand │ │ │ │ commands[3]: GET key1 │ │ argv: ["GET", "key1"] │ │ cmd: getCommand │ └──────────────────────────────────────────────────┘入队阶段的两种错误
这是Redis事务中最容易踩坑的地方。入队阶段有两种截然不同的错误类型,处理方式也完全不同。
类型一:命令格式错误(语法错误)
如果入队的命令格式不对(比如拼写错误、参数个数不对),Redis会立即返回错误,同时将客户端标记为REDIS_DIRTY_EXEC。
# 示例:命令拼写错误MULTI OK SET key value QUEUED INCR key1 key2 key3# INCR 只接受1个参数,这是语法错误(error)ERR wrong number of argumentsfor'incr'commandGET key QUEUED EXEC(error)EXECABORT Transaction discarded because of previous errors.结果:整个事务被丢弃!一条命令语法错误,所有命令都不执行。
类型二:运行时错误(类型错误)
如果命令格式正确,但运行时才发现类型不匹配,Redis会在EXEC时执行到那条命令时报错,但不影响其他命令的执行。
# 示例:运行时类型错误SET key"hello"OK MULTI OK SET key"world"QUEUED INCR key# "world" 不是数字,运行时才报错QUEUED SET other_key"value"QUEUED EXEC1)OK ← SET成功2)(error)ERR value is not an integer or out of range ← INCR失败3)OK ← SET成功# 检查结果:GET key"world"← SET成功了,但INCR失败了,key还是"world"GET other_key"value"← 其他命令正常执行结果:出错的命令被跳过,其他命令正常执行。Redis不会回滚。
两种错误对比
入队阶段两种错误对比 ┌──────────────────────────────────────────────────────┐ │ 语法错误(入队时发现) │ │ │ │ MULTI → SET → INCR(参数错) → GET → EXEC │ │ OK QUEUED ERROR! QUEUED EXECABORT! │ │ │ │ 结果: 所有命令都不执行 │ └──────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────┐ │ 运行时错误(EXEC时发现) │ │ │ │ MULTI → SET → INCR(类型错) → SET → EXEC │ │ OK QUEUED QUEUED QUEUED 执行... │ │ OK │ │ ERROR │ │ OK │ │ │ │ 结果: 出错命令跳过,其他命令正常执行 │ └──────────────────────────────────────────────────────┘踩坑提示:这是Redis事务和MySQL事务最大的区别之一。MySQL会回滚所有操作,Redis不会。所以在MULTI之前就要确保命令是正确的,特别是INCR、ZADD这类对值类型有要求的命令。
DISCARD 命令
DISCARD用于取消当前事务:
MULTI OK SET key1 value1 QUEUED SET key2 value2 QUEUED# 改变主意了,取消事务DISCARD OK# 此时事务队列已清空,客户端回到正常模式# key1 和 key2 都没有被设置DISCARD做了三件事:
- 清空事务队列中的所有命令
- 清除客户端的
REDIS_MULTI标志 - 如果有WATCH监视,也一并取消
WATCH 命令——乐观锁
WATCH是Redis事务中最有意思的命令。它实现了**乐观锁(Optimistic Locking)**机制,让你可以在事务执行前检查某个key是否被修改过。
WATCH 的工作流程
WATCH 乐观锁流程 Client-A Redis Client-B │ │ │ │── WATCH balance ──────────────>│ │ │<── OK ─────────────────────────│ │ │ │ │ │── GET balance ────────────────>│ │ │<── "1000" ─────────────────────│ │ │ │ │ │── MULTI ──────────────────────>│ │ │<── OK ─────────────────────────│ │ │── DECRBY balance 100 ─────────>│ (入队) │ │<── QUEUED ─────────────────────│ │ │ │ │ │ │← SET balance │ │ │ "500" ──────│ │ │← (balance被 │ │ │ Client-B修改)│ │ │ │ │── EXEC ────────────────────────>│ │ │<── nil ────────────────────────│ │ │ (事务被取消!balance │ │ │ 在WATCH后被修改了) │ │ │ │ │WATCH 的实现原理
Redis在服务端维护了一个watched_keys字典:
// watched_keys 字典结构// Key: 被监视的Redis key// Value: 监视该key的客户端链表dict*watched_keys;// 示例:// "balance" → [Client-A, Client-C]// "stock" → [Client-B]watched_keys 字典 ┌────────────┬──────────────────────────┐ │ Key │ Value (客户端链表) │ ├────────────┼──────────────────────────┤ │ "balance" │ [Client-A] → [Client-C] │ │ "stock" │ [Client-B] │ └────────────┴──────────────────────────┘ 当 "balance" 被修改时: ① 遍历 [Client-A, Client-C] ② 给每个客户端设置 REDIS_DIRTY_CAS 标志 ③ EXEC时检查该标志,如果被设置则返回nilCAS 操作的代码示例
# 场景:实现一个安全的转账(A给B转100元)# --- 不使用WATCH(不安全)---GET balance:A"1000"# 如果此时别人也在操作A的余额...MULTI DECRBY balance:A100INCRBY balance:B100EXEC# 可能导致A的余额变成负数!# --- 使用WATCH(安全)---WATCH balance:A OK GET balance:A"1000"# 检查余额是否足够MULTI DECRBY balance:A100INCRBY balance:B100EXEC# 如果EXEC返回nil,说明balance:A在我们检查之后被修改了# 此时需要重试整个操作# --- 完整的CAS重试逻辑(伪代码)---def transfer(from_account, to_account, amount):whileTrue: WATCH from_account balance=GET from_accountifint(balance)<amount: UNWATCHreturn"余额不足"MULTI DECRBY from_account amount INCRBY to_account amount result=EXECifresult is not None:return"转账成功"# EXEC返回nil,说明被其他客户端修改了,重试踩坑提示:WATCH + MULTI + EXEC 只能保证"如果key没变就执行,变了就不执行"。它不保证事务的完整性(如前所述,运行时错误不会回滚)。所以WATCH适合做CAS操作,但不适合需要严格一致性的场景。
事务在集群模式下的限制
在Redis Cluster中,事务有一个重要的限制:事务中的所有Key必须落在同一个槽位。
# 集群模式下:# key "user:1001" 的槽位: 14520# key "user:1002" 的槽位: 12302MULTI SET user:1001"张三"QUEUED SET user:1002"李四"(error)CROSSSLOT Keysinrequest don'thashto the same slot# 解决方案:使用 {} 哈希标签MULTI SET{user}:1001"张三"# 槽位由"user"计算QUEUED SET{user}:1002"李四"# 槽位由"user"计算QUEUED EXEC1)OK2)OK# ✓ 成功!因为 {user} 确保了两个key在同一个槽位MULTI/EXEC 嵌套的限制
Redis不支持嵌套事务。如果你在事务中又执行了MULTI,会得到错误:
MULTI OK SET key1 value1 QUEUED MULTI# 试图嵌套事务(error)ERR MULTI calls can not be nested EXEC1)OK如果你确实需要在事务中做条件判断,请使用Lua脚本(下一篇会详细介绍),Lua脚本天然支持条件逻辑和原子性。
事务 vs 普通命令的性能
# 对比测试:1000次SET操作# 方式1: 逐条发送redis-benchmark-tset-n1000-c1# 约 10000 requests/sec# 1000次网络往返!# 方式2: 事务(MULTI ... EXEC)# 使用 pipeline + MULTI/EXECredis-benchmark-tset-n1000-P1-c1# 约 20000 requests/sec# 只需1次网络往返!# 方式3: Pipeline(不含事务)redis-benchmark-tset-n1000-P1000-c1# 约 100000 requests/sec# 1次网络往返,1000条命令!事务的主要价值是减少网络往返(RTT),而不是原子性。如果你只需要减少RTT,Pipeline可能比事务更高效。
本章小结
Redis 事务核心要点 ┌─────────────────────────────────────────────┐ │ │ │ MULTI → 开启事务,命令入队 │ │ EXEC → 批量执行,返回所有结果 │ │ DISCARD → 取消事务,清空队列 │ │ WATCH → 乐观锁,监视key变化 │ │ │ │ 语法错误 → EXECABORT,全部不执行 │ │ 运行时错误 → 跳过错误命令,其余正常执行 │ │ 集群模式 → 所有key必须在同一槽位 │ │ 不支持嵌套 → MULTI不能嵌套 │ │ │ │ 本质: 批量命令执行器 + 乐观锁 │ │ 不是: 传统ACID事务 │ │ │ └─────────────────────────────────────────────┘| 操作 | MULTI/EXEC | WATCH | 适用场景 |
|---|---|---|---|
| 批量执行 | ✓ | - | 减少网络往返 |
| 条件执行 | ✓ | ✓ | CAS操作 |
| 错误回滚 | ✗ | - | 需要回滚请用Lua |
| 嵌套事务 | ✗ | - | 用Lua脚本替代 |
| 条件判断 | ✗ | - | 用Lua脚本替代 |
上一篇【第54篇】发布订阅实战——实时消息推送、聊天室、事件通知
下一篇【第56篇】Redis事务的ACID分析——它到底算不算ACID事务
