一次由 Redis + Spring Security + Jackson 引发的"看不见的坑"
------ 从一个诡异错误到系统性解决的完整复盘
一、问题背景(发生了什么)
我们项目使用的是一套很常见的技术组合:
- Spring Boot
- Spring Security
- Spring Session + Redis
- Jackson(JSON 序列化)
系统中有一个自定义的用户对象:
java
UserInfo implements UserDetails
👉 这是 Spring Security 官方推荐的做法。
现象一:系统突然启动报错
在没有修改 UserInfo 类结构的情况下,系统启动或访问接口时报错:
com.xxx.UserInfo not in the allowlist
错误发生在 Spring Security 从 Redis 反序列化 Session 的过程中。
二、第一次"误判"(为什么一开始很迷惑)
1️⃣ 我没改 UserInfo,为什么会报错?
直觉判断是:
- ❌ Redis 有问题?
- ❌ 类加载问题?
- ❌ Spring Bug?
2️⃣ 神奇现象:执行 FLUSHDB 后,错误消失了
bash
redis-cli
FLUSHDB
系统立刻恢复正常。
👉 这让人产生了一个非常危险的错觉:
"那是不是 Redis 脏数据?
清一下就好了?"
这是第一个关键误区。
三、真正的原因(第一层真相)
1️⃣ Redis 里存的不是"数据",而是"对象快照"
Spring Session 会把**登录状态(SecurityContext)**序列化进 Redis。
Redis 里存的是类似这样的 JSON:
json
{
"principal": {
"@class": "com.xxx.UserInfo",
"username": "admin",
...
}
}
2️⃣ Spring Security 做了一件"安全升级"
为了防止反序列化漏洞(反序列化 RCE),
Spring Security 只允许反序列化白名单中的类(allowlist)。
👉 问题来了:
- Spring Security 不信任你的自定义 UserInfo
- 即使它实现了
UserDetails - 即使类真实存在
只要不在 allowlist,就拒绝反序列化
四、为什么 FLUSHDB 能"治好"?
因为:
FLUSHDB清掉了 旧 Session- 系统不再需要反序列化旧的
UserInfo - 错误就"暂时消失"了
⚠️ 但这不是修复,是"把炸弹扔了"
一旦:
- 服务重启
- 多实例部署
- Redis Session 再次被读取
👉 错误必然复发
五、第一次正式修复方案(方案二)
修复思路
告诉 Jackson:
"UserInfo 是安全的,可以反序列化"
技术手段:Jackson Mixin
java
@JsonTypeInfo(
use = JsonTypeInfo.Id.CLASS,
include = JsonTypeInfo.As.PROPERTY,
property = "@class"
)
public abstract class UserInfoMixin {}
并注册到 ObjectMapper。
六、第二次错误(更诡异,也更隐蔽)
修完之后,登录没问题了。
但普通接口突然开始报错:
text
JSON parse error:
Unexpected token (START_OBJECT),
expected VALUE_STRING:
need JSON String that contains type id
(for subtype of java.util.List)
错误出现在:
java
@RequestBody List<FormDataVO>
👉 这和 Security、UserInfo 看起来完全没关系!
七、第二层真相(真正的根因)
1️⃣ 我们"误伤"了整个系统的 JSON 规则
问题出在这里:
java
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModules(SecurityJackson2Modules.getModules(...));
mapper.addMixIn(UserInfo.class, UserInfoMixin.class);
return mapper;
}
Spring Boot 的默认行为是:
只要你定义了一个 ObjectMapper Bean
👉 Spring MVC、@RequestBody、@ResponseBody
👉 全部都会用它
2️⃣ SecurityJackson2Modules 是"手术刀"
它的作用是:
- 启用多态反序列化
- 要求 JSON 中带
@class
这对 Redis Session 是必须的
但对 HTTP 请求 JSON 是灾难性的
于是:
json
[
{ "field": "a", "value": "b" }
]
在 Jackson 看来变成了:
❌ "你这是 List?
那 @class 在哪?"
八、最终正确解法(生产级)
核心原则(请记住)
Security 的 ObjectMapper
≠ MVC 的 ObjectMapper
正确做法:只给 Redis 用专用 ObjectMapper
java
@Configuration
public class RedisSessionConfig {
@Bean
public RedisSerializer<Object> springSessionRedisSerializer() {
ObjectMapper mapper = new ObjectMapper();
// 只用于 Spring Session / Security
mapper.registerModules(
SecurityJackson2Modules.getModules(
getClass().getClassLoader()
)
);
mapper.addMixIn(UserInfo.class, UserInfoMixin.class);
return new GenericJackson2JsonRedisSerializer(mapper);
}
}
✅ 不定义全局 ObjectMapper
✅ 不影响 Spring MVC
✅ Security 问题彻底解决
九、完整"踩坑 → 修坑"路线总结
| 阶段 | 做了什么 | 结果 |
|---|---|---|
| 初始 | Redis 存 UserInfo | 升级后反序列化失败 |
| FLUSHDB | 清 Redis | 暂时不报错(假象) |
| 方案二 | 放行 UserInfo | Security 正常 |
| 误操作 | ObjectMapper 设为全局 | MVC JSON 崩溃 |
| 最终方案 | Redis 专用 ObjectMapper | 全系统稳定 |
十、给初级开发者的三条铁律
✅ 铁律一
FLUSHDB 不是修复,是掩盖问题
✅ 铁律二
Security 的序列化配置,永远不要直接影响 MVC
✅ 铁律三
看到以下关键词同时出现,立刻警惕:
- Redis Session
- Spring Security
- Jackson
- allowlist / type id
👉 99% 是序列化边界问题
抽象类UserInfoMixin的作用是什么?
一、一句话先给结论(先记住)
java
@JsonTypeInfo(
use = JsonTypeInfo.Id.CLASS,
include = JsonTypeInfo.As.PROPERTY,
property = "@class"
)
作用只有一个:
在 JSON 里明确写清楚"这个对象原本是哪一个 Java 类",
让 Jackson 在反序列化时知道"该 new 谁"。
二、为什么 Jackson "不知道该 new 谁"?
先看一个很普通的 Java 场景
java
UserDetails user = new UserInfo();
在 Java 运行时:
- 变量类型:
UserDetails - 实际对象:
UserInfo
👉 这是多态
但是 JSON 是"无类型语言"
如果你把 user 转成 JSON:
json
{
"username": "admin",
"roles": ["ADMIN"]
}
你现在问 Jackson:
"请你把这段 JSON 再变回
UserDetails"
Jackson 会懵:
❓ 是
UserInfo?❓ 还是
User?❓ 还是别的实现类?
👉 JSON 里没有答案
三、@JsonTypeInfo 是怎么解决这个问题的?
它的作用是:
把"真实 Java 类型"直接写进 JSON
加上这个注解后,JSON 会变成这样
json
{
"@class": "com.hectdi.springsecurity.vo.UserInfo",
"username": "admin",
"roles": ["ADMIN"]
}
现在 Jackson 再反序列化时:
- 看到
@class - 读到
"com.hectdi.springsecurity.vo.UserInfo" - 用反射
new UserInfo() - 填充字段
👉 精准无误
四、逐个解释这几个参数(非常重要)
java
@JsonTypeInfo(
use = JsonTypeInfo.Id.CLASS,
include = JsonTypeInfo.As.PROPERTY,
property = "@class"
)
1️⃣ use = JsonTypeInfo.Id.CLASS
意思:
类型信息用 "完整类名" 表示
json
"@class": "com.hectdi.springsecurity.vo.UserInfo"
(而不是用别名)
2️⃣ include = JsonTypeInfo.As.PROPERTY
意思:
把类型信息作为 普通 JSON 字段 放进去
json
{
"@class": "...",
"username": "admin"
}
而不是:
json
["com.xxx.UserInfo", { ... }]
3️⃣ property = "@class"
意思:
这个字段名就叫
@class
五、那为什么不用直接在 UserInfo 上加这个注解?
你用了 Mixin:
java
public abstract class UserInfoMixin {}
Mixin 的作用是:
"不改原类,也能给它'临时贴注解'"
原因通常有三种:
UserInfo来自公共模块,不方便改- 不想污染领域模型
- 只在某些序列化场景需要
👉 这在框架级开发中是标准做法
六、结合你这次问题,UserInfoMixin 的真实作用
回到你的错误场景
- Redis 里存的是
Authentication.principal - 类型是
UserDetails - 实际对象是
UserInfo
反序列化时:
- Spring Security 说:
👉 "我需要知道你到底是哪一个具体类" - Jackson 说:
👉 "JSON 里没写类型,我不敢 new"
于是炸了 💥
加了 UserInfoMixin 之后
- Redis 里的 JSON 变成:
json
"principal": {
"@class": "com.hectdi.springsecurity.vo.UserInfo",
...
}
- Spring Security allowlist 校验通过
- Jackson 成功反序列化
- Session 恢复正常
七、一个非常重要的⚠️警告(你已经踩过)
@JsonTypeInfo是"核武器"级别的注解
为什么?
因为:
- 它会改变 JSON 的结构
- 会影响反序列化规则
- 一旦用在 MVC ObjectMapper
- 所有请求 JSON 都可能炸
👉 所以你最后的做法是对的:
只给 Redis Session 用,不给 MVC 用
八、给初级开发者的记忆口诀
Java 有多态,JSON 没有;
要想反序列化,类型必须写。
安全模块能信谁,
必须白名单 + 明示类。