我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [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 包一大批命令会导致:
- 命令全部排队到 EXEC 才执行,中间别的请求完全被挡住(单线程下影响更大);
- MULTI 里的命令不能依赖前一条的返回值(所有命令在 EXEC 前只是入队);
- 没有回滚(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) |
七、总结
- pipeline 省的是 RTT,不是 CPU ------ 慢命令打包还是慢;
- 3 类误用:单包过大抬高全员延迟 、把 MULTI 当 pipeline 、塞入有返回值依赖的命令;
- 正确姿势:分批 100~500 条 ;有依赖用 Lua;需要隔离用 MULTI;
- 同槽批量首选
MSET/MGET,pipeline 是跨槽通用解; - 别指望 pipeline 提速,要测就测 P99,不只看总耗时。
留个问题给你:你们批量写入用的是 pipeline 还是 Lua 脚本?批次大小是怎么定的------拍脑袋还是压测出来的?