适用版本:Redis 7.2.x
在很多初学者的印象里,Redis 是“单线程 + 命令原子执行”,于是很容易得出一个误解:既然单条命令已经是原子的,那是不是就不需要事务了?
答案是:单条命令的原子性,不等于多条命令组合后的业务原子性。
比如“先读取余额,再扣减金额,再写入流水状态”这种操作,往往由多条命令组成。如果多个客户端同时操作,同样可能产生竞争条件。Redis 为这类场景提供了两套常见能力:
- 使用
MULTI/EXEC/DISCARD组织事务 - 使用
WATCH做乐观锁控制
这篇文章重点讲清楚 Redis 事务到底解决什么问题、WATCH 的机制是什么、它和数据库事务有哪些不同,以及在实际业务里该怎么用、什么时候不该滥用。
一、先建立正确认知:Redis 事务不等于数据库事务
很多人第一次看到“事务”两个字,会自动代入 MySQL 里的 ACID 事务。但 Redis 的事务模型和关系型数据库并不是一回事。
Redis 事务主要提供的是:
- 把一组命令按顺序打包提交
- 在执行期间,不会被其他客户端命令插入到中间
- 可以配合
WATCH做提交前的冲突检测
但它并不提供典型关系型数据库里的这些能力:
- 复杂的隔离级别
- 自动回滚到执行前状态
- 事务内读未提交、可重复读之类的语义
换句话说,Redis 事务更像是:
“把多条命令排队,然后一次性按顺序执行;如果你担心并发修改,就在提交前用 WATCH 做版本检查。”
这一定义非常重要。后面理解 Redis 事务的行为时,几乎所有“为什么会这样”的问题,都能从这里找到答案。
二、Redis 事务的核心命令
Redis 事务常见命令有 4 个:
MULTI:开启事务EXEC:提交事务DISCARD:放弃事务WATCH:监视一个或多个 key
另外还有一个常见配套命令:
UNWATCH:取消监视
先看最基本的事务流程。
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET order:1:status paid
QUEUED
127.0.0.1:6379(TX)> INCR stats:paid_count
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) (integer) 1
这里有两个关键现象:
- 执行
MULTI后,后续命令并不会立刻执行,而是进入队列 - 直到执行
EXEC,这些命令才真正按顺序执行
这就是 Redis 事务最基础的工作方式。
三、事务队列机制:为什么会看到 QUEUED
在 MULTI 之后,Redis 会把客户端状态切换到事务上下文。此时你发送的命令不会马上修改数据,而是先进入队列。
例如:
MULTI
SET user:1:name alice
INCR login:count
GET user:1:name
EXEC
它的执行顺序可以理解为:
MULTI开启事务上下文SET、INCR、GET依次入队EXEC到来时,Redis 依次执行队列中的命令- 将每条命令的返回结果按数组形式返回给客户端
返回示例:
1) OK
2) (integer) 1
3) "alice"
因此,Redis 事务不是“边执行边提交”,而是“先排队,后统一执行”。
四、Redis 事务的原子性,到底体现在哪里
这也是最容易混淆的地方。
1. Redis 保证“事务中的命令串行执行”
一旦 EXEC 触发,Redis 会按顺序执行事务队列中的所有命令。执行过程中,不会插入其他客户端的命令。
也就是说,下面两组命令不会交错成这样:
- 客户端 A:事务命令 1
- 客户端 B:某条写命令
- 客户端 A:事务命令 2
Redis 保证的是:事务队列在提交后会连续执行完。
2. Redis 不保证“像数据库一样失败后自动回滚”
例如:
SET balance 100
MULTI
DECRBY balance 30
INCRBY balance abc
EXEC
这里第二条事务内命令 INCRBY balance abc 是错误命令参数。执行结果可能是:
1) (integer) 70
2) (error) ERR value is not an integer or out of range
你会发现:
- 第一条
DECRBY已经生效 - 第二条出错并不会让第一条自动撤销
这说明 Redis 事务不提供传统意义上的回滚。
3. 因此更准确的说法是
Redis 事务保证的是:
- 提交后的执行顺序性
- 执行期间的串行性
- 可选的提交前冲突检测
但不保证:
- 失败后自动恢复到事务前状态
五、事务里的两类错误:入队错误与执行期错误
理解这个细节,对排查问题很有帮助。
1. 入队阶段错误
如果某条命令在入队时就有明显语法问题,Redis 会记录事务状态为错误,EXEC 时整个事务不会执行。
示例:
MULTI
SET k1 v1
INCRBY
EXEC
这里 INCRBY 参数不完整,会导致事务在排队阶段就出错。
典型表现是:
- 某条命令不是
QUEUED - 最后
EXEC返回事务执行失败
2. 执行阶段错误
如果命令语法没问题,但执行时发现数据类型不匹配,那么事务会继续执行其他命令,只是报错的那条失败。
示例:
SET user:name alice
MULTI
SET counter 1
LPOP user:name
INCR counter
EXEC
因为 user:name 是字符串,不是列表,LPOP user:name 会报类型错误。但:
SET counter 1会成功LPOP user:name会报错INCR counter仍会继续执行
这也是很多人误以为 Redis 事务“失效”的原因。其实不是失效,而是它的设计就是这样。
六、DISCARD:在提交前放弃事务
如果你在 MULTI 之后发现命令排错了,或者逻辑条件不满足,可以用 DISCARD 取消整个事务队列。
MULTI
SET draft:1 pending
INCR counter:test
DISCARD
执行 DISCARD 后:
- 队列会被清空
- 事务上下文结束
- 已排队命令不会执行
这个命令很适合交互式调试时使用。
七、为什么还需要 WATCH
仅靠 MULTI / EXEC,只能保证“提交后连续执行”,但不能解决“提交前数据已经被别人改了”的问题。
看一个经典场景:扣库存。
初始库存为 1,两个客户端都这样做:
- 先
GET stock:item:1001 - 判断库存是否大于 0
- 如果大于 0,则执行事务
DECR stock:item:1001
如果没有并发控制,两边都可能在读取时看到库存是 1,然后都去提交扣减,最后出现超卖。
这时就需要 WATCH。
八、WATCH 的本质:乐观锁
WATCH 的作用不是“加锁阻塞别人”,而是:
在提交事务前,监视指定 key 是否被其他客户端修改;如果被改过,当前事务提交失败。
这属于典型的乐观锁思想。
它的假设是:
- 大多数时候并发冲突并不频繁
- 先乐观执行
- 提交前再检查有没有人改过
- 如果冲突了,就放弃并重试
和悲观锁相比,WATCH 不会长时间占住资源,也不会让其他客户端被阻塞。
九、WATCH 的基本使用流程
标准流程通常是这样的:
WATCH key- 读取 key 当前值
- 在客户端做业务判断
MULTI- 发送准备提交的命令
EXEC- 如果
EXEC返回空结果,说明监视的 key 在提交前被改动,事务失败,需要重试
示例:
WATCH stock:item:1001
GET stock:item:1001
MULTI
DECR stock:item:1001
EXEC
如果 WATCH 之后、EXEC 之前,其他客户端改动了 stock:item:1001,那么本次 EXEC 返回:
(nil)
或者在不同客户端里表现为“空数组 / null reply”。这表示:
- 事务没有执行
- 不是命令出错
- 而是乐观锁检查未通过
十、一个完整的库存扣减示例
先初始化数据:
SET stock:item:1001 5
客户端 A:
WATCH stock:item:1001
GET stock:item:1001
假设读到结果:
"5"
此时客户端 B 执行了:
DECR stock:item:1001
库存变成了 4。
客户端 A 继续提交:
MULTI
DECR stock:item:1001
EXEC
结果会是:
(nil)
因为在 A 提交前,B 已经修改了被监视的 key,A 的事务自动失效。
这说明 WATCH 解决的不是“执行中串行”,而是“提交前版本是否仍然有效”。
十一、UNWATCH 与自动取消监视
当你执行以下任一动作时,监视关系会结束:
EXECDISCARDUNWATCH- 客户端连接关闭
如果你只是想读取一下数据,但后续决定不提交事务,可以主动执行:
UNWATCH
这在客户端代码里也很常见,尤其是分支逻辑较多时,可以避免让连接一直带着监视状态。
十二、Redis 事务 + WATCH 的典型应用场景
1. 条件更新
例如只有当前状态还是 pending 时,才允许改为 paid。
思路:
WATCH order:1001:status- 读取状态
- 如果还是
pending,进入事务设置为paid - 否则放弃
命令示意:
WATCH order:1001:status
GET order:1001:status
MULTI
SET order:1001:status paid
EXEC
2. 基于当前值计算新值
例如“余额扣减”“版本号递增”“配额减少”等场景,常常都需要:
- 先读取当前值
- 再根据业务规则决定写回内容
3. 防止并发覆盖
比如多个客户端同时更新购物车汇总字段,如果没有版本检查,很容易把别人的更新覆盖掉。
十三、客户端伪代码示例:带重试的 WATCH 事务
在真实业务中,WATCH 往往要配合重试逻辑使用。
下面给一个接近 Python 的伪代码示例:
import time
import redis
r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
def deduct_stock(key: str, amount: int, max_retry: int = 5) -> bool:
for _ in range(max_retry):
try:
with r.pipeline() as pipe:
pipe.watch(key)
current = pipe.get(key)
if current is None:
pipe.unwatch()
return False
stock = int(current)
if stock < amount:
pipe.unwatch()
return False
pipe.multi()
pipe.decrby(key, amount)
pipe.execute()
return True
except redis.WatchError:
time.sleep(0.05)
continue
return False
这个示例里有几个关键点:
watch()后先读取数据- 判断业务条件是否满足
multi()后才开始组织事务命令- 如果发生并发冲突,捕获
WatchError并重试
这正是 Redis 乐观锁的常规用法。
十四、事务与 Lua 脚本有什么区别
学习到这里,很多人会问:
既然 Redis 有事务,为什么后面还要学 Lua?
原因是:
1. 事务适合“客户端先读,再决定怎么写”
它依赖客户端参与逻辑判断,适合实现乐观锁流程。
2. Lua 更适合“把判断和写入一起放到服务端执行”
如果你想把“读取、判断、修改”合成一个不可拆分的原子操作,Lua 往往更直接。
例如库存扣减,Lua 可以一次完成:
- 读取库存
- 判断是否足够
- 足够则扣减
- 不足则返回失败
这样就不需要客户端 WATCH + 重试。
所以从工程角度看:
- 事务 + WATCH:适合乐观锁、冲突重试模型
- Lua:适合把业务逻辑压缩成一次原子执行
十五、Redis 事务使用中的几个常见误区
误区 1:以为事务里的命令会立即执行
不会。MULTI 后只是入队,真正执行在 EXEC 时发生。
误区 2:以为事务失败会自动回滚
不会。执行期错误不会让前面成功的命令回退。
误区 3:以为 WATCH 会阻塞其他客户端
不会。WATCH 是乐观锁,不加阻塞锁。
误区 4:以为 WATCH 能解决所有并发问题
也不一定。高冲突场景下,WATCH 可能导致频繁重试,吞吐并不一定理想。这时更适合改用 Lua 脚本。
十六、实践建议
1. 先区分“命令原子”与“业务原子”
INCR、DECR 这种单命令本身已经是原子的,不要为了一个简单计数器强行上事务。
2. 有“先读再写”需求时,再考虑 WATCH
如果你的逻辑依赖当前值,例如“库存足够才扣减”“状态是 pending 才能支付”,那么 WATCH 才有意义。
3. 为 WATCH 失败设计重试上限
乐观锁不是一定成功的。高并发下冲突很正常,因此一定要:
- 控制最大重试次数
- 设置合理的退避时间
- 超限后返回明确失败结果
4. 高冲突热点场景优先考虑 Lua
如果某个 key 被大量并发竞争,WATCH 可能让客户端不停重试。把逻辑收敛到 Lua 脚本里,通常更稳。
5. 不要把 Redis 事务想象成数据库事务替代品
Redis 事务适合做轻量级原子提交与并发校验,不适合承载复杂跨对象强一致业务。
十七、小结
这篇文章的核心内容可以归纳为 4 点:
- Redis 事务通过
MULTI/EXEC实现命令排队与顺序提交 - 它保证事务提交后的串行执行,但不提供数据库式自动回滚
WATCH本质上是乐观锁,用于提交前检查 key 是否被别人改动- 对于“先读再写”的并发场景,事务 + WATCH 很实用;对于高竞争热点场景,Lua 往往更合适
如果你之前把 Redis 事务理解成“简化版 MySQL 事务”,那现在应该把模型修正过来了:
Redis 事务更像“批量顺序提交 + 可选的乐观锁检查”,而不是“完整的 ACID 事务系统”。
理解这一点,后面再学习 Lua 脚本、分布式锁、库存扣减、限流等内容时,思路会清晰很多。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!