ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis 从 0 到 1(二十四):1 万次 GET 从 800ms 降到 9ms——Pipeline 的 Go 压测

Redis 从 0 到 1(二十四):1 万次 GET 从 800ms 降到 9ms——Pipeline 的 Go 压测

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

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

文章目录

  • Redis 从 0 到 1(二十四):1 万次 GET 从 800ms 降到 9ms——Pipeline 的 Go 压测
    • 一、现象:循环里 1 万次 GET,接口 800ms
    • 二、三种批量方式压测对比(收藏点 1)
    • 三、Pipeline 的两个误区(收藏点 2)
    • 四、Go 实操:批量操作模板(收藏点 3)
    • 五、命令清单与排查步骤
    • 六、总结

Redis 从 0 到 1(二十四):1 万次 GET 从 800ms 降到 9ms——Pipeline 的 Go 压测

上一篇《Redis 从 0 到 1(二十三):一个热 key 打到 10 万 QPS——hotkey 与本地缓存的 Go 实现》我们用二级缓存卸掉了单点压力,链接:https://blog.csdn.net/2501_92769340/article/details/167399896 。
本篇优化另一个"看不见的开销":RTT(网络往返时间)。

一、现象:循环里 1 万次 GET,接口 800ms

某个"批量查询"接口:for 循环里逐条GET user:1001、GET user:1002……1 万次。每条命令本身 0.05ms 就执行完了,但接口耗时 800ms——96% 的时间花在网络上。本地 Docker 环境 RTT 约 0.08ms,1 万次就是 800ms。

这就是 RTT 税:每条命令 = 1 次发送 + 1 次等待 + 1 次接收。命令越快,RTT 占比越高。解法有两个:MGET(单条多 key)和 Pipeline(批量打包)。

二、三种批量方式压测对比(收藏点 1)

用第 5 篇的压测思路改造,对比 1 万次读:

方式耗时(本地实测量级)原理
逐条 GET × 10000~800ms1 万次 RTT
MGET 一次~2ms单条命令带 1 万个 key,1 次 RTT
Pipeline 100 条/批~9ms100 次 RTT,客户端服务端都不等单条

MGET 看着碾压,但注意它的限制:key 必须在同一个槽(Cluster 下,第 10 篇 CROSSSLOT);而且 MGET 的参数列表太长也有解析开销。Pipeline 是通用解。

三、Pipeline 的两个误区(收藏点 2)

误区 1:Pipeline 是并行的。不是。Pipeline 只是把多条命令一次性发过去、一次性收回来,Redis 仍按顺序单线程执行(第 5 篇的模型)。它省的是 RTT,不是执行时间。所以 100 条慢命令打包,总耗时还是 100 条之和。

误区 2:Pipeline 是原子的。也不是。打包的 100 条之间,别的客户端的命令可能插进来。要原子性请用 WATCH/MULTI(第 13 篇)或单命令。

还有一个实际约束:大包会抬高所有人的延迟。1 万条打包成一个大包,主线程处理这批的几毫秒里别人都在等——所以生产上按 100~500 条分批(第 5 篇压测里 Pipeline(100) 的 P99 3.5ms 就是这个量级)。

四、Go 实操:批量操作模板(收藏点 3)

packagemainimport("context""fmt""time""github.com/redis/go-redis/v9""redis-0to1/redisx")// BatchGet 分批 Pipeline 读取:通用模板,batchSize 建议 100~500funcBatchGet(ctx context.Context,rdb*redis.Client,keys[]string,batchSizeint)(map[string]string,error){result:=make(map[string]string,len(keys))forstart:=0;start<len(keys);start+=batchSize{end:=start+batchSizeifend>len(keys){end=len(keys)}batch:=keys[start:end]cmds,err:=rdb.Pipelined(ctx,func(pipe redis.Pipeliner)error{for_,k:=rangebatch{pipe.Get(ctx,k)}returnnil})iferr!=nil&&err!=redis.Nil{// redis.Nil 表示部分 key 不存在returnnil,fmt.Errorf("批次 %d-%d 失败: %w",start,end,err)}fori,cmd:=rangecmds{val,err:=cmd.(*redis.StringCmd).Result()iferr==nil{result[batch[i]]=val}}}returnresult,nil}// BatchSet 同理:写操作也可以 Pipeline,但注意失败时的部分成功问题funcBatchSet(ctx context.Context,rdb*redis.Client,kvmap[string]string,ttl time.Duration,batchSizeint)error{keys:=make([]string,0,len(kv))fork:=rangekv{keys=append(keys,k)}forstart:=0;start<len(keys);start+=batchSize{end:=min(start+batchSize,len(keys))_,err:=rdb.Pipelined(ctx,func(pipe redis.Pipeliner)error{for_,k:=rangekeys[start:end]{pipe.SetEx(ctx,k,kv[k],ttl)}returnnil})iferr!=nil{returnfmt.Errorf("批次 %d-%d 部分失败: %w(需对账补写)",start,end,err)}}returnnil}funcmain(){rdb,err:=redisx.NewClient(redisx.Config{Addr:"127.0.0.1:6379",Password:"redis123",PoolSize:20})iferr!=nil{panic(err)}ctx:=context.Background()// 准备 1 万个 keykeys:=make([]string,10000)fori:=rangekeys{keys[i]=fmt.Sprintf("user:%d",100000+i)}rdb.Pipelined(ctx,func(pipe redis.Pipeliner)error{for_,k:=rangekeys{pipe.SetEx(ctx,k,"v",time.Hour)}returnnil})start:=time.Now()m,err:=BatchGet(ctx,rdb,keys,200)iferr!=nil{panic(err)}fmt.Printf("1 万次读取(Pipeline 200/批): %v,命中 %d\n",time.Since(start),len(m))// 典型输出:1 万次读取: 8~12ms,命中 10000 —— 对比逐条的 ~800ms}

写 Pipeline 的注意事项:Pipelined返回时整批已执行,错误检查要逐条看(部分成功是常态);对一致性要求高的批量写,宁可慢也要逐条或走 MULTI。

五、命令清单与排查步骤

命令/指标作用
MGET/MSET同槽批量(Cluster 受限)
Pipeline 分批 100~500通用批量
client-output-buffer-limit防客户端大包把输出缓冲打爆
slowlog + INFO commandstats验证大包期间没有误伤别人

排查"批量接口慢":

  1. 算理论值:条数 × RTT(redis-cli --latency测 RTT)≈ 实际耗时?是 → RTT 税,上 Pipeline;
  2. 上 Pipeline 后还慢 → 看批次大小(太大 P99 高)和池大小(并发批量时连接不够);
  3. 仍慢 → 慢命令问题,回第 21 篇。

六、总结

  1. 快命令的瓶颈常是 RTT 不是执行:1 万次 GET 800ms 里 96% 是网络;
  2. MGET 最快但受同槽限制;Pipeline 是通用解,分批 100~500;
  3. Pipeline 不并行、不原子——它只省 RTT;
  4. 批量写要防部分成功,失败要有对账补写。

下一篇预告:命令快了,连接别拖后腿——下一篇《连接池又打满了——PoolSize 公式、泄漏排查与 PoolStats》,我们把"拍脑袋配池"变成一道公式。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进