20 KiB
Redis 序列化结构变更检测 — 实施方案
版本:v0.1
日期:2026-07-13
技术栈:Java 11 + Maven + JavaParser
目标仓库:redisCheck(工具) /jnpf-java-cloud(被检测业务仓库)
1. 背景与目标
1.1 背景
业务仓库 jnpf-java-cloud 是多模块 Java 微服务项目,广泛使用 Redis 缓存业务对象。当开发者修改 VO/DTO 字段、调整序列化包装结构、或变更 Redis 写入逻辑时,线上 Redis 中可能仍存在旧结构数据,导致:
- 反序列化失败
- 字段读取为空
- 新旧结构并存引发隐蔽 Bug
典型变更示例(租户库信息缓存):
变更前(逻辑上等价于直接缓存 TenantVO):
{
"dbName": "",
"linkList": [{ "id": "", "serviceName": "", "...": "..." }]
}
变更后(TenantDbContentCacheHelper 包装为 CacheEnvelope):
{
"vo": {
"dbName": "",
"linkList": [{ "id": "", "serviceName": "", "...": "..." }]
},
"expiresAtMs": 0
}
该变更在业务代码中已有真实对应:
- Key:
tenant:db:content:{encode} - 写入类:
jnpf.util.TenantDbContentCacheHelper#cacheSuccess - Value 类型:
CacheEnvelope { TenantVO vo; Long expiresAtMs; }
1.2 目标
在 push 时 自动执行检测:
- 对比两次提交(
old-shavsnew-sha)之间的代码差异 - 识别 Redis value 序列化结构是否发生变更
- 生成结构化变更报告
- 通过企微机器人发送通知
- 通过开关控制 仅通知 或 阻断流水线
1.3 非目标(第一版不做)
- 不连接真实 Redis 实例做运行时校验
- 不扫描 Maven 依赖 jar 中的类(仅分析业务仓库源码)
- 不做全量历史扫描(仅 diff 触发)
- 不替代 CodeChecker / AI Code Review 等现有能力
2. 业务仓库调研结论
基于对 jnpf-java-cloud 的静态检索,得出以下判断(作为方案输入,不再向业务方重复确认):
2.1 Redis 序列化方式
| 类型 | 出现频率 | 第一版策略 |
|---|---|---|
JSON.toJSONString(obj) / JSONObject.toJSONString(obj) |
高 | 重点支持 |
JsonUtil.getObjectToString(obj) |
高 | 重点支持(按 Fastjson/Jackson 默认字段规则推断) |
RedisTemplate.opsForValue().set(key, obj) 直接写对象 |
中 | 第二阶段支持 |
RedisTemplate.opsForHash().put(key, field, obj) |
中 | 第二阶段支持 |
StringRedisTemplate 写 JSON 字符串 |
高 | 重点支持 |
| 锁 / 计数器 / token 简单值 | 高 | 默认忽略 |
2.2 Key 与实体映射
- 不存在统一的「Key → 类型」注册中心
- 存在大量
static final String、String.format(...)、buildCacheKey(...)等模式 - 第一版采用:写入点静态推断 + 可选 YAML 人工补充映射
2.3 多模块特征
- 根
pom.xml下约 30+ 顶层模块、200+ 子模块 - Java 版本混用(8/9/10/11),工具统一使用 JDK 11 编译运行
- 公共工具类
RedisUtil、JsonUtil来自外部依赖,不在业务仓源码内 — 仅分析调用方,不深入依赖实现
3. 总体架构
3.1 交付形态
沿用现有 code-checker 模式(见 redisCheck/.gitea/demo.yaml):
redisCheck 仓库
├── 开发 Java 分析工具
├── mvn package 打 fat-jar
├── 发布到 Nexus:com.codechecker:redis-schema-checker:{version}
└── 提供默认配置模板
jnpf-java-cloud 仓库
├── .gitea/workflows/redis-schema-check.yaml
├── .gitea/config/redis-schema-check-config.yaml
└── push 时下载 jar 并执行检测
3.2 架构图
flowchart TB
subgraph Gitea["Gitea Push Pipeline"]
A[push 事件] --> B[浅克隆 old/new 提交]
B --> C[下载 redis-schema-checker.jar]
C --> D[java -jar 执行检测]
end
subgraph Checker["redis-schema-checker (JDK 11)"]
D --> E[GitDiffScanner]
E --> F[RedisWritePointDetector]
F --> G[JavaSchemaExtractor]
G --> H[SchemaDiffer]
H --> I{有结构变更?}
I -->|否| J[exit 0]
I -->|是| K[ReportBuilder]
K --> L[WeComNotifier]
L --> M{mode=block 且含变更?}
M -->|是| N[exit 1]
M -->|否| J
end
3.3 核心设计原则
- 纯静态分析:基于 Java 源码 AST + 符号解析,不启动 Spring 容器
- Diff 驱动:只分析本次 push 变更涉及的文件及其关联类型
- 本仓限定:类型解析仅在业务仓库
src/main/java范围内 - 可配置:忽略规则、严重级别、通知开关、阻断开关均可 YAML 配置
- 可演进:第一版聚焦 JSON 字符串写入,后续扩展 Template 直写对象
4. 技术选型
| 组件 | 选型 | 版本建议 | 说明 |
|---|---|---|---|
| 语言 | Java | 11 | 与 CI Runner jdk11 对齐 |
| 构建 | Maven | 3.8+ | 与现有私库发布流程一致 |
| AST 解析 | JavaParser | 3.25.x | 完整 Java 语法树 |
| 符号解析 | javaparser-symbol-solver-core | 3.25.x | 跨文件类型推断 |
| 配置 | SnakeYAML | 2.x | 读取检测配置 |
| HTTP 通知 | JDK HttpClient / OkHttp | 11 内置 / 4.x | 企微 Webhook |
| 报告 | Jackson | 2.15.x | JSON/Markdown 报告序列化 |
| 测试 | JUnit 5 | 5.10.x | 单元测试 + 夹具样本 |
不采用 Spoon / Eclipse JDT 的原因:JavaParser 足够覆盖第一版需求,依赖更轻,CLI 启动更快。
Lombok 处理策略:第一版基于源码字段 + @Data 等注解推断序列化字段;对 @Builder、@SuperBuilder 等复杂场景标记为「低置信度」并降级为 P2 提示。后续可选集成 lombok.ast 或 delombok 预处理。
5. 工程结构(redisCheck 仓库)
redisCheck/
├── pom.xml # 单模块工程(无父子结构)
├── docs/
│ ├── 实施方案.md
│ ├── 配置说明.md
│ └── CI集成说明.md
├── src/
│ ├── main/
│ │ ├── resources/
│ │ │ └── default-config.yaml # 内置默认配置(随 jar 发布)
│ │ └── java/com/codechecker/redis/
│ │ ├── cli/ # 命令行入口
│ │ ├── config/ # 配置模型
│ │ ├── git/ # Git 操作
│ │ ├── analyze/ # 编排与工作树扫描
│ │ ├── detector/ # Redis 写入点检测
│ │ ├── schema/ # Schema 提取
│ │ ├── diff/ # 结构对比
│ │ ├── key/ # Key 推断
│ │ ├── report/ # 报告
│ │ └── notify/ # 企微通知
│ └── test/
│ ├── resources/fixtures/tenant/
│ └── java/...
├── .gitea/
│ ├── workflows/redis-schema-check.yaml
│ └── config/redis-schema-check-config.yaml
└── target/ # 构建产物
5.1 Maven 坐标
<groupId>com.codechecker</groupId>
<artifactId>redis-schema-checker</artifactId>
<version>1.0.0-SNAPSHOT</version>
打包为 shaded/fat jar,主类:com.codechecker.redis.cli.RedisSchemaCheckerMain
6. 执行流程详解
6.1 CLI 参数
java -jar redis-schema-checker-1.0.0.jar \
--config .gitea/config/redis-schema-check-config.yaml \
--repo-root /path/to/jnpf-java-cloud \
--old-sha abc123 \
--new-sha def456 \
--branch feature/xxx \
--modifier zhangsan \
--modify-time "2026-07-13 14:00:00"
| 参数 | 必填 | 说明 |
|---|---|---|
--config |
是 | 检测配置文件路径 |
--repo-root |
是 | 业务仓库根目录 |
--old-sha |
是 | 对比基准提交 |
--new-sha |
是 | 当前提交 |
--branch |
否 | 分支名,用于报告展示 |
--modifier |
否 | 提交人(Gitea actor) |
--modify-time |
否 | 提交时间 |
6.2 对比基准(old-sha)获取策略
与 demo.yaml 保持一致,优先级:
- 流水线显式传入
--old-sha(通常为HEAD~1) - 若
HEAD~1不存在(首次提交)→ 跳过检测,exit 0 - 浅克隆
--depth 2确保HEAD~1可用
不支持一次 push 多个 commit 时逐个分析;第一版仅对比
HEAD~1..HEAD。后续可扩展为before..after范围分析。
6.3 处理步骤
Step 1:加载配置
读取 redis-schema-check-config.yaml,合并默认值(见 docs/配置说明.md)。
Step 2:Git Diff 扫描
git diff --name-only {old-sha} {new-sha} -- '*.java'
输出变更 Java 文件列表。同时记录 diff hunks,用于判断「是否仅注释/格式变更」。
Step 3:构建双版本源码索引
对 old-sha 和 new-sha 分别:
git show {sha}:path/to/File.java提取文件内容(无需完整 checkout 两个 worktree)- 解析为
CompilationUnit - 建立
类全名 → CompilationUnit索引(仅本仓src/main/java)
Step 4:Redis 写入点检测
在 变更文件 中扫描以下 AST 模式:
| 模式 ID | 匹配表达式 | 提取信息 |
|---|---|---|
| W01 | redisUtil.insert(key, JSON.toJSONString(expr), ttl) |
key 表达式、value 表达式 |
| W02 | redisTemplate.opsForValue().set(key, JSON.toJSONString(expr), ...) |
同上 |
| W03 | stringRedisTemplate.opsForValue().set(key, JsonUtil.getObjectToString(expr), ...) |
同上 |
| W04 | redisTemplate.opsForValue().set(key, expr, ...) 且 expr 非字面量 |
第二阶段 |
| W05 | redisTemplate.opsForHash().put(key, field, expr) |
第二阶段 |
| W06 | JSON.parseObject(cacheValue, Xxx.class) |
辅助反向确认读取类型 |
忽略规则(自动):
- value 为字符串字面量、数字、
UUID、"1"等 - 方法名含
setIfAbsent、increment、delete、remove、expire - key 匹配
ignore_key_patterns配置
Step 5:类型推断
对每个写入点的 expr,使用 JavaParser Symbol Solver 推断类型:
// 示例:TenantDbContentCacheHelper.cacheSuccess
CacheEnvelope envelope = new CacheEnvelope();
envelope.setVo(vo);
envelope.setExpiresAtMs(expiresAtMs);
redisUtil.insert(buildCacheKey(encode), JSON.toJSONString(envelope), ttl);
推断链:
JSON.toJSONString(envelope)→ 实参类型CacheEnvelope- 定位
CacheEnvelope类(内部类需支持Outer$Inner) - 读取字段
vo: TenantVO、expiresAtMs: Long - 递归展开
TenantVO→dbName: String、linkList: List<TenantLinkModel> - 继续展开
TenantLinkModel全部字段
注解处理(第一版):
| 注解 | 行为 |
|---|---|
@JSONField(serialize = false) |
排除字段 |
@JSONField(name = "xxx") |
字段名映射 |
@JsonIgnore |
排除字段 |
@JsonProperty("xxx") |
字段名映射 |
@Schema |
忽略(不影响序列化) |
Step 6:生成 JSON Schema
将 Java 类型转为统一的 TypeSchema 树:
{
"typeName": "jnpf.util.TenantDbContentCacheHelper.CacheEnvelope",
"fields": [
{
"path": "vo",
"javaType": "jnpf.model.TenantVO",
"jsonType": "object",
"children": [
{ "path": "vo.dbName", "jsonType": "string" },
{ "path": "vo.linkList", "jsonType": "array", "itemType": "jnpf.model.TenantLinkModel" }
]
},
{
"path": "expiresAtMs",
"javaType": "java.lang.Long",
"jsonType": "number"
}
]
}
Step 7:Schema Diff
对比同一写入点在 old/new 两个版本的 TypeSchema,输出 SchemaChange 列表。
变更类型与严重级别:
| 变更类型 | 示例 | 默认级别 |
|---|---|---|
FIELD_REMOVED |
删除 dbName |
P0 |
TYPE_CHANGED |
linkList 从数组变对象 |
P0 |
WRAPPER_ADDED |
顶层增加 vo 包装 |
P0 |
FIELD_PATH_MOVED |
dbName → vo.dbName |
P0 |
FIELD_ADDED |
新增 expiresAtMs |
P1 |
KEY_PATTERN_CHANGED |
key 常量变更 | P1 |
WRITE_POINT_REMOVED |
删除缓存写入 | P1 |
WRITE_POINT_ADDED |
新增缓存写入 | P2 |
LOW_CONFIDENCE |
类型推断失败 | P2 |
Step 8:报告与通知
生成 CheckReport,包含:
- 仓库名、分支、old/new sha、提交人、时间
- 变更列表(按严重级别排序)
- 每项:Key 模式、写入位置(类#方法:行号)、旧结构、新结构、变更摘要
调用企微 Webhook 发送 Markdown 消息。
Step 9:退出码
| 条件 | 退出码 |
|---|---|
| 无变更 / 仅 P2 | 0 |
mode=notify 且存在 P0/P1 |
0(仍通知) |
mode=block 且存在 P0/P1/P2 |
1 |
| 配置错误 / 执行异常 | 2 |
7. Redis Key 推断策略
7.1 自动推断
| 优先级 | 模式 | 示例 | 结果 |
|---|---|---|---|
| 1 | 字符串字面量 | "tenant:db:content:" + encode |
tenant:db:content:* |
| 2 | 常量引用 | CACHE_KEY_PREFIX + encode |
追溯常量值 |
| 3 | String.format(CONST, args) |
String.format(ATTENDANCE_BASE_SETTING_CACHE_KEY, tenantId) |
fbt:attendance:base_setting:cache:* |
| 4 | 方法调用 | buildCacheKey(encode) |
读取方法内 return 表达式 |
| 5 | 变量 | redisKey |
unknown-key |
7.2 人工补充(配置)
manual_mappings:
- id: tenant-db-content
writer_method: "jnpf.util.TenantDbContentCacheHelper#cacheSuccess"
key_pattern: "tenant:db:content:*"
value_type: "jnpf.util.TenantDbContentCacheHelper.CacheEnvelope"
当自动推断置信度低时,以 manual_mappings 为准。
8. 配置与开关设计
采用 双层配置合并 策略:
| 层级 | 位置 | 职责 |
|---|---|---|
| 默认配置 | 工具 jar 内 default-config.yaml |
检测模式、忽略规则、严重级别默认值 |
| 业务覆盖 | jnpf-java-cloud/.gitea/config/redis-schema-check-config.yaml |
mode、notify、include_modules、manual_mappings |
合并规则:业务配置覆盖默认配置,未声明的项沿用默认值。
CLI 调用:
java -jar redis-schema-checker.jar \
--config .gitea/config/redis-schema-check-config.yaml \
...
工具启动时自动加载 jar 内 default-config.yaml,再与 --config 指定的业务配置深度合并。
详见 docs/配置说明.md。核心开关:
# 运行模式:notify(仅通知)| block(P0/P1/P2 全部阻断流水线)
mode: notify
# block 模式下触发 exit 1 的严重级别(全部阻断)
block_severities:
- P0
- P1
- P2
# 是否发送企微通知
notify:
enabled: true
webhook_env: WECOM_ROBOT_WEBHOOK
9. CI 集成方案
详见 docs/CI集成说明.md。核心流程:
# jnpf-java-cloud/.gitea/workflows/redis-schema-check.yaml
name: Redis序列化结构检查
on: [push]
jobs:
redis-schema-check:
if: ${{ gitea.ref != 'refs/heads/pre' && gitea.ref != 'refs/heads/dev' && gitea.ref != 'refs/heads/master-2.0' }}
runs-on: jdk11
steps:
- name: 检出代码
run: |
git clone --depth 2 --single-branch --branch "${{ gitea.ref_name }}" \
"https://${{ gitea.token }}@git.niujiekeji.com/${{ gitea.repository }}.git" .
git checkout -B "${{ gitea.ref_name }}" "${{ gitea.sha }}"
- name: 下载检测工具
run: |
# 从 Nexus 下载 redis-schema-checker jar
...
- name: 执行检测
env:
WECOM_ROBOT_WEBHOOK: ${{ secrets.WECOM_ROBOT_WEBHOOK }}
run: |
OLD_SHA=$(git rev-parse HEAD~1 2>/dev/null || echo "")
[ -z "$OLD_SHA" ] && exit 0
java -jar /tmp/redis-schema-checker-1.0.0.jar \
--config .gitea/config/redis-schema-check-config.yaml \
--repo-root . \
--old-sha "$OLD_SHA" \
--new-sha "$(git rev-parse HEAD)" \
--branch "${{ gitea.ref_name }}" \
--modifier "${{ gitea.actor }}" \
--modify-time "$(git log -1 --format=%cd --date=format:'%Y-%m-%d %H:%M:%S')"
10. 分阶段交付计划
Phase 1 — MVP(约 1.5 周)
目标:跑通端到端链路,覆盖租户缓存典型场景。
| 任务 | 产出 |
|---|---|
| Maven 工程骨架 + CLI | 可执行 fat-jar |
| Git diff 扫描 | 变更文件列表 |
| W01~W03 写入点检测 | 覆盖 JSON 字符串写入 |
| 基础 Schema 提取 | 支持普通类、内部类、List、嵌套 |
| Schema Diff P0/P1 | 字段增删、包装、路径迁移 |
| 企微通知 | Markdown 消息 |
| notify/block 开关 | 配置驱动 |
| 夹具测试 | TenantVO/CacheEnvelope 样本 |
验收标准:
- 对
TenantDbContentCacheHelper的结构变更能输出 P0 报告 - 流水线 push 后能收到企微通知
mode=block时任意 P0/P1/P2 变更导致 exit 1
Phase 2 — 增强(约 1 周)
| 任务 | 说明 |
|---|---|
| W04/W05 模式 | RedisTemplate 直写对象、Hash 写入 |
| 注解完整支持 | Fastjson/Jackson 注解 |
| Key 推断增强 | String.format、常量追溯 |
| 忽略规则完善 | 锁/计数器/token 自动过滤 |
| 多模块性能优化 | 并行解析、缓存索引 |
Phase 3 — 运营(约 0.5 周)
| 任务 | 说明 |
|---|---|
| 报告落盘 | 可选输出 JSON 报告文件 |
| 误报反馈 | suppressions 配置支持按写入点忽略 |
| 更多业务场景覆盖 | 考勤、文件下载进度等 |
11. 测试策略
11.1 单元测试
SchemaDifferTest:纯字段路径对比逻辑JavaSchemaExtractorTest:类字段展开、注解、内部类RedisWritePointDetectorTest:各种写入 AST 模式匹配RedisKeyResolverTest:常量、format、拼接推断
11.2 夹具集成测试
在 src/test/resources/fixtures/ 放置真实业务代码片段(从 jnpf-java-cloud 提取并脱敏),模拟 old/new 两个版本:
| 夹具 | 验证点 |
|---|---|
tenant-cache/ |
包装结构变更 P0 |
attendance-base-setting/ |
Map 结构缓存 |
evaluate-config/ |
VO 字段新增 P1 |
lock-only/ |
应被忽略 |
11.3 端到端测试
在 jnpf-java-cloud 开测试分支,故意提交一个 VO 字段变更,验证流水线 + 企微通知。
12. 风险与限制
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| 类型推断失败 | 漏报 | 标记 LOW_CONFIDENCE,配置 manual_mappings |
| Lombok 复杂注解 | 字段遗漏 | 基于源码字段 + 注解;后续 delombok |
| 同一 key 多分支写不同类型 | 误报 | 报告注明置信度;人工 suppression |
| 浅克隆 parent 不可用 | 跳过检测 | --depth 2;文档明确要求 |
| 一次 push 多 commit | 仅检最后一个 | 文档说明;后续扩展 range |
| 依赖 jar 中的类型 | 字段展开不完整 | 配置 manual_mappings 补充 |
| JsonUtil 实现不可见 | 序列化规则猜测 | 默认按字段名序列化;与 Fastjson 对齐 |
13. 已确认决策(Grill 共识)
| # | 决策项 | 结论 |
|---|---|---|
| 1 | 阻断范围 | block 模式下 P0/P1/P2 全部阻断(exit 1) |
| 2 | 发布坐标 | 独立产物 com.codechecker:redis-schema-checker:1.0.0 |
| 3 | 配置归属 | 双层配置:jar 内 default-config.yaml + 业务仓覆盖合并 |
| 4 | 上线策略 | 先 notify 观察 1 周,稳定后手动切 block |
| 5 | 检测范围 | 仅 src/main/java,不扫描测试代码 |
以上决策已纳入实施方案,可进入开发阶段。
14. 附录:关键类设计草图
WritePoint
public class WritePoint {
String filePath;
int lineNumber;
String enclosingClass;
String enclosingMethod;
String keyExpression; // 原始 AST 表达式
String resolvedKeyPattern; // 推断结果,如 tenant:db:content:*
String valueExpression;
String resolvedValueType; // 全限定类名
double confidence; // 0.0 ~ 1.0
}
SchemaChange
public class SchemaChange {
Severity severity; // P0, P1, P2
ChangeType changeType; // FIELD_REMOVED, WRAPPER_ADDED, ...
String keyPattern;
String writeLocation; // class#method:line
String fieldPath; // 如 vo.dbName
String oldValue;
String newValue;
String message; // 人类可读描述
}
CheckReport
public class CheckReport {
String repository;
String branch;
String oldSha;
String newSha;
String modifier;
String modifyTime;
String mode;
List<SchemaChange> changes;
boolean blocked;
int exitCode;
}