本次导航
MULTI/EXEC/DISCARD:先排队,再一口气执行- 两种失败长得不像:排队阶段语法错 vs 执行阶段类型错
- 官方为啥不做回滚
- Pipeline:省的是往返次数,不是原子性
- 一张表拆开:Pipeline vs 事务 vs Lua
WATCH:乐观锁,被别人改过就整单作废
发车前提醒:上一篇 Lua 已经能做带判断的原子操作。这篇解决的是另外两件事------
- 没有分支,只是想少跑几次网络 → Pipeline
- 没有脚本,只想一串命令连续执行、别被插队 → MULTI / EXEC
- 读了再写,但判断放在客户端 → WATCH
这仨经常被造成"批量操作"。先拆开,后面才不会用错。
一、先把三件事拆开
| 你真正想要的 | 该用谁 | 一句话 |
|---|---|---|
| 少几次网络往返 | Pipeline | 客户端把命令攒一起发出去 |
| 一串命令执行时别人插不进来 | MULTI / EXEC |
Redis 在 EXEC 时连续跑完队列 |
| 根据读到的值决定写不写 | Lua / Functions | 服务端能写 if/else |
| 读了再写,冲突就重试 | WATCH + 事务 |
客户端乐观锁 |
最常见的误会有两个:
- 以为 Pipeline 是原子的:不是。别的客户端可以插在你那串命令中间。
- 以为 Redis 事务能像 MySQL 那样
ROLLBACK:不能。一条执行失败,旁边那条照样提交。
上一篇 Lua 已经演示过:写了一半再报错,前面的写不会撤销。事务是同一套脾气,只是不会在服务端做判断。
二、MULTI / EXEC:先排队,再一口气跑
2.1 基本用法
bash
docker exec -it redis-demo redis-cli
bash
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET user:1001:name zhangsan
QUEUED
127.0.0.1:6379> INCR user:1001:login
QUEUED
127.0.0.1:6379> GET user:1001:name
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (integer) 1
3) "zhangsan"
流程就三步:
MULTI进入事务状态,Redis 回OK- 后面每条命令都不立刻执行,只回
QUEUED,进队列 EXEC按顺序把队列跑完,一次性把结果数组给你
EXEC 执行期间,别的客户端的命令插不进来。这就是事务提供的原子性:连续执行,不穿插。
临时不想跑了:
bash
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET tmp:x 1
QUEUED
127.0.0.1:6379> INCR tmp:x
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> GET tmp:x
(nil)
DISCARD 扔掉队列,连接回到普通状态。tmp:x 从未被写入。
2.2 排队时读不到结果
这是事务和 Lua 的分水岭。
GET 写在 MULTI 里,返回值要等到 EXEC 才给你。你没法在同一段事务里说:先取出库存,再根据数字决定扣不扣。
bash
MULTI
GET stock:sku_1001 # 只是 QUEUED,此刻你拿不到 "10"
DECRBY stock:sku_1001 1
EXEC
客户端在 EXEC 之前两眼一抹黑。
事务只适合事先就确定要发哪些命令的场景。
三、两种失败,别混为一谈
Redis 事务里的错误分两拨,处理方式完全不同。
3.1 排队阶段就写错了:整单作废
参数个数不对、命令名不认识,Redis 在入队时就报错。之后 EXEC 会直接放弃整段事务:
bash
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET ok:key 1
QUEUED
127.0.0.1:6379> INCR
(error) ERR wrong number of arguments for 'incr' command
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> GET ok:key
(nil)
SET ok:key 1 虽然已经 QUEUED,但因为后面那条 INCR 语法不成立,整单丢弃,ok:key 不会出现。
3.2 执行阶段类型不对:旁边的命令照跑
命令本身合法,只是对着错误的类型动手------比如对字符串做 INCR。这种错发生在 EXEC 里面,只影响这一条,队列里其他命令继续:
bash
127.0.0.1:6379> SET user:1001:name zhangsan
OK
127.0.0.1:6379> SET counter 0
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INCR user:1001:name
QUEUED
127.0.0.1:6379> INCR counter
QUEUED
127.0.0.1:6379> EXEC
1) (error) ERR value is not an integer or out of range
2) (integer) 1
127.0.0.1:6379> GET counter
"1"
127.0.0.1:6379> GET user:1001:name
"zhangsan"
INCR name 失败了,INCR counter 还是成功了,没有自动回滚。
四、官方为啥不做回滚
Redis 文档说得很直白:命令失败,通常是因为语法写错 ,或者对着错误的数据类型操作。这是程序 bug,应该在开发时修掉,而不是运行时靠回滚兜底。
对照 MySQL 你可能不习惯:磁盘满了、约束冲突了,事务可以 ROLLBACK。
Redis 把这些排除在事务模型之外------内存数据库,命令要么能执行,要么是你调用姿势不对。
所以 Redis 的事务保证的是:
EXEC期间命令连续执行,不被其他客户端打断- 入队时发现语法错误,整单不执行
它不保证:
- 某条命令执行失败后,把已经成功的命令撤回去
- 像 Lua 那样根据中间结果做分支
接受这两条,事务才不会被当成"小 MySQL"来用。
五、事务里几个容易忽略的坑
坑 1:阻塞命令不会真的阻塞
BRPOP、BLPOP 在事务里被执行时,如果列表是空的,不会傻等,直接当没取到:
bash
127.0.0.1:6379> DEL task_queue
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> BRPOP task_queue 30
QUEUED
127.0.0.1:6379> EXEC
1) (nil)
EXEC 立刻返回,不会卡 30 秒。要阻塞读,把 BRPOP 放在事务外面,可参考 Redis 核心数据结构(二)------List 与消息队列 这篇文章中介绍的的 List 队列。
坑 2:MULTI 到 EXEC 之间,别人照样能改数据
事务的"不插队"只发生在 EXEC 那一小段 。MULTI 之后、EXEC 之前,别的客户端完全可以改你盯着的 Key。
这个时候 WATCH 就派上用场了,下面会专门说它,不加 WATCH,你在客户端算好的值,提交时可能已经过期了。
坑 3:redis-cli 里一条条敲,并不省网络
交互式敲 MULTI、SET、INCR、EXEC,每一行仍然是一次往返,只是服务端先记下再跑。
真正少 RTT 的做法,是客户端把 MULTI + 队列 + EXEC 用 Pipeline 一次刷出去。
Jedis、Lettuce、go-redis 里的事务 API,底层都是这么干的。
六、Pipeline:省的是网络,不是互斥
Pipeline 不是一条 Redis 命令,是客户端策略:把多条命令写进缓冲区,一次发给 Redis,再一次把回复收齐。
不 Pipeline 时:

Pipeline 时:

命令还是一条条执行,只是路上不再来回握手。
延迟 1ms 的机房里,1000 条命令从约 1 秒变成几毫秒,这才是 Pipeline 存在的理由。
6.1 redis-cli 体验
--pipe 专门吃批量协议。把命令从标准输入喂进去即可:
bash
printf "SET user:1002:name lisi\r\nSET user:1002:city beijing\r\nINCR user:1002:login\r\n" | docker exec -i redis-demo redis-cli --pipe
成功会长这样:
sql
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 3
Windows PowerShell 下可以这样写:
powershell
"SET user:1002:name lisi`r`nSET user:1002:city beijing`r`nINCR user:1002:login`r`n" | docker exec -i redis-demo redis-cli --pipe

6.2 业务代码里长什么样
以 Jedis 为例,核心就是 pipelined() + sync():
java
try (Jedis jedis = jedisPool.getResource()) {
Pipeline pipeline = jedis.pipelined();
pipeline.set("user:1002:name", "lisi");
pipeline.set("user:1002:city", "beijing");
pipeline.incr("user:1002:login");
List<Object> replies = pipeline.syncAndReturnAll();
// replies: OK, OK, 1
}
Lettuce、Redisson 都有对应的 pipeline / batch API,思路一样:攒命令、一次 flush、再读回复。
6.3 Pipeline 不是事务
Pipeline 执行过程中,别的客户端可以插进来。
你发了 DECRBY stock:sku_1001 1 和 INCR sold:sku_1001,中间完全可能被别人的 GET 甚至另一次扣减打断。
两条命令各自原子,这一对动作不原子。
所以:
- 单纯预热缓存、批量
SET、批量HGET→ Pipeline 就够了 - 必须连续执行、不允许中间插入 → 再套
MULTI/EXEC - 必须带着判断 → Lua / Functions
七、Pipeline vs MULTI / EXEC vs Lua / Functions 如何抉择
| Pipeline | MULTI / EXEC | Lua / Functions | |
|---|---|---|---|
| 省网络 | 是 | 本身不省;配合 Pipeline 才省 | 是(一次调用) |
EXEC/脚本期间别人插不进来 |
否 | 是 | 是 |
| 能根据中间结果分支 | 否 | 否 | 是 |
| 某条失败会回滚 | 否 | 否 | 否 |
| 实现位置 | 客户端 | 服务端队列 | 服务端脚本 |
| 典型场景 | 批量读写、预热 | 事先确定的一组写 | 解锁、限流、扣库存 |
个人建议:
- 批量
MGET搞不定的复杂读取、批量写入 → Pipeline - 两三个已经定死的写,只要连续执行 →
MULTI/EXEC - check-then-act(先看再改)→ 继续用脚本,别指望事务能写 if
八、WATCH:把乐观锁补上
MULTI 不管你入队前 Key 有没有被改过,要盯着某个 Key,用 WATCH。
语义很简单:WATCH 之后到 EXEC 之前,只要有人改了被监视的 Key,EXEC 就返回空,整单不执行。没人改,照常提交。
8.1 没有冲突
bash
127.0.0.1:6379> SET stock:sku_1001 10
OK
127.0.0.1:6379> WATCH stock:sku_1001
OK
127.0.0.1:6379> GET stock:sku_1001
"10"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY stock:sku_1001 1
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 9
GET 在 MULTI 外面 ,客户端先看到 10,再决定扣 1。
WATCH 保证从看到到提交之间,这个 Key 没被别人动过。
8.2 有冲突,整单作废
同一个连接里先 WATCH,再在事务外把 Key 改掉,模拟"被人抢先写了":
bash
127.0.0.1:6379> SET stock:sku_1001 10
OK
127.0.0.1:6379> WATCH stock:sku_1001
OK
127.0.0.1:6379> GET stock:sku_1001
"10"
127.0.0.1:6379> SET stock:sku_1001 8
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY stock:sku_1001 1
QUEUED
127.0.0.1:6379> EXEC
(nil)
127.0.0.1:6379> GET stock:sku_1001
"8"
EXEC 返回 (nil),DECRBY 没发生,库存停在别人写入的 8,客户端看到 nil,应该重新 WATCH + GET,再决定扣不扣。
这就是乐观锁的重试循环。
8.3 UNWATCH
看走眼了,不想盯了:
bash
UNWATCH
EXEC 和 DISCARD 也会自动取消当前连接上的监视,不用每次手动清。
8.4 和 Lua 扣库存怎么选
读、判断、扣减都在 Redis 里,一次完成,没有重试。
WATCH 版本:判断在客户端,冲突就 EXEC 失败,应用自己循环。并发高的时候重试会打成一团。
扣库存、解锁、限流这类 check-then-act,优先 Lua。
WATCH 适合逻辑必须放在客户端、冲突又不算频繁的场景,比如改一个很少碰撞的配置对象。
九、实操演练:同一笔库存,三种写法走一遍
照例先起一个 Redis:
bashdocker run -d --name redis-demo -p 6379:6379 redis:latest docker exec -it redis-demo redis-cli
实操 1:事务连续写,看 QUEUED 和 EXEC
先敲到这里,先别 EXEC:
bash
127.0.0.1:6379> MULTI
127.0.0.1:6379> HSET user:1001 name zhangsan city beijing
127.0.0.1:6379> INCR user:1001:login
另开一个 redis-cli 窗口执行 HGETALL user:1001,这时候新字段还没落地------命令在排队。回到第一个窗口敲 EXEC,再 HGETALL,数据才出现。
实操 2:造一个"失败但不回滚"
bash
127.0.0.1:6379> SET user:1001:name zhangsan
127.0.0.1:6379> SET counter 0
127.0.0.1:6379> MULTI
127.0.0.1:6379> INCR user:1001:name
127.0.0.1:6379> INCR counter
127.0.0.1:6379> EXEC
127.0.0.1:6379> GET counter
counter 会是 1。记住这个结果:事务失败 ≠ 全部撤销。
实操 3:WATCH 冲突
开两个 redis-cli 窗口,连同一台 Redis。
窗口 A:
bash
SET stock:sku_1001 10
WATCH stock:sku_1001
GET stock:sku_1001
MULTI
DECRBY stock:sku_1001 1
先不要 EXEC。切到窗口 B:
bash
SET stock:sku_1001 8
回窗口 A 敲 EXEC,应该看到 (nil),再 GET stock:sku_1001,值是 8。
这就是乐观锁被打断的现场。重试的话:再 WATCH、再 GET、再决定扣不扣。
实操 4:Pipeline 批量写入
bash
printf "SET cache:item:1 a\r\nSET cache:item:2 b\r\nSET cache:item:3 c\r\n" | docker exec -i redis-demo redis-cli --pipe
然后 MGET cache:item:1 cache:item:2 cache:item:3,三条都在。对比一下在 redis-cli 里手动敲三条 SET 的手感:Pipeline 是给程序用的,不是给手敲用的。
十、小结
- Pipeline:客户端打包发送,砍 RTT。不保证连续执行不被插入
- MULTI / EXEC :
EXEC时连续跑完队列。不能按中间结果分支,失败默认不回滚 - 入队语法错 :
EXECABORT,整单丢弃;执行期类型错:只炸那一条,旁边照提交 - WATCH :监视 Key,被改过则
EXEC返回 nil,客户端负责重试 - check-then-act 继续用 Lua;事先定死的一组写用事务;纯批量用 Pipeline
现在上手试试 :用 WATCH 写一段"库存 >= 购买数才扣减"的客户端伪代码,冲突就重试,最多 3 次。然后对照上一篇的 dec_stock 函数,看哪种在高并发下更省事。
下篇预告:Redis 5.0 起的 Streams 才是正经的消息队列:有 ID、有消费者组、有 XACK。我们下一次盘它。
本次导航结束,欢迎 关注、点赞、转发。