在日常开发中,很多开发者要么不知道 Redis 有事务,要么以为它和 MySQL 的事务是一回事。两者差挺远的,搞混了线上容易出问题。这篇文章把核心机制、坑点、乐观锁和面试常问的几个问题串一遍。

把多条命令塞进一个队列,到点了按顺序一口气执行完。执行过程中其他客户端插不进来。就这四个命令:
- 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 事务里的错误分两种,处理逻辑完全不同,搞混了线上真的会出事。
命令拼错了、参数不对,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 都没写进去。这种还好,至少是"全有或全无"。
语法没问题,执行时栽了——比如对字符串做 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,这应该在测试阶段就发现。为此让 Redis 支持回滚,意味着每次操作前要保存旧数据、出错时逐条恢复,这对一个追求极简和高性能的东西来说太重了。
所以用 Redis 事务,你得自己保证命令是对的。别把它当 MySQL 用。
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
你可以这样说:
- 原子性 — 有条件的。队列命令按顺序执行、不被 插入,这部分是原子的。但运行出错不回头,不是严格意义的原子性。
- 一致性 — 语法错误全取消,不会脏写。
- 隔离性 — EXEC 前队列里的命令对其他客户端不可见,执行不被打断。
- 持久性 — 看你开了什么。AOF + appendfsync always 就有持久性;RDB 或 AOF everysec 就有可能丢数据。
| 对比项 | Redis事务 | 关系型数据库事务 |
| 回滚支持 | 不支持 | 支持 |
| 运行时错误处理 | 跳过出错命令,继续执行 | 回滚整个事务 |
| 隔离级别 | 执行期间不被打断 | 多级隔离级别可选 |
| 持久性 | 取决于持久化配置 | 默认持久化 |
| 使用场景 | 简单批量操作 | 复杂业务逻辑,强一致性要求 |
简单说:Redis 事务轻、快、弱一致。别拿它处理复杂业务逻辑。
别把 Redis 事务当 MySQL 事务用。 它不回滚。如果你真的需要"要么全做要么不做",光靠 Redis 事务做不到。
复杂逻辑用 Lua 脚本。 Redis 执行 Lua 是原子的,脚本里还能加判断逻辑,比事务灵活。生产环境里,需要复杂原子操作的基本都用 Lua,很少裸写事务。
WATCH 重试要设上限。 并发高的时候可能反复失败,不设上限就是死循环。
事务里的命令尽量短。 Redis 单线程处理,你的队列越长,所有其他命令都得等着。
有条件的原子。命令顺序执行不被打断,但出错不回头。回答时分两种情况:语法错全取消,运行错只跳当前命令。
官方觉得运行错误是 bug,测试阶段就该抓到。加回滚等于给 Redis 塞一套恢复机制,又复杂又慢,跟 Redis 的设计方向冲突。
乐观锁。不加锁,不阻塞,EXEC 时检查 key 有没有被改过。被改了返回 nil,客户端自己决定要不要重试。
都能保证原子执行。Lua 脚本能做条件判断,有逻辑控制,事务就是纯命令队列。生产上用复杂原子操作,优先 Lua。
Redis 事务就是命令排队 + 顺序执行,有隔离性但不回滚。WATCH 补上了乐观锁的能力。技术本身不复杂,复杂的是搞清楚什么场景该用什么工具。别把 Redis 事务想成 MySQL 事务的平替——它不是。