个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <
文章目录
- [Redis 入门(三):Spring Boot 整合 Redis,RedisTemplate 实战与序列化乱码](#Redis 入门(三):Spring Boot 整合 Redis,RedisTemplate 实战与序列化乱码)
-
- [一、加依赖:starter 和连接池](#一、加依赖:starter 和连接池)
-
- [Lettuce 和 Jedis 怎么选](#Lettuce 和 Jedis 怎么选)
- 二、写配置:application.yml
- 三、默认序列化器为什么会乱码
- [四、配一个可读的 RedisTemplate](#四、配一个可读的 RedisTemplate)
-
- [StringRedisTemplate 可以直接用](#StringRedisTemplate 可以直接用)
- 五、用模板操作五种数据类型
- [六、封装一个 RedisUtils](#六、封装一个 RedisUtils)
- [七、跑通一次 set/get 验证](#七、跑通一次 set/get 验证)
- 八、踩坑清单
- 小结
Redis 入门(三):Spring Boot 整合 Redis,RedisTemplate 实战与序列化乱码
上一篇我们把五种数据类型和常用命令在 redis-cli 里敲了一遍,命令算是认识了。可回到真实项目里,没人会在 Java 代码里拼命令字符串,我们用的是 Spring Boot 提供的 RedisTemplate。这篇就解决一个问题:怎么把上一篇学到的命令,变成 Java 里一行行可维护的调用,顺便拆掉新手最容易踩的那颗雷------用 redis-cli 一看,key 前面多了一串乱码。
一、加依赖:starter 和连接池
Spring Boot 官方把 Redis 的客户端封装在了 spring-boot-starter-data-redis 里,它属于 Spring Data Redis 模块,对外统一暴露 RedisTemplate 这套 API。引入它的时候,spring-data-redis 和底层客户端会被一起带进来。
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 想用连接池参数就必须加它,Lettuce 的连接池基于 commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
注意第二段依赖。很多人只在 yml 里写了 lettuce.pool.max-active,却忘了引 commons-pool2,结果配置静默失效,连接池根本没生效,还以为是池子参数没调好。这是个典型的"配置写了但没生效"的坑,排查时优先怀疑依赖。
还有一点需要理清:RedisTemplate 自己并不负责跟 Redis 通信,它只是一层统一的 API 门面,真正拿连接、发命令的是 RedisConnectionFactory,再往下才是具体的客户端实现。所以"用 Lettuce 还是 Jedis"这个问题,本质上换的是工厂和客户端,模板层的代码一行都不用改。想知道项目里现在到底用的是哪个,看一眼依赖树就清楚了:
bash
mvn dependency:tree -Dincludes=io.lettuce:lettuce-core,redis.clients:jedis
Lettuce 和 Jedis 怎么选
Spring Boot 2.x 之后默认的客户端是 Lettuce,早期版本才是 Jedis。两者思路完全不同:
- Jedis 走的是传统的阻塞式 TCP 连接,一个连接同一时刻只能被一个线程使用,所以必须靠连接池来撑并发,池子越大吞吐越高。
- Lettuce 基于 Netty,底层是多路复用 + 异步非阻塞,一个连接可以被多个线程共享,不需要靠堆连接数来换性能。
如果看过压测对比,结论大致是:Jedis 的吞吐量数字更漂亮,但并发一上来就开始出现连接等待和超时;Lettuce 的响应时间更稳,基本稳定在毫秒级。还有个反直觉的点------Lettuce 调大连接池反而可能变慢,因为真正干活的连接数受 CPU 核数限制,连接多了只是徒增线程上下文切换,实践里把池子按 CPU 核数 + 1 来估就够用了。
想换回 Jedis 也很简单,把 Lettuce 排除掉,再单独引 Jedis:
xml
<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>
二、写配置:application.yml
Spring Boot 3.x 的配置前缀是 spring.data.redis,2.x 是 spring.redis,别抄错版本,抄错了不会报错,只会默默连上 localhost:6379。
yaml
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: # 没设密码就留空
database: 0 # 默认 0 号库,范围 0-15
timeout: 3000ms # 命令读写超时,不是连接超时
lettuce:
pool:
max-active: 8 # 最大连接数,-1 表示不限制
max-idle: 8 # 最大空闲连接
min-idle: 0 # 最小空闲连接,想让池子常备连接就调大
max-wait: 1000ms # 池子拿不到连接时的最长等待,-1 表示一直等
timeout 建议显式配。默认值在 Redis 卡住时会让请求线程挂很久,配一个 2~3 秒的值,至少能让故障快速暴露而不是拖垮整个线程池。另外 database 这个坑也值得记一笔:不同库之间数据隔离,程序连 0 号库,你在 redis-cli 里用默认的 0 号库才看得到数据,切换库用 select 1。
密码不建议直接写死在配置文件里。本地开发图省事可以填明文,上了服务器最好改成 ${REDIS_PASSWORD} 这类占位符,让 Spring 从环境变量或配置中心取值,免得密码跟着代码一起进 Git。同理,多个应用共用一个 Redis 实例时,用库号做隔离是最省事的办法,但要知道 Redis Cluster 只支持 0 号库,一旦将来上了集群,这套隔离就失效了,所以更可靠的隔离手段还是给 key 加业务前缀。
三、默认序列化器为什么会乱码
依赖和配置都有了,先按最直觉的写法试一下:
java
@Autowired
private RedisTemplate redisTemplate;
public void saveUserName() {
redisTemplate.opsForValue().set("user:1:name", "tom");
}
代码不报错,值也取得出来,但打开 redis-cli 一敲 keys *,看到的却是 \xac\xed\x00\x05t\x00\x0cuser:1:name 这种东西。原因在于 RedisTemplate 默认给 key 和 value 都套了 JdkSerializationRedisSerializer,它把 Java 对象用 JDK 自带的 ObjectOutputStream 序列化成二进制字节数组,再原样塞进 Redis。于是 redis-cli 里既看不懂 key,也没法用 GET 手工排查。
这个问题的本质不是"数据错了",而是"编码方式不通用":JDK 序列化是 Java 私有的二进制协议,只有 Java 自己反序列化得回来,别的语言、别的中间件一概不认识。它还有两个副作用,一是强制对象实现 Serializable 接口,不实现就抛 DefaultSerializer requires a Serializable payload 之类的异常;二是体积明显偏大,因为它会把完整的类名和字段描述一起写进去。所以你会看到那串乱码的开头是固定的 \xac\xed,那是序列化流的魔数,后面紧跟着就是类的全限定名。
生产项目里基本都会换掉它,换成 JSON。理由很实在:JSON 是跨语言的通用格式,Python 脚本、Go 服务、甚至你手工敲 redis-cli,都能读懂同一份数据;缓存里的数据还能直接被人肉排查,出问题时不用写额外的测试代码去 dump。代价是对象类型信息需要额外记录,这点下面会讲怎么处理。
四、配一个可读的 RedisTemplate
目标很明确:key 用纯字符串,value 用 JSON,这样 redis-cli 里人能读、跨语言能解析。做法是自定义一个 RedisTemplate Bean 覆盖掉自动配置的默认实现。
java
package com.example.redisdemo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@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();
// key 和 hashKey 用字符串,保证 redis-cli 里可读
template.setKeySerializer(keySerializer);
template.setHashKeySerializer(keySerializer);
// value 和 hashValue 用 JSON
template.setValueSerializer(valueSerializer);
template.setHashValueSerializer(valueSerializer);
template.afterPropertiesSet();
return template;
}
}
四个序列化器要一起改,这是最容易漏的地方。只改了 key 不改 value,或者只改了 value 忘了 hashValue,都会让你在操作 Hash 类型时看到半截乱码。
GenericJackson2JsonRedisSerializer 比另一个常用类 Jackson2JsonRedisSerializer 更省事:后者需要在构造时指定具体类型,一个序列化器只能服务一种对象;前者在 JSON 里额外写入了 @class 字段来记录原始类型,反序列化时能自动还原成对应的对象,所以一个 Bean 就能通吃所有 value 类型。代价是 JSON 会多一个属性,且要求类有无参构造函数。如果你要存 LocalDateTime,可以自己构造 ObjectMapper 并注册 JavaTimeModule 后传给它的构造函数,否则时间字段会报错。
StringRedisTemplate 可以直接用
Spring Boot 还自动配置了一个 StringRedisTemplate,它的 key、value、hashKey、hashValue 全部使用 StringRedisSerializer,天生不存在乱码问题,@Autowired 进来就能用。所以有个简单的选型建议:只存字符串,直接用 StringRedisTemplate;要存对象,用上面自定义的 RedisTemplate。
但要记住一件事:这两个模板的序列化方式不同,不要用 A 写、用 B 读 。StringRedisTemplate 存进去的 "tom",用默认 JDK 序列化的模板去读是读不出字符串的,反之也一样。同一个 key 在项目里最好只由一种模板维护。
五、用模板操作五种数据类型
RedisTemplate 通过 opsForXxx() 拿到对应类型的操作对象,方法名和上一篇的命令基本能对上号。下面这些语句都写在 Service 的方法体里(redisTemplate 是注入进来的 RedisTemplate<String, Object>),不是独立的类,只看调用方式就行。
java
// String:缓存、计数器、分布式锁
redisTemplate.opsForValue().set("user:1:name", "tom");
redisTemplate.opsForValue().set("sms:code:138", "9527", 5, TimeUnit.MINUTES);
Object name = redisTemplate.opsForValue().get("user:1:name");
redisTemplate.opsForValue().setIfAbsent("lock:order:1001", "1", 30, TimeUnit.SECONDS);
redisTemplate.opsForValue().increment("article:1:views", 1);
// Hash:存对象的多个字段,可以只改其中一个
redisTemplate.opsForHash().put("user:1", "name", "tom");
redisTemplate.opsForHash().put("user:1", "age", 18);
Map<Object, Object> user = redisTemplate.opsForHash().entries("user:1");
redisTemplate.opsForHash().hasKey("user:1", "name");
redisTemplate.opsForHash().delete("user:1", "age");
// List:有序可重复,常见于消息队列和最新列表
redisTemplate.opsForList().rightPush("queue:mail", "mail-1");
redisTemplate.opsForList().leftPushAll("timeline:1", "a", "b", "c");
List<Object> range = redisTemplate.opsForList().range("timeline:1", 0, -1);
Object first = redisTemplate.opsForList().leftPop("queue:mail");
redisTemplate.opsForList().trim("timeline:1", 0, 99); // 只留最新 100 条
// Set:无序去重,适合标签、点赞用户集合
redisTemplate.opsForSet().add("tag:1:users", "u1", "u2");
Set<Object> members = redisTemplate.opsForSet().members("tag:1:users");
redisTemplate.opsForSet().isMember("tag:1:users", "u1");
redisTemplate.opsForSet().remove("tag:1:users", "u2");
// ZSet:带分数,适合排行榜
redisTemplate.opsForZSet().add("rank:score", "tom", 95);
redisTemplate.opsForZSet().add("rank:score", "jerry", 88);
Set<Object> top10 = redisTemplate.opsForZSet().reverseRange("rank:score", 0, 9);
Long rank = redisTemplate.opsForZSet().rank("rank:score", "tom");
redisTemplate.opsForZSet().incrementScore("rank:score", "tom", 5);
通用的 key 级操作不挂在 opsForXxx() 上,直接调模板本身:
java
redisTemplate.expire("user:1:name", 10, TimeUnit.MINUTES);
Long ttl = redisTemplate.getExpire("user:1:name", TimeUnit.SECONDS); // -1 永不过期,-2 key 不存在
Boolean exists = redisTemplate.hasKey("user:1:name");
redisTemplate.delete("user:1:name");
redisTemplate.delete(Arrays.asList("k1", "k2"));
有两点要留意。第一,opsForHash().put() 不会自动给 key 加过期时间,如果这个 Hash 是缓存,得自己补一次 expire,否则它会一直留在内存里。第二,opsForXxx() 每次调用都返回一个新的操作对象,但它很轻量,不涉及真实网络往返,所以没必要提前缓存到字段里,直接在方法里链式调用就好。
类型怎么选
命令记住了,落到代码里还是容易挑错类型,这里给几条能直接用的判断依据。
只存一个值,或者要做计数、加锁、存验证码,用 String。它是最简单也最省内存的一种,setIfAbsent 和 increment 两个方法顺手就能实现简易锁和计数器。
要存一个对象的多个字段,而且经常只改其中一两个,用 Hash。比如用户资料有昵称、头像、签名三个字段,用 Hash 的话改昵称只更新一个 field,不用把整个对象读出来再写回去,既省带宽也少了一次并发覆盖的风险。反过来,如果每次都是整体读写,字段也不多,其实直接用 String 存 JSON 更简单。
需要保持顺序、允许重复,用 List。典型场景是消息队列和"最新 N 条"列表,配合 trim 把长度卡住,内存就不会无限涨。
需要去重或者做集合运算,用 Set。比如给文章打标签、统计一天内的活跃用户,sadd 天然去重,交并差运算可以直接在 Redis 侧算完再返回。
需要按分数排序,用 ZSet。排行榜是它的经典用途,reverseRange 取前几名、rank 查某人排第几、incrementScore 改分数,都是 O(log N) 的操作,比在数据库里 order by 划算得多。
六、封装一个 RedisUtils
业务代码里到处写 redisTemplate.opsForValue().get(...) 有点啰嗦,而且 increment、getExpire 这类方法返回的是包装类型,可能为 null,直接拆箱会 NPE。包一层工具类,把强制转换和空值判断收进去,调用方会舒服很多。
java
package com.example.redisdemo.util;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
public class RedisUtils {
private final RedisTemplate<String, Object> redisTemplate;
public RedisUtils(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/** 写入并设置过期时间,单位由 unit 指定 */
public void set(String key, Object value, long timeout, TimeUnit unit) {
redisTemplate.opsForValue().set(key, value, timeout, unit);
}
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
public boolean delete(String key) {
return Boolean.TRUE.equals(redisTemplate.delete(key));
}
public boolean hasKey(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
/** 返回剩余秒数:-1 表示永不过期,-2 表示 key 不存在 */
public long getExpire(String key) {
Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
return ttl == null ? -2L : ttl;
}
public boolean expire(String key, long timeout, TimeUnit unit) {
return Boolean.TRUE.equals(redisTemplate.expire(key, timeout, unit));
}
/** 计数器自增,key 不存在时按 0 起算 */
public long increment(String key, long delta) {
Long value = redisTemplate.opsForValue().increment(key, delta);
return value == null ? 0L : value;
}
/** 不存在才写入,天然适合做简易分布式锁 */
public boolean setIfAbsent(String key, Object value, long timeout, TimeUnit unit) {
return Boolean.TRUE.equals(
redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit));
}
public void hSet(String key, String field, Object value) {
redisTemplate.opsForHash().put(key, field, value);
}
public Object hGet(String key, String field) {
return redisTemplate.opsForHash().get(key, field);
}
public void rightPush(String key, Object value) {
redisTemplate.opsForList().rightPush(key, value);
}
}
这个类不长,但有三处是刻意这么写的。
第一,方法返回值统一用基本类型而不是包装类型。redisTemplate.delete(key) 返回的是 Boolean,极端情况下可能为 null,直接 return redisTemplate.delete(key) 遇到自动拆箱就会抛 NPE。工具类是要被全项目调用的,这种地方必须自己兜住,外面用起来才不用每次都判空。
第二,用构造器注入而不是 @Autowired 挂字段。一是这样依赖关系写在签名上,谁依赖什么一目了然,也方便写单元测试;二是能顺带解决泛型问题,RedisTemplate<String, Object> 这个类型参数会完整地传到字段上。
第三,不要图省事把 redisTemplate 声明成 static 字段再用 @Autowired 注入。Spring 不会给静态字段注入依赖,结果就是每次调用都 NPE,这个坑几乎每个新手都踩过一次。真想要静态方法,正确做法是让工具类持有实例,或者在 @PostConstruct 里把注入的模板赋值给静态变量。
七、跑通一次 set/get 验证
最快的方式是写个单元测试,比启服务再发请求省事。
java
package com.example.redisdemo;
import com.example.redisdemo.util.RedisUtils;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest
class RedisTests {
@Autowired
private RedisUtils redisUtils;
@Test
void testSetAndGet() {
redisUtils.set("user:1:name", "tom", 10, TimeUnit.MINUTES);
assertEquals("tom", redisUtils.get("user:1:name"));
System.out.println("剩余秒数: " + redisUtils.getExpire("user:1:name"));
}
}
测试通过后,别急着关掉,去 redis-cli 里再确认一遍,这一步才是验证序列化配置有没有生效的关键:
bash
127.0.0.1:6379> keys user:*
1) "user:1:name"
127.0.0.1:6379> get user:1:name
"tom"
127.0.0.1:6379> ttl user:1:name
(integer) 598
key 是干净的字符串、value 能原样读出来、TTL 正常递减,说明配置全对。如果存对象,get 会返回带 @class 的 JSON 字符串,比如 {"@class":"com.example.redisdemo.entity.User","id":1,"name":"tom"},这也是预期结果。
想用接口的形式验证,写个 Controller 调 RedisUtils 效果一样,注意别把 Redis 操作直接写在 Controller 里,那只适合做一次性连通性测试,真实代码还是应该放在 Service 层,让 Controller 保持薄薄一层。另外测试类如果连的是本机 Redis 还好,要是连的是团队共用环境,@SpringBootTest 里的写操作记得用独立的 key 前缀,别把别人的数据覆盖了。
八、踩坑清单
1. 注入泛型丢失。 直接写 @Autowired private RedisTemplate redisTemplate;(裸类型),或者用 @Resource 注入时按名字匹配到了别的 Bean,编译能过,但取出来的对象全被判成 Object,opsForValue() 的泛型也跟着丢,后续要到处强转,还可能出现 ClassCastException。要么给字段写全 RedisTemplate<String, Object>,要么像上面那样用构造器注入。
2. 半截乱码。 只配了 keySerializer 和 valueSerializer,忘了 hashKeySerializer 和 hashValueSerializer,结果 String 类型正常、Hash 类型乱码。四个一起配。
3. 两个模板混用。 StringRedisTemplate 和自定义的 RedisTemplate 序列化规则不同,数据互不可见。另外如果你用过 Spring Cache 的 @Cacheable,缓存 key 会自动带上 cacheName:: 前缀,看到形如 userCache::1 的 key 不要以为是乱码。
4. 连接池耗尽。 报 Could not get a resource from the pool 或 RedisCommandTimeoutException,通常是慢命令占着连接不放,或者 max-active 给小了。先排查有没有在生产环境跑 keys *、hgetall 大 key、smembers 大集合这类 O(N) 命令,再考虑加连接池参数。
5. Redis 挂了业务要不要降级。 缓存本质上是"加速层",不是"数据源"。查询类缓存建议 try-catch 兜底,Redis 不通就直接回源数据库,接口慢一点但还能用;而像分布式锁、验证码、幂等令牌这类依赖 Redis 保证正确性的场景,绝不能静默吞异常,必须快速失败,否则会出现锁没加上却以为加上了的严重后果。要不要降级,取决于这份数据丢了会不会算错业务结果,判断标准是业务正确性,不是"能不能不报错"。
6. 序列化器不要随便改。 今天的配置一旦上线,Redis 里就沉淀了大量历史数据。哪天你把 value 序列化器从 JSON 换成别的,或者换了 ObjectMapper 的配置,线上就会出现一批反序列化失败的异常------数据还在,只是读不出来了。所以这个配置要在项目早期定下来,中途要改就得配合数据迁移,或者干脆换个新 key 前缀灰度过渡。另外,GenericJackson2JsonRedisSerializer 依赖 JSON 里记录的类名来还原对象,这也意味着类名和包路径一旦调整,缓存里的老数据同样会反序列化失败,重构包结构时别忘了这一步。
7. key 的命名和 keys 命令。 建议用冒号分层,像 user:1:name、order:1001:status 这样,好处是 redis-cli 里能按前缀快速定位,也方便后续做统计和清理。但千万别用 keys user:* 去找------这个命令会阻塞整个 Redis 实例,生产环境敲一次就可能拖慢所有业务,要遍历请改用 scan:
bash
127.0.0.1:6379> scan 0 match user:* count 100
需要提醒的是,keys() 在 RedisTemplate 上同样存在,写代码时一样要避免。
8. 大 key 和热 key 先避开。 一个 key 存了几兆的数据,或者几万个 member 的集合,读写时都会长时间占用连接,既容易触发超时,也会在集群迁移时成为麻烦。入门阶段的做法很简单:控制单个 value 的大小,集合类数据做好分片或者定期 trim,别让它无限长。
(至于缓存穿透、击穿、雪崩怎么防,那是本系列最后一篇的事,这里先记一笔。)
小结
这一篇从加依赖开始,一路走到自定义序列化器、五种类型的模板调用、工具类封装,最后用单元测试加 redis-cli 双重验证。最核心的一句话是:RedisTemplate 的问题几乎都和序列化有关,把四个序列化器配清楚,后面就顺了。
到这里,我们的数据已经能正常写进 Redis 并读出来。但还有一个问题没回答:这些数据到底存在哪儿?如果 Redis 进程被杀掉、机器断电重启,我们刚才写的 user:1:name 还在吗?下一篇就聊 RDB 和 AOF 这两种持久化方式,看看它们的原理、优缺点,以及生产环境到底该怎么配。