ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Redis高并发批量查询实战:MGET、Pipeline、Lua与并发连接

Redis高并发批量查询实战:MGET、Pipeline、Lua与并发连接 高并发场景下Redis批量查询这四个字看着简单真正做起来坑比想象中多。我经历过一次线上事故秒杀活动一开接口里循环GET十几个key单次查询只要零点几毫秒串行跑下来RT直接翻了几倍数据库反而被漏下去的请求打爆。后来改成批量方案整个接口的TP99从八百多毫秒降到了四十毫秒以内。这篇文章把我实际用过的四种批量查询技巧完整拆一遍MGET、Pipeline、Lua脚本、并发连接各自原理、适用场景、参数选择和避坑经验都会讲到适合正在做缓存优化、接口性能调优的开发和架构同学参考。1. 为什么高并发场景必须重视批量查询1.1 一次线上事故背后的真相先说那次事故。当时是一个商品详情聚合接口需要同时获取商品基础信息、库存、销量、用户标签等十几个业务数据旧代码是这么写的for (String key : keys) { String value jedis.get(key); // 处理业务逻辑 }单个GET很快本地毫秒级但问题是高并发下每个请求都串行发十几个命令。假设一次RTT是0.5ms10个key就是5ms这还只是Redis层。一旦某一两个key变热或者网络出现抖动命令就会在客户端排队尾延迟急剧上升。峰值流量一来接口超时率飙升Redis连接池被打满请求开始穿透到数据库整个链路雪崩。这不是代码风格问题是系统设计问题。批量查询的本质不是帮你省几条命令而是帮你把“多次网络往返”压缩成“一次”。网络往返是分布式系统最大的隐藏成本它比Redis服务端的执行时间高一个数量级。1.2 批量查询的本质把多次网络往返压缩成一次很多人把RTTRound-Trip Time当成一个恒定值实际上在并发场景下它是动态变化的。客户端发出命令后要经历网络传输、服务端排队、执行、响应返回整个链路。高并发下Redis主线程繁忙命令会在socket缓冲区里排队这种排队延时往往比执行本身还长。我们算一笔账。假设单次RTT为0.2ms执行一次GET需要0.02ms串行查10个key10 × (0.2 0.02) 2.2msMGET批量查10个key0.2 10 × 0.02 0.4ms流量稍微大一点比如每秒10000个请求串行方式光Redis层就产生22秒的累计耗时而批量方式只需要4秒。这差距直接决定了系统能不能撑住峰值。所以批量查询在架构上的真正意义是用更少的网络交互交换更多的数据。这是所有批量方案的共同底层逻辑。至于具体选哪种取决于你还有没有别的诉求——比如可不可以容忍非原子、key是不是落在同一个节点、单次结果会不会太大。1.3 高并发场景对批量查询的硬性要求从架构角度看高并发批量查询有三个硬性要求低延迟。这是首要目标批量方案必须把RT压住否则就没意义。稳定性。高并发下不能因为批量查询引入了新的风险点。比如一次塞了10万个key进PipelineRedis可能会阻塞并发连接数开太大连接池先炸。稳定性比性能更优先。可控的资源消耗。批量查询看似省事实际上是在用吞吐换延迟。单位时间拉出来的数据总量越大网络带宽、客户端内存、Redis内存的压力就越大。你要对每次批量的大小、并发度有明确规划。这三个要求决定了选型方向也是后面四种技巧各自的侧重所在。2. 技巧一MGET——最简单直接的批量读取2.1 MGET底层原理与复杂度分析MGET是Redis原生的多键查询命令一次命令传多个keyRedis服务端顺序取出所有value一次性返回。使用方式非常简单MGET user:1001 user:1002 user:1003import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) values r.mget([user:1001, user:1002, user:1003]) print(values)时间复杂度是O(N)N是key的数量但这里的O(N)是服务端的内存查找操作非常快。真正省下来的是网络RTT原来N次命令的网络往返现在变成了1次。MGET为什么在多数场景下赢过循环GET看一下Redis的线程模型就知道了。Redis是单线程处理命令所有请求在一个线程里轮询。循环GET时同一个连接上要处理完一个命令才能发下一个服务端即使很闲客户端也要等RTT。MGET则是把多个键查找合并到一条命令的处理过程中减少了事件循环的调度次数也减少了客户端与服务端之间的交互次数。2.2 实战商品信息批量查询最常见的场景是前端需要批量展示数据。比如商品列表接口传入一批商品ID列表需要批量获取商品信息缓存。// Jedis示例 ListString keys ids.stream() .map(id - product: id) .collect(Collectors.toList()); String[] keyArray keys.toArray(new String[0]); ListString values jedis.mget(keyArray); // 处理返回值 ListProductInfo products new ArrayList(); for (int i 0; i keys.size(); i) { String value values.get(i); if (value ! null) { ProductInfo info JSON.parseObject(value, ProductInfo.class); products.add(info); } }注意MGET返回结果的下标顺序与传入key顺序完全一致这是它最好用的地方。如果某个key不存在返回空值但你需要自己在业务层区分“没有缓存”和“查询失败”一般我会在有数据的地方同时塞一个空值标记防止缓存穿透。还有一个实用技巧MGET没有mget时未命中的数量上限但key数量不建议无限涨。我实测在value平均几百字节、key数量100以内时性能和稳定性最优。超过这个量级MGET返回的数据包会变大单次序列化/反序列化耗时明显增加反而得不偿失。2.3 避坑要点空值、大key与集群CROSSSLOTMGET最经典的坑是Redis集群下的CROSSSLOT错误。Redis Cluster要求一个命令的所有key必须落在同一个哈希槽否则报错。这个问题的解决办法是使用Hash TagMGET {user:1001} {user:1002} {user:1003}将公共部分用大括号包起来Redis会只对大括号内的部分计算哈希槽使这些key强制落在同一个槽位。设计key时要提前考虑哪些数据会一起批量查询尽量让它们能通过Tag对齐。另一个坑是大value。MGET是批量拉数据假设每个value是100KB一次性取50个key就是5MB的响应网络带宽瞬间被打满客户端内存也会飙升。我一般控制一条MGET拉取的数据总大小在1MB以内宁可多分几条也不要一次拉爆。还有key过期时间的坑。MGET对已过期的key返回空值但不会触发任何删除动作。如果业务中有大量同时过期的key批量查询时可能大面积返回空值此时要结合缓存治理策略打散过期时间。3. 技巧二Pipeline——把往返次数彻底打下来3.1 Pipeline到底做了什么Pipeline和MGET的区别经常有人搞混。MGET是一条命令处理多个keyPipeline是多条命令打包一次性发给服务端服务端处理后再一次性返回所有结果。从网络角度理解假设有10条GET命令普通方式是10个来回。Pipeline只是1个来回但服务端仍然执行10次独立的命令只是这些命令在一个请求里到达处理完再一起打包返回。用生活类比就是你去超市买10件商品MGET是你直接列了个购物清单让售货员一次拿齐Pipeline是你自己一趟一趟往收银台搬只是结账时一次性结算。MGET通常更快但Pipeline灵活得多——它不只是GET也不限于是查询任何命令都可以打包比如在批量查询的同时顺带做SET、EXPIRE、INCR。3.2 实战批量读取与更新的混合操作Pipeline真正的价值在于混合操作。举个例子一个用户信息聚合接口既要批量读取用户基本信息又要批量更新用户的最近访问时间戳还要批量获取用户的积分排名。import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) pipe r.pipeline(transactionFalse) # 关闭事务模式纯批量 uids [1001, 1002, 1003, 1004, 1005] for uid in uids: pipe.get(fuser:{uid}) pipe.zscore(click_rank, uid) pipe.set(fuser:{uid}:last_seen, 1700000000, ex600) results pipe.execute() # results是按命令顺序排列的结果列表前5个是get结果中间5个是zscore最后5个是set用Java/Jedis也是一样的套路Jedis jedis jedisPool.getResource(); Pipeline p jedis.pipelined(); for (String uid : uidList) { p.get(user: uid); p.zscore(click_rank, uid); p.set(user: uid :last_seen, 1700000000); } ListObject results p.syncAndReturnAll();注意Java中Pipeline一旦创建所有命令都会进入同一个缓冲区可以后续统一提交。pipeline()返回的Pipeline对象支持链式添加命令最后通过syncAndReturnAll()提交并获取结果。3.3 Pipeline的四个使用禁忌第一不要一条Pipeline塞太多命令。我见过有人把几千条SET打包进一条PipelineRedis主线程要连续执行几千条写命令期间无法处理其他请求大量客户端会同时超时。经验值是单条Pipeline控制在几百个命令内或者根据总耗时控制在几十毫秒内。如果数据量大分成多批提交中间留一点空隙。第二Pipeline不保证原子性。它只是把网络往返合并服务端仍然是逐条执行命令。执行过程中如果某条命令失败不会回滚之前已经执行成功的命令。需要原子性时不要用Pipeline直接看后面的Lua方案。第三返回结果顺序与加入顺序一致但不能依赖某个中间结果。Pipeline期间服务端不返回中间值你没法根据前一个命令的结果决定后面执行什么因为命令都已经发出去了。Pipeline适用于命令之间逻辑完全独立的场景。第四集群模式下Pipeline要分节点执行。Redis Cluster中每个节点是独立的跨节点的Pipeline需要针对每个节点各建一个Pipeline分别提交最后合并结果。这个操作手写很麻烦建议用Lettuce的客户端它内置了集群Pipeline支持。4. 技巧三Lua脚本——批量操作也能保证原子性4.1 为什么需要原子性批量查询很多业务场景不只是“查多个key”而是“检查一批key再决定后续操作”。比如库存扣减需要先读取一批库存key的值判断全部满足条件后再统一扣减。这种读改写场景用MGET或Pipeline都处理不了并发竞争——两个请求同时读取到相同库存各自扣减后覆盖写入数据就乱了。Lua脚本的价值在于在Redis服务端以原子方式执行一段脚本脚本内部可以做任意的读取、判断、写入。整个脚本作为一个整体执行期间其他命令不会被插入。这既解决了原子性问题又把网络交互压缩到一次高并发场景下非常实用。4.2 实战Lua脚本实现“读取条件更新”我用一个典型的抽奖场景举例需要批量检查用户抽奖次数是否已达上限如果未达上限则统一增加抽奖次数并返回剩余次数。Lua脚本local counts {} for i 1, #KEYS do local key KEYS[i] local count redis.call(GET, key) if count and tonumber(count) tonumber(ARGV[1]) then -- 如果任意一个key次数已达上限全部不更新 return {err limit exceeded} end counts[i] count end for i 1, #KEYS do redis.call(INCR, KEYS[i]) redis.call(EXPIRE, KEYS[i], ARGV[2]) end return countsJava端调用String script ...; // 上面那段Lua ListString keys Arrays.asList(lottery:user:1001, lottery:user:1002, lottery:user:1003); ListString args Arrays.asList(3, 86400); Object result jedis.eval(script, keys, args); // 返回所有旧值这段脚本的核心是先检查所有key的当前次数如果任何一个已达上限就返回错误全部满足才执行INCR。整个过程原子执行并发请求不会出现“一个检查通过、另一个同时写入”的竞态。4.3 Lua脚本的踩坑记录第一大坑是在脚本里硬编码key。Redis Cluster要求脚本里的所有key必须通过KEYS参数动态传入并保证它们在同一个哈希槽。如果把key直接写在脚本字符串里部署在集群上一定会出问题。第二大坑是脚本复杂度失控。Redis是单线程执行脚本脚本里面有循环遍历大量数据等于给整个Redis实例上了锁。比如遍历一个几十万成员的集合做复杂计算期间所有其他请求全部阻塞。所以Lua脚本一定要控制数据量和循环次数复杂逻辑宁可拆成多个小脚本分批执行。第三大坑是网络传输浪费。如果脚本很长每次eval传输脚本内容也会消耗网络带宽。可以用SCRIPT LOAD先加载脚本然后用EVALSHA传脚本的SHA值调用SCRIPT LOAD return redis.call(GET, KEYS[1]) # 返回一个SHA值 EVALSHA sha 1 user:1001Java端可以用Jedis的evalsha方法把脚本内容注册一次后续全部复用SHA带宽占用大幅降低。另外建议在测试环境写好脚本后做一次压测重点观察Redis的慢日志确保执行时间在可控范围。5. 技巧四并发连接批量获取——榨干多核性能5.1 什么时候才需要并发连接可能有同学会问前面三种方案已经把网络往返降到1次了为什么还需要并发连接看局限性就明白了。MGET虽然一次查多个key但单命令的数据量有上限一次拉几百个value就开始吃力Pipeline和Lua虽然打包N条命令但服务端毕竟是单线程逐条执行N条命令的总执行时间是固定的。当需要查询的key总量特别大比如一次要取上千个key而且这些key均匀分布在不同节点或不同分片时单条命令的实现不一定最优。这时候可以换个思路既然单路吞吐到顶了就多开几路并发跑。利用连接池建立多个连接每个连接处理一批key并行执行后汇总结果。从整体延迟看把原本串行的N个key的查询分成几个批次并行执行吞吐量可以直接翻几倍。适用场景很鲜明单个key的数据量不大但key数量非常多比如几万个用户ID从缓存批量拉取基础信息或者面对的是Redis集群多个节点天然支持并行。5.2 实战基于连接池与Future的并发查询Java里最直接的方式是用线程池Jedis连接池每个线程拿一个独立连接查询一部分key最后用Future汇总。int batchSize 500; int total keys.size(); ExecutorService pool Executors.newFixedThreadPool(8); ListFutureListString futures new ArrayList(); for (int i 0; i total; i batchSize) { ListString subKeys keys.subList(i, Math.min(i batchSize, total)); futures.add(pool.submit(() - { try (Jedis jedis jedisPool.getResource()) { return jedis.mget(subKeys.toArray(new String[0])); } })); } MapString, String resultMap new HashMap(); for (FutureListString future : futures) { ListString values future.get(5, TimeUnit.SECONDS); // 按批次key顺序组装结果 }用Lettuce异步API更好它是Netty驱动的非阻塞不需要每批任务都占一个线程RedisAsyncCommandsString, String async redisClient.connect().async(); ListRedisFutureString futures keyList.stream() .map(k - async.get(user: k)) .collect(Collectors.toList()); RedisFuture.allOf(futures).get(5, TimeUnit.SECONDS); for (RedisFutureString f : futures) { String value f.get(); // 业务处理 }Lettuce的异步API天然支持高并发批量查询底层是同一个连接复用通过Future机制并发发送命令响应回来后再逐条取结果。连接数不受线程数限制更轻量。5.3 并发度的设定与稳定性设计并发批量查询最容易翻车的是并发度设置。并发太高会导致Redis服务端每秒处理的命令数暴增虽然单个命令快但积少成多Redis主线程仍然可能成为瓶颈。并发太低又发挥不出多路优势。我给一个经验参考值500个key批量大小50并发数10到20之间延迟收益最大。再往上加线程延迟改善有限反而增加线程切换和连接池压力。如果是在集群上并发数可以按节点数分每个节点一个连接各自批量查询总延迟相当于单个节点一次批量的时间。稳定性上的核心原则是并发批量查询一定要有超时控制和熔断机制。用Future.get(timeout)而不是无限等待连接池用完要归还并发任务数量要做信号量限制防止峰值流量时创建几千个线程。注意Jedis连接池的maxTotal要大于等于实际并发数否则线程会等连接延迟更高。6. 四种方案横向对比与选型建议6.1 方案对比速查表维度MGETPipelineLua脚本并发连接网络往返次数1次1次1次多路次每路1次原子性不支持不支持支持不支持适用查询量级中小量建议100以内中量几百命令内中量脚本内循环可控大规模上千key集群支持需要Hash Tag需要按节点拆需要Hash Tag天然支持分布到多节点实现复杂度低中中高高常见风险大key/CROSSSLOT非原子/阻塞脚本执行阻塞连接池/超时/线程爆炸典型场景商品列表批量查批量读写混合库存扣减/抽奖用户画像全量拉取6.2 按场景选型的实战建议我平时选型的判断逻辑可以概括成三句话查一批key且只读用MGET。这是最轻量的方案代码最简单性能也足够好。只有在key数量特别多、value特别大时才需要考虑别的。既要读又要写而且写之间有依赖用Lua。比如先查再扣减这类“读改写”用Pipeline会出并发问题用Lua就顺理成章。写和读相互独立用Pipeline。比如批量设置缓存加批量读取互不依赖Pipeline效率最高。超大批量、多节点均匀分布用并发连接。当单条命令的数据量已经超出合理范围或者面对Redis集群直接用并发连接摊薄延迟。还有一类场景需要特别提醒如果你的批量查询是发生在缓存层面查询的key是业务ID建议在设计阶段就把每个key对应的value压缩小一点。过大的value不仅拖累单次查询也会让所有批量方案失效因为网络带宽成了瓶颈怎么批量都没用。7. 常见问题与排查技巧实录7.1 高并发批量查询六大经典问题这些是我在实际线上环境真刀真枪遇到过的整理成速查表问题现象可能原因解决方案集群执行MGET报CROSSSLOTkey分散在不同哈希槽用Hash Tag强制key同槽或按节点拆分大批量Pipeline导致Redis阻塞单条Pipeline命令过多拆分批次控制在几百条命令内客户端报RedisCommandTimeoutException连接池阻塞/慢命令/网络抖动排查慢日志调整超时与连接池参数批量查询返回大量null缓存穿透/大批key同时过期加空值标记打散过期时间并发批量查询时内存暴涨单批次拉取数据量过大控制每批key数所有结果提前预估容量一个key被大量热点请求命中热key问题被批量放大加本地缓存或对key做副本/打散7.2 一次Pipeline超时排查全过程有一次线上反馈缓存接口偶发超时堆栈里出现了io.lettuce.core.RedisCommandTimeoutException第一反应查Redis慢日志结果并没有明显慢命令。后来才发现问题出在客户端一位同学在管线里塞了将近2000条MGET单次执行确实没慢多少但多个请求同时进来时服务端事件循环被连续大包阻塞形成了排队放大。复现步骤很简单用一个普通的Pipeline接口每次查询1000个key开50个并发线程压测。结果很快看到RT从正常的5ms飙到300ms而Redis server的CPU占用率却不高——瓶颈在网络包大小和事件循环调度。解决方式是分批次提交每500个key一批中间暂停几毫秒。同时给Lettuce设置了合理的超时时间和请求队列大小避免服务器跟不上时客户端无限堆积。改完后接口不再超时吞吐也恢复正常。这次的教训是Pipeline不是无限大批量也有上限任何批量方案都要有上限意识。不是说一条管子里塞得越多越省服务端的处理能力才是天花板。7.3 稳定性清单批量查询上线前对照检查最后给出一份我在每次上线前会过一遍的检查清单直接照着做基本不会踩大坑所有key数量是否控制在合理范围MGET建议100以内Pipeline建议500命令以内value大小是否提前评估是否可能因为大key导致单条响应过大集群环境下是否处理了哈希槽和CROSSSLOT问题并发连接数是否小于连接池上限是否有超时配置Lua脚本是否用了KEYS动态传参循环次数是否可控是否需要为批量查询增加熔断和降级开关我个人在实际操作中的体会是批量查询方案没有绝对优劣只有适不适合当前场景。先用压测验证再小流量上线最后逐步放量这比任何纸上谈兵都靠谱。如果你正在优化这类接口不妨从把循环GET改成MGET开始这一步往往就能解决大半问题。之后再根据数据量、并发形状和原子性需求逐步引入Pipeline、Lua或并发方案。先跑起来再优化永远比设计完美但上不了线更有意义。
返回列表