用错Redis Pipeline反而更慢?原理、踩坑与正确姿势
发布时间:2026/9/16 5:28:50 作者:尧图编辑部 阅读量:1,286

先问你一个问题提到Redis性能优化你脑子里第一个蹦出来的是不是Pipeline大多数人都觉得把一批命令打包批量发送省掉往返网络开销天然就是快的。但我前段时间在真实业务里踩了个大坑一个Pipeline重构上线后接口P99不降反升从原来的8ms涨到30多毫秒压测还时不时冒出一两秒的毛刺。查了两天才发现问题恰恰出在Pipeline本身。这期就把整个排查过程和原理拉开讲清楚看完你至少能避开我趟过的这堆雷。Pipeline到底适合谁简单说当你频繁执行互相独立的Redis命令、网络RTT又占了大部分开销时它是最直接的提速手段。适合刚入门想优化缓存写入的初级开发者也适合线上被慢接口折磨、准备拿Pipeline开刀的负责人。但如果只学打包命令这个操作而不理解背后的单线程模型、缓冲区和网络行为Pipeline就是一把反向利刃。1. 先搞清楚Pipeline到底优化了什么1.1 从一次网络请求说起RTT才是最大的敌人Redis跑得再快也架不住你每条命令都来一次网络往返。假设客户端和Redis服务器在同一机房内网RTT大概是0.1ms到0.5ms这个数字看着不大但如果你要写入500个key比较笨的办法是循环执行SET命令那总共要500次网络往返。即使每次RTT只有0.2ms光网络耗时就是100ms命令本身的执行时间几乎可以忽略不计。如果把执行时间画成图你就会发现整条命令的生命周期里绝大部分时间花在了等待网络上真正在Redis服务器上执行的时间占比很小。Pipeline的思路说白了就是打包把之前一次一条地发改成一次把500条命令全部发给服务器然后服务器执行完一次性把结果返回。这样500条命令只需要一次RTT理想情况下总耗时从500 × RTT 执行时间变成1 × RTT 500 × 执行时间。这个逻辑没有任何问题理论上Pipeline就是把网络成本摊薄了。我在大量场景下实测正常使用Pipeline能把批量操作提速5到10倍。但这里有一个潜台词Pipeline只能解决网络往返问题它并不能缩短Redis服务器的执行时间。只要命令本身执行得慢Pipeline只会把慢放大而不会消除慢。1.2 Pipeline快在哪里又没快在哪里接下来要划重点了。Pipeline的快主要体现在减少了TCP报文交互次数让客户端不用每发一条命令都阻塞等待响应。对Redis服务器来说它依然会按顺序串行执行这些命令。Redis本身是单线程事件循环同一时刻只能处理一条命令。这个特性决定了Pipeline并不能让命令并行执行。你发过来100条命令服务器还是老老实实排着队处理只是处理完一批之后再统一写响应。换句话说Pipeline优化的是网络I/O路径不是CPU执行路径。再说一个更关键的Pipeline和事务MULTI/EXEC完全是两码事。MULTI/EXEC会把命令打包在一个队列里保证执行期间不让其他客户端的命令插进来有原子性语义。Pipeline压根不保证这一点它只是客户端把命令一起发给服务器服务器挨个执行。执行过程中其他客户端的命令完全可以穿插进来。从日常使用的角度我把这两种方式的分工解释清楚对比项普通批量循环命令PipelineMULTI/EXEC事务网络往返N次1次左右1次左右命令隔离执行不隔离不隔离隔离原子性无无有适合场景小批量、低延迟大批量、无依赖命令需要保证一批操作不被打断这个表格值得收藏。很多人用着Pipeline却在潜意识里当它是事务后来出问题就开始甩锅给Redis其实从根上就没有理解这几者的边界。2. 为什么用错Pipeline反而更慢2.1 一个缓存批量写入的实测复盘直接说我当时那个项目吧。业务场景很简单每天晚上有一个定时任务把几千个商品的附加值数据预热到Redis缓存里。原来的写法就是一个循环从数据库分批查数据然后逐条SET。上线一段时间后发现耗时太长了大约40秒才能跑完于是自然的想法就是用Pipeline优化。我当时的实现也很常规把全量数据做一次Pipeline一次性add到批量队列然后sync执行。代码长这样用Java的Redisson举例RBatch batch redissonClient.createBatch(); for (ProductData data : productList) { batch.getMapCache(product:detail) .putAsync(data.getId(), data.getJsonStr()); } BatchResult? result batch.execute();逻辑看着没啥问题但上线之后日志里的耗时就离谱了从之前的40秒变成了最差80秒而且系统里其他读写Redis的接口延迟也跟着上涨。起初我以为是数据量变了检查后发现数据量基本一致。后来定位到根因我一次性把这个批次里的3000多个PUT命令全部塞进了Pipeline。这会导致两个连锁问题。第一Redis服务器要一次性处理3000多条命令期间这个单线程实例全部在处理我的批量任务其他正常业务请求全被堵在后面排队。第二这3000多条命令的响应结果会一次性返回如果期间某条命令执行较慢整体等待时间就会拉到很夸张。有人可能会问之前一条条SET不是更慢吗但之前每条SET之间是有间隙的服务器处理完一条马上就能处理其他客户端的小请求别人感觉不到我的任务占着茅坑。而Pipeline一压进来相当于一口气占住了事件循环不放短则几十毫秒长则上百毫秒对下游那些要求5ms内返回的接口来说这就是一场灾难。2.2 慢命令卡住整个队列谁用谁完蛋第二个坑和慢命令有关。Redis处理Pipeline里的命令时是按顺序执行的假设你这一批Pipeline里有100条命令前99条都是毫秒级返回的GET但最后一条是一个大key的SMEMBERS或者是一个没有命中索引的ZRANGEBYSCORE那么服务器会先卡在这一条慢命令上直到把它执行完才继续处理后面的命令、才会统一写回响应。结果就是不仅是你的Pipeline整体变慢连在这期间发进来的其他客户端请求都会被拖住。Redis是单线程的只要你占住了事件循环所有客户端都在等。我当时排查的时候抓到了现场Pipeline发出的某一条命令在Redis slow log里显示执行耗时180ms。查出来是一条对巨大ZSET做ZRANGEBYSCORE的统计命令排在批量预热任务靠后的位置。它在Pipeline里执行的时候把整条链路的耗时都拉爆了。后来把这类的慢查询命令单独挪出去定时低峰执行才把耗时压下来。2.3 一次性塞太多命令缓冲区先扛不住再往底层挖还有网络缓冲区的因素。一次Pipeline塞太多命令报文体积会非常大。比如每条命令带几百字节的value3000条就是接近1MB的数据包要经过TCP层拆分传输。这个过程中如果网络质量稍微有点波动比如丢包重传那就不光要重传这一个包整个TCP窗口可能都要调整反而拉长了传输时间。另外Redis服务器端对输入缓冲区和输出缓冲区是有大小限制的。如果Pipeline一次性写入的数据量太大超过了client-output-buffer-limit的软限制或硬限制服务器会直接断开连接。一旦连接断开客户端会收到异常如果又做了重试这就是雪上加霜。实际上我在压测中还遇到过一种情况就是客户端发送了Pipeline但服务器还没来得及处理完所有命令客户端的读超时先触发了。结果就是前端请求已经返回超时但Redis那边命令还在慢慢执行产生了一堆无人认领的响应。这时候如果你因为超时换了个连接重试Redis又开始执行重复的命令数据就会出现双写问题。这些都是把异步变成异步黑洞的常见方式。2.4 我有一次在弱网环境里做的数据对比为了把用错Pipeline反而更慢这个结论坐实我做过一个对比实验。在一个模拟50ms高延迟、1%随机丢包的弱网环境里分别执行三种模式A模式逐条SET每条独立等待响应。B模式把100条SET打包成一个Pipeline。C模式把100条SET拆成10个Pipeline每个包含10条命令依次执行。实验结果很有意思。A模式毫无疑问最慢总耗时大约5秒。B模式也慢到3.4秒比预期的只花一次RTT差了很多。C模式耗时大约1.1秒反而是最快的。原因就是B模式把100条命令打包到一个1MB多的TCP包段里在弱网条件下一旦触发丢包重传整个大包都要重新传还不如拆成多个小包分批发送来得稳定。这让我意识到Pipeline不是越大越好克制才是美德。3. 正确使用Pipeline的四个核心建议3.1 批量大小控制按耗时动态调不按固定值算既然一次性塞太多会出问题那到底应该塞多少条合适很多人喜欢抄网上的固定建议比如100条一批或者300条一批。但我要泼一盆冷水最合适的批量大小取决于你的命令复杂度、value大小、网络延迟和服务器负载不存在一个万能数字。现在说一个我自己经过大量压测定出来的经验也推荐你直接抄同步空闲环境以每批总执行耗时不超过5ms为一个底线。每批命令条数控制在100到300条之间具体看value大小。如果value普遍小于100字节可以往300条靠如果value有几千字节甚至更大建议降到50到100条。总数据包大小尽量控制在512KB以内别去触碰大报文传输的边界。用Java写一个动态分批的工具方法很简单核心是按字节数累计和按命令条数累计双重判断public static T ListListT partitionForPipeline(ListT commands, int maxBatchSize, long maxBatchBytes) { ListListT batches new ArrayList(); ListT currentBatch new ArrayList(); long currentBytes 0; for (T cmd : commands) { long cmdBytes estimateCommandBytes(cmd); if (currentBatch.size() maxBatchSize || currentBytes cmdBytes maxBatchBytes) { batches.add(currentBatch); currentBatch new ArrayList(); currentBytes 0; } currentBatch.add(cmd); currentBytes cmdBytes; } if (!currentBatch.isEmpty()) { batches.add(currentBatch); } return batches; }分批这件事想清楚了后续的问题就解决了一大半。核心心法就是Pipeline的批量上限不是由你能不能塞下更多命令决定的而是由延迟容忍度和系统稳定性决定的。3.2 把慢命令和批量快命令隔离第二个核心建议是Pipeline里只放快命令慢命令单独走自己的通道。所谓快命令就是单条执行时间稳定在亚毫秒级到1ms以内的命令。慢命令包括但不限于KEYS 和 SCAN 的第一次大范围匹配。对大集合执行SMEMBERS、HGETALL、LRANGE 0 -1。对大ZSET做ZREVRANGE、ZRANGEBYSCORE。包含复杂计算逻辑的Lua脚本或SORT命令。如果一个大批量任务里混入了哪怕一条慢命令那一整批Pipeline都会跟着遭殃这就是木桶效应。我的做法是在拆分批量之前先对命令做一个执行成本的预估。如果某些命令涉及集合全量遍历就走单独的低优先级队列在业务低峰期跑不再和普通缓存读写混在同一个Pipeline里。另外还有一个容易被忽略的细节不要在Pipeline里夹杂过大的key查询响应。比如某个key的value有5MB你一个GET把它放进Pipeline光这个响应就能占满输出缓冲区。这同样会把一整批拖垮。3.3 连接池复用与Pipeline的搭配姿势Pipeline和连接池配合起来有一些细节要注意。Pipeline本身不会长期占用连接只是在批量发送和等待响应的时候会暂时占用。如果使用连接池池的最大连接数和等待超时设置要特别小心。我见过一个生产事故是这样发生的业务高峰期几十个线程同时在用连接池执行Pipeline批量任务每个Pipeline又因为包含大量命令导致执行时间偏长结果连接池里的连接全部被占满新的请求进来只能排队等待获取连接。最终的表现不是Redis变慢而是Redis客户端连接池打满应用线程全部阻塞在获取连接上。针对这个问题我当前的方案是这样给批量Pipeline场景单独配置一个连接池不要和普通读写请求共用同一个池。连接池的maxWaitMillis不要设置太大建议500ms以内宁可让个别批量任务失败也不能把线程拖死。使用Jedis时每次Pipeline用完一定确保close连接归还到池子里。用Spring Data Redis的SessionCallback时也要注意finally关闭。连接本身是宝贵资源Pipeline把这个资源占用的时长拉长了如果不对连接池做精细化设计批量优化最终会变成一个新的瓶颈。3.4 Redis Cluster下的Pipeline曲线救国如果你用的是Redis Cluster或者Codis这类的代理模式Pipeline还有一个特殊的坑跨slot问题。Redis Cluster将数据按照CRC16哈希分布在16384个slot上然后分散到不同节点。标准的Pipeline并没有跨节点的路由能力。你在随便一个节点上发送一批key的Pipeline如果这批key落在其他节点上服务器只会返回MOVED错误不会自动帮你转发。常见的解决方案有这么几条封装一层客户端路由把需要操作的key按slot切分分别对对应的节点发送Pipeline。使用支持Cluster Pipeline的客户端比如lettuce的advancedClusterPipeline可以自动处理slot路由。在业务层直接按业务维度分片让同一个业务域的key集中在同一个slot范围天然规避跨节点访问。如果公司有支持Pipeline透传的代理中间件可以用代理层来屏蔽这个复杂性但要确认代理自身的批量并发能力。以下是在Redis Cluster下用Lettuce发送分片Pipeline的示意代码先算出key对应的slot再分组MapInteger, ListString slotToKeys keys.stream() .collect(Collectors.groupingBy(key - slotForKey(key))); slotToKeys.forEach((slot, keyList) - { RedisAdvancedClusterCommandsString, String commands clusterClient.connect().sync(); // 发送只包含同一slot范围内key的pipeline });我个人的经验是如果你的架构已经上了Redis Cluster尽量别自己造路由轮子优先选lettuce这种原生支持Cluster的客户端否则你会被各种MOVED重定向折腾到怀疑人生。4. 如何定位和排查Pipeline反而更慢的问题4.1 先量网络RTT再量命令执行时间如果你也遇到了用了Pipeline反而变慢的现象第一步不是去改代码而是先量化两个基础指标网络RTT和命令执行时间。网络RTT的测量可以用redis-cli自带的延迟检测工具或者更简单的办法用ping命令看内网延迟。如果你的应用和Redis不在同一个机房中间跨了地域网络RTT可能高达几十毫秒。这种情况下Pipeline的效果反而很明显因为节省的是几十毫秒一次的网络往返。但如果你的应用和Redis在同一个内网RTT本身就小于0.5msPipeline的优势窗口就被压缩了。此时如果批量里命令本身执行慢Pipeline不仅没有任何帮助还会因为一次性占住事件循环带来副作用。命令执行时间可以直接看Redis的慢日志。将slowlog-log-slower-than设置为10ms甚至5ms把阈值降到合理区间然后观察Pipeline执行期间有没有命令上慢日志。CONFIG SET slowlog-log-slower-than 5000 CONFIG SET slowlog-max-len 1024 SLOWLOG GET 50如果慢日志里有频繁出现的命令比如大集合遍历、大key读取那问题的根源就清晰了Pipeline被慢命令拖死了。4.2 客户端侧统计排队时间与执行时间分开排查Pipeline慢的时候必须把时间拆成两块一部分是命令在客户端排队和网络传输的时间另一部分是服务端实际执行的时间。很多人拿到一个接口耗时的总值就开始拍脑袋这样定位不了问题。建议在客户端对Pipeline做打点监控。用Spring Data Redis的RedisTemplate可以通过在回调里记录开始时间和结束时间粗略估算long start System.currentTimeMillis(); ListObject results redisTemplate.executePipelined( (RedisCallbackObject) connection - { for (String key : keys) { connection.get(key.getBytes()); } return null; }); long cost System.currentTimeMillis() - start;但这段代码只能看到client端的全量耗时。要拆开客户端和服务端可以同时启动Redis的monitor命令或者抓取Redis的执行命令耗时。把两端的数据放在一起对比基本就能判断耗时到底花在网络上还是在执行上。4.3 七种常见症状速查表我把平时工作中最常见的Pipeline反而慢的场景整理成一张速查表收藏一下基本够用症状可能原因排查方向处理建议Pipeline整体耗时高但单命令都很快一次塞的数据量过大检查批次大小与包体大小拆分批次控制在100-300条其他接口也变慢Redis CPU拉高Pipeline里混入慢命令查看slow log检查大key大集合操作慢命令单独执行与Pipeline隔离偶发超时重试后成功客户端读超时设置过短对比服务端执行耗时与客户端超时时间合理调大读超时同时缩小批量连接池被打满批量任务并发高连接占用久查看连接池活跃数和等待数批量场景单独用连接池Redis命令返回执行成功但客户端报错输出缓冲区超限连接被断开检查client-output-buffer-limit日志减小批量包体分批发送在Cluster上使用Pipeline报MOVED跨slot访问查看异常中的slot信息按slot分组发送或使用支持Cluster的客户端接口慢但Redis命令耗时很低网络传输本身就有问题在弱网条件下做对比测试拆分小包发送避免大包重传这张表的价值在于大部分Pipeline问题都不是代码表面的问题而是批量和执行模型不匹配导致的系统性瓶颈。逐个排查下来基本能一击命中。5. 高频翻车场景与避坑清单5.1 不一定非要Pipeline先看MSET和MGET在写Pipeline之前先问自己一个问题你到底有多少命令是结构完全相同的比如一次性写几千个key直接用MSET多key命令一次调用就搞定连Pipeline都不用。类似场景还包括一次性GET多个key用MGET一次性往集合里添加多个成员用SADD多参。我自己在实际项目里做过对比同样的批量写2000个key场景下MSET单次调用耗时约12msPipeline拆成10批耗时约18ms差距不算大但MSET写起来简单太多、排查问题也更直观。所以我要给出一条明确建议如果能用一条Redis命令表达的事情就别用Pipeline来组合多条命令。不过MSET也有自己的限制比如不支持设置不同的过期时间如果你需要对不同key设置不同TTL那还是得回到Pipeline或者逐条SET。这就需要根据业务灵活做取舍。5.2 部分失败时的重试陷阱刚才提到过Pipeline一旦出现超时你会面临一个比较尴尬的局面你不知道这批命令在服务器上到底执行了多少条。如果盲目重试可能导致部分key被重复写入覆盖了业务上不应该被覆盖的旧值。这个问题的底层原因是Pipeline是异步批量提交客户端和服务端之间的状态并不完全同步。我的建议是给Pipeline的命令加上业务幂等标记比如value里塞一个requestId或者用SETNX带上能够标识批次状态的key。这样即使出现超时重试也最多是多执行一次相同的写入不会造成数据错乱。另外在重试策略上不要整批重试。如果Pipeline因为超时失败正确的姿势是把这一个批次标记为需检查而不是直接重新发送。可以通过先读取部分key的值或者用一个额外的状态key来确认哪些命令已经生效。5.3 Pipeline与Lua脚本、发布订阅的边界还有一个容易被忽视的组合Pipeline里不要嵌入调用Lua脚本的命令尤其是这个脚本本身逻辑比较重的时候。原因很简单Lua脚本在Redis中本身就是原子执行的一旦脚本执行超时Redis会自动终止它并把错误返回给客户端可能打乱整个Pipeline的执行流程。另外不要把PUBLISH订阅消息的命令放进Pipeline。发布订阅本身要求低延迟实时投递如果和批量任务混在一起会让订阅端消息出现异常的延迟抖动订阅端很容易先超时。我在一个实时消息推送模块里试过把PUBLISH放进批量Pipeline结果就是订阅端频繁掉线重连消费者处理顺序也乱了套。如果业务上确实需要往Redis里写数据的同时触发订阅通知正确做法是拆成两个链路数据写入用Pipeline消息推送走独立的发布通道或者用Redis Stream 消费组异步解耦以避免一个慢批量任务拖垮实时链路。最后分享一点个人体会踩过这次坑之后我对Pipeline的认知有了质的改变。以前我觉得Pipeline是Redis性能优化的万能药哪里批量哪里用。现在我的态度是凡是引入Pipeline的地方必须先回答三个问题这批操作是不是有延迟容忍度命令里有没有慢命令数据包大小会不会超过安全线我现在的默认选择是优先用原生的多key命令只有遇到跨命令组合需求时才考虑Pipeline一旦决定用Pipeline一定控制批次、隔离慢命令、单独分配连接池并且把监控打点做好。如果你最近也被Pipeline折腾得够呛别急着删掉重构回循环按照上面的排查思路逐条对照大部分情况都能找到明确原因。最后还是那句话Redis的瓶颈大多数时候不是CPU不是内存而是你对它工作模型的理解。Pipeline是把双刃剑用对了是性能利器用错了就是线上事故说明书。这篇文章如果能在你排查问题时少走两步弯路那就是它最大的价值了。