前言:Redis 事务机制与常见误区深度解析
在日常开发中,Redis 事务是一个容易被误解的概念。许多开发者要么不了解它有事务机制,要么直接将其视为 MySQL 事务的替代品。然而两者本质差异巨大,混淆使用可能导致线上事故。本文将深入解析 Redis 事务的核心机制、常见陷阱、乐观锁的应用以及面试高频问题,一次性梳理清楚。

Redis 事务的核心机制是什么
简单来说,就是将多条命令放入一个队列,在指定时刻按顺序一次性执行。执行期间,其他客户端无法插入任何命令。整个机制仅由四个命令支撑:
- MULTI — 开始命令入队
- EXEC — 执行队列中所有命令
- DISCARD — 取消事务,清空队列
- WATCH — 监视一个或多个 key,若在 EXEC 前被其他客户端修改,事务自动取消
上手:转账场景实例
A 账户扣减 100,B 账户增加 100。先来看正常流程:
127.0.0.1:6379> SET account:A 500OK127.0.0.1:6379> SET account:B 200OK127.0.0.1:6379> MULTIOK127.0.0.1:6379> DECRBY account:A 100QUEUED127.0.0.1:6379> INCRBY account:B 100QUEUED127.0.0.1:6379> EXEC1) (integer) 4002) (integer) 300
注意:MULTI 之后每条命令都会返回 QUEUED,表示命令已进入队列等待执行。执行 EXEC 后,结果按入队顺序依次返回。
如果想反悔,操作也很简单:
127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET k1 v1QUEUED127.0.0.1:6379> DISCARDOK
队列清空,一切归零,就好像什么都没发生过。
最容易踩的坑:错误处理机制详解
这里其实藏着两种不同的错误场景,处理逻辑完全不同,混淆使用很可能引发线上事故。
第一种:语法错误(入队时即可检测)
命令拼写错误、参数不合法,Redis 在入队时就会报错。此时执行 EXEC,整个事务直接取消:
127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET k1 v1QUEUED127.0.0.1:6379> NOTACMD(error) ERR unknown command 'NOTACMD'127.0.0.1:6379> SET k2 v2QUEUED127.0.0.1:6379> EXEC(error) EXECABORT Transaction discarded because of previous errors.
结果 k1 和 k2 都没有写入。这类错误至少遵循了“全有或全无”的逻辑,相对安全。
第二种:运行时错误(执行时才暴露问题)
命令语法完全正确,但执行时因数据类型不匹配等原因失败——例如对字符串 key 执行 INCR。Redis 会跳过出错的那条命令,其余命令照常执行。已经执行成功的命令不会回滚:
127.0.0.1:6379> SET k1 "hello"OK127.0.0.1:6379> MULTIOK127.0.0.1:6379> SET k1 "world"QUEUED127.0.0.1:6379> INCR k1QUEUED127.0.0.1:6379> SET k2 v2QUEUED127.0.0.1:6379> EXEC1) OK2) (error) ERR value is not an integer or out of range3) OK
k1 被改为 world,k2 也成功写入,只有 INCR 那条失败。这就是 Redis 事务与 MySQL 事务最本质的区别——Redis 事务不支持回滚。
不支持回滚是刻意设计
坦白说,初次接触这个特性时,很多人会觉得这个设计有些坑人。
但 Redis 官方的思路其实很清晰:运行时错误本质上是程序员的 bug——比如对 String 类型的 key 执行 INCR,这种问题在测试阶段就应该被发现。如果为了这类 bug 去支持回滚,意味着每次操作前都要备份旧数据,出错时再逐条恢复。对于一个追求极简和高性能的中间件来说,这种代价过于沉重。
因此,使用 Redis 事务时,开发者必须自己保证命令的正确性。别把它当 MySQL 用。
WATCH:乐观锁机制
MULTI/EXEC 还有一个盲区:如果事务中的命令依赖于某个 key 的当前值,而这个值在 MULTI 之后、EXEC 之前被其他客户端修改,事务照样正常执行,结果就会出错。
举个典型例子:库存只剩 1 件,两个客户端同时读到 1,都以为自己能下单。结果两个都执行了 DECR,库存直接变成 -1。
WATCH 正是为解决这个问题而生。
使用方式
127.0.0.1:6379> SET stock 1OK127.0.0.1:6379> WATCH stockOK127.0.0.1:6379> GET stock"1"127.0.0.1:6379> MULTIOK127.0.0.1:6379> DECR stockQUEUED127.0.0.1:6379> EXEC1) (integer) 0
如果被其他客户端抢先修改:
另一个客户端在 WATCH 之后、EXEC 之前修改了 stock,那么你的 EXEC 会返回 nil,一条命令都不执行:
127.0.0.1:6379> EXEC(nil)
收到 nil 表示事务未执行。标准做法是:重新读取数据、重新判断条件、再试一次。这就是乐观锁的玩法——不加锁、不阻塞、简洁高效。
几个值得记住的细节:
- EXEC 执行后(无论成功与否),WATCH 自动解除
- DISCARD 也会解除所有 WATCH
- 如果想主动解除,可以直接执行 UNWATCH
ACID 四个维度如何评估
如果面试官问到,可以这样回答:
- 原子性 — 有条件的。队列中的命令按顺序执行、不被插入,这部分确实是原子的。但运行时出错不终止,所以不算严格意义上的原子性。
- 一致性 — 语法错误导致全取消,不会出现脏写的情况。
- 隔离性 — EXEC 前队列中的命令对其他客户端不可见,执行期间也不会被中断。
- 持久性 — 取决于配置。AOF + appendfsync always 能保证持久性;若使用 RDB 或 AOF everysec,则可能丢失数据。
Redis 事务 vs 关系型数据库事务
| 对比维度 | Redis事务 | 关系型数据库事务 |
| 回滚支持 | 不支持 | 支持 |
| 运行时错误处理 | 跳过出错命令,继续执行 | 回滚整个事务 |
| 隔离级别 | 执行期间不被打断 | 多级隔离级别可选 |
| 持久性 | 取决于持久化配置 | 默认持久化 |
| 适用场景 | 简单批量操作 | 复杂业务逻辑,强一致性要求 |
一句话总结:Redis 事务轻量、快速、弱一致。不要用它处理复杂业务逻辑。
实际开发中的建议
首先,不要将 Redis 事务视为 MySQL 事务的替代品。它不支持回滚。如果你确实需要“要么全做要么不做”的语义,仅靠 Redis 事务无法实现。
复杂逻辑请使用 Lua 脚本。Redis 执行 Lua 脚本是原子的,脚本内可以加入判断逻辑,比事务更灵活。在生产环境中,需要复杂原子操作的地方,基本都使用 Lua,很少直接使用裸事务。
WATCH 重试要设置合理上限。并发高时可能反复失败,不设上限容易陷入死循环。
事务内的命令尽量简短。Redis 是单线程处理,队列越长,其他所有命令的等待时间就越长。
疑点解惑
Redis 事务是原子的吗?
有条件的原子。命令按顺序执行不被中断,但出错不终止。回答时应分两种情况:语法错误全取消,运行时错误只跳过当前命令。
为什么不支持回滚?
官方明确解释:运行时错误是程序员的 bug,测试阶段就应该发现。增加回滚意味着给 Redis 加入一套恢复机制,既复杂又降低性能,与设计理念相悖。
WATCH 是悲观锁还是乐观锁?
乐观锁。不加锁,不阻塞,EXEC 时检查 key 是否被修改过。若被修改则返回 nil,由客户端决定是否重试。
Redis 事务和 Lua 脚本的区别?
两者都能保证原子执行。区别在于 Lua 脚本支持条件判断和逻辑控制,而事务只是命令队列。生产环境中需要复杂原子操作时,优先选择 Lua 脚本。
最后
Redis 事务的本质就是命令排队 + 顺序执行,具备隔离性但不支持回滚。WATCH 补充了乐观锁的能力。技术本身并不复杂,复杂的是明确什么场景该用什么工具。不要将 Redis 事务视为 MySQL 事务的平替——它真不是。
