☰
Jedis与Spring Data Redis核心差异及RedisTemplate实践
2026/10/26 18:48:35 网站建设 项目流程

第一次在项目里翻到RedisTemplate这个 Bean,我本能地以为它跟 Jedis 是一回事,只是换了个名字。直到某个夜里排查一批缓存"写进去了读不出来"的问题,把 key 贴进redis-cli一查,前面挂着一串\xac\xed的乱码前缀,才意识到自己一直把两个不同层次的东西当成了同一个东西在用。Jedis是 Java 世界里最直接的 Redis 客户端实现,它把 RESP 协议一层层剥开递到你手上,你写什么命令它就发什么字节;Spring Data Redis则是 Spring 生态里的门面层,它根本不关心底下接的是谁,换成 Lettuce 照样跑。这篇内容就是围绕这两个客户端展开的:它们各自解决什么问题、调用链路长什么样、连接池和序列化这些真正会让人熬夜的细节怎么处理、以及在一个具体项目里到底该选哪一套。适合已经能连上 Redis、写过几行set/get,但一碰到连接池耗尽、乱码 key、Pipeline 写法就卡住的同学,也适合准备把老项目从 Jedis 迁到 Spring 体系的人先摸个底。

1. 先把层次掰开:Jedis 是协议驱动,Spring Data Redis 是上层门面

1.1 两条截然不同的调用链路

把这两者的调用链路画出来,很多困惑会自己消失。纯 Jedis 的路径非常短:业务代码直接调jedis.set("k","v"),Jedis 内部把命令编码成 RESP 数组写进 socket 输出流,然后阻塞读输入流,解析出返回值。中间没有任何抽象层,你写的就是 Redis 收到的。这条链路的好处是透明、可控、性能开销极小;代价是所有东西都得自己管——连接的开闭、异常处理、超时、重试,一样不落。

Spring Data Redis 的路径要长一截:业务代码调的是redisTemplate.opsForValue().set("k","v"),ValueOperations把 key 和 value 交给配置好的RedisSerializer序列化成字节数组,再交给RedisConnectionFactory取一个RedisConnection,最后由JedisConnection或LettuceConnection去调用真正的驱动。这中间多出来的每一层都带副作用:序列化器决定了你存进去的字节长什么样,连接工厂决定了连接从哪来、能不能复用,模板类决定了异常怎么转换。

这里有个容易被忽略的事实:RedisTemplate之所以能换底层,是因为RedisConnection这层抽象。用 Jedis 的时候,JedisConnection内部持有一个Jedis实例,而这个实例来自你在配置里指定的JedisConnectionFactory,工厂背后其实还是一个JedisPool。所以哪怕你全程只用RedisTemplate,连接池参数该调还是得调,只是配置位置从 Java 代码挪到了application.yml里。

1.2 依赖坐标里藏着的默认值

新起一个 Spring Boot 项目,加spring-boot-starter-data-redis,大多数人不会去看它到底拉了什么进来。实际默认带的是 Lettuce,不是 Jedis。这个默认值从 Spring Boot 2.x 一直延续到 3.x,很多人以为自己在用 Jedis,其实跑的是 Lettuce,直到某天要设置一个只有 Jedis 才有的参数才发现对不上。

想切回 Jedis,得显式排除再引入,这一步的写法在版本升级时经常被漏掉:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <exclusions> <exclusion> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </dependency>

版本这块有个硬约束值得单独说:Spring Boot 2.7 管理的 Jedis 是 3.8.x,Spring Boot 3.x 管理的是 4.3.x 往上。Jedis 4 开始引入了UnifiedJedis、JedisPooled这一套新 API,老的Jedis、JedisPool还留着但被标记为维护状态。如果你在 Boot 3 项目里照抄 Boot 2 时代的 Jedis 配置代码,编译能过,但某些方法签名已经变了,比如JedisPoolConfig的setMaxWaitMillis在新版对象池里推荐用setMaxWait(Duration),虽然旧方法还在,但心里要有数。

另一个常被踩的坑是配置前缀。Spring Boot 2.x 写spring.redis.host,到 3.x 改成了spring.data.redis.host。升级时如果只改了依赖没改配置,启动不会报错,因为 Redis 客户端会退化到默认的localhost:6379,本地开发环境往往还真就连上了,一上测试环境才炸。这种"本地正常、线上异常"的问题最难查。

对比项JedisSpring Data Redis
定位Redis 协议的原生 Java 驱动Spring 的 Redis 操作抽象层
底层依赖自己就是底层可接 Jedis 或 Lettuce
连接管理手动获取与归还由 ConnectionFactory 托管
序列化自己处理字节数组由 RedisSerializer 统一处理
命令覆盖全量,跟随 Redis 版本全量,另有 Session/事务封装
学习成本低,会 Redis 命令就会用中,需理解模板与序列化机制

2. Jedis 的两种用法:单连接与 JedisPool,线程安全是硬约束

2.1 为什么一个 Jedis 实例绝不能跨线程共享

新手最容易犯的错,是把Jedis对象存成类的成员变量或者静态变量,觉得这样省事。测试环境单线程跑得好好的,一上并发就开始出现各种诡异的返回值错位:A 线程发的get user:1收到了 B 线程set user:2的OK,甚至直接抛JedisConnectionException: Unexpected end of stream。

根子在 RESP 协议本身。Redis 的单条连接是严格的一问一答顺序模型,客户端发一条命令,服务端回一条响应,客户端再去读。Jedis 内部维护的是一个RedisInputStream和一个RedisOutputStream,两个线程同时往同一个输出流写命令,字节流会交错;同时从同一个输入流读响应,谁先读到哪条响应完全不可控。这不是 Jedis 的设计缺陷,而是协议本身决定的,换任何同步驱动都一样。

正确的写法只有两种。第一种是短生命周期,用完就关:

try (Jedis jedis = new Jedis("127.0.0.1", 6379, 2000)) { jedis.set("greeting", "hello"); String v = jedis.get("greeting"); }

第二种,也是生产环境唯一推荐的做法,是从池里借、用完还:

try (Jedis jedis = jedisPool.getResource()) { jedis.set("greeting", "hello"); }

getResource()返回的Jedis是一个池化对象,close()不等于断开连接,而是把连接归还给池。这点非常关键——很多人误以为close()会关掉 TCP 连接所以不敢关,结果就是连接泄漏,池子很快被掏空。

顺带提一句 Jedis 4 之后新增的JedisPooled,它内部就是池化加同步,声明成单例给多线程用是安全的,写法上接近RedisTemplate的味道。但如果你要继续用 Spring 的JedisConnectionFactory,走的还是JedisPool那条老路,没必要混着来。

2.2 JedisPool 参数不是拍脑袋填的

连接池参数这件事,见过太多项目直接抄一段maxTotal=100, maxIdle=100就上线了。参数背后的逻辑其实不难推。

先看maxTotal,也就是池里最多有多少个连接。它决定的是"同时最多有多少个请求能拿到连接"。按 Little 定律粗算:需要的连接数 ≈ 峰值 QPS × 单次占用连接的时长(秒)。举个具体例子,某个商品详情接口峰值 2000 QPS,每次请求在 Redis 上执行 5 条命令,单条往返 0.5ms,那么单次请求占用连接的时长是 2.5ms,也就是 0.0025 秒。2000 × 0.0025 = 5,理论上 5 个连接就够。但网络抖动、慢查询、GC 停顿都会让这个数字翻倍,所以工程上一般给 3 到 5 倍冗余,maxTotal落在 20 到 30 区间比较合理。反过来,如果你给到 500,Redis 服务端单线程处理那 500 条连接的命令,上下文切换和内存开销反而更高。

maxIdle和minIdle管的是空闲连接的上下限。maxIdle设得比maxTotal小太多,会导致高峰期扩出来的连接在空闲时被大量销毁,然后又要在下一个高峰重建,反复抖动。通常建议maxIdle = maxTotal,minIdle取maxTotal的三分之一左右,保持一个常驻的热连接池。

maxWaitMillis是借连接的最长等待时间,默认是 -1,也就是无限等。这个默认值在生产环境非常危险——一旦池子耗尽,所有业务线程会在这里挂住,线程池很快被打满,整个服务雪崩。必须显式设一个值,一般 500ms 到 2000ms,超时后抛异常快速失败,让上游能感知到。

参数含义常见取值建议设错的后果
maxTotal池内最大连接数峰值 QPS × RT × 3~5过小排队,过大压垮服务端
maxIdle最大空闲连接数与 maxTotal 持平频繁创建销毁,延迟抖动
minIdle最小空闲连接数maxTotal 的 1/3冷启动时建连慢
maxWaitMillis借连接最大等待500~2000ms默认无限等导致线程堆积
testOnBorrow借出时校验一般关闭开启会多一次 PING 往返
testWhileIdle空闲时校验建议开启关闭后可能借到死连接

testOnBorrow和testWhileIdle的组合也值得说说。前者每次借出都发一次 PING,代价是每次操作多一个 RTT,高 QPS 场景下这个开销不能忽略。后者由后台线程定期检查空闲连接,代价小得多,还能把被服务端踢掉的死连接清理出去。我自己的习惯是关前者、开后者,配合timeBetweenEvictionRunsMillis设个 30 秒左右的巡检周期。

2.3 连接池耗尽的排查链路

Could not get a resource from the pool这个异常,几乎每个用 Jedis 的人都遇到过。看到它的第一反应不应该是调大maxTotal,而应该先确认连接为什么没回来。

第一步,看池的实时状态。通过 JMX 或者自己写个健康检查接口,把JedisPool.getNumActive()、getNumIdle()、getNumWaiters()打出来。如果NumActive长期贴着maxTotal而NumIdle是 0,说明连接确实被占满了,问题出在使用侧。

第二步,翻代码找没关闭的连接。典型写法有这么几种:在try块外面拿连接,finally里忘了close();用了jedis.pipelined()之后只关了 pipeline 没关连接;在RedisCallback里手动getConnection()却没走模板的自动释放。Spring Data Redis 的模板方法内部会自动关连接,但只要你绕开模板直接操作RedisConnectionFactory,就得自己兜住。

第三步,查慢命令。一个keys *在百万级 key 的库上能跑好几秒,这几秒里那条连接一直是被占用的状态。池子只有 20 个连接,几个慢命令并发打进来,池子秒空。monitor命令或者慢查询日志slowlog get 10能直接定位到这类问题。大 key 的hgetall、smembers也是同类。

第四步,看网络。TCP 层的重传、服务端timeout配置(默认 0 表示不主动断开)、中间的连接跟踪表超时,都可能让客户端以为连接还活着,实际对端已经没了。表现是借出来的连接一用就报错,然后这个坏连接被销毁,池子容量持续下降。这时候testWhileIdle就能帮上忙。

3. RedisTemplate 的装配细节:自动配置给了你什么,又藏了什么

3.1 两个模板 Bean 的分工

Spring Boot 的RedisAutoConfiguration在满足条件时会注册两个 Bean:redisTemplate和stringRedisTemplate。前者泛型是<Object, Object>,后者是<String, String>。很多人看到两个都能用,随手挑一个就开始写,结果在跨语言、跨服务的场景里踩了坑。

StringRedisTemplate的序列化器是StringRedisSerializer,key 和 value 都按 UTF-8 直接转成字节数组。你存set user:1 jack,redis-cli里get user:1拿到的就是jack,干净利落。如果你的 Redis 还要被其他语言的客户端读、被运维用命令行查、被监控系统扫描,这个模板是首选。

RedisTemplate的默认序列化器是JdkSerializationRedisSerializer,也就是把对象扔进ObjectOutputStream。这个默认值放在今天看基本不能直接用,但它是历史遗留,官方一直没改,因为改成 JSON 会破坏老项目的兼容性。

3.2 JDK 序列化带来的三个后遗症

第一个后遗症是 key 不可读。你set("user:1", userObj),实际存进 Redis 的 key 是\xac\xed\x00\x05t\x00\x06user:1这一串。用redis-cli敲keys *出来全是乱码,运维想帮你清个缓存都无从下手。更麻烦的是监控告警按 key 前缀配规则,一条都匹配不上。

第二个后遗症是跨语言不通。Java 序列化的字节流带着类描述信息,Python、Go、Node 的客户端读出来是一堆二进制,根本解析不了。现在稍微像样一点的系统都是多语言混布,这一条几乎直接判了 JDK 序列化的死刑。

第三个后遗症是存储膨胀。一个只有三个字段的小对象,JDK 序列化出来动辄两三百字节,JSON 可能只要八十字节。按千万级 key 算,这部分浪费是实打实的内存和带宽。

所以结论很明确:RedisTemplate的默认序列化器必须改,除非你的项目从第一天起就只用 Java 读写、且永远不打算用命令行排查。

3.3 换一套序列化器的完整写法

最常见的方案是 key 用 String,value 用 JSON。配置类的写法如下:

@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字段记录全限定类名,反序列化时靠它还原类型。好处是opsForValue().get("k")能直接拿到原始对象,不用手动转。代价是多存了一个字段,并且对包名重构不友好——你把类挪个包,历史数据就反序列化失败了。

如果不需要自动还原类型,用Jackson2JsonRedisSerializer<Object>配合明确的ObjectMapper会更干净。这里有个细节:直接new Jackson2JsonRedisSerializer<>(Object.class)反序列化回来的是LinkedHashMap而不是原对象,很多人第一次遇到会懵。解决办法是构造时传入具体的实体类,或者老老实实用GenericJackson2JsonRedisSerializer。

ObjectMapper的配置也要交代清楚,否则LocalDateTime一序列化就抛InvalidDefinitionException:

ObjectMapper om = new ObjectMapper(); om.registerModule(new JavaTimeModule()); om.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); om.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);

顺带说一句,Spring Boot 3 之后GenericJackson2JsonRedisSerializer的构造器可以接受一个ObjectMapper,把上面这个配置好的om传进去,两边的行为就对齐了。这个改动不大,但对排查"Redis 里的 JSON 格式和接口返回的 JSON 格式不一致"这类问题很有帮助。

4. 批量与原子:Pipeline、事务、Lua 在两套 API 下的写法

4.1 Jedis 的 Pipeline 与关闭时机

单条命令一个 RTT,一万条命令就是一万个 RTT。在跨机房场景下,单次 RTT 按 1ms 算,一万条就是 10 秒,这是纯浪费。Pipeline 的作用是把命令攒起来一次性发出去,再一次性读回所有响应,把网络往返压缩成一次。

Jedis 的写法很直观:

try (Jedis jedis = jedisPool.getResource()) { Pipeline pipeline = jedis.pipelined(); for (int i = 0; i < 10000; i++) { pipeline.set("batch:key:" + i, "v" + i); } List<Object> results = pipeline.syncAndReturnAll(); }

这里有个必须记住的限制:Pipeline 里不能有依赖前一条结果的命令。比如你先get拿结果再决定set什么值,这在 Pipeline 里做不到,因为命令是在发送阶段就确定好的,你根本拿不到中间结果。真要这种逻辑,得用 Lua 脚本。

还有个更隐蔽的坑:syncAndReturnAll()之后,Pipeline 对象本身要处理。老版本 Jedis 里Pipeline继承了MultiKeyPipelineBase,close()的语义在不同版本有差异,有的版本close()会把底层连接一起关掉,导致归还到池里的是个废连接。安全做法是用sync()只发送不接收,或者干脆把Jedis放在 try-with-resources 里,让连接无论如何都正常归还。

4.2 executePipelined 的回调形态与返回值陷阱

Spring Data Redis 的 Pipeline 走的是executePipelined,参数是一个RedisCallback,返回值是List<Object>:

List<Object> results = redisTemplate.executePipelined((RedisCallback<Object>) connection -> { for (int i = 0; i < 10000; i++) { connection.stringCommands().set( ("batch:key:" + i).getBytes(StandardCharsets.UTF_8), ("v" + i).getBytes(StandardCharsets.UTF_8)); } return null; });

注意回调里必须返回null,返回别的东西没有意义,反而会干扰结果收集。返回值results的顺序和命令发送顺序一致,但类型不一定是你要的——SET命令返回的是byte[]形式的OK,GET返回的是byte[],需要自己用序列化器转。这也是很多人第一次用executePipelined时卡住的地方:明明get了字符串,results里拿到的却是一串字节。

如果回调里要做到类型安全,可以用SessionCallback配合opsForValue(),Spring 会在内部帮你做命令和序列化的转换,代码可读性好很多:

List<Object> results = redisTemplate.executePipelined(new SessionCallback<Object>() { @Override @SuppressWarnings({"unchecked", "rawtypes"}) public Object execute(RedisOperations operations) { for (int i = 0; i < 10000; i++) { operations.opsForValue().set("batch:key:" + i, "v" + i); } return null; } });

4.3 Lua 脚本在 Spring 里的执行方式

需要"读-判断-写"原子执行的时候,Lua 是标准答案。释放分布式锁那段经典脚本,两边写法都不复杂。

纯 Jedis:

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

Spring Data Redis 走DefaultRedisScript:

DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText("if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end"); script.setResultType(Long.class); Long released = redisTemplate.execute(script, Collections.singletonList(lockKey), requestId);

setResultType(Long.class)这行不能省,不设的话返回的是Long还是byte[]取决于底层版本,容易出类型转换异常。另外脚本对象建议做成单例 Bean 缓存起来,DefaultRedisScript在第一次执行时会算 SHA1 并尝试EVALSHA,重复 new 会让缓存失效,每次退化成EVAL,白白多传一遍脚本体。

5. 选型不是站队:三种组合各自的适用场景

5.1 纯 Jedis、Spring Data Redis + Jedis、Spring Data Redis + Lettuce 的横向对比

市面上常见的组合其实是三种,而不是两种。第一种是裸用 Jedis 加JedisPool,第二种是RedisTemplate底层走 Jedis,第三种是RedisTemplate底层走 Lettuce。它们的差异不只在 API 长相上,更在并发模型上。

组合并发模型优点短板典型场景
纯 Jedis + JedisPool同步阻塞,一连接一请求透明、可控、开销最小全手写,无 Spring 集成工具类、批处理任务、老系统
Spring Data Redis + Jedis同步阻塞,池化模板便利 + 连接池可控同步模型,高并发需大池中等并发、代码规范优先
Spring Data Redis + LettuceNetty 异步,单连接多路复用连接数少,天然支持异步与响应式调试链路长,参数需理解高并发网关、响应式栈

Lettuce 的核心优势是基于 Netty 的多路复用:一个连接上可以并发跑多个命令,靠请求 ID 匹配响应。这意味着同样 2000 QPS,Lettuce 可能只需要 1 个连接就够了,而 Jedis 需要十几个。连接少了,服务端的负担和客户端的线程开销都跟着降。

但多路复用不是没有代价。一旦某个命令特别慢,它会阻塞这个共享连接上的后续命令——这就是所谓的队头阻塞。Jedis 因为一连接一请求,慢命令只影响自己那条连接,隔离性反而更好。所以有些团队会在 Lettuce 上配置spring.data.redis.lettuce.pool开一个连接池,用commons-pool2做连接复用,把隔离性补回来。注意这个池默认是不生效的,必须显式引入commons-pool2依赖。

5.2 从业务形态倒推技术选择

选型这件事,我一般按三个问题来问。

第一个问题:Redis 是纯缓存还是有业务语义?如果只是给数据库挡一层,key 过期就过期,那StringRedisTemplate配 JSON 序列化就够了,用哪个底层驱动影响不大,按团队熟悉度选。如果是分布式锁、计数器、排行榜这类有语义的场景,那就要格外关注原子性和超时设置,客户端的选择反而次要。

第二个问题:并发峰值和延迟要求。QPS 在几千以内、单次 Redis 操作在毫秒级,Jedis 池化完全撑得住,而且排查问题直观——jstack一看就知道线程卡在借连接还是等响应。QPS 上万、或者要接响应式编程(WebFlux +ReactiveRedisTemplate),那就只能用 Lettuce,Jedis 没有响应式实现。

第三个问题:存量代码和技术债。如果项目里已经有大量JedisPool的封装,硬迁到RedisTemplate的收益往往抵不上回归测试的成本。反过来,如果项目是新建的 Spring Boot 应用,直接上spring-boot-starter-data-redis,用StringRedisTemplate加 JSON 序列化,是最省心的路径,以后要换驱动改个依赖就行,业务代码不用动。

有一点需要提醒:混用是有代价的。同一个项目里既有裸 Jedis 又有RedisTemplate,意味着有两套连接池、两套序列化规则,key 的格式很可能对不上。我见过一个项目里,A 模块用 Jedis 存的是 UTF-8 字节,B 模块用默认序列化器的RedisTemplate读,怎么都读不出来,最后查了两天才定位到序列化器不一致。要么统一,要么至少在 key 命名上加前缀区分。

6. 真机上最容易翻车的几个点与处理办法

6.1 序列化不一致导致的"写进去了读不出来"

这是所有问题里排查成本最高的一个,因为现象很迷惑:redis-cli里明明能看到 key,业务代码就是读不到。

核心原因是 key 的字节序列不一样。纯 Jedis 用jedis.set("user:1", ...),key 的字节是75 73 65 72 3a 31。用默认序列化器的RedisTemplate<String,Object>存,key 经过JdkSerializationRedisSerializer变成了ac ed 00 05 74 00 06 75 73 65 72 3a 31。两个 key 在 Redis 眼里完全不同,自然互相读不到。

定位手法很简单,用redis-cli --scan --pattern '*'看实际字节,或者干脆在代码里打一行日志,把keySerializer.serialize("user:1")的十六进制打出来,跟redis-cli里object encoding的结果对一遍。对不上,就是序列化器的问题。

修复方式有两种:一是统一序列化方案,把所有读写都收口到同一个模板 Bean;二是如果你必须和外部系统共享 key,那就全部用StringRedisSerializer,值也用字符串,复杂对象自己writeValueAsString手动转。后一种做法牺牲了便利性,但可移植性最好,跨语言、跨版本都不会出问题。

6.2 超时缺失与线程堆积的连锁反应

Jedis 的JedisPool构造函数有一堆重载,最容易被忽略的就是timeout这个参数。默认值是2000毫秒,但很多人在写配置的时候直接new JedisPool(config, host, port),把后面几个参数全丢了,结果连接超时和读写超时都用默认。

单看 2 秒不算长,但连锁反应是:Redis 那边某个主从切换的瞬间,所有请求都卡在等响应,每条连接占满 2 秒。池子 20 个连接,2 秒内能处理的请求数就是 20 个,剩下的全部在maxWaitMillis里排队,超时后抛异常。上游如果没做降级,线程池迅速被打满,接口整体超时。

所以三组超时都要显式配:连接超时(连不上 Redis 多久放弃)、读写超时(发出去多久没响应放弃)、借连接超时(池里没空闲连接等多久)。Spring Boot 里的对应配置在spring.data.redis.timeout(读写)、spring.data.redis.connect-timeout(连接),连接池的借出超时则在spring.data.redis.jedis.pool.max-wait。

除了超时,还要考虑重试。Jedis 本身不做自动重试,Redis 集群模式下由JedisCluster处理重定向。如果你在业务层自己加重试,一定要控制次数并且加退避,否则 Redis 抖动时你的重试会把抖动放大成雪崩。

6.3 批量清理时 KEYS 与 SCAN 的选择

缓存清理脚本里出现jedis.keys("prefix:*")是很常见的写法,在测试环境毫无问题,因为 key 只有几百个。生产环境上百万 key 的时候,KEYS是 O(N) 全量遍历,而且 Redis 是单线程的,这条命令执行期间整个实例阻塞,所有其他请求排队。几秒钟的阻塞足以让上游的熔断器全部打开。

替代方案是SCAN,用游标分批遍历,每次只返回一小批,不会长时间占用主线程:

String cursor = ScanParams.SCAN_POINTER_START; ScanParams params = new ScanParams().match("prefix:*").count(500); do { ScanResult<String> result = jedis.scan(cursor, params); List<String> keys = result.getResult(); if (!keys.isEmpty()) { jedis.del(keys.toArray(new String[0])); } cursor = result.getCursor(); } while (!ScanParams.SCAN_POINTER_START.equals(cursor));

这里有两个注意点。第一,SCAN是弱一致性的,遍历过程中新增或删除的 key 可能被漏掉或重复,做清理任务时不必追求精确,多跑一轮就行。第二,del一次传太多 key 同样会阻塞,一次 500 个是相对安全的量,count参数只是提示,实际返回数量可能更多,所以del之前最好再切一刀。

Spring Data Redis 里对应的是redisTemplate.scan(ScanOptions),返回Cursor<byte[]>,用起来更顺一些,但同样要注意别把游标结果一次性全collect到内存里——百万级 key 拉进 List,堆内存直接给你颜色看。

6.4 连接池泄漏的几种隐蔽写法

最后补充一个我实际遇到过的情况:连接池慢慢被耗尽,但代码里每处getResource()都写了close(),肉眼查不出来。

问题出在异常路径上。有一种写法是:

Jedis jedis = pool.getResource(); jedis.set("k", "v"); jedis.close();

如果set抛了异常,close()就永远执行不到,连接泄漏。必须用 try-with-resources 或者finally。还有一种更隐蔽的,是在finally里close()之前又调了一次 Redis 操作,那个操作抛异常,close()同样被跳过。

排查这类问题的有效办法是盯着池的NumActive指标做趋势观察:正常业务下它应该围绕均值上下波动,如果看到它阶梯式上涨、只增不减,那就是泄漏。配合jstack抓一下线程栈,看看哪些线程持有连接没释放,基本能锁定范围。另外,对象池的abandon相关配置(setRemoveAbandonedOnBorrow)可以在开发环境临时打开,把借出超过一定时间的连接强制回收,虽然不能作为生产方案,但定位问题时很好用。

一路写下来,我自己在 Jedis 和 Spring Data Redis 之间来回切换过好几次,最深的一点感受是:这两个东西的复杂度都不在 API 本身,API 看半小时文档就能上手,真正耗时的是序列化规则、连接生命周期、超时与重试这些"不写在快速入门里"的细节。上手一个新项目,我现在的习惯是先花十分钟把序列化器和连接池参数这两块配置看清楚,再动手写业务代码——这十分钟通常能省掉后面好几个晚上的排查。

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

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

立即咨询