ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java连接Redis首选Jedis:连接池、Spring Boot集成与线上排查实战

Java连接Redis首选Jedis:连接池、Spring Boot集成与线上排查实战

搞Java服务端的人,十有八九都要和Redis打交道,而Jedis就是Java世界里最老牌、最直接的Redis客户端。哪怕现在Spring Boot默认给你配上的是Lettuce,很多老项目、老团队依然会在pom.xml里把Jedis单独拎出来用,原因很简单:API一眼能懂,命令和Redis原生指令长得一模一样。这片文章我就把“Java中Jedis连接Redis”这件事从头到尾聊透,从最基础的连接参数、依赖引入,到连接池、常用数据结构操作、管道和Lua,再到线上经常踩的连接超时、连接池耗尽、认证失败问题,最后落成一套可以直接抄回团队使用的规范。无论你是刚转Java后端,还是被线上Redis连接问题折磨过,这篇都值得花十分钟认真看一遍。

1. 为什么在Java里连接Redis,我依然首选Jedis

1.1 一个老牌客户端的价值:从API设计说起

Jedis是Redis官方长期维护的Java客户端之一,它的设计思路特别纯粹:Redis有什么命令,Jedis就有什么方法。你用SET、GET、HSET,Jedis里就是set()、get()、hset(),没有中间商,没有过度封装。这对Java后端开发来说特别友好,尤其是团队里有人已经熟悉Redis命令行的时候,用Jedis写出来的代码几乎就是命令行的逐行翻译,排查问题时心智负担极小。

举个例子,Redis里执行hset user:1001 name "zhangsan" age "28",Jedis里就是:

jedis.hset("user:1001", "name", "zhangsan"); jedis.hset("user:1001", "age", "28");

再看Spring Data Redis里的RedisTemplate,虽然它也很好用,但多了ValueOperations、HashOperations这一层抽象,新手经常搞不清该用opsForValue()还是opsForHash()。而Jedis几乎没有这个概念。你只需要学会两个核心对象:直连场景下的Jedis,和池化场景下的JedisPool。

另外,Jedis本身非常轻。它基于原生Socket和RESP协议实现,不需要额外依赖重型框架,对于要打包成瘦Jar、对依赖体积敏感的项目来说,这一点很香。而且它的问题也比较好定位,栈信息简单,基本不会出现“底层通过Netty异步串了八层回调”这种越查越懵的情况。

1.2 和Lettuce、Redisson相比,该怎么选

现在的Java生态里,主流Redis客户端大概就是三驾马车:Jedis、Lettuce、Redisson。很多人一开始就被“Spring Boot默认用Lettuce”这句话带走了,但默认并不等于最适合你。

Lettuce基于Netty,核心特点是连接复用和异步、响应式编程。同一个连接可以被多个线程共享,理论上并发能力很强。但这也带来了心智成本,尤其是排查问题时,Lettuce的异常往往经过Netty事件循环包装,栈信息比Jedis要绕。Redisson则走得最远,它不只是客户端,更像一个分布式工具箱,分布式锁、布隆过滤器、延迟队列都内置好了,最适合需要复杂分布式协调功能的场景。

但这不代表Jedis就落后。Jedis的优势是简单直接、命令完备、和原生协议对齐。很多所谓的缓存项目,其实根本用不到Redisson的分布式锁,也用不到Lettuce的响应式并发,一个连接池加上几十个常用命令就够了。在这种情况下,引入Lettuce或Redisson反而会让项目更重。

我之前接手过一个老服务,排查Redis连接峰值上不去的问题时,发现项目里同时引入了Redisson和Lettuce,两套连接池各管各的,资源都没法统一管控。这种复杂度完全是可以避免的。选型时不要跟风,按照下面这张表去对号入座就行:

客户端定位适合场景不擅长
Jedis轻量同步客户端传统Spring MVC项目、简单缓存、团队熟悉Redis命令响应式编程、连接级多路复用
Lettuce异步/响应式客户端Spring Boot默认场景、WebFlux、高并发读多写少复杂分布式对象能力
Redisson分布式工具集分布式锁、消息队列、分布式对象追求轻量、低依赖的项目

2. 连接Redis之前,必懂的基础知识与环境准备

2.1 连接一个Redis实例,本质上要搞定哪几件事

很多人一上来就写new Jedis("localhost"),跑通了就以为会了。实际上连接Redis要考虑的东西远不止IP和端口。Redis是一个运行在TCP协议之上的内存服务,Java客户端要连上它,本质上要解决四件事:网络定位、认证鉴权、逻辑库选择、以及连接生命周期管理。

先说网络定位,就是host和port。默认端口6379,没什么好说的,但要特别注意网络环境,生产环境Redis一般不会裸奔在公网,而是放在内网或私有子网里,要确认Java应用所在的主机能不能路由到Redis主机。还有防火墙和安全组,我在实际项目里遇到的“Connection refused”有一半其实是安全组规则没放行,不是Redis没启动。

然后是认证。Redis从早期版本就有requirepass,连接后要执行AUTH password。Redis 6之后又引入了ACL,可以给不同用户分配不同权限,这时候AUTH username password也变成了合法姿势。如果你的Redis设置了密码,而代码里没认证,Jedis会直接抛NOAUTH Authentication required;反过来,如果Redis没设密码你偏要auth,又会报ERR Client sent AUTH, but no password is set。这两种错误我后面都会详细列出来。

再然后是逻辑库。Redis默认有16个逻辑数据库,编号0到15,通过SELECT命令切换。Jedis对应的方法是select(int index)。这里有个容易踩的坑:有些云厂商的Redis服务看起来支持多个库,实际操作会发现只有db0可用,或者不同db之间的数据要按客户维度隔离,这个必须在架构阶段确定,而不是靠代码里随机选db。

最后是连接生命周期。每次新建一个Jedis对象,底层都会经历TCP三次握手、认证、选库这一整套流程。如果业务请求每次访问Redis都重新走一遍,性能损耗会非常大。这就是为什么生产环境一定要用连接池,而不是每次new Jedis()。

2.2 依赖引入与第一段连接代码

在Maven项目里引入Jedis非常简单,坐标是redis.clients:jedis。版本号不要随手写latest,要根据你用的Java版本和Redis服务端版本来定。我用得比较多的是4.x,它在Java 8上运行良好,也兼容Redis 6和Redis 7的大多数特性。如果你还在用非常老的Jedis 2.x,会遇到和Redis 6 ACL机制不兼容的问题,建议尽早升级。

<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>4.4.6</version> </dependency>

引入依赖之后,最基础的一段连接代码长这样:

import redis.clients.jedis.Jedis; public class QuickStart { public static void main(String[] args) { try (Jedis jedis = new Jedis("127.0.0.1", 6379)) { jedis.auth("your-redis-password"); jedis.select(0); jedis.set("greeting", "hello jedis"); System.out.println(jedis.get("greeting")); } } }

这里用到了try-with-resources,作用是在代码块执行完之后自动调用close()。有一个新手很容易误解的点:如果用连接池获取连接,close()并不是真正关闭TCP连接,而是把连接归还给池子;只有直连模式下,close()才真正断开连接。

还有一个线程安全级别的提醒:Jedis实例不是线程安全的,千万不要定义一个全局共享的Jedis实例给所有线程直接使用。如果你确实只有一个连接且想让它长期活着,也要在每次操作时做同步控制,但这是典型的性能反模式。正确做法是走连接池。

2.3 从直连到连接池,不要跳过这一步

直连代码适合本地测试和单机Demo,但一旦上到真实业务,并发一上来,直连就会变成灾难。原因不难理解:每个线程如果都new Jedis(),每来一个请求就要经历一次完整建连,还要加上Redis服务端创建文件描述符、分配内存的开销,QPS稍高一点,连接数就会飙升,Redis默认的maxclients上限很快被打满。

所以进入生产环境之前,一定要把“连接池”这个概念焊死在脑子里。Jedis连接池基于Apache Commons Pool实现,核心类是JedisPool和JedisPoolConfig。配置参数不算多,但每一个都直接影响性能表现。

下面这张表是我常用的初始参数参考值,具体数值要按业务调整:

参数推荐值(示例)说明
maxTotal50池中最多同时存在的连接数,按并发峰值估算
maxIdle30最大空闲连接数,超过的部分会被回收
minIdle5最低空闲连接数,避免流量骤降后连接全部释放
maxWaitMillis2000拿到连接的最大等待时间,超过则抛异常
testOnBorrowtrue从池中借出连接时做一次PING校验
testWhileIdletrue后台线程定时校验空闲连接是否可用
blockWhenExhaustedtrue池耗尽时阻塞等待,而不是直接抛异常

testOnBorrow特别值得说一下。它会在你每次从连接池拿到连接时执行一次PING命令,确保拿到的连接不是半死的。很多人担心这样会多一次网络往返,影响性能。实际上,只要你的Redis延迟在毫秒级,这个开销完全能接受,但它能救你于“连接池里全是断掉的死连接”这种线上事故。

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

3.1 手写一个高性能JedisPool工具类

直接暴露JedisPool给业务代码用不是不行,但很容易出现有人忘了close()、有人直接在循环里getResource导致连接耗尽这类问题。我习惯的做法是封装一个极简的工具类,对外只暴露一个“执行回调”的方法,连接获取和归还全部收口在内部。

import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; public class JedisTemplate { private static final JedisPool POOL; static { JedisPoolConfig config = new JedisPoolConfig(); config.setMaxTotal(50); config.setMaxIdle(30); config.setMinIdle(5); config.setMaxWaitMillis(2000); config.setTestOnBorrow(true); config.setTestWhileIdle(true); POOL = new JedisPool(config, "127.0.0.1", 6379, 2000, "your-redis-password", 0); } public <T> T execute(JedisCallback<T> callback) { try (Jedis jedis = POOL.getResource()) { return callback.doInJedis(jedis); } } public interface JedisCallback<T> { T doInJedis(Jedis jedis); } }

注意POOL用静态代码块初始化,保证整个JVM生命周期内只创建一次。构造JedisPool时,倒数第二个参数是password,最后一个参数是databaseIndex,这里直接指定为0,省得业务代码里还要每次执行select(0)。

使用时的姿势非常清爽:

String name = jedisTemplate.execute(jedis -> jedis.get("user:1001:name"));

这个模板的好处有几个。第一,连接生命周期完全可控,execute()执行完一定会归还连接。第二,异常处理统一,如果Redis内部抛了JedisDataException,回调里不想处理的异常可以继续往上抛。第三,业务代码不用接触JedisPool,降低误用概率。

3.2 常用数据结构操作实例:String、Hash、List、Set、ZSet

很多人以为连接上Redis就是set和get,这远远低估了Redis的实用性。Jedis把五种基本数据结构都映射成了非常直观的方法,我结合业务场景逐一说一遍。

String类型最常用的是缓存和计数器。比如用户注册后记录token有效期,对应Jedis就是:

String token = "a1b2c3"; jedis.setex("login:token:1001", 7200, token);

setex是“带过期时间的SET”,比先set再expire优雅,而且原子。计数器场景用incr和decr,注意它们在Jedis里没有那种“先get再set”的并发问题,因为Redis本身就是单线程执行命令:

long newValue = jedis.incr("counter:order:20250601");

Hash类型适合保存一个对象的多个字段。我之前做用户中心时,就习惯用一个大Hash存用户基础信息,而不是拆成一堆String key:

jedis.hset("user:1001", "name", "zhangsan"); jedis.hset("user:1001", "age", "28"); String name = jedis.hget("user:1001", "name"); Map<String, String> userMap = jedis.hgetAll("user:1001");

用Hash的好处是:要改一个字段时不用把整个对象读出来反序列化再写回去,内存占用也更低。

List类型最适合做简单的消息队列。生产端lpush,消费端brpop阻塞获取:

jedis.lpush("queue:task", "task-1"); List<String> tasks = jedis.brpop(5, "queue:task");

brpop的第一个参数是阻塞秒数,这里最多等5秒。注意brpop返回的是一个List,结构是[队列名, 值],别当成一个字符串直接取。

Set类型天然去重,适合做“我关注的用户集合”、“抽奖参与人”等,还能直接求交集:

jedis.sadd("user:1001:follows", "2001", "2002", "2003"); jedis.sadd("user:1002:follows", "2002", "2004"); Set<String> common = jedis.sinter("user:1001:follows", "user:1002:follows");

ZSet类型是排行榜的标配,分数值由Jedis的zadd方法传入。取Top N不要用zrange,因为那是按字典序,正确方法是用zrevrangeWithScores按分数从高到低取:

jedis.zadd("rank:score", 98.5, "player:001"); jedis.zadd("rank:score", 87.3, "player:002"); List<Tuple> topList = jedis.zrevrangeWithScores("rank:score", 0, 9);

3.3 管道、事务与Lua:把多步操作变成批量与原子

如果你写过“for循环里执行100次set”的代码,一定要重视管道。每执行一次set,都要走一次完整的“请求到达Redis、Redis执行、结果返回”的网络往返,100次就是100个RTT。即使在局域网,一次RTT大概零点几毫秒,100次也可能到几十毫秒;如果跨机房,这个时间会被放大到几百毫秒。

管道的作用是,在一个连接上连续写入多条命令,最后一次性读取所有结果。Jedis里用法很直接:

try (Jedis jedis = pool.getResource()) { Pipeline pipeline = jedis.pipelined(); for (int i = 0; i < 1000; i++) { pipeline.set("batch:" + i, String.valueOf(i)); } List<Object> results = pipeline.syncAndReturnAll(); }

需要澄清一点:管道能降低网络延迟开销,但并不能保证这1000条命令被当作一个整体原子执行。它们依然是顺序执行,只是省掉了每次等待结果的时间。如果你要求原子性,就得用事务或Lua脚本。

Jedis的事务用法是multi()开启,然后执行命令,最后exec()提交:

try (Jedis jedis = pool.getResource()) { Transaction tx = jedis.multi(); tx.set("balance:1001", "80"); tx.decr("total", "20"); List<Object> results = tx.exec(); }

事务的局限是它只有隔离性,没有事务回滚,如果执行过程中某条命令在Redis侧报错,其他命令已经执行的不会自动撤销。所以更多时候,我更推荐用Lua脚本来做既要原子、又要带逻辑的操作。比如经典的“先比较再删除”场景:

if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end

Jedis执行Lua脚本很简单:

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("lock:order:1001"), Collections.singletonList("random-token-xyz"));

eval的语义是:第一个参数是脚本内容,第二个参数是KEYS列表,第三个参数是ARGV列表。脚本在Redis服务端执行,不产生额外网络往返,还能保证原子性,是复杂业务逻辑下的首选方案。

3.4 接入Spring Boot的三种姿势

现在Java后端项目基本都是Spring Boot,Jedis接入Spring Boot有三条路线,按控制力从强到弱排。

第一种是最直接的:把上面写的JedisTemplate做一个@Bean注入Spring容器,业务代码直接@Autowired使用。这样你和Spring Data Redis没有任何耦合,所有行为都是自己掌控的。适合那种不希望被Spring封装的“隐藏行为”搞晕的团队。

第二种是Spring Boot原生的方式:把Jedis作为底层客户端,通过spring.redis配置连接参数。配置文件长这样:

spring: data: redis: host: 127.0.0.1 port: 6379 password: your-redis-password database: 0 timeout: 2000 jedis: pool: max-active: 50 max-idle: 30 min-idle: 5 max-wait: 2000

注意Spring Boot 3.x之后,配置前缀从spring.redis变成了spring.data.redis,网上很多老文章还在用旧前缀,花了半天发现没生效,一定要看清楚自己项目的大版本。只要classpath里有Jedis依赖,Spring Boot就会自动使用Jedis而不是默认的Lettuce,这个自动选择机制很有意思。

第三种是使用RedisTemplate,这也是最常用的方式。但用RedisTemplate最需要注意的是序列化。默认的JdkSerializationRedisSerializer存进去的数据是人眼完全看不懂的二进制,而且跨语言很难解析。我通常会在配置类里重新定义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 stringSerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }

这样data key和field都用字符串序列化,value用JSON,既方便排查问题,也能让其他语言的服务读取同一份数据。

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

4.1 连接失败的错误清单

Jedis连接Redis最容易遇到的问题,我整理成了一张速查表。这张表里的每一个错误我都真刀真枪在线上见过,不是闭门造车:

报错关键词常见原因处理思路
Connection refusedRedis服务没启动、端口不对、防火墙拦截先本地telnet host 6379确认通不通
Could not get a resource from pool连接池耗尽、密码错误导致借不到连接看maxTotal是否太小、Redis是否可达
NOAUTH Authentication requiredRedis设置了密码,但客户端没有调用auth检查连接池构造参数是否传入了password
WRONGPASS invalid username-password密码错误或ACL用户名密码不匹配用redis-cli手动试一次AUTH
ERR Client sent AUTH, but no password is setRedis没设密码,但客户端强行auth去掉password参数
Read timed out网络延迟高、慢命令阻塞、大key调soTimeout、查slowlog、拆分大key
Connection reset by peer连接空闲被服务端或网络设备断开开启testWhileIdle和testOnBorrow

4.2 超时与资源耗尽的排查思路

连接超时是Jedis项目里最有故事的话题。我遇到过一类特别典型的情况:应用日志里不断出现RedisConnectionFailureException,重启之后好一阵子,然后又复发。很多人第一反应是改大timeout,但这是治标不治本。

正确的排查顺序应该是这样的:先用redis-cli -h host -p 6379 ping确认Redis本身是否还活着。接着查Redis的慢查询,用SLOWLOG GET 10看最近10条慢命令。很多时候超时不是因为网络,而是某条命令阻塞了Redis主线程,比如一次HGETALL一个大Hash,或者SORT一个超大集合,Redis是单线程模型,一条慢命令会堵住后续所有请求,Jedis这边自然就是一片读超时。

大key问题也经常被忽视。如果你不确定是否存在大key,可以用redis-cli --bigkeys做一个简单的扫描。一旦发现某个String有几十MB,某个ZSet有上百万个元素,就要考虑拆分、压缩,或者换存储方案。我之前排查过一个跨机房调用,Redis延迟从2毫秒飙到200毫秒,最后定位到是一个List在日积月累下存了几十万条消息,LRANGE一下就把主线程卡住了。

资源耗尽问题则要关注连接池参数本身。当maxTotal设得太小而瞬时流量又很高时,所有线程都在等待连接,maxWaitMillis耗尽之后就会抛Could not get a resource。这时候不要盲目把maxTotal调大,要结合Redis服务端的maxclients限制一起看。另外,如果连接池设置了blockWhenExhausted=true,高并发下线程会同时阻塞在getResource()上,一旦超时,表现为大量的超时堆积,需要做好熔断和降级。

4.3 版本兼容与数据安全的一组提醒

Jedis版本与服务端Redis版本的兼容性,是升级时很容易踩的坑。Redis 6之前只有requirepass,Jedis 2.x时代用auth(password)就够了。Redis 6引入ACL后,如果你要用ACL用户,就需要在连接参数里同时指定username和password。Jedis 3.x之后的构造方法才完整支持这种写法。Redis 7又增加了一些新命令和高版本协议特性,老版本Jedis不一定都支持。我的建议是:Redis 6以上环境,Jedis尽量用4.x;如果是Redis 7,更要确认所用的Jedis版本对Redis 7新增特性是否有兼容问题。

数据安全方面,最容易翻车的是序列化问题。使用RedisTemplate时如果沿用Jdk序列化,存进去的key和value带着一堆二进制头,一旦将来要换非Java语言的服务去读,或者要用redis-cli手动排查数据,你会非常痛苦。上面已经给了序列化配置,值得养成习惯。另一个安全风险是密码硬编码。我在一些内部项目里见过把Redis的password直接写在Java常量类里的情况,一旦代码仓库泄露,Redis数据等于裸奔。正确做法是从环境变量或配置中心读取。

还有一点要提醒:不要在持久的连接里执行MONITOR命令。MONITOR会把所有写命令实时打印出来,对性能影响极大,而且日志里可能包含用户敏感数据。如果确实要排查线上问题,短时间开一下,赶紧关掉,日志级别也不要开成DEBUG后长时间不关。

4.4 一套可以落到团队的Jedis使用规范

最后把我这几年总结的几条Jedis使用规范分享出来,可以直接抄进团队的代码规范文档里。

第一,禁止在业务代码里直接new Jedis()。所有连接必须通过JedisPool获取,由统一模板类执行。这样能保证连接池参数、密码、超时配置都集中管理。

第二,连接归还必须放在finally或者try-with-resources里。很多“连接池耗尽”的线上事故,真相就是某个方法里获取了连接但忘了close,连接越积越多。

第三,禁止用KEYS *做模糊匹配。Redis的KEYS命令会遍历所有key,阻塞主线程,Jedis里对应方法就是jedis.keys("*")。要扫key请用SCAN命令,Jedis有ScanParams支持。

第四,循环里禁止逐条执行Redis命令。批量写入用Pipeline,批量读取尽量用mget,能一条命令搞定的事就不要做十次网络往返。

第五,日志里禁止打印connection.setPassword这类内容,不要为了排查问题把密码拼进错误日志。真要打,就打脱敏后的信息。

第六,连接参数要分开环境配置。本地、测试、生产使用不同的配置文件,尤其是maxTotal和timeout,不同环境流量差别很大,生产环境用测试环境的小池子参数,早晚要出事。

结尾:最后一次连接排查的经验

我最后想分享一个具体到“连接超时”的排查技巧,虽然简单,但几乎每次都能派上用场。不要只盯着Java代码里的异常栈,先拿到Redis侧的数据:用INFO clients看当前连接数,用INFO commandstats看命令调用次数,用SLOWLOG GET看慢命令,再配合Jedis连接池参数去判断。绝大多数Jedis连接问题都不是Jedis本身的bug,而是Redis侧慢了、连接池配小了、或者死连接没有被检测到。学会这套排查顺序,比记住一百个API更有用。做Java服务端,Jedis会是长期陪伴你的老朋友,它不花哨,但扎实可靠。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进