☰
SpringBoot整合Redis管道实战:批量操作性能提升10倍
2026/10/19 9:37:06 网站建设 项目流程

1. 项目缘起与核心思路拆解

1.1 这个实例到底要解决什么问题

做过后端开发的朋友大概率都遇到过这种场景:一个接口需要从缓存里批量读取几十甚至上百个键值,或者需要在短时间内写入大量数据。如果老老实实写个 for 循环,每次redisTemplate.opsForValue().get(key),你会发现响应时间随着数据量线性增长,几百个 key 下来接口直接奔着几百毫秒甚至秒级去了。问题不在于 Redis 本身慢,而在于每一次操作都是一次完整的网络往返——客户端发命令、等服务端响应、再发下一条。这种模式在批量场景下就是性能杀手。

这个实例的核心目标很明确:用 SpringBoot 整合 Redis 的管道(pipeline)能力,把 N 次网络往返压缩成 1 次,让批量读写缓存的性能提升一个数量级。它适合有一定 SpringBoot 基础、正在被缓存批量操作性能问题困扰的后端开发者,也适合想搞清楚 pipeline 底层原理、避免踩坑的进阶同学。说白了,只要你手上有“批量查缓存”“批量写缓存”的业务,这篇内容就能直接拿去用。

1.2 为什么选 pipeline 而不是其他方案

批量操作 Redis,摆在面前的路其实有好几条,我挨个说说为什么最后落到 pipeline 上。

第一条路是老老实实循环单次操作。优点是代码简单、逻辑清晰,缺点是网络往返次数等于操作次数,性能最差。数据量小的时候无所谓,一旦上到几百条,RT 直接爆炸。

第二条路是用 Redis 的MGET、MSET这类原生命令。这个确实快,一次往返搞定,但它有两个硬伤:一是只能处理同一种数据结构的同类操作,比如 MGET 只能批量取 String,你要是想同时取 String 和 Hash,或者想混合执行读写,它就无能为力了;二是命令本身有参数数量限制,虽然实际业务很少触碰到,但设计上不够灵活。

第三条路就是 pipeline。它的本质是把多条命令打包,客户端一次性发出去,服务端依次执行后把结果一次性返回。关键在于,pipeline 不限制命令类型,你可以把 GET、SET、HGETALL、EXPIRE 任意组合塞进一个管道里,灵活性远超 MGET/MSET。代价是它不像事务那样保证原子性,中间某条命令失败不影响其他命令执行——这一点后面会专门讲怎么处理。

还有一条路是 Lua 脚本。Lua 确实能保证原子性,也能减少往返,但脚本的编写和调试成本高,而且脚本里不能有太复杂的逻辑,可读性差。对于纯粹的批量读写场景,pipeline 是性价比最高的选择。

提示:如果你的场景要求“要么全成功要么全失败”的强原子性,pipeline 不是正确答案,应该考虑 Lua 脚本或 Redis 事务(MULTI/EXEC)。pipeline 追求的是吞吐量,不是原子性。

1.3 整体方案设计

方案本身不复杂,拆开看就三块:一是配置好 RedisTemplate 或者 StringRedisTemplate,二是用executePipelined方法把操作包起来,三是正确处理返回结果和异常。Spring 的RedisTemplate已经内置了 pipeline 支持,通过executePipelined(RedisCallback)或者executePipelined(SessionCallback)就能用,不需要引入额外的依赖。

我倾向于用SessionCallback版本,因为它在管道内部可以直接用operationsForXxx那套 API,写起来跟平时操作 Redis 没区别,学习成本几乎为零。而RedisCallback版本需要自己处理底层字节数组的序列化,代码可读性差不少。当然,如果你对序列化有极致控制需求,RedisCallback更底层更灵活。

序列化这块要单独拎出来说。默认的JdkSerializationRedisSerializer会把 key 和 value 都序列化成带类型信息的二进制,可读性差、体积大。生产环境我一般用StringRedisSerializer处理 key,用GenericJackson2JsonRedisSerializer处理 value,这样在 redis-cli 里能直接看懂数据,排查问题方便太多。这个配置在 pipeline 场景下同样生效,不会因为用了管道就改变序列化行为。

2. 核心细节解析与实操要点

2.1 executePipelined 的两种回调方式怎么选

先看RedisCallback的写法。它接收一个RedisConnection,你得自己调connection.get(key)、connection.set(key, value)这种底层方法,返回的 key 和 value 都是byte[]。这种写法的问题在于,你得手动处理序列化,比如redisTemplate.getKeySerializer().serialize(key),代码又长又容易出错。除非你要做一些非常底层的操作,否则不推荐。

再看SessionCallback的写法。它接收一个RedisOperations,你可以直接operations.opsForValue().get(key),跟平时写业务代码一模一样。Spring 会在管道执行期间把命令缓存起来,等回调执行完统一发送。这种写法可读性高,维护成本低,是我在项目里的默认选择。

List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) throws DataAccessException { for (String key : keys) { operations.opsForValue().get(key); } return null; } });

注意回调的返回值必须是null,因为管道模式下这个返回值没有意义,真正的结果在executePipelined的返回列表里。如果你返回了非 null 值,Spring 会抛异常。这个坑我第一次用的时候就踩了,当时返回了个true,结果直接报错,排查了半天。

2.2 返回结果的顺序与类型处理

executePipelined返回的是一个List<Object>,里面的元素顺序严格对应你往管道里塞命令的顺序。第一条命令的结果在results.get(0),第二条在results.get(1),以此类推。这个顺序保证是可靠的,因为 Redis 单线程执行命令,返回也是按顺序来的。

但类型处理有个坑:返回的元素类型取决于你用的序列化器。如果你用GenericJackson2JsonRedisSerializer,取出来的对象可能是LinkedHashMap而不是你期望的实体类。这时候要么手动转换,要么在序列化器里配置好类型信息。我一般会在GenericJackson2JsonRedisSerializer构造时传入配置了activateDefaultTyping的ObjectMapper,这样反序列化能自动还原成原始类型。

还有一种情况是命令本身返回的不是值,比如SET返回的是状态字符串OK,EXPIRE返回的是布尔值。这些在结果列表里都会如实体现,处理的时候要按命令类型分别对待,不能一股脑当成同一种类型强转。

2.3 管道大小的控制策略

管道不是越大越好,这一点很多人有误解。你把一万条命令塞进一个管道,客户端内存要缓存这一万条命令,服务端也要缓存对应的返回结果,内存压力不小。而且管道执行期间会阻塞 Redis 处理其他客户端请求(虽然单条命令很快,但一万条累积起来也不容忽视)。

我的经验值是单管道控制在 500 到 1000 条命令之间。超过这个量就分批,比如一万条数据分成 10 个管道,每个管道 1000 条。这样既享受了批量带来的性能提升,又不会因为单次管道过大导致内存抖动或阻塞。

分批的代码结构大概是这样:

int batchSize = 500; for (int i = 0; i < keys.size(); i += batchSize) { List<String> batchKeys = keys.subList(i, Math.min(i + batchSize, keys.size())); List<Object> batchResult = redisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) { for (String key : batchKeys) { operations.opsForValue().get(key); } return null; } }); // 处理 batchResult }

具体批次大小要根据你的单条数据大小和 Redis 实例的内存情况调整。数据小可以适当放大,数据大就要缩小。

2.4 异常处理与部分失败

pipeline 最让人头疼的地方就是异常处理。因为命令是批量发送的,如果中间某条命令执行出错,Redis 不会中断整个管道,而是把错误信息作为结果返回。也就是说,你的结果列表里可能既有正常值,也有异常对象。

比如你对一个 String 类型的 key 执行了HGETALL,Redis 会返回一个RedisSystemException或者类似的错误对象。你必须在遍历结果时判断每个元素的类型,遇到异常对象要单独处理,不能直接强转。

for (int i = 0; i < results.size(); i++) { Object result = results.get(i); if (result instanceof Exception) { // 记录日志,处理异常 log.warn("Pipeline command at index {} failed: {}", i, ((Exception) result).getMessage()); continue; } // 正常处理 }

另外,如果整个管道在发送阶段就失败了(比如网络断了),executePipelined会直接抛异常,这时候所有命令都没执行,你需要根据业务决定是重试还是降级。重试要注意幂等性,写操作重试可能导致重复写入。

注意:pipeline 不保证原子性,部分成功部分失败是常态。设计业务逻辑时必须考虑这种中间状态,不能假设管道里的命令要么全成功要么全失败。

3. 实操过程与核心环节实现

3.1 环境准备与依赖配置

先搭环境。SpringBoot 项目里引入 spring-boot-starter-data-redis,版本跟着 SpringBoot 走就行。底层连接池默认用 Lettuce,如果你习惯 Jedis 也可以换,但 Lettuce 是基于 Netty 的异步客户端,在管道场景下表现更好,推荐保留默认。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

application.yml 里配置连接信息,重点是超时时间。管道操作因为一次性发送多条命令,执行时间比单条命令长,超时时间要适当放大,否则容易误报超时。

spring: redis: host: 127.0.0.1 port: 6379 timeout: 5000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2

连接池大小也要注意。管道操作会占用连接较长时间,如果并发高、连接池又小,容易出现连接等待。我一般把 max-active 设成 CPU 核数的 2 到 4 倍,具体看压测结果调整。

3.2 RedisTemplate 的序列化配置

这一步很关键,配置不好后面全是坑。写一个配置类,把 key 和 value 的序列化器都换掉。

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }

GenericJackson2JsonRedisSerializer默认会在 JSON 里写入@class字段来记录类型,反序列化时能自动还原。如果你不想让@class出现在数据里,可以自定义ObjectMapper关掉它,但那样反序列化就得手动指定类型,各有利弊。

3.3 批量读取的完整实现

假设有个场景:根据一批用户 ID 批量查询用户信息,缓存里存的是 User 对象。先定义实体类,注意要有无参构造函数,Jackson 反序列化需要。

public class User implements Serializable { private Long id; private String name; private Integer age; // getter setter 省略 }

然后是批量读取的核心方法:

public List<User> batchGetUsers(List<Long> userIds) { List<String> keys = userIds.stream() .map(id -> "user:" + id) .collect(Collectors.toList()); List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) { for (String key : keys) { operations.opsForValue().get(key); } return null; } }); List<User> users = new ArrayList<>(); for (int i = 0; i < results.size(); i++) { Object result = results.get(i); if (result instanceof User) { users.add((User) result); } else if (result == null) { // 缓存未命中,记录待回源 log.debug("Cache miss for key: {}", keys.get(i)); } else { log.warn("Unexpected result type for key {}: {}", keys.get(i), result.getClass()); } } return users; }

这里有个细节:结果列表的长度和 keys 的长度一定相等,即使某个 key 不存在,对应位置也会有一个null。所以你可以通过索引把结果和 key 一一对应起来,方便处理缓存未命中的情况。

3.4 批量写入的完整实现

批量写入和批量读取结构类似,只是命令换成了 SET。假设要把一批 User 对象写入缓存,同时设置过期时间。

public void batchSetUsers(List<User> users, Duration ttl) { redisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) { for (User user : users) { String key = "user:" + user.getId(); operations.opsForValue().set(key, user, ttl); } return null; } }); }

注意set(key, value, ttl)这个重载方法在管道里同样有效,它会生成SET key value EX seconds这样的命令。如果你用的是老版本 Spring Data Redis,可能没有这个重载,那就得手动调expire,多一条命令而已,管道里无所谓。

写入场景下,返回结果通常是OK字符串或者true,一般不需要逐个检查,除非你要确认每条都写成功了。如果对写入可靠性要求高,可以遍历结果检查是否有异常对象。

3.5 混合命令管道的实现

pipeline 真正的威力在于混合命令。比如一个场景:读取用户信息的同时,更新用户的最后访问时间,还要给某个计数器加一。这三类操作可以塞进同一个管道。

public User getUserAndUpdateAccess(Long userId) { String userKey = "user:" + userId; String accessKey = "user:access:" + userId; String counterKey = "user:visit:count"; List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) { operations.opsForValue().get(userKey); operations.opsForValue().set(accessKey, System.currentTimeMillis()); operations.opsForValue().increment(counterKey); return null; } }); // results.get(0) 是 user 对象 // results.get(1) 是 set 的状态 // results.get(2) 是自增后的计数值 return (User) results.get(0); }

这种混合管道把三次网络往返压缩成一次,在 QPS 高的接口里效果非常明显。我实测过一个接口,原来循环操作 RT 在 80ms 左右,改成管道后降到 12ms,提升接近 7 倍。

3.6 性能对比实测数据

为了让大家有个直观感受,我搭了个测试环境跑了一组数据。测试条件:本地 Redis 单实例,1000 个 String 类型的 key,value 大小约 200 字节,分别用循环单次操作和 pipeline 操作各跑 10 次取平均。

操作方式平均耗时网络往返次数相对提升
循环单次 GET420ms1000基准
Pipeline 单批 1000 条38ms1约 11 倍
Pipeline 分批 500 条45ms2约 9.3 倍
MGET 批量32ms1约 13 倍

从数据看,pipeline 相比循环单次操作提升了 10 倍以上,和 MGET 差距不大。但别忘了 MGET 只能处理 String 类型的批量读,pipeline 能处理任意命令组合,灵活性是它的核心优势。分批 500 条比单批 1000 条略慢一点,但内存占用更平稳,生产环境我更推荐分批。

4. 常见问题与排查技巧实录

4.1 返回结果全是 null 是怎么回事

这是新手最容易遇到的问题。管道执行完,结果列表里全是 null,但数据明明在 Redis 里。排查下来通常是两个原因。

第一个原因是序列化器不匹配。你写入的时候用的是JdkSerializationRedisSerializer,读取的时候换成了GenericJackson2JsonRedisSerializer,反序列化失败就返回 null。解决办法是确保读写用同一套序列化配置,别中途换。

第二个原因是 key 的序列化问题。如果你写入时 key 是 String 类型,但 RedisTemplate 的 keySerializer 是 JDK 序列化,实际存进去的 key 是带二进制前缀的,你用 redis-cli 看到的 key 和程序里拼的 key 对不上。统一用StringRedisSerializer处理 key 就能避免这个问题。

4.2 管道执行报连接超时

管道命令多、数据大的时候,执行时间会变长,如果超过配置的 timeout 就会报连接超时。解决办法有三个:一是调大 timeout,二是减小单批管道大小,三是检查网络延迟。

我遇到过一次,单批 5000 条命令,timeout 设的 2000ms,结果频繁超时。后来把批次降到 500 条,timeout 保持 2000ms,问题消失。所以批次大小和 timeout 要配合着调,不能只调一个。

还有一种情况是连接池耗尽。管道操作占用连接时间长,如果并发请求多,连接池里的连接很快被占满,后续请求就得排队,排队超时也会报错。这时候要调大max-active,或者优化业务逻辑减少管道操作的频率。

4.3 部分命令失败怎么定位

前面说过 pipeline 不保证原子性,部分失败是正常的。定位失败命令的关键是拿到结果列表后逐个检查,把异常对象和对应的命令索引关联起来。

for (int i = 0; i < results.size(); i++) { Object result = results.get(i); if (result instanceof Exception) { log.error("Command index {} failed, key: {}, error: {}", i, keys.get(i), ((Exception) result).getMessage()); } }

日志里把索引、key、错误信息都打出来,排查起来就快了。常见的失败原因包括:对错误的数据类型执行命令(比如对 String 执行 HGETALL)、命令参数格式错误、key 被其他客户端删了等等。

4.4 常见问题速查表

问题现象可能原因排查方向解决办法
结果全为 null序列化器不匹配检查读写序列化配置统一序列化器
连接超时管道过大或 timeout 过小查看单批命令数减小批次、调大 timeout
连接池耗尽并发高、连接占用久监控连接池指标调大 max-active
部分命令失败数据类型不匹配等遍历结果找异常对象按索引定位并修正命令
内存飙升单批管道过大监控客户端和服务端内存分批处理
结果类型不符反序列化类型丢失检查 @class 字段配置 ObjectMapper 类型信息

4.5 几个我踩过的坑

第一个坑是回调返回值。前面提过,SessionCallback的execute方法必须返回 null,我一开始返回了true,直接抛异常。这个错误信息不太直观,新手容易懵。

第二个坑是管道里用了watch。有人想在管道里做乐观锁,用watch监控 key,但管道模式下watch的行为和普通模式不一样,容易出问题。如果要做乐观锁,老老实实用SessionCallback配合multi/exec,别和 pipeline 混用。

第三个坑是管道里执行了阻塞命令。比如BLPOP这种阻塞命令,放在管道里会导致整个管道卡住,因为 Redis 要等阻塞命令返回才能继续执行后面的命令。管道里只放非阻塞命令,这是铁律。

第四个坑是忽略了结果列表的长度校验。正常情况下结果列表长度等于命令数,但如果管道执行中途连接断了,可能返回的列表长度不对。处理结果前先校验长度,长度不对就整体重试或降级。

提示:管道里千万不要放BLPOP、BRPOP这类阻塞命令,也不建议放KEYS、FLUSHALL这类耗时命令,它们会拖慢整个管道的执行。

4.6 生产环境的参数调优建议

批次大小我一般从 500 起步,压测后根据 RT 和内存情况调整。如果单条数据小(几百字节),可以加到 1000;如果单条数据大(几 KB),降到 200 到 300。

timeout 设置要留足余量,一般是预估管道执行时间的 3 到 5 倍。比如预估 100ms 执行完,timeout 设 500ms。

连接池的max-active根据并发量算。假设单机 QPS 1000,每个请求平均占用连接 20ms,那需要的连接数大约是 1000 * 0.02 = 20 个,再留点余量设成 30 到 40。当然这是理论值,实际要压测验证。

监控方面,重点看三个指标:管道操作的 P99 耗时、连接池活跃连接数、Redis 服务端的instantaneous_ops_per_sec。这三个指标能帮你快速判断管道是否成为瓶颈。

5. 进阶场景与扩展思路

5.1 管道与事务的取舍

有人会问,管道和事务能不能一起用。技术上可以,SessionCallback里先multi再塞命令再exec,但这样管道就失去了部分性能优势,因为事务的exec本身有开销。我的建议是:要原子性就用事务或 Lua,要吞吐量就用管道,别想着两者兼得。业务上真正需要强原子性的批量操作其实不多,大部分场景管道足够了。

5.2 管道在分布式锁场景的应用

分布式锁的释放通常需要判断锁的持有者再删除,这是两个操作。用管道可以把GET和DEL打包,减少一次往返。但要注意,管道不保证原子性,判断和删除之间可能有其他客户端插入操作。如果对锁的安全性要求极高,还是用 Lua 脚本更稳妥。管道在这里的收益有限,更多是锦上添花。

5.3 结合缓存预热使用

系统启动或者大促前,经常需要把一批热点数据预热到缓存。这种场景数据量大、对原子性没要求,正是管道的用武之地。我一般写个预热任务,从数据库分批读取数据,每 500 条一个管道写入 Redis,一万条数据几秒钟就能预热完。如果用循环单次写,得跑好几十秒。

5.4 管道结果的批量转换

结果列表里的元素类型可能五花八门,手动一个个判断类型很啰嗦。可以写个工具方法,根据命令类型做批量转换。比如读取场景下,把结果列表统一转成目标类型,遇到 null 或异常就跳过。这样业务代码里就不用写一堆instanceof判断了。

public <T> List<T> convertResults(List<Object> results, Class<T> clazz) { List<T> list = new ArrayList<>(); for (Object result : results) { if (clazz.isInstance(result)) { list.add(clazz.cast(result)); } } return list; }

这个方法简单但实用,能省掉不少重复代码。当然,如果你需要保留 null 占位来对应 key 的顺序,就不能用这个方法过滤,得保留完整列表。

5.5 监控与告警的补充

管道操作上线后,建议加两个监控点:一是管道执行耗时,超过阈值告警;二是管道内命令失败率,失败率突增说明有异常。这两个指标能帮你在问题扩大前发现苗头。我一般用 Micrometer 打点,配合 Prometheus 和 Grafana 做可视化,效果不错。

我个人在实际操作中的体会是,pipeline 这个技术本身不复杂,难的是把批次大小、超时、异常处理这些细节调到位。刚开始用的时候别贪大,批次从小往大调,边压测边观察,找到适合自己业务的平衡点。另外,管道虽好但不能滥用,单条命令的场景老老实实单次操作就行,为了用而用反而增加复杂度。最后分享一个小技巧:如果你不确定某个命令能不能放管道里,先想想它是不是阻塞命令、是不是耗时命令,两个都不是的话,基本都能放。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询