Redis pipeline 用了反而更慢:3 类误用与正确的批量姿势

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [Redis pipeline 用了反而更慢:3 类误用与正确的批量姿势](#Redis pipeline 用了反而更慢:3 类误用与正确的批量姿势)
    • [一、先纠正认知:pipeline 省的是 RTT,不是 CPU](#一、先纠正认知:pipeline 省的是 RTT,不是 CPU)
    • [二、误用 1:单包过大,抬高所有人的延迟](#二、误用 1:单包过大,抬高所有人的延迟)
    • [三、误用 2:把 MULTI 事务当 pipeline 用](#三、误用 2:把 MULTI 事务当 pipeline 用)
    • [四、误用 3:在 pipeline 里塞了有返回值依赖的命令](#四、误用 3:在 pipeline 里塞了有返回值依赖的命令)
    • [五、对比实验:单命令 / pipeline / Lua](#五、对比实验:单命令 / pipeline / Lua)
    • [六、什么时候该用 MGET/MSET 而不是 pipeline](#六、什么时候该用 MGET/MSET 而不是 pipeline)
    • 七、总结

Redis pipeline 用了反而更慢:3 类误用与正确的批量姿势

"我把 1000 个 SET 打包进一个 pipeline,QPS 反而掉了。"

一位同事拿着压测报告来找我。直觉上 pipeline 是"批量提速神器",怎么会越用越慢?我看了一眼他的代码,问题很明显------他把 pipeline 当成了"并行批量",而它其实是"批量省 RTT"。

这篇讲清 pipeline 到底省什么、3 类典型误用、以及和 Lua / MGET 的正确选型。

一、先纠正认知:pipeline 省的是 RTT,不是 CPU

很多人以为 pipeline 是"把命令并行发出去"。不是 。Redis 是单线程执行命令的,pipeline 只是把多条命令一次性发过去、一次性收回来:

复制代码
无 pipeline:  SEND → wait → RECV → SEND → wait → RECV → ...   (N 次 RTT)
有 pipeline:  SEND×N → wait → RECV×N                          (1 次 RTT)

关键推论:

  • pipeline 对慢命令无效------如果单条命令本身要 10ms,1000 条打包还是 10 秒(省的是网络,不是执行);
  • pipeline 对网络 RTT 大的场景收益最大(跨机房、云环境);
  • pipeline 不保证原子性------中间的坑见第三节。

二、误用 1:单包过大,抬高所有人的延迟

Redis 单线程处理一个巨大的 pipeline 包时,这整批命令会连续执行,期间其他所有客户端的请求都在排队。

bash 复制代码
# 危险:10000 条打包成一个
for i in range(10000): pipe.set(f"k{i}", "v")
pipe.execute()     # 这一批执行期间,别人全在等

判据 :压测时 P99 飙升,但 QPS 没提升多少;slowlog 里出现执行时间很长的"命令"(其实是整批)。用 redis-cli --latency 观察延迟毛刺。

正确姿势:分批,每批 100~500 条:

go 复制代码
func BatchSet(ctx context.Context, rdb *redis.Client, kv map[string]string, batchSize int) error {
	keys := make([]string, 0, len(kv))
	for k := range kv {
		keys = append(keys, k)
	}
	for start := 0; start < len(keys); start += batchSize {
		end := start + batchSize
		if end > len(keys) {
			end = len(keys)
		}
		_, err := rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
			for _, k := range keys[start:end] {
				pipe.Set(ctx, k, kv[k], 0)
			}
			return nil
		})
		if err != nil {
			return fmt.Errorf("批次 %d-%d 失败: %w", start, end, err)
		}
	}
	return nil
}

批量大小怎么选:100~500 是经验值。太小省不了 RTT,太大阻塞别人。可以用压测确定自己的甜点区。

三、误用 2:把 MULTI 事务当 pipeline 用

这是个概念混淆。MULTI/EXEC 是事务 (保证这批命令之间没有其他客户端插入),PIPELINE 是网络优化(不保证隔离)。

python 复制代码
# ❌ 错误:以为 MULTI 就是 pipeline
pipe = r.pipeline(transaction=True)   # 这其实开了 MULTI/EXEC
pipe.set("a", 1)
pipe.set("b", 2)
pipe.execute()

用 MULTI 包一大批命令会导致:

  1. 命令全部排队到 EXEC 才执行,中间别的请求完全被挡住(单线程下影响更大);
  2. MULTI 里的命令不能依赖前一条的返回值(所有命令在 EXEC 前只是入队);
  3. 没有回滚(Redis 事务不支持回滚,见另一篇讲 MULTI 的文章)。

正确选择:

需求 用什么
只是批量提速,不需要原子 Pipelined(不包 MULTI)
需要"这批之间无他人插入" TxPipelined(包 MULTI/EXEC)
需要读后写(检查再修改) WATCH + MULTI 或 Lua

四、误用 3:在 pipeline 里塞了有返回值依赖的命令

pipeline 的特性是"命令一起发",所以你没法在发送前知道前一条的结果:

go 复制代码
// ❌ 错误:想先 GET 再根据结果 SET
_, err := rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
    val := pipe.Get(ctx, "counter")   // 这里拿到的是 *StringCmd,还没执行!
    n, _ := val.Int()                  // 拿到的永远是 0(尚未执行)
    pipe.Set(ctx, "counter", n+1, 0)   // 这里 n=0,逻辑错误
    return nil
})

正确姿势 :pipeline 只用于互相独立 的命令;有依赖关系的用 Lua 脚本(服务端原子执行):

lua 复制代码
-- incr_if_less.lua:读-判断-写,三步原子
local cur = tonumber(redis.call('GET', KEYS[1]) or '0')
if cur < tonumber(ARGV[1]) then
  redis.call('SET', KEYS[1], cur + 1)
  return 1
end
return 0
go 复制代码
var incrIfLess = redis.NewScript(`
local cur = tonumber(redis.call('GET', KEYS[1]) or '0')
if cur < tonumber(ARGV[1]) then
  redis.call('SET', KEYS[1], cur + 1)
  return 1
end
return 0
`)

n, err := incrIfLess.Run(ctx, rdb, []string{"counter"}, 100).Int()

五、对比实验:单命令 / pipeline / Lua

用一个本地压测,对比同一批操作的吞吐(1 万次写):

go 复制代码
// bench_test.go
func BenchmarkBatch(b *testing.B) {
	ctx := context.Background()
	rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})

	b.Run("单命令", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			rdb.Set(ctx, fmt.Sprintf("k%d", i), "v", 0)
		}
	})
	b.Run("Pipeline-100", func(b *testing.B) {
		for i := 0; i < b.N; i += 100 {
			rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
				for j := 0; j < 100; j++ {
					pipe.Set(ctx, fmt.Sprintf("k%d", i+j), "v", 0)
				}
				return nil
			})
		}
	})
	b.Run("Pipeline-1000", func(b *testing.B) {
		for i := 0; i < b.N; i += 1000 {
			rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
				for j := 0; j < 1000; j++ {
					pipe.Set(ctx, fmt.Sprintf("k%d", i+j), "v", 0)
				}
				return nil
			})
		}
	})
}

典型结果量级(本地 Docker,RTT ≈ 0.08ms):

方式 1 万次写耗时 P99 说明
逐条 SET ~800ms 低 1 万次 RTT
Pipeline-100 ~12ms 低 100 批,延迟平稳
Pipeline-1000 ~10ms 较高 更快但毛刺大
Lua(1 脚本 1 万次) ~5ms 低 服务端原子执行

现象 / 已排除 / 未证实:

  • 现象:pipeline 大幅降低总耗时(省 RTT),但批次越大 P99 越高;
  • 已排除:"pipeline 是并行的"(Redis 单线程,总执行时间不变);
  • 未证实:批次大小与 P99 的精确关系 ------ 需要结合具体 RTT 和命令耗时实测。

六、什么时候该用 MGET/MSET 而不是 pipeline

如果只是"批量读写同一种 key",MSET/MGET 是单条命令,天然只有 1 次 RTT,比 pipeline 更优:

go 复制代码
// 优先选 MGET
vals, err := rdb.MGet(ctx, "k1", "k2", "k3").Result()

限制 :MGET 的 key 必须在同一个槽 (Cluster 模式下,见 CROSSSLOT 错误),而 pipeline 可以跨槽。

场景 选
同槽批量读写 MSET/MGET(最优)
跨槽批量 Pipelined 分批
有依赖的读后写 Lua
需要隔离 TxPipelined(MULTI)

七、总结

  1. pipeline 省的是 RTT,不是 CPU ------ 慢命令打包还是慢;
  2. 3 类误用:单包过大抬高全员延迟 、把 MULTI 当 pipeline 、塞入有返回值依赖的命令;
  3. 正确姿势:分批 100~500 条 ;有依赖用 Lua;需要隔离用 MULTI;
  4. 同槽批量首选 MSET/MGET,pipeline 是跨槽通用解;
  5. 别指望 pipeline 提速,要测就测 P99,不只看总耗时。

留个问题给你:你们批量写入用的是 pipeline 还是 Lua 脚本?批次大小是怎么定的------拍脑袋还是压测出来的?

相关推荐
Tsuki_tl1 小时前
【总结】Redis五大数据类型应用场景及基本命令
数据库·redis·后端·缓存·缓存中间件·redis命令·redis 五大数据类型
代码丰16 小时前
大 JSON 放进 Redis 之后,如何用 Stream 解耦耗时任务?
redis·后端·系统架构
枫子有风19 小时前
卡码网盘项目深挖
java·redis·jwt·es·pgvector
福大大架构师每日一题20 小时前
redis 8.10.3正式发布:聚焦集群总线超大消息传播场景的安全修复,8.10.3 Latest更新解读
数据库·redis·安全
江屿风21 小时前
【Plain Language Large Model】【从麦当劳点餐到Redis接入:MCP如何让AI工具集成变简单】流食般投喂
人工智能·redis·笔记·语言模型·llm·mcp
码智社1 天前
Redis 哨兵模式详解:从原理到生产实践
java·redis
努力努力再努力wz1 天前
【Lua入门系列】从 C++ 迭代器到 Lua 迭代器:一文深入理解 Lua 有状态以及无状态迭代器与泛型for、闭包、ipairs、pairs 和 next
开发语言·c++·lua
与数据交流的路上1 天前
一次 redis 幽灵内存的分析
数据库·redis·缓存
夕除2 天前
redis--模型
redis