刚开始接触Redis的时候,很多人的第一个动作就是去装一个Redis服务,然后打开命令行敲SET foo bar试试。等敲完几个命令、确认Redis能跑起来之后,第二个问题马上就来了:我写Java业务代码,到底该怎么跟Redis打交道?是直接拼字符串发命令过去,还是有现成的客户端库可以用?网上搜了一圈,发现主流方案有Jedis、Lettuce、Spring Data Redis,眼花缭乱,又不知道该选哪个。
这篇文章就是专门解决这个问题的。我会从Java操作Redis的几种主流方式讲起,对比它们的适用场景,然后给出完整的项目依赖配置、核心代码示例、序列化问题的排查思路,以及我在实际开发中踩过的一些坑。目标很简单:看完之后,你能在自己的Java项目里顺畅地把Redis用起来,并且知道出了问题该往哪个方向排查。
1. 为什么要在Java程序里操作Redis
命令行里敲Redis命令很方便,但那只适合学习语法和临时验证。真实业务系统里,Redis要么是缓存层,要么是分布式锁的载体,要么是排行榜、队列、计数器等场景的存储后端,这些功能都必须在Java服务内部调用。比如用户登录后把Session塞进Redis、下单时扣减库存、Feed流接口的热点数据缓存,这些逻辑都是Java代码在跑,不可能让运维同事开个终端帮你执行命令。
所以Java操作Redis的本质,就是用一个客户端库把Redis的请求协议封装成好用的Java API。Redis通信协议本身是文本协议(RESP),理论上你甚至可以用原生的Socket去拼命令字符串发送给Redis服务端,再把响应解析回来。实际项目中没人这么干,因为太容易出错,也不好维护。客户端库帮你处理了连接管理、请求编码、响应解码、连接池复用、断线重连这些脏活累活。
在Java世界里,最常见的Redis客户端有三个:Jedis、Lettuce,以及Spring Data Redis封装之后的RedisTemplate。要理解它们的关系,可以这么类比:Jedis和Lettuce是直接跟Redis对话的“驱动程序”,你自己决定什么时候建立连接、什么时候发命令;而Spring Data Redis是在驱动之上又包了一层,让你不用关心底层用的是哪个驱动,通过模板方法就能操作Redis,就像Spring的JdbcTemplate把原生JDBC包装得更顺手一样。
2. 主流Java客户端选型对比
选型这件事,不同项目、不同团队,结论不完全一样。但从我接触过的项目来看,现在的主流趋势已经很明确了:Spring Boot项目里基本都用Spring Data Redis,它底层的默认驱动不是Jedis,而是Lettuce。老项目里还在用Jedis的也很多,尤其是一些没上Spring Boot的纯Java服务。
几个客户端的区别可以简单总结一下:
| 客户端 | 线程模型 | 连接管理 | 典型使用场景 |
|---|---|---|---|
| Jedis | 阻塞式,非线程安全 | 需要连接池 | 简单项目、低并发、习惯传统同步编码 |
| Lettuce | 基于Netty,线程安全 | 单连接多线程复用 | Spring Boot默认、高并发、需要异步能力 |
| Spring Data Redis | 对上面两种的封装 | 由底层驱动决定 | Spring生态项目、需要Template抽象 |
2.1 Jedis:老牌客户端,简单直接
Jedis的历史最久,API设计非常贴近Redis命令本身。比如jedis.set("name", "张三"),跟Redis命令SET name 张三一一对应,几乎没有学习成本。但Jedis有个硬伤:它的连接实例不是线程安全的。多个线程共用同一个Jedis实例,在高并发下会出现数据错乱甚至连接崩溃。所以用Jedis必须搭配连接池,每个线程从池里借一个连接、用完归还。
连接池的使用方式如下:
JedisPool pool = new JedisPool("localhost", 6379); try (Jedis jedis = pool.getResource()) { jedis.set("name", "张三"); }用完之后close归还连接。如果是在Java 8以下的老代码里,通常写成finally手动归还。这种模式虽然不够优雅,但胜在逻辑清晰,排查问题很直白——连接池满了就是满了,等待超时就在getResource那里报错。
2.2 Lettuce:Spring Boot默认的选择
Lettuce是基于Netty实现的,线程安全,同一个连接可以被多个线程并发使用。它不需要像Jedis那样每次都从池里借连接,所以连接管理的开销更小。Spring Boot 2.x之后,Spring Data Redis的默认底层驱动从Jedis换成了Lettuce,这是个大趋势。原因主要有两个:Lettuce天生支持异步和响应式编程,能适配Spring WebFlux;另外它的连接复用机制在多数场景下比Jedis连接池更省资源。
但这不等于Lettuce没有坑。它的默认行为是有超时配置的,而且一旦连接断掉,重连逻辑有时候会表现得比较隐晦。我之前遇到过一个问题:Redis服务端重启之后,Lettuce连接没有自动恢复,业务请求一直卡在超时边缘,直到手动重启Java服务才好。后来排查发现是缺少重连配置。所以用Lettuce时,一定要显式配置合理的超时时间和重连机制,不能完全依赖默认值。
2.3 Spring Data Redis:干活最顺手的封装
Spring Data Redis把Redis操作封装成了RedisTemplate和StringRedisTemplate。它最大的价值不是省几行代码,而是把序列化、连接管理、事务、管道这些复杂度收敛到了一套统一的API里。你写的业务代码不需要关心底层是Jedis还是Lettuce,将来即使换驱动,业务代码基本不用动。
不过Spring Data Redis也引入了它的经典麻烦——序列化。这个话题在后面专门展开,这里先提一句:默认的RedisTemplate用的是JDK序列化,存进Redis里的数据是一堆以xAC ED 00 05开头的二进制字节,肉眼没法看,跨语言读取更是灾难。所以实战中几乎所有人都会定制序列化方式。
2.4 我推荐的选型策略
如果你的项目是Spring Boot,直接用Spring Data Redis + Lettuce,这是最省心的路径。如果你在写一个轻量的非Spring工具,或者需要非常精细地控制Redis连接行为,选Jedis或者直接用Lettuce的原生API都行。不需要在选型上纠结太久,因为核心的Redis命令知识在不同客户端里是通用的,换客户端只是换API外壳而已。
3. 环境准备与项目搭建
动手写代码之前,先把环境准备好。假设你本机已经装了Redis服务,端口默认6379。如果没装,可以用Docker快速起一个:
docker run -d --name redis -p 6379:6379 redis:7.0这里要提醒一句:如果是拿Redis做学习测试,用Docker起一个裸容器很方便;但如果在公司开发环境,一定要先确认Redis实例的密码、端口、ACL规则,别用默认配置去连不认识的实例,容易把别人的缓存数据搞坏。
3.1 Spring Boot项目里加依赖
我用Maven举例。创建一个Spring Boot 2.7或者3.x项目,在pom.xml里加入:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>这个starter会自动引入Spring Data Redis和Lettuce。不需要手动加Jedis。如果你确定要用Jedis,可以再加:
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </dependency>然后需要在application.yml里配置连接信息:
spring: data: redis: host: localhost port: 6379 password: database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2注意:Spring Boot 2.x用的是spring.redis,到了Spring Boot 3.x改成了spring.data.redis。如果你是照着老的博客文章写,经常会被这个配置项卡住半天,报错又不太明显,最后发现只是前缀写错了。
3.2 不用Spring Boot的话怎么办
如果你的项目没有引入Spring Boot,只有Spring或者压根是纯Java,那用Jedis反而更直接。Maven坐标如下:
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>4.4.3</version> </dependency>然后用连接池配置类初始化JedisPool,代码上和上面的示例类似。这种方式的优点是依赖少、启动快、不受Spring版本影响。缺点是连接管理、序列化都要自己写,代码量会多一点。
3.3 连接池参数到底怎么定
连接池参数是很多人容易忽略的地方,但它直接影响线上稳定性。max-active是连接池里最多能同时存在的连接数,注意这里的单位是“连接”,不是“请求”。Redis单机处理几万QPS没问题,但不意味着你就要把连接池开到几千。连接本身是有内存开销的,连接数太多反而会增加Redis端的线程调度压力。
比较合理的起始参数是:
max-active:根据业务并发峰值估算,通常是CPU核数的2到3倍,或者压测得到。max-idle:控制在最大连接数的一半左右,避免空闲连接占用太多资源。min-idle:如果需要应对突发流量,设置为2到4,保证启动后就有连接可用。timeout:客户端等待连接的超时时间,一般设1到3秒,不建议太长,因为超过2秒用户已经明显感知到卡顿。
连接数不是越大越好,我曾经遇到过把max-active设成1000的情况,Redis本身没问题,但Jedis的getResource在峰值时会出现大量线程阻塞,因为连接池经过了TCP四次挥手和重新建连的过程,反而比复用连接慢了。这个参数一定要压测,别拍脑袋。
4. 五种基本数据类型的核心操作
Redis有五种基本数据类型:String、Hash、List、Set、ZSet。在Java客户端里,每一种都有对应的操作命令。下面我用Spring Data Redis的StringRedisTemplate和RedisTemplate分别演示,因为这是最主流的Java操作方式。
4.1 String类型:缓存与计数
String是最基础也是用得最多的类型。缓存用户信息、存Token、做计数器,都用它。在Redis命令里对应SET/GET/INCR/EXPIRE。
用StringRedisTemplate操作:
@Autowired private StringRedisTemplate stringRedisTemplate; // 写入缓存,10秒过期 stringRedisTemplate.opsForValue().set("user:login:count", "1", 10, TimeUnit.SECONDS); // 读取 String count = stringRedisTemplate.opsForValue().get("user:login:count"); // 原子自增 Long newCount = stringRedisTemplate.opsForValue().increment("user:login:count"); // 如果key不存在才设置(分布式锁的基本操作) Boolean ok = stringRedisTemplate.opsForValue().setIfAbsent("lock:pay:123", "1", 30, TimeUnit.SECONDS);这里要注意increment返回的是Long,如果value不是数字类型,会报错。另外setIfAbsent对应Redis的SETNX命令,是分布式锁的关键底层原语。
4.2 Hash类型:对象存储的利器
Hash适合存储一个对象的多个字段,比如用户信息、商品详情。在Java侧,对应opsForHash()的put和entries。
// 写入哈希 stringRedisTemplate.opsForHash().put("user:profile:1001", "name", "张三"); stringRedisTemplate.opsForHash().put("user:profile:1001", "age", "28"); // 读取单个字段 String name = (String) stringRedisTemplate.opsForHash().get("user:profile:1001", "name"); // 读取整个哈希 Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries("user:profile:1001");用Hash表示对象,比用String拼接一大段JSON更灵活,因为你可以单独更新某个字段,其他字段不受影响。但注意:entries会把整个Hash一次性拉回来,如果字段特别多(比如上百个),要考虑性能,最好限定字段范围。
4.3 List类型:队列与消息列表
List类型在Redis里就是个双向链表,可以从左边或者右边压入、弹出元素。常见的用途是简单队列、消息列表、最新通知列表。
// 从右侧压入三个元素 stringRedisTemplate.opsForList().rightPushAll("task:queue", "task1", "task2", "task3"); // 从左侧弹出元素(相当于队列的消费者) String task = stringRedisTemplate.opsForList().leftPop("task:queue"); // 获取列表长度 Long size = stringRedisTemplate.opsForList().size("task:queue"); // 根据索引范围获取,0到-1表示全部 List<String> tasks = stringRedisTemplate.opsForList().range("task:queue", 0, -1);List用作队列的一个问题是:leftPop如果队列为空,会立即返回null,在高频轮询场景下会产生大量无效请求。解决方法是使用阻塞式弹出方法,leftPop(key, timeout),比如等待5秒,有数据就返回,没数据阻塞到超时。这在Redis里有专门的命令BLPOP,可以有效降低Redis的QPS压力。
4.4 Set类型:去重与集合运算
Set类型的特征是元素唯一且无序。适合做标签、去重、共同好友这类集合运算。
// 添加元素 stringRedisTemplate.opsForSet().add("user:tags:1001", "java", "redis", "mysql"); // 判断元素是否存在 Boolean isMember = stringRedisTemplate.opsForSet().isMember("user:tags:1001", "redis"); // 获取集合中所有元素 Set<String> tags = stringRedisTemplate.opsForSet().members("user:tags:1001"); // 求两个集合的交集 Set<String> intersect = stringRedisTemplate.opsForSet().intersect("user:tags:1001", "user:tags:1002");集合运算在社交类业务里特别好用,比如“和你有共同关注的用户”,如果存的是Set,直接一个sinter命令搞定,不用在业务代码里遍历。
4.5 ZSet类型:排行榜的核心
ZSet(有序集合)比Set多了一个score分数,元素按分数排序。排行榜场景基本非它莫属。
// 给用户增加分数 stringRedisTemplate.opsForZSet().incrementScore("rank:game:level1", "player1", 100); // 获取排名前10(分数从高到低) Set<String> top10 = stringRedisTemplate.opsForZSet().reverseRange("rank:game:level1", 0, 9); // 获取某个用户的分数 Double score = stringRedisTemplate.opsForZSet().score("rank:game:level1", "player1"); // 获取某个用户的排名 Long rank = stringRedisTemplate.opsForZSet().reverseRank("rank:game:level1", "player1");这里有个小细节:rank是从0开始的,也就是说排名第一的用户返回的是0,如果业务上要把第一名显示为“第1名”,记得把查询结果加1。
5. 序列化问题:你的key为什么带着\xac\xed前缀
这是Java操作Redis时最容易踩的坑,几乎所有新手都会遇到。用默认的RedisTemplate写一个值,然后用Redis命令行客户端查看,发现key不是你想的名字,而是一长串类似\xac\xed\x00\x05t\x00\x04name的乱码。原因是Spring Data Redis默认用JdkSerializationRedisSerializer来序列化key和value,JDK序列化会在对象前面加上类型头信息,结果就是你看到的乱码。
5.1 为什么默认用JDK序列化
Spring Data Redis选JDK序列化作为默认是有历史原因的——它可以保证任何Serializable对象都能被序列化存储,开箱即用。问题是它产出的数据既不是人可读的,也不是跨语言通用的。你存一个Java对象,用Python去读那个key,就会发现完全没法解析。另外JDK序列化后的数据体积非常大,额外浪费Redis内存。
5.2 配置自定义序列化方案
实战中最常见的方案是:key用StringRedisSerializer,value用GenericJackson2JsonRedisSerializer(存JSON格式)。这样key在Redis里可读,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; } }配置完成之后,key和value都变成可读的人类字符串,用命令行就能直接查看,排查问题的时候会舒服很多。
5.3 注意JSON序列化带来的类型信息冗余
GenericJackson2JsonRedisSerializer会在JSON里额外写入@class字段来记录Java类型信息,方便反序列化时还原。但它的缺点也很明显:增加了存储空间,而且如果未来Java类重命名或包路径变化,老数据就反序列化不了了。比如com.example.dto.User改成com.example.domain.User,Redis里存的@class还是老的路径,读取时直接报反序列化异常。
解决这个问题,要么将就一下,保证类路径稳定;要么自己扩展一个基于Jackson的序列化器,在序列化时自动去掉@class,反序列化时直接转成JSONObject,再由业务层手动转换。对很多项目来说,后者反而更可控。
5.4 什么时候用StringRedisTemplate
StringRedisTemplate是另一条实用路径。它的key和value都强制用String序列化,也就是只存字符串。如果你的业务场景就是纯字符串数据——比如缓存JSON字符串、存数字字符串——直接用StringRedisTemplate,完全不用配置序列化器,简洁省事。而且它的API比RedisTemplate少一些泛型麻烦,对初学者更友好。
6. 事务与管道:批量操作的正确姿势
业务开发中经常会遇到“一次要执行多个Redis操作”的场景,比如扣库存时要检查余量、扣减数量、记录流水。这种场景可以用Redis事务和管道两种手段优化,但很多人搞不清楚它们有什么区别。
6.1 Redis事务:MULTI和EXEC
Redis事务本质上是一系列命令的批量执行,中间不会被其他客户端插进来。在Java里,Spring Data Redis提供了事务支持:
stringRedisTemplate.execute(new SessionCallback<List<Object>>() { @Override public List<Object> execute(RedisOperations operations) { operations.multi(); operations.opsForValue().set("key1", "value1"); operations.opsForValue().set("key2", "value2"); return operations.exec(); } });Redis事务不是传统关系型数据库那种ACID事务。它保证的是命令的原子执行,也就是在这一批命令执行期间别的客户端不会插入命令,但如果某条命令执行失败(比如对String类型执行了LPUSH),不会自动回滚之前的命令。这一点要特别清楚,不能用关系型数据库的思维去理解Redis事务。
6.2 Pipeline:减少网络往返
如果说事务是为了“原子性”,那么管道就是为了“性能”。管道把一批命令一次性发送到Redis服务端,再一次性接收所有响应。尤其在批量操作时,效果立竿见影,因为省掉了每条命令的网络往返时间。
stringRedisTemplate.executePipelined(new SessionCallback<Object>() { @Override public Object execute(RedisOperations operations) { for (int i = 0; i < 1000; i++) { operations.opsForValue().set("batch:key:" + i, "value" + i); } return null; } });实测下来,往Redis里写入5000个key,如果用循环逐条写,耗时可能在几百毫秒甚至更多;用管道批量写,基本就是一两秒内的几十毫秒级别。差距主要来自网络Round Trip,而不是Redis本身的执行速度。
6.3 事务+管道的组合用法
如果既要保证原子性,又要减少网络往返,可以把两者结合,先multi()再exec(),然后放进executePipelined里。但这里有个重要提示:事务中间如果某个命令报错,事务并不会回滚,你需要自己在业务代码里判断执行结果。所以不要把过度复杂的业务逻辑交给Redis事务去保证数据一致性,Redis事务适合的是“一组简单命令需要不被插队执行”的场景,而不是“多数据源联动强一致”的场景。
6.4 Lua脚本:更高级的原子操作
如果事务能满足不了你的需求——比如你需要在“检查-执行”之间防止其他客户端干扰,事务能做到,但要额外写代码。实际上更优雅的做法是用Lua脚本,把多个Redis命令打包成一个原子操作在服务端执行。Redis执行Lua脚本时,整个脚本是原子的。Spring Data Redis也支持:
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 result = stringRedisTemplate.execute(script, Collections.singletonList("lock:key"), "expectedValue");上面这段是经典的“用Lua实现安全释放分布式锁”的逻辑:只有value匹配才删除key,避免误删别人的锁。用Java代码加事务去模拟这段逻辑会很别扭,而用Lua脚本几行就搞定,而且绝对安全。
7. 常见问题排查与避坑技巧
最后这部分是我个人的实战经验汇总,遇到过的典型问题都列出来,每一条都对应着真实的踩坑经历。
7.1 连接超时:为什么Redis明明启动着,Java连不上
常见原因有三个:
- 端口没通:Redis默认6379,检查服务器防火墙和云安全组。
- bind配置限制:Redis的
bind如果只配了127.0.0.1,那么远程就连不上,需要改成内网IP或者注释掉bind这一行。 - 密码错误:在高版本Redis中,如果配置了
requirepass但Java连接没带密码,会明确报NOAUTH Authentication required。
排查时先用命令行工具试一下:
redis-cli -h 127.0.0.1 -p 6379 ping如果命令行通而Java不通,基本可以确定问题出在Java客户端配置上,而不是Redis服务本身。
7.2 连接池耗尽引发的雪崩
一个线上服务器如果Redis连接池用完了,所有请求都在等连接,等待超时后就会报错,进而可能导致业务线程池堆积,形成雪崩。这个问题的典型特征是监控上看到Redis连接数飙升到某个上限之后不再增长,但接口错误率突然上去了。
处理思路:先看max-active是否合理,再看连接释放是否正常,最后看Redis本身是否有慢查询。有一类隐蔽的泄漏是:用RedisTemplate的executePipelined时抛了异常,没有正确归还连接。虽然底层框架会尽力归还,但异常分支处理不好结果就难说了。排查的时候可以开Spring Data Redis的日志,观察连接创建和销毁日志,或者用info clients看当前连接数。
7.3 序列化异常:NotSerializableException
如果使用默认JDK序列化,业务对象没有实现Serializable接口,写入Redis时就会抛NotSerializableException。解决方式要么让对象实现Serializable,要么换用JSON序列化方案。我个人更推荐后者,因为JSON序列化对类结构不敏感,后面加字段、删字段都不会因为旧的序列化字节流而报错。
7.4 key过期时间:为什么设置过期了还没删除
Redis的过期删除策略是“惰性删除+定期删除”结合。如果一个key设置了过期时间,但一直没有被访问,同时Redis定期删除的采样落点又没扫到它,它可能还会存在一小段时间,直到某个请求访问它或者后续定期任务扫描到它才真正删除。这在业务上偶尔会有“过期了还能读到”的错觉。缓解办法是:对于时效性要求极高的数据,除了设置过期时间,还可以在读的时候做一次逻辑时间校验;但绝大多数场景不需要这么较真,Redis的过期时间精度足够应付日常需求。
7.5 大key问题
一个key里放了特别大的value,比如一个Hash有几十万个字段,或者一个List有几百万个元素,这种大key在查询、删除、迁移时都会拖累Redis主线程。尤其是删除大key时,Redis主线程可能卡住几百毫秒甚至更久,导致整个实例的其他请求全部被阻塞。解决办法是避免设计上就往同一个key里堆数据,如果实在无法避免,删除时要用渐进式扫描删,或者直接换一个专门的分拆方案。Java侧要做的是在写入前对value大小设个安全检查,该拆分就拆分,别等到线上出问题了再补救。
7.6 本地环境连不上Lettuce的一个小坑
Spring Boot 2.x之后用Lettuce时,如果Redis设置了密码,但是application.yml里漏了password字段,启动时不一定会立刻报错,第一次连接才会抛RedisConnectionFailureException。而且Lettuce的默认连接不是用完即关,它会维护长连接,所以这个报错可能在请求处理时才出现,不是启动时暴露。排查这些隐蔽问题时最直接的手段是打开调试日志:
logging: level: org.springframework.data.redis: DEBUG io.lettuce.core: DEBUG日志会详细显示连接过程和协议交互,基本能定位到具体卡在哪一步。
8. 从入门到独立的必经之路
写到这里,核心内容已经覆盖了。最后分享一点个人经验:学Redis的Java操作,最靠谱的路径不是背API,而是先把Redis命令本身的含义搞清楚,再去看Java客户端怎么映射这些命令。比如你理解了SETNX是分布式锁的原语,再去看setIfAbsent这个方法的原理就一目了然;你理解了BLPOP是阻塞式弹出,再看leftPop(key, timeout)就毫无障碍。
很多新手一上来就看Java API,反而越看越迷——因为Spring Data Redis的API做了很多封装,一些名字跟Redis原生命令对应不上。当你对Redis命令足够熟悉,再用Java客户端去操作,你会感觉到它不过是一层外壳,里面兜着的还是Redis核心的几种数据结构和命令思想。
我在实际项目中还发现一个习惯很值得养成:写操作Redis的代码时,先想想这个操作在命令行里对应的Redis命令是什么、要怎么手动验证结果,然后再去写Java代码。这样调试起来会顺手很多,排查问题也更快。Redis这个工具本身不复杂,复杂的是你如何在自己的业务体系里合理使用它。这一篇把Java侧的操作基础铺好,下一步就可以去深入缓存穿透、缓存雪崩、分布式锁这些经典话题了。