Redis事务的核心是MULTI和EXEC这对命令,它允许开发者把多个命令打包成一个整体依次执行,执行期间不会被其他客户端的命令插入。不过Redis事务与MySQL等关系型数据库的事务存在本质区别:它不支持回滚,入队阶段出错的命令不会阻止其他命令执行。下面从基本用法、WATCH乐观锁、与Lua脚本的对比等角度,系统讲解Redis事务的正确使用方式。

一、MULTI和EXEC的基本用法
Redis事务的执行分为三个阶段:首先使用MULTI命令开启事务,随后客户端发送的命令不会立即执行,而是返回QUEUED表示已加入队列;最后调用EXEC命令,Redis会按顺序执行队列中的所有命令,并把每条命令的执行结果按顺序返回。
来看一个完整的示例,演示一次性完成设置用户余额和记录日志两个操作:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379(TX)> SET user:1001:balance 500 QUEUED 127.0.0.1:6379(TX)> INCRBY user:1001:balance 200 QUEUED 127.0.0.1:6379(TX)> GET user:1001:balance QUEUED 127.0.0.1:6379(TX)> EXEC 1) OK 2) (integer) 700 3) "700"
从输出可以看到,前三条命令都返回QUEUED,说明它们只是被缓存到了队列里,真正执行发生在EXEC那一刻。执行期间即使有其他客户端并发请求,Redis也会保证整个队列连续执行完毕,这正是Redis事务的核心价值—— isolation隔离性。
如果想放弃一个已经开始的事务,可以使用DISCARD命令清空命令队列并退出事务状态。这在动态拼接命令、发现条件不满足时非常有用:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379(TX)> SET stock:1001 50 QUEUED 127.0.0.1:6379(TX)> DISCARD OK 127.0.0.1:6379> GET stock:1001 (nil)
二、Redis事务没有回滚:两类错误的不同表现
很多人以为事务出错会整体回滚,这是Redis事务最大的认知误区。官方文档明确说明:Redis事务不支持回滚,但会区分两种错误类型,它们的处理方式完全不同。
第一种是命令入队错误。如果某条命令本身存在语法问题,或者命令参数数量不对,入队时Redis就会直接报错。这种情况下,整个事务会被拒绝执行,EXEC会返回一个错误信息,队列中所有命令都不会运行,相当于事务整体失败:
127.0.0.1:6379> MULTI OK 127.0.0.1:6379(TX)> SET key1 "hello" QUEUED 127.0.0.1:6379(TX)> WRONGCOMMAND key1 (error) ERR unknown command 'WRONGCOMMAND' 127.0.0.1:6379(TX)> EXEC (error) EXECABORT Transaction discarded because of previous errors.
第二种是运行时错误。命令语法正确并成功入队,但执行时因为类型不符等原因失败,比如对一个字符串类型的key执行LPUSH。这种情况下,只有出错的那条命令返回错误,队列中其他命令照常执行,之前已执行的命令结果也不会被撤销:
127.0.0.1:6379> SET name "redis" OK 127.0.0.1:6379> MULTI OK 127.0.0.1:6379(TX)> SET name "mysql" QUEUED 127.0.0.1:6379(TX)> LPUSH name element QUEUED 127.0.0.1:6379(TX)> EXEC 1) OK 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value 127.0.0.1:6379> GET name "mysql"
可以看到name的值已经被修改成了mysql,后面的LPUSH失败并不影响它。官方的解释是:回滚机制不能避免编程逻辑错误,而保留这种简单的设计能让Redis内部实现更简洁、执行速度更快。因此在使用事务时,务必在代码层面做好类型校验和业务校验。
三、WATCH乐观锁:让事务具备条件执行能力
MULTI和EXEC本身只提供隔离性,不具备条件判断能力。比如扣减库存时,我们需要先检查库存是否充足再执行扣减,但GET和DECRBY之间可能被其他客户端插入修改。这时就需要WATCH命令实现乐观锁。
WATCH的原理是:在MULTI之前对一个或多个key加监视,当EXEC执行时,Redis会检查这些key在事务期间是否被修改过(包括自身修改、过期删除、其他客户端修改)。只要任一被监视的key发生了变化,整个事务直接放弃执行,EXEC返回nil。典型的扣库存流程如下:
import redis
client = redis.Redis(host='127.0.0.1', port=6379)
def deduct_stock(product_id, quantity):
key = f"stock:{product_id}"
while True:
with client.pipeline() as pipe:
try:
# 监视库存key,开启乐观锁
pipe.watch(key)
stock = int(pipe.get(key) or 0)
if stock < quantity:
pipe.unwatch()
return False # 库存不足
# 开启事务并重新获取连接控制权
pipe.multi()
pipe.decrby(key, quantity)
pipe.execute()
return True
except redis.WatchError:
# 库存被其他客户端修改,重试整个流程
continue
print(deduct_stock(1001, 2))上面代码中的watch对应WATCH命令,multi对应MULTI,整个逻辑构成了CAS(Check And Set)模式:先检查、再执行、失败重试。当并发量非常高导致频繁出现WatchError时,说明冲突激烈,可以考虑改用Lua脚本来降低重试开销。
四、Redis事务与Lua脚本的对比选择
Redis从2.6版本开始支持EVAL命令执行Lua脚本,Lua脚本同样具备原子性,而且天然支持条件逻辑,不需要重试。两者在各维度上的差异可以用下面的表格概括:
| 对比维度 | MULTI/EXEC事务 | Lua脚本 |
|---|---|---|
| 原子性保证 | 队列顺序执行,不被打断 | 整个脚本作为单命令执行 |
| 条件逻辑 | 不支持,需配合WATCH重试 | 原生支持if判断 |
| 命令间数据传递 | 无法读取中间结果做判断 | 可读取中间结果灵活处理 |
| 集群兼容性 | 要求所有key在同一slot | 同样要求key在同一slot |
| 调试与维护 | 简单直观 | 需要维护脚本,可先用SCRIPT LOAD缓存 |
同样实现扣库存,Lua脚本写起来更简洁,一个脚本内完成检查和扣减:
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local quantity = tonumber(ARGV[1])
if stock >= quantity then
redis.call('DECRBY', KEYS[1], quantity)
return 1
else
return 0
end调用方式为EVAL script 1 stock:1001 2。选型建议很简单:如果命令之间没有依赖关系,只是批量执行,用MULTI/EXEC即可;如果需要根据中间结果做分支判断,或者高并发场景下WATCH重试成本高,优先使用Lua脚本。需要注意的是,在Redis Cluster环境下,无论事务还是脚本,涉及的所有key必须落在同一个slot上,通常通过hash tag(如key写成stock:{1001})来解决。
五、生产环境使用注意事项
第一,事务队列中命令过多会带来风险。所有命令在EXEC前都缓存在服务端内存中,如果入队了上万个命令,会长时间占用内存,也可能造成EXEC执行时长时间阻塞单线程的Redis。建议单次事务控制在几十条命令以内,大批量操作可拆分成多个事务或改用pipeline配合批量写入。
第二,区分事务和pipeline。pipeline是客户端行为,只是把多条命令一次性发送减少网络往返,不保证原子性;事务是服务端行为,保证队列连续执行。很多客户端库(如Jedis、redis-py)在pipeline中开启multi后两者会结合使用,既减少网络开销又获得事务语义,这是生产中常见的最佳实践。
第三,WATCH之后如果事务被放弃,记得重新执行WATCH再进入下一轮重试,否则新一轮监视是失效的。同时避免监视过多的key,被监视的key越多,冲突概率越大,重试次数也会随之上升。合理设计key粒度,把乐观锁的监视范围控制在业务必需的最小集合内,才能让Redis事务在高并发场景下稳定发挥作用。
Redis事务MULTI EXECRedis命令修改时间:2026-09-01 22:58:42