Redis 事务与 Pipeline:批量操作的正确姿势

本次导航

  • 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 + 事务 客户端乐观锁

最常见的误会有两个:

  1. 以为 Pipeline 是原子的:不是。别的客户端可以插在你那串命令中间。
  2. 以为 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"

流程就三步:

  1. MULTI 进入事务状态,Redis 回 OK
  2. 后面每条命令都不立刻执行,只回 QUEUED,进队列
  3. 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:

bash 复制代码
docker 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。我们下一次盘它。


本次导航结束,欢迎 关注、点赞、转发。

相关推荐
fengkai45452 小时前
十二、Redis -1
运维·数据库·redis
写后端的胖头鱼4 小时前
Redis 的 RDB 和 AOF
java·数据库·redis
禾小西4 小时前
Redis AOF 日志:宕机了,如何避免数据丢失?
java·redis·mybatis
鱼宵5 小时前
Spring AI 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢
人工智能·redis·spring·elasticsearch·springai
for_ever_love__6 小时前
Redis 数据类型全景:五种基础类型怎么用、什么时候用
数据库·redis·数据类型·hash·使用场景·zset·排行榜
yuezhilangniao9 小时前
阿里云 CentOS 7 升级 Redis 到 7.2 实录-centos7安装redis7
redis·阿里云·centos
我不会起名字3229 小时前
Redis 缓存与数据库一致性:先删缓存还是先更新库的 4 种方案
数据库·redis·缓存·一致性·延迟双删
ly768921 小时前
Redis 内存优化与性能调优:编码转换、内存碎片与慢查询的定位链路
redis·性能调优·内存优化·慢查询·编码转换
我不会起名字3221 天前
Redis 入门(三):Spring Boot 整合 Redis,RedisTemplate 实战与序列化乱码
redis·spring·工具类·jedis·lettuce