SpringBoot4系列07 - Redis数据库

上一篇结尾留了个话头,说从这一篇开始进 P-2,第一个要面对的问题是「Redis 的操作什么时候算成功,跟本地事务没有半点关系」。

这一篇就是。

Redis 不太愿意把自己叫数据库,官方给它的定位是数据结构服务器。String、Hash、List、Set、Sorted Set 这些结构在它这里是一等公民,每个命令都是原子操作,进去了就出结果,没有一半提交一半回滚这回事。

这个定位上的差别,写代码的时候处处能感觉到。最典型的例子是验证码。

邮箱注册场景人手一个的功能:五分钟内有效,发完 60 秒内不许再发,错满五次作废。这道题我先拿 MySQL 在脑子里过了一遍:建一张表,写三个字段,再配一个定时任务,每隔几分钟把过期的行扫掉。写到定时任务那一步我就停了,清理任务忘了部署或者扫表间隔配大了,过期的验证码就一直在表里躺着,这种隐患在代码评审里还看不出来。

换 Redis 做这道题,设键的时候带一个过期时间,完事。

到点 Redis 自己删,一行清理代码都不用写。本篇的工程就是这么做的:验证码的「有效期」、冷却的「60 秒」、作废的「错五次」,全部由键的 TTL 表达,业务规则和过期时间是一回事。我后来把这个对比想了很久,结论是 Redis 把「状态是有寿命的」这件事做成了原生能力,关系型数据库里那套要靠表结构和定时任务模拟。差距不在功能,在清理这步活儿归谁。

https://static.xiongneng.me/redis-ttl-is-the-business-rule-20260922000138.png

这一篇的工程在 springboot4-redis 目录下,场景就是上面这个验证码,外加一个访问统计,九个测试用例全部连着真实 Redis 跑,服务端是树莓派上的 Redis 8.0.6。每个接口我都在真实运行里手工过了一遍,下面的输出全部是那次运行的原始记录。

照例,先交代 4.1.1 里 Redis 这一块的现状。这一段我翻了本地仓库里 4.1.1 的制品:POM、字节码、配置元数据都看过。结论是,Redis 在 Boot 4 上的改动比 MongoDB 少,但序列化器这一块特别容易带偏人,我把它放在第一个讲。

4.1.1 里 Redis 的现状

自动配置模块的包名与组成

Boot 4.1.1 里,Redis 的自动配置位于独立模块 spring-boot-data-redis,包名是

1
org.springframework.boot.data.redis.autoconfigure

属性类叫 DataRedisProperties,连接工厂的组装逻辑、DataRedisConnectionDetails、健康检查、可观测这一摊,全在这个包底下。工程代码里包名写错的话,编译期直接报错,藏不住。

starter 的组成,我用 dependency:list 跑了一遍,结果是这样

1
2
3
4
5
6
spring-boot-starter-data-redis
├── spring-boot-starter
├── spring-boot-data-redis      ← 自动配置本体(4.1.1 新拆出来的)
│   ├── spring-data-redis:4.1.1
│   └── lettuce-core:7.5.2.RELEASE   ← 默认驱动,直接带进来
└── spring-messaging

两个要点。一是默认驱动 Lettuce 是非可选依赖,starter 直接带入 7.5.2.RELEASE,想用 Jedis 得自己另加 redis.clients:jedis,BOM 管着版本(7.4.1),但不会主动引入。二是 starter 里没有 Jackson,后面讲序列化器的时候这一条会变得很重要。

属性前缀保持 spring.data.redis

4.1.1 里 Redis 的连接属性就写在 spring.data.redis 下面。DataRedisProperties 的 @ConfigurationProperties 注解上写的是 spring.data.redis,配置元数据里 45 条正式属性全部在这个前缀底下。

4.1.1 里 Redis 的连接属性就写在 spring.data.redis 下面,别改。

写成 spring.redis.* 会命中 error 级废弃标记,应用启动直接失败。这一条在配置元数据里写得清清楚楚,level 是 error。error 级废弃没有告警余地,05 篇讲过,写了它启动就是失败。

https://static.xiongneng.me/redis-prefix-not-renamed-20260922000138.png

所以正确的记忆方式:Redis 的属性前缀是 spring.data.redis.*。我自己的记法是把 MongoDB 和 Redis 分开记,看到「Redis 属性改名」这类讲法时先核对版本,写错前缀的代价是启动失败。

两套 JSON 序列化器并存

这一节是本篇的重头。

spring-data-redis:4.1.1 的 serializer 包里,JSON 序列化器有两套并存

Jackson 2(旧)Jackson 3(新)
GenericJackson2JsonRedisSerializerGenericJacksonJsonRedisSerializer
Jackson2JsonRedisSerializerJacksonJsonRedisSerializer

名字只差一个字符,用的是两个完全不同的库。带 2 的那套吃 com.fasterxml.jackson,不带的那套吃 tools.jackson,也就是 Jackson 3。

系列前几篇反复出现的一个事实在这里再次应验:starter 和自动配置模块都不带 Jackson。这两个序列化器对应的依赖在 spring-data-redis 的 POM 里全部标了 optional,Boot 的逻辑是序列化器开发者来选、Jackson 开发者来带。Web 工程天然没问题,spring-boot-starter-webmvc 会把 Jackson 3 带进来(dependency:list 实测 3.1.5),所以本篇工程用带 webmvc 的那套组合,classpath 上只有 tools.jackson,带 2 的那套序列化器直接是废的,new 出来在运行期才会炸。我第一次扫这个包的时候,把带 2 的类名当成了顺手的选择,编译器一声不吭,这个坑埋得相当深。

https://static.xiongneng.me/redis-serializer-two-generations-20260922000138.png

于是只剩一个选择:用 Jackson 3 的 GenericJacksonJsonRedisSerializer。它还牵出一个 API 层面的细节,单独开一节讲。

测试注解的包名与测试 starter

@DataRedisTest 位于

1
org.springframework.boot.data.redis.test.autoconfigure

同一个包里还有 @AutoConfigureDataRedis 和配套的 TypeExcludeFilter。测试 starter 是 spring-boot-starter-data-redis-test,它和 MongoDB 的测试 starter 一样不含 webmvc,端到端 HTTP 测试要另外补 spring-boot-starter-webmvc-test。

依赖和配置

pom 的依赖部分,完整版在仓库里,这里说三处有讲究的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- Redis 主依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- Lettuce 连接池的前置条件,见下文 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

第一处讲究,为什么显式加 commons-pool2。要配 Lettuce 连接池,也就是 spring.data.redis.lettuce.pool.* 这一族属性,前提是 classpath 上有 commons-pool2。而它不是传递依赖,spring-data-redis 的 POM 里只有它进 dependencyManagement 的影子,版本有人管,包没人带。漏了它,池配置不会报错,只会静默不生效,连接永远不进池。说实话,这种「配了等于没配」的问题排查起来最耗人,表现和原因隔着两层,日志里一个字都不提,所以 pom 里我把它写成显式依赖,注释里写明因果。

第二处讲究,没有加 Jackson。webmvc starter 已经带了,原因上面说过。

第三处,application.yml 的连接配置长这样

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
spring:
  data:
    redis:
      host: ${REDIS_HOST:192.168.1.97}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:root123456}
      timeout: 2s
      connect-timeout: 2s
      lettuce:
        pool:
          enabled: true
          max-active: 8
          max-idle: 8
          min-idle: 2

注意前缀,spring.data.redis,原因前面讲过,不再重复。

测试环境在 application-test.yml 里把 database 从 0 切到 1。Redis 默认开 16 个逻辑库,编号 0 到 15,这是它最朴素的隔离手段,键空间物理分开,测试数据随便造,我的测试环境就是这么隔出来的。多说一句,这 16 个库只适合做环境隔离,不适合做业务隔离,一个项目里塞不下几个业务方,而且很多集群模式底下逻辑库不可用,别把它当多租户方案使。

核心代码

RedisTemplate 定制,以及 JDK 序列化为什么不能用

自动配置给了两个现成的模板 bean,RedisTemplate<Object, Object> 和 StringRedisTemplate。前者看着通用,是个陷阱:key 和 value 都走 JDK 序列化。

JDK 序列化写进 Redis 的东西,redis-cli 里看到的是 \xac\xed\x00\x05 开头的二进制乱码,存的对象必须实现 Serializable,跨语言读不了,升级类结构还有兼容问题。四条占全,没有一条能忍,我排查线上问题的时候,光是把乱码还原成对象这一步就够烦的。

所以教程工程的做法是自定义一个模板,key 用字符串、value 用 JSON

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
            .allowIfSubType("com.xncoding.")
            .build();

    GenericJacksonJsonRedisSerializer jsonSerializer = GenericJacksonJsonRedisSerializer.create(
            builder -> builder.enableDefaultTyping(typeValidator));

    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(connectionFactory);
    template.setKeySerializer(RedisSerializer.string());
    template.setHashKeySerializer(RedisSerializer.string());
    template.setValueSerializer(jsonSerializer);
    template.setHashValueSerializer(jsonSerializer);
    return template;
}

键选字符串序列化器,还有个额外的好处:键名在 redis-cli 里保持人类可读,captcha:me@example.com 就长这样,出问题的时候直接就能上手排查,不用先解一层码。

顺带交代驱动选型。

Lettuce 和 Jedis 是 Java 生态唯二的主流 Redis 客户端。Jedis 是同步阻塞的实现,一个连接同时只能跑一条命令,配池子是命根子。Lettuce 从 6.x 起基于 Netty,连接是线程安全的,一个连接可以被所有线程共享,天生支持响应式和集群拓扑自动刷新。Boot 的默认值选了 Lettuce,而且 4.1.1 里把它做成了非可选依赖。我的建议是跟着默认走,除非项目里有非 Jedis 不可的历史包袱。

注意一个细节,spring-boot-data-redis 模块的依赖里多了一个 spring-boot-netty,这是 4.x 拆出来的独立模块。Netty 本来就是 Lettuce 的传输层,Boot 4 只是把这层关系在模块化的时候摆到了明面上,功能上没有可感知的变化,不用为它做任何事。

GenericJacksonJsonRedisSerializer,没有无参构造

这一节讲最容易踩的那个坑。

GenericJacksonJsonRedisSerializer 没有无参构造,new 一下用不了。对着 4.1.1 的字节码确认过,可用的入口只有吃 ObjectMapper 的构造器,外加一组静态工厂,create(...) 和 builder()。

多态类型信息是一个必须显式拨动的开关。看上面那段代码,enableDefaultTyping(typeValidator) 这一行就是在拨它,并且 API 强制同时给一个 PolymorphicTypeValidator,用来限制反序列化时允许还原哪些类型。

为什么要强制。因为「JSON 里带类型、读的时候按类型实例化」是反序列化攻击的经典入口:攻击者构造一个 @class 指向恶意类的 JSON,应用读进来就替攻击者完成了实例化。Jackson 3 的方案是把默认值改成安全,把不安全变成显式选项,同一个类里留着 enableUnsafeDefaultTyping() 方法,名字都替调用方写好了。

示例工程里 validator 只放行 com.xncoding. 开头的类型,这是工程自己的包。别的项目换成对应包名就行,原则只有一条,白名单放行自己的类型,其余全部拒绝。

record 进不了多态通道

还有一个容易撞上的暗坑,我测试就栽在这里。

Java 21 之后,存的对象用 record 看起来很自然,但这条路走不通,这个决定连同原因写进了 CaptchaRecord 的注释里。

原因是默认类型信息只对非 final 类型生效。record 是 final 类,序列化的时候 JSON 里不带 @class,读回来 Jackson 不知道该实例化成谁,只能退化成 LinkedHashMap。代码拿着 Map 当 CaptchaRecord 用,编译期一点毛病没有,运行期在 instanceof 那里判空走异常分支。挺有意思的一点是这个失败特别安静:不报错、不打日志,就是数据悄悄变了形。

切片测试里验证过普通类的往返是通的,类型、字段值、时间戳全部原样还原。record 要走通得额外注册类型解析,性价比不高,不值得为一个语法的舒适度引入特殊配置,所以我没做。

存进 Redis 的领域对象,用普通类,别用 record。

验证码,TTL 就是业务规则

发送验证码的完整逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public CaptchaSendResult send(String email) {
    Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(KEY_LIMIT + email, "1", RESEND_COOLDOWN);
    if (!Boolean.TRUE.equals(acquired)) {
        throw new BusinessException(HttpStatus.TOO_MANY_REQUESTS,
                "发送过于频繁,请 60 秒后再试");
    }

    String code = "%06d".formatted(ThreadLocalRandom.current().nextInt(1_000_000));
    CaptchaRecord record = new CaptchaRecord(code, Instant.now());
    redisTemplate.opsForValue().set(KEY_CAPTCHA + email, record, CAPTCHA_TTL);

    return new CaptchaSendResult(email, CAPTCHA_TTL.toSeconds(), RESEND_COOLDOWN.toSeconds());
}

两个键,captcha:{email} 存验证码本体,TTL 五分钟,captcha:limit:{email} 是冷却标记,TTL 六十秒。没有定时任务,没有状态字段,过期这个概念 Redis 原生承担。

https://static.xiongneng.me/redis-key-layout-20260922000138.png

setIfAbsent,一个命令顶一层锁

第一行值得单独讲。

setIfAbsent 对应 Redis 的 SET key value NX EX,意思是这个键不存在才写入,写入成功带过期时间,整条命令在 Redis 侧一次执行完。

它解决的问题很具体:接口被并发打的时候怎么办。两个请求同时到了服务端,都执行「检查 60 秒内发过没有,没有就发」,如果检查和写入是两步,两个请求都能通过检查,验证码发两遍。把检查和写进冷却标记合成一条原子的 setIfAbsent,两个请求只有一个能拿到 true,另一个拿到 false,走 429。

有人在这儿先 synchronized 再查再写。单机下能跑,应用起了两个实例就穿。反过来想,锁的目的是让「检查加写入」成为不可分割的动作,而 Redis 本来就是单线程执行命令的,这条命令天生不可分割,锁白加了。

分布式语义的互斥标记,用 SET NX,不用锁。

顺带一提,这个模式就是很多分布式锁实现的底座,Redisson 的那些花活都在这条命令上长出来的。理解了 setIfAbsent,再看分布式锁的源码会亲切很多。

https://static.xiongneng.me/redis-setnx-race-20260922000138.png

写回时丢 TTL 的经典事故

校验的逻辑是读出来、比一比、错了累加次数、对了删键。中间有个步骤要放大看

1
2
3
4
5
6
7
8
9
record.setAttempts(record.getAttempts() + 1);
if (record.getAttempts() >= MAX_ATTEMPTS) {
    redisTemplate.delete(key);
    throw new BusinessException(HttpStatus.FORBIDDEN, "失败次数过多,验证码已作废,请重新获取");
}

// 写回时把剩余 TTL 一起带上
Long remaining = redisTemplate.getExpire(key);
redisTemplate.opsForValue().set(key, record, remainingTtl(remaining));

问题来了,为什么写回要带 TTL。

因为 RedisTemplate 的 set(key, value) 不带过期参数的时候,是覆盖整个键的元数据,原来键上的过期时间会被清掉,一个本来五分钟就消失的键,被写成了永久键。

这大概是 Redis 应用最经典的线上事故之一。验证码过了有效期还在 Redis 里躺着,攻击面、内存、合规,每一头都出问题。而且它没有报错,测试里只断言「校验返回 false」的话照样全绿,去看键的 TTL 才能发现。我自己的测试就漏过这一层,功能断言全过,回头一查 TTL,铁证摆在那里,键早就成永久键了。

本篇的测试里专门有一步对账:错四次之后去读键的剩余 TTL,断言它还大于零且不大于发送时的时间。凡是要把对象写回去的地方,先问一句,这个键上的 TTL 还在吗。

顺着 TTL 再讲一个 API 层的细节,getExpire 的返回值。

这个方法返回的是键的剩余秒数,但它有三种可能的取值。正数,正常,还剩这么多秒。负一,键存在但没有过期时间,永久键。负二,键不存在。

第三种最容易被漏。先读键再读 TTL,两步之间键正好过期的话,getExpire 给负二,代码没防着就会拿去拼 Duration,结果是异常或者一个荒唐的剩余时间。remainingTtl 里那个 remainingSeconds <= 0 的兜底分支,防的就是这个窗口。TTL 的读和键的读永远是两个命令,中间键可能消失,读到的每个负数都要有去处。

另外,这段读改写不保证原子。两个请求并发校验同一个邮箱,attempts 可能少算一次。示例工程留着这个不精确,因为验证码场景多错一次的代价是零。什么时候可以不较真、什么时候必须较真,开发者自己心里要有数:需要严格原子计数的场景,看下面这节。

INCR,为什么不用读加一写

访问统计的计数器

1
2
3
4
public VisitStatsResponse recordVisit(String email) {
    Long total = redisTemplate.opsForValue().increment(KEY_TOTAL);
    ...
}

increment 对应 INCR,原子自增,两个并发请求分别拿到 1001 和 1002,不重不漏。

要是用「读出来,加一,写回去」,并发下两个请求同时读到 1000,都写出 1001,少计一次。06 篇看到的「数据对不上但又说不出哪错了」,放在这个计数器上就是丢计数,而且永远不知道丢在哪次。这类问题我最怕的不是丢,是丢了你还不知道。

当天去重访客用集合

1
2
redisTemplate.opsForSet().add(KEY_VISITORS + today, email);
Long unique = redisTemplate.opsForSet().size(KEY_VISITORS + today);

SADD 天然去重,同一个人一天来八百次,集合里只有他一个,SCARD 拿集合大小就是去重访客数。键里带日期,隔天自然切新集合。

一次请求到底发了几条命令,日志对账

开发配置里把 org.springframework.data.redis.core 开到了 DEBUG,它会输出每次连接的借还。真实运行的时候,发送验证码那一次请求,日志是这样的

1
2
3
4
DEBUG o.s.d.redis.core.RedisConnectionUtils : Fetching Redis Connection from RedisConnectionFactory
DEBUG o.s.d.redis.core.RedisConnectionUtils : Closing Redis Connection
DEBUG o.s.d.redis.core.RedisConnectionUtils : Fetching Redis Connection from RedisConnectionFactory
DEBUG o.s.d.redis.core.RedisConnectionUtils : Closing Redis Connection

一对 Fetching 和 Closing 就是一次连接借还。send() 方法里两笔命令,setIfAbsent 一笔,带 TTL 的 set 一笔,日志里正好两对。

这个数日志的习惯是 06 篇传下来的。事务那篇靠数「提交次数加回滚次数」和连接借还数的差值抓出了 NESTED,这一篇我用同样笨的办法确认每条路径下发的命令数。状态查询那个接口,get 一笔加 getExpire 一笔,也是两对。校验失败的路径是三对,get、getExpire、写回的 set。

这套数法看着原始,但有一个无可替代的好处,不依赖对框架行为的理解,只依赖事实本身。以为只会发一条命令的接口,日志里数出来三条,这种事读十遍文档不如数一遍日志。

StringRedisTemplate 和 RedisTemplate 的分工

统计这个服务里注入的是 StringRedisTemplate,验证码那个服务用的是自定义的 RedisTemplate<String, Object>。

这有明确的原因。StringRedisTemplate 是自动配置自带的 bean,key 和 value 都是字符串序列化器,直接对应 Redis 的文本世界。计数器、集合这类场景,值本身就是简单字符串,用它最顺手。验证码的 value 是一个对象,需要 JSON 序列化,才轮到自定义模板出场。

一个经验法则,value 是字符串就用 StringRedisTemplate,value 是对象才上 RedisTemplate。 两个都用上的工程才是正常形态,别为了统一注入硬把字符串塞进 Object 模板。

异常层比上一篇干净

对比 06 篇那边的异常处理,这一篇的 GlobalExceptionHandler 少了两个处理器,乐观锁和唯一索引冲突,全删了。

原因不难想。Redis 没有版本号机制,没有唯一索引,那两类冲突在数据结构服务器这个语境里不存在。写操作要么成功要么连接失败,业务层面的冲突全靠键的语义自己表达,本篇工程里就是冷却期的 429 和错满五次的 403。

上一篇那些「异常穿过几层框架之后类型都变了」的问题,到这里一个都没有。每种中间件有它自己的脾气,接入之前想清楚它给什么、不给什么,异常层自然就知道该写什么。

完整案例,跑起来看

服务起来之后,整条链路我手工过了一遍,下面的输出都是真实运行抓的。

发送

1
2
POST /api/captchas  {"email":"shot-1789802034@example.com"}
201 {"email":"shot-1789802034@example.com","expiresInSecond":300,"resendAfterSecond":60}

https://static.xiongneng.me/redis-captcha-created-201-20260919151356.png

六十秒内重发,撞上冷却标记

1
429 Too Many Requests

https://static.xiongneng.me/redis-resend-429-20260919151358.png

查状态,剩余秒数和剩余尝试次数都在

1
2
3
GET /api/captchas/shot-1789802034@example.com
200 {"email":"shot-1789802034@example.com","remainingSecond":296,
     "remainingAttempts":5,"createdAt":"2026-09-19T07:13:55.329326500Z"}

https://static.xiongneng.me/redis-captcha-status-200-20260919151400.png

空邮箱,参数校验,RFC 9457 格式的错误响应

1
2
3
4
POST /api/captchas  {"email":""}
400 {"detail":"请求体字段校验未通过,逐字段原因见 errors","instance":"/api/captchas",
     "status":400,"title":"请求体校验失败",
     "errors":[{"message":"邮箱不能为空","field":"email"}]}

统计接口,连打两次,总数精确加二,同一个邮箱重复上报,去重访客数不动

1
2
GET /api/stats/visits?email=...    {"totalVisits":1,"date":"2026-09-19","todayUniqueVisitors":1}
GET /api/stats/visits?email=...    {"totalVisits":2,"date":"2026-09-19","todayUniqueVisitors":1}

校验的错误路径,拿一个错码去试,返回的是 200 加 verified:false,错误校验按业务结果返回,不走异常

1
2
POST /api/captchas/verify  {"email":"...","code":"000000"}
200 {"email":"...","verified":false}

错满五次之后,键已经被服务端删了,这时候再校验或者查状态,全是 404

1
GET /api/captchas/...   404 application/problem+json

手动作废,DELETE 删键,204

1
DELETE /api/captchas/...   204

删除一个已经不存在的验证码,返回 404。过期、校验成功后的自动删除、手动作废,三条路最后都汇到同一个语义上,键没了就是不存在,对上层一个样。这就是把状态放进 Redis 的世界观:不用维护一个「已作废」状态机,键的存在性本身就是状态。

最后是本篇最想展示的部分。打开 redis-cli,直接读验证码那个键

1
2
3
GET captcha:me@example.com
{"@class":"com.xncoding.redis.domain.CaptchaRecord","code":"860476","attempts":0,
 "createdAt":"2026-09-19T07:13:55.329326500Z"}

https://static.xiongneng.me/redis-browser-status-json-20260919151401.png

@class 就在第一个字段,前面讲的默认类型信息长这样。值是纯 JSON 文本,redis-cli 里一眼看懂,Python 脚本一读就解析,这就是换掉 JDK 序列化的意义。排查线上问题的时候,能直接看懂数据的人,和要拉起一套反序列化环境才能看数据的人,效率不是一个量级。

这一篇的测试怎么写

九个用例,分两摊。

切片测试用 @DataRedisTest,注意包名,还要显式 @Import(RedisConfig.class)。切片只装 Redis 那一摊自动配置,自定义的配置类不会被扫进来,不 Import 的话注入的就是自动配置那个 JDK 序列化的模板,类型直接对不上。这个约束反而是个提醒:value 序列化器换没换,切片测试第一个知道。

四个用例的骨架长这样

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@DataRedisTest
@ActiveProfiles("test")
@Import(RedisConfig.class)
class RedisSerializationSliceTest {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Test
    void recordRoundTrip() {
        String key = "test:roundtrip:" + System.nanoTime();
        try {
            redisTemplate.opsForValue().set(key, new CaptchaRecord("483920", ...),
                    Duration.ofMinutes(5));
            Object value = redisTemplate.opsForValue().get(key);
            // 类型还原成功,拿到的是 CaptchaRecord 而不是 Map
            assertThat(value).isInstanceOf(CaptchaRecord.class);
        } finally {
            redisTemplate.delete(key);
        }
    }
}

每条用例的键都带 nanoTime 后缀,finally 里删键,用例之间互不踩。

切片里测的四件事:对象往返后类型还原成 CaptchaRecord 而不是 Map、原始值是带 @class 的 JSON 文本、TTL 生效、INCR 原子性。都是这一篇的关键机制,不是接口行为。

端到端用 @SpringBootTest 加 @AutoConfigureMockMvc,连真实 Redis 走完整 HTTP 栈。每个用例对关键事件单独计数,比如统计接口那两次访问,断言写的是第二次的 total 等于第一次加一,而不是「两次都返回 200 就算过」。06 篇那个教训记到现在,只看状态码的测试,会放过所有对不上数的问题。

测试数据用带运行后缀的独立邮箱,收尾统一清理,全局计数器用 DECR 回退增量,演示数据不被测试抬高。两个测试类开头都做了可用性探测,Redis 连不上就整体 skip,不做一片红。

避坑指南

这一篇的坑,大半是「静默」两个字的变体,我基本都亲手碰过。

坑一,写成 spring.redis. 前缀,error 级废弃,启动即失败。* 正确前缀 spring.data.redis.*,4.1.1 沿用没变。别把 MongoDB 那篇的前缀改名结论平移到 Redis 上,我就差点平移过去。

坑二,commons-pool2 不是传递依赖,漏了它池配置静默失效。 表现为连接池永远不建,不报任何错。显式加依赖,别指望 starter 帮忙带。

坑三,GenericJacksonJsonRedisSerializer 没有无参构造,多态类型信息是显式开关。 用 create(...) 或 builder(),配 BasicPolymorphicTypeValidator 白名单放行自己的包。看到 enableUnsafeDefaultTyping 绕道走。

坑四,存 Redis 的对象用 record,读回来会变成 LinkedHashMap。 默认类型信息只对非 final 类型生效。存进 Redis 的领域对象用普通类。

坑五,不带 TTL 的 set 会清掉过期时间,临时键变永久键。 读改写之后写回时,把剩余 TTL 一起带上。测试要专门断言 TTL 还在。

坑六,自动配置的 RedisTemplate 走 JDK 序列化,别直接用。 要么自定义 JSON 序列化的模板,要么 value 本来就是字符串就直接用 StringRedisTemplate。

小结

这一篇的接口行为表

操作行为本篇的实现
有限寿命的状态写入时带 TTL,到期自动消失set(key, value, duration)
一次性的标记不存在才写入,原子setIfAbsent(key, value, duration)
并发安全的计数服务端自增INCR
去重计数集合SADD + SCARD
主动作废删键DELETE

怎么选型,我的建议按 value 的形态走:值是字符串或纯数字,StringRedisTemplate 加上 INCR、SADD 这类服务端命令就够了,别引入对象序列化;值是业务对象,才自定义模板上 JSON 序列化器,并且把白名单 validator 一起配上。键的设计比配置重要,键名怎么组织、TTL 给多久、哪些操作要靠原子命令兜底,这三个问题想清楚了,Redis 就用对了。

没解决的事也说清楚。读改写校验那段的非原子窗口我留着没修,验证码场景的代价是零,真要严格计数得换 Lua 脚本或者 WATCH 事务,那是另一个主题的篇幅。切片测试只覆盖了序列化与 TTL,连接池行为、超时重试这些运行期特性没有专门用例,等后面讲到生产配置的时候我再补测。

工程在 springboot4-redis 目录下,自带 README,四个接口手工测试的 curl 命令都在里面。建议跑一遍,然后在 redis-cli 里开着 MONITOR 看每次点接口时 Redis 收到了什么命令,看一遍比读三遍代码有用。

署名 Xiong Neng,许可 MIT。

参考链接