SpringBoot4系列07 - Redis数据库
上一篇结尾留了个话头,说从这一篇开始进 P-2,第一个要面对的问题是「Redis 的操作什么时候算成功,跟本地事务没有半点关系」。
这一篇就是。
Redis 不太愿意把自己叫数据库,官方给它的定位是数据结构服务器。String、Hash、List、Set、Sorted Set 这些结构在它这里是一等公民,每个命令都是原子操作,进去了就出结果,没有一半提交一半回滚这回事。
这个定位上的差别,写代码的时候处处能感觉到。最典型的例子是验证码。
邮箱注册场景人手一个的功能:五分钟内有效,发完 60 秒内不许再发,错满五次作废。这道题我先拿 MySQL 在脑子里过了一遍:建一张表,写三个字段,再配一个定时任务,每隔几分钟把过期的行扫掉。写到定时任务那一步我就停了,清理任务忘了部署或者扫表间隔配大了,过期的验证码就一直在表里躺着,这种隐患在代码评审里还看不出来。
换 Redis 做这道题,设键的时候带一个过期时间,完事。
到点 Redis 自己删,一行清理代码都不用写。本篇的工程就是这么做的:验证码的「有效期」、冷却的「60 秒」、作废的「错五次」,全部由键的 TTL 表达,业务规则和过期时间是一回事。我后来把这个对比想了很久,结论是 Redis 把「状态是有寿命的」这件事做成了原生能力,关系型数据库里那套要靠表结构和定时任务模拟。差距不在功能,在清理这步活儿归谁。

这一篇的工程在 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,包名是
| |
属性类叫 DataRedisProperties,连接工厂的组装逻辑、DataRedisConnectionDetails、健康检查、可观测这一摊,全在这个包底下。工程代码里包名写错的话,编译期直接报错,藏不住。
starter 的组成,我用 dependency:list 跑了一遍,结果是这样
| |
两个要点。一是默认驱动 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 篇讲过,写了它启动就是失败。

所以正确的记忆方式:Redis 的属性前缀是 spring.data.redis.*。我自己的记法是把 MongoDB 和 Redis 分开记,看到「Redis 属性改名」这类讲法时先核对版本,写错前缀的代价是启动失败。
两套 JSON 序列化器并存
这一节是本篇的重头。
spring-data-redis:4.1.1 的 serializer 包里,JSON 序列化器有两套并存
| Jackson 2(旧) | Jackson 3(新) |
|---|---|
GenericJackson2JsonRedisSerializer | GenericJacksonJsonRedisSerializer |
Jackson2JsonRedisSerializer | JacksonJsonRedisSerializer |
名字只差一个字符,用的是两个完全不同的库。带 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 的类名当成了顺手的选择,编译器一声不吭,这个坑埋得相当深。

于是只剩一个选择:用 Jackson 3 的 GenericJacksonJsonRedisSerializer。它还牵出一个 API 层面的细节,单独开一节讲。
测试注解的包名与测试 starter
@DataRedisTest 位于
| |
同一个包里还有 @AutoConfigureDataRedis 和配套的 TypeExcludeFilter。测试 starter 是 spring-boot-starter-data-redis-test,它和 MongoDB 的测试 starter 一样不含 webmvc,端到端 HTTP 测试要另外补 spring-boot-starter-webmvc-test。
依赖和配置
pom 的依赖部分,完整版在仓库里,这里说三处有讲究的
| |
第一处讲究,为什么显式加 commons-pool2。要配 Lettuce 连接池,也就是 spring.data.redis.lettuce.pool.* 这一族属性,前提是 classpath 上有 commons-pool2。而它不是传递依赖,spring-data-redis 的 POM 里只有它进 dependencyManagement 的影子,版本有人管,包没人带。漏了它,池配置不会报错,只会静默不生效,连接永远不进池。说实话,这种「配了等于没配」的问题排查起来最耗人,表现和原因隔着两层,日志里一个字都不提,所以 pom 里我把它写成显式依赖,注释里写明因果。
第二处讲究,没有加 Jackson。webmvc starter 已经带了,原因上面说过。
第三处,application.yml 的连接配置长这样
| |
注意前缀,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
| |
键选字符串序列化器,还有个额外的好处:键名在 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 就是业务规则
发送验证码的完整逻辑
| |
两个键,captcha:{email} 存验证码本体,TTL 五分钟,captcha:limit:{email} 是冷却标记,TTL 六十秒。没有定时任务,没有状态字段,过期这个概念 Redis 原生承担。

setIfAbsent,一个命令顶一层锁
第一行值得单独讲。
setIfAbsent 对应 Redis 的 SET key value NX EX,意思是这个键不存在才写入,写入成功带过期时间,整条命令在 Redis 侧一次执行完。
它解决的问题很具体:接口被并发打的时候怎么办。两个请求同时到了服务端,都执行「检查 60 秒内发过没有,没有就发」,如果检查和写入是两步,两个请求都能通过检查,验证码发两遍。把检查和写进冷却标记合成一条原子的 setIfAbsent,两个请求只有一个能拿到 true,另一个拿到 false,走 429。
有人在这儿先 synchronized 再查再写。单机下能跑,应用起了两个实例就穿。反过来想,锁的目的是让「检查加写入」成为不可分割的动作,而 Redis 本来就是单线程执行命令的,这条命令天生不可分割,锁白加了。
分布式语义的互斥标记,用 SET NX,不用锁。
顺带一提,这个模式就是很多分布式锁实现的底座,Redisson 的那些花活都在这条命令上长出来的。理解了 setIfAbsent,再看分布式锁的源码会亲切很多。

写回时丢 TTL 的经典事故
校验的逻辑是读出来、比一比、错了累加次数、对了删键。中间有个步骤要放大看
| |
问题来了,为什么写回要带 TTL。
因为 RedisTemplate 的 set(key, value) 不带过期参数的时候,是覆盖整个键的元数据,原来键上的过期时间会被清掉,一个本来五分钟就消失的键,被写成了永久键。
这大概是 Redis 应用最经典的线上事故之一。验证码过了有效期还在 Redis 里躺着,攻击面、内存、合规,每一头都出问题。而且它没有报错,测试里只断言「校验返回 false」的话照样全绿,去看键的 TTL 才能发现。我自己的测试就漏过这一层,功能断言全过,回头一查 TTL,铁证摆在那里,键早就成永久键了。
本篇的测试里专门有一步对账:错四次之后去读键的剩余 TTL,断言它还大于零且不大于发送时的时间。凡是要把对象写回去的地方,先问一句,这个键上的 TTL 还在吗。
顺着 TTL 再讲一个 API 层的细节,getExpire 的返回值。
这个方法返回的是键的剩余秒数,但它有三种可能的取值。正数,正常,还剩这么多秒。负一,键存在但没有过期时间,永久键。负二,键不存在。
第三种最容易被漏。先读键再读 TTL,两步之间键正好过期的话,getExpire 给负二,代码没防着就会拿去拼 Duration,结果是异常或者一个荒唐的剩余时间。remainingTtl 里那个 remainingSeconds <= 0 的兜底分支,防的就是这个窗口。TTL 的读和键的读永远是两个命令,中间键可能消失,读到的每个负数都要有去处。
另外,这段读改写不保证原子。两个请求并发校验同一个邮箱,attempts 可能少算一次。示例工程留着这个不精确,因为验证码场景多错一次的代价是零。什么时候可以不较真、什么时候必须较真,开发者自己心里要有数:需要严格原子计数的场景,看下面这节。
INCR,为什么不用读加一写
访问统计的计数器
| |
increment 对应 INCR,原子自增,两个并发请求分别拿到 1001 和 1002,不重不漏。
要是用「读出来,加一,写回去」,并发下两个请求同时读到 1000,都写出 1001,少计一次。06 篇看到的「数据对不上但又说不出哪错了」,放在这个计数器上就是丢计数,而且永远不知道丢在哪次。这类问题我最怕的不是丢,是丢了你还不知道。
当天去重访客用集合
| |
SADD 天然去重,同一个人一天来八百次,集合里只有他一个,SCARD 拿集合大小就是去重访客数。键里带日期,隔天自然切新集合。
一次请求到底发了几条命令,日志对账
开发配置里把 org.springframework.data.redis.core 开到了 DEBUG,它会输出每次连接的借还。真实运行的时候,发送验证码那一次请求,日志是这样的
| |
一对 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。
上一篇那些「异常穿过几层框架之后类型都变了」的问题,到这里一个都没有。每种中间件有它自己的脾气,接入之前想清楚它给什么、不给什么,异常层自然就知道该写什么。
完整案例,跑起来看
服务起来之后,整条链路我手工过了一遍,下面的输出都是真实运行抓的。
发送
| |

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

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

空邮箱,参数校验,RFC 9457 格式的错误响应
| |
统计接口,连打两次,总数精确加二,同一个邮箱重复上报,去重访客数不动
| |
校验的错误路径,拿一个错码去试,返回的是 200 加 verified:false,错误校验按业务结果返回,不走异常
| |
错满五次之后,键已经被服务端删了,这时候再校验或者查状态,全是 404
| |
手动作废,DELETE 删键,204
| |
删除一个已经不存在的验证码,返回 404。过期、校验成功后的自动删除、手动作废,三条路最后都汇到同一个语义上,键没了就是不存在,对上层一个样。这就是把状态放进 Redis 的世界观:不用维护一个「已作废」状态机,键的存在性本身就是状态。
最后是本篇最想展示的部分。打开 redis-cli,直接读验证码那个键
| |

@class 就在第一个字段,前面讲的默认类型信息长这样。值是纯 JSON 文本,redis-cli 里一眼看懂,Python 脚本一读就解析,这就是换掉 JDK 序列化的意义。排查线上问题的时候,能直接看懂数据的人,和要拉起一套反序列化环境才能看数据的人,效率不是一个量级。
这一篇的测试怎么写
九个用例,分两摊。
切片测试用 @DataRedisTest,注意包名,还要显式 @Import(RedisConfig.class)。切片只装 Redis 那一摊自动配置,自定义的配置类不会被扫进来,不 Import 的话注入的就是自动配置那个 JDK 序列化的模板,类型直接对不上。这个约束反而是个提醒:value 序列化器换没换,切片测试第一个知道。
四个用例的骨架长这样
| |
每条用例的键都带 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。