做Java后端这几年,Redis几乎是每个项目都跑不掉的基础设施。缓存、限流、分布式锁、消息队列、排行榜,哪儿都能见到它的身影。但每次新建项目,到了加依赖这一步,总有个问题躲不开:Redis客户端到底选哪个?Jedis、Lettuce、Redisson,三个名字都熟,可真到要用的时候,光看网上的对比文章经常是越看越乱——说Jedis线程不安全,说Lettuce性能好,说Redisson功能全,然后呢?还是不知道自己的项目该用哪个。
我自己的经历比较典型:早期项目一直用Jedis,后来维护一个Spring Boot服务,发现框架默认带的是Lettuce,再后来做了一个需要分布式锁的模块,又引入了Redisson。这三种客户端我都在线上环境跑过,也都踩过不少坑。这篇就把我对它们的理解、实测结果和选型思路整理出来,给正在纠结选型的同学一个参考。
这里面的核心逻辑其实是:选Redis客户端,表面是选一个API库,本质是在选连接模型、线程模型和功能边界。不理解这一点,光看什么“性能排行”,很容易选错。
1. 三者定位完全不同:先看懂定位差异再选型
1.1 Jedis:直连模式的老牌选手
Jedis是历史最久的Redis Java客户端,Redis官网上长期把它摆在推荐位置。它走的是“直连”路线:客户端里维护一个Socket连接到Redis,每次执行命令,就是往这个Socket里写命令、读响应。API设计上几乎没做任何抽象,get就是get,set就是set,hgetall就是hgetall,跟你用redis-cli敲命令的感觉差不多。
这种直连模式的好处是简单、透明、好排查。命令发出去就是发出去了,返回什么就是什么,调试的时候在代码里打断点,能一眼看到底层在做什么。坏处也很明显:每个Jedis实例是一个阻塞IO的连接,并不是线程安全的。多线程场景下如果共用同一个实例,连接状态会混乱,命令交错返回错乱这类问题非常容易出现。
所以Jedis的正确打开方式,是配一个JedisPool。用的时候从池里借一个连接,用完还回去。这里有个小细节,很多人借了连接不还,或者忘了close,线上就慢慢把连接池耗尽,最后所有线程都卡在获取连接上。这样的小坑,后面我会单独展开。
1.2 Lettuce:Netty加持的连接复用方案
Lettuce底层跑的是Netty,是一个非阻塞IO客户端。它的最大特点,是一个连接可以被多个线程安全共享。你在代码里new一个RedisClient,然后多个线程同时对它发命令,底层Netty的EventLoop会负责把命令异步地写到IO通道里。因为是异步非阻塞,单连接就能扛住很高的并发量。
Lettuce同时支持三种编程模型:同步调用(调用后阻塞等结果)、异步调用(返回CompletableFuture)、响应式调用(返回Flux/Mono)。这种灵活性让它成了Spring Boot 2.x之后的默认Redis客户端——Spring生态里既能用同步模板,也方便上响应式技术栈。
不过共享连接也不是没有代价。如果某一个线程在这个连接上执行了阻塞命令,比如BLPOP这类阻塞读,那这条命令会占住这个连接的整个通道,其他线程的所有请求都只能排队等着。所以Lettuce的“线程安全”,并没有你想象的那么绝对,遇到阻塞命令还是要小心。
1.3 Redisson:面向分布式场景的完整工具箱
Redisson跟前面两个不是一个物种。它虽然也具备普通Redis客户端的读写能力,但核心卖点是把Redis封装成了一层“分布式对象服务”。你用它的时候,操作的不是get、set这种命令,而是RMap、RSet、RQueue、RLock这些分布式数据结构。
比如要搞一个分布式锁,Jedis你得上SETNX、EXPIRE、Lua脚本自己抠;Redisson呢,直接lock()、unlock(),还带自动续期watch dog。再比如要做个延迟队列,Redisson的RDelayedQueue开箱即用,基于Redis的ZSet和PubSub实现。这种能力确实省事,但也意味着它内部封装了序列化、编解码、Lua脚本、延迟任务调度等一堆逻辑,学习成本和二开成本都不低。
Redisson底层同样基于Netty,共享连接,也支持异步和响应式API,但它的主要价值在分布式协同,不在“跑分”。
这样三个定位就出来了:Jedis是纯粹的命令执行器,Lettuce是高性能连接复用器,Redisson是分布式工具集。明白了定位,后面的选择逻辑才顺。
2. 线程模型与连接管理:选客户端真正要看的底层逻辑
2.1 连接是最大的隐性成本
先聊一个很多人忽略的点:Redis本身是单线程处理命令的,所以不管客户端怎么选,Redis服务端的处理能力上限就在那里。那客户端的差异体现在哪?体现在“怎么把命令送到Redis”这件事上。
Jedis直连模式下,每个连接占用一个系统Socket,如果工程并发高,连接池可能要开到几十上百。BIO的模型下,一个线程在等待Redis返回时,这个线程是阻塞的。虽然现代JVM线程不贵,但大量线程阻塞在IO上,对系统资源的影响是实打实的。而且连接本身在Redis服务端也有内存开销,连接数太多会挤占Redis的可用内存,这在以内存换取性能的Redis体系里有点得不偿失。
Lettuce和Redisson的Netty模型就完全不一样。Netty的EventLoop是少数几个线程轮询处理所有连接的读写事件,连接本身是异步的,等待响应的时候线程可以去处理其他事情。所以一个连接就能承载大量并发请求,服务端连接数少,内存占用也低。这是它们能在高并发场景下表现更好的根本原因,而不是什么“代码优化得好”这种玄学。
2.2 三种客户端的线程安全姿势
落实到代码里,三种客户端的多线程使用方法完全不同,我习惯把它们称为三种“防御姿势”。
Jedis走的是资源隔离路线:
GenericObjectPoolConfig config = new GenericObjectPoolConfig(); config.setMaxTotal(50); config.setMaxIdle(20); config.setMinIdle(5); config.setMaxWaitMillis(3000); JedisPool pool = new JedisPool(config, host, port, timeout, password); try (Jedis jedis = pool.getResource()) { jedis.set("key", "value"); }核心就一句话:每个线程都从池里拿独立的连接,用完归还。所以Jedis的“线程安全”是一种通过资源隔离实现的伪安全,本质是让每个线程都不共享连接,而不是连接本身具备并发能力。
Lettuce走的是连接复用路线:
RedisClient client = RedisClient.create("redis://password@host:port"); StatefulRedisConnection<String, String> conn = client.connect(); RedisCommands<String, String> commands = conn.sync(); commands.set("key", "value");一份代码,多线程直接用commands做操作,连接是共享的。如果你要用异步,换成conn.async()就行,返回的都是Future。要记住的是,阻塞命令会占用整个连接通道,哪怕你有100个线程,只要有一个在执行BLPOP,其余99个在命令上都要排队。
Redisson走的是完全封装路线:
Config config = new Config(); config.useSingleServer().setAddress("redis://host:port").setPassword("password"); RedissonClient redisson = Redisson.create(config); RMap<String, String> map = redisson.getMap("myMap"); map.put("key", "value");日常用对象接口就行,连接管理被封装得干干净净。看起来最省心,但要知道底层同样受“单连接上阻塞命令互相影响”的规则约束。做技术选型的时候,要把这种封装带来的便利和潜在约束一起算进去。
3. 性能实测对比与典型场景建议
3.1 单命令吞吐量:没想象中那么悬殊
很多人选型都喜欢先看跑分,我一开始也这样。后来在自己的测试环境里(一台普通虚拟机上的Redis 7,客户端在同一台机器上,用JMH做基准测试)简单测了一些场景,发现一个有意思的现象:纯get/set这种简单命令,单线程跑,Jedis跟Lettuce的差距其实很小,大概在个位数百分比以内;Redisson因为内部多了一层序列化和对象转换,会慢一点,但也不至于让人不能用。
真正拉开差距的,是并发度上来之后的表现。Jedis在高并发下需要把连接池扩到很大,连接池本身的获取、释放、等待都成了瓶颈。Lettuce和Redisson单连接就能扛住,吞吐量反而更稳定。
我没把具体数字贴出来,刻意不贴,因为这种测试结果换台机器、换套数据就全变了。你本地拿这三个客户端跑一遍自己的场景,比看任何博客的数字都靠谱。
3.2 异步和响应式场景:Lettuce的优势是压倒性的
如果你的工程用了响应式编程,或者需要高吞吐的异步调用,Lettuce就是天然匹配的选择。它原生支持返回CompletableFuture和Flux/Mono,可以轻松和WebFlux、Project Reactor配合。Jedis是BIO模型,基本没有异步能力,硬要异步只能在自己的线程池里包一层,效果一般,还要额外维护一个异步封装层。
Redisson虽然也有异步API,但它的主要定位不是性能,所以为了“快”而选Redisson,方向就反了。异步场景,我通常直接建议Lettuce。
3.3 集群和哨兵场景:连接管理能力决定运维成本
Redis集群模式下,客户端需要根据key的slot路由到对应节点。Jedis集群版(JedisCluster)会自己维护一份slot映射,但每次新增节点或发生主从切换时,路由信息更新比较麻烦,很多情况需要重启客户端才能恢复。
Lettuce在集群模式下支持拓扑刷新机制,通过Cluster Topology Refresh自动感知集群拓扑变化,用的是Redis集群的MOVED/ASK重定向机制来逐步修正路由缓存。哨兵模式下也能自动跟随主从切换,运维上省心很多。
Redisson对集群、哨兵、主从都做了封装,配置成对应的模式后基本能做到无感切换,底层还带了一些自动探测的逻辑。实际项目里,如果你的Redis是单机或主从,Jedis完全够用;一旦上了集群或者哨兵架构,我建议优先考虑Lettuce,或者直接用Redisson,别让路由问题成为线上事故的隐患。
4. Spring Boot里的默认选择与连接池配置要点
4.1 默认客户端是Lettuce,别被旧文档带偏
Spring Boot 1.x时代,默认的Redis客户端是Jedis;从2.0开始,默认换成了Lettuce。这个变化影响面很大,但直到现在,很多网上的教程还在教你怎么配spring.redis.host、spring.redis.port,而那些配置在Spring Boot 2.x里已经分模块了。
实际上,当你引入spring-boot-starter-data-redis之后,Lettuce就已经在类路径里了。Spring Boot的RedisAutoConfiguration自动装配了一个默认的LettuceConnectionFactory,再包一层RedisTemplate。如果你想切回Jedis,需要手动排除lettuce-core依赖,加上jedis和commons-pool2,还要配置连接池参数。不是不行,但没必要——除非你的团队历史代码里到处都是依赖Jedis API的调用,迁移成本太高。
建议很简单:新项目直接用默认的Lettuce,别折腾。它能同步用,也能异步用,Spring Boot还给你把连接工厂、事务、序列化都配好了,少操一份心。
4.2 引入Redisson时,和Spring Data Redis的配合问题
如果需要在Spring Boot里用Redisson,最方便的是引入redisson-spring-boot-starter。它会自动创建RedissonClient Bean,也能和Spring的RedisTemplate底层连接打交道。配置上,可以通过单独的redisson.yaml文件,或者在application.yml里加redisson节点来指定连接方式,支持单机、哨兵、集群、云托管等模式。
有个常见的坑:Redisson的分布式对象和Spring Data Redis的RedisTemplate各自有自己的序列化策略。如果你一部分代码用Redisson存,一部分用RedisTemplate读,就会遇到格式对不上的问题。最简单的解决办法是统一序列化器,比如都用JSON序列化,而不是一边JDK序列化一边Redisson默认的编解码。这事看起来小,一旦线上出现“读出来是乱码”的问题,排查起来相当浪费时间。
4.3 连接池到底还要不要配?
Lettuce单连接共享的设计,让很多Spring Boot项目连连接池都不配就直接跑。这是对的吗?得分情况。
单机、低并发、操作简单,一个共享连接确实够用。但如果你的服务并发很高,或者会有阻塞操作,还是建议给Lettuce开一个连接池。Spring Boot 2.x之后,配置路径是spring.data.redis.lettuce.pool.*,可以设置max-active、max-idle、min-idle、max-wait等参数。注意,Boot 2.x早期的路径是spring.redis.lettuce.pool.*,后来统一改成了spring.data.redis.*,版本差异经常让人配错地方。
Redisson一般不用再手动配连接池,它内部自己管理连接,你只需要关注整体连接数是否被其他配置吃满。
5. 线上踩坑实录:连接池、断连、锁失效的排查方法
5.1 连接池耗尽:症状极其隐蔽
Jedis连接池耗尽的典型症状是:接口偶尔超时、线程卡在getResource上、CPU不高但RT飙升。查起来最直接的方式是看线程堆栈,如果大量线程都停在JedisPool.getResource这一行,那基本就是池太小或者有连接泄漏。
排查步骤可以按这个顺序来:先检查代码里所有用连接的地方是否都在finally里close了;再看连接池的maxTotal是否和你的并发量匹配;最后监控池的active、idle、waiting数量,把池大小做成配置项,压测时逐步调参。有一个不算冷的知识:获取连接这一行如果同时有大量线程在等待,那连接泄漏的概率远大于“并发量很大”的概率,先查泄漏总没错。
这个问题在Lettuce共享连接模式下很少出现,因为它相当于永远只有一个连接在用,不存在池打满的问题。但如果真给Lettuce配了连接池,原理一样,该查还是查。
5.2 Lettuce连接被服务端断开后的重连问题
Lettuce共享一个连接,如果Redis服务端因为超时把空闲连接关了,Lettuce的下一次请求会先触发重连。大多数情况下这是透明的,但在某些环境下(比如防火墙空闲超时、云数据库的server timeout),可能表现为偶发的高延迟或超时。
解决办法有几个方向:确认Lettuce的自动重连配置是开着的(默认就是开着);适当调整客户端的超时时间,不要过短;把TCP keepalive打开,让连接别彻底闲下来。还有一招,就是给Lettuce也配一个小连接池,这样即使某一条连接被服务端断开,池里还有别的连接可用,不会瞬间把一次请求拖垮。
5.3 Redisson分布式锁的续期与释放陷阱
Redisson的watch dog机制,默认会为锁自动续期(默认锁超时30秒,每10秒续一次)。这本来是防止业务没执行完锁就过期,但很多人在测试环境里会发现:代码里明明调用了lock,程序崩溃后锁还在,后续线程进不来。为什么?因为lock()不带leaseTime参数时,watch dog会一直续,直到unlock。可如果进程挂了,watch dog没了,锁会在30秒后自动释放,不会永久死锁。这个机制本身是合理的,就怕代码里用了lock()之后忘记unlock,同时业务里也没有捕获异常的逻辑,锁就会被一直续期,形成事实上的死锁。
我的建议是统一封装一个带过期时间的lock方法,比如lock(10, TimeUnit.SECONDS),显式传递leaseTime,让它不走watch dog后台续期那条路,超时强制释放,避免因为异常分支漏掉unlock造成的死锁。
还有一个常见误区:多个业务用的是同一个RedissonClient,误以为不同业务代码里lock("A")和lock("B")是独立的,其实只要锁的key相同就会互相排斥。平时开发要多注意锁key的命名规范,避免两个完全不相关的业务因为共用了一个key名,莫名其妙地互相阻塞。
5.4 序列化问题:客户端之间不兼容的典型表现
这是Spring Data Redis最经典的坑:默认使用JDK序列化,写进去的value是看不见的二进制。如果你后端服务A用JDK序列化写入,服务B用Fastjson或Jackson去读,就会发现读出来的是乱码,或者反序列化直接报错。这种问题严格来说不是客户端选型造成的,但它确实是Redis项目里的高频事故。
解决思路很简单:全局统一序列化器。在网上搜一下RedisTemplate配置,把默认的JdkSerializationRedisSerializer换成StringRedisSerializer或GenericJackson2JsonRedisSerializer,不同服务节点之间用同一套序列化标准,问题就消失了。Redisson这边同理,可以显式指定编解码器,比如new Kryo5Codec()或者自定义Jackson编解码,保持跨节点的一致性。
最后聊几句我的选型习惯
我自己的体会是,选型这事没有绝对的标准答案,关键是把场景想清楚。单体应用、缓存为主、并发可控,用哪个都行,选团队最熟的就行。高并发读写、异步友好、Spring Boot生态,Lettuce是稳妥的默认项。需要分布式锁、分布式集合、延迟队列这种分布式协同能力,Redisson会大幅降低开发成本,值得直接引入。更合理的思路是:以Lettuce作为基础客户端,在需要分布式功能的地方局部引入Redisson,而不是把三个全堆上去,也不是迷信某一个。
这个结论听起来不惊艳,但我发现很多项目最后出问题,恰恰是因为在一开始选型时没有想清楚这条逻辑,后面被各种连接异常、序列化问题、锁失效问题拖着走。先定场景、再定客户端,能省掉后面一大半的排查时间。