13 KiB
V1.1 非结构写入收敛 — 整改方案
版本:v1.1
日期:2026-08-03
状态:已落地
依据:业务仓实跑误报 + 单测复现RecordingTodoReplaceScenarioTest
关联:docs/redis序列化结构检测实施方案.md、docs/配置说明.md
约束:不新增配置项;收敛为工具默认行为,随 jar 升级生效
1. 背景与已证实问题
1.1 实跑样本(jnpf-java-cloud)
【序列化结构变更】 jnpf-java-cloud
分支: patrol/dev_patrol_2.0
提交: adbb15c6 → 6ddc72dc
- Key --> recording:todo:replace:*:*
位置: RecordingTodoDomainServiceImpl#replaceText:437
类型: <未解析>
value 新增为: “{}”
- Key --> recording:todo:replace:title:*:*
位置: RecordingTodoDomainServiceImpl#replaceText:452
类型: <未解析>
value 新增为: “{}”
业务代码(摘要):
// 缓存替换前原文,纯 String,无 JSON / VO 序列化
stringRedisTemplate.opsForValue().set(contentRedisKey, originalContent);
stringRedisTemplate.opsForValue().set(titleRedisKey, originalTitle);
1.2 复现结论
单测 RecordingTodoReplaceScenarioTest 已稳定复现:
| 环节 | 现状 |
|---|---|
| 检测 | 命中 W04(直写对象) |
| 类型 | String 不在本仓 SourceIndex → resolvedValueType = null → 展示 <未解析> |
| Schema | extract(null) 空结构 → 骨架 "{}" |
| 报告 | 旧提交无该写入点 → WRITE_POINT_ADDED →「value 新增为」 |
核心缺口:新增写入点(WRITE_POINT_ADDED)路径上,没有对「无结构 / 不可展开」的 value 做收敛;只要 AST 命中 W04/W05(及部分边缘写法),就会告警。
1.3 本方案范围
| 纳入 | 不纳入(另案) |
|---|---|
| Redis 非结构写入误报收敛 | MQ asyncSend 漏检(作用域过窄) |
| 同类场景枚举与统一收敛规则 | 新增 Key 企微文案 / 整段染色体验 |
| 检测层 + 分析层双闸门设计 | 业务仓 ignore 配置补丁(仅作临时手段) |
2. 问题本质
工具目标是监控 可 JSON/对象 Schema 化的序列化结构变更。
当前实现把「能调用 set/insert/put」近似当成「值得监控」,收敛只做了很窄的一层:
现有收敛
├─ 方法黑名单:setIfAbsent / increment / delete …
├─ isTrivialValue:字面量、UUID、toString/valueOf/randomUUID
└─ ignore.key_patterns:lock / loginCount / Authorization …
缺失收敛
├─ value 声明类型为 String / 数字 / 布尔 / byte[](变量,非字面量)
├─ value 类型无法在本仓展开(null / 空 Schema)
├─ 先 JSON 序列化到局部 String 再写入(Redis 未 unwrap,误成 String-W04)
└─ WRITE_POINT_ADDED 不检查「是否有可对比结构」
因此:凡是「新增一段无结构写入」都会变成「类型未解析 + value 新增为 {}」,与 RecordingTodo 同形。
3. 同类场景排查(是否会中招)
判定标准:能否在默认配置(W01~W05 全开)下产出与实跑同形的告警
(WRITE_POINT_ADDED + <未解析> 或空/{} 骨架,或明显无业务结构)。
3.1 高概率同形(与实跑同类,应优先收敛)
| ID | 场景 | 典型代码 | 命中模式 | 结果形态 | 与实跑关系 |
|---|---|---|---|---|---|
| S1 | String 变量直写 | stringRedisTemplate.opsForValue().set(k, originalContent) |
W04 | <未解析> + {} |
已证实 |
| S2 | String 方法返回值直写 | set(k, todo.getContent()) |
W04 | 同 S1(返回类型 String 时) | 同形;isTrivialValue 不认 getXxx |
| S3 | 数字 / 布尔变量直写 | set(k, ttlSeconds) / set(k, enabled) |
W04 | <未解析> + {} |
同形;标量无字段 |
| S4 | Hash 写入 String/标量 | opsForHash().put(k, f, nameStr) |
W05 | 同 S1 | W05 仅挡字面量,不挡变量 |
| S5 | redisUtil.insert 写 String 变量 | redisUtil.insert(k, token, ttl) |
W04 | 同 S1 | insert + 含 redis 的 scope → 走 W04 分支 |
| S6 | 外部依赖类型直写 | set(k, externalDto) 且 DTO 不在本仓 |
W04 | <未解析> + {} |
同形;「有对象语义」但工具无法展开,告警无信息量 |
| S7 | 类型擦除 Object / 原始 Map | set(k, (Object) x) / set(k, map) 且无法解析元素 |
W04 | 常为 <未解析> / 空或弱 Schema |
同形或近似 |
3.2 中概率变形(会误报,但表现略不同)
| ID | 场景 | 典型代码 | 命中模式 | 结果形态 | 说明 |
|---|---|---|---|---|---|
| S8 | 局部 JSON 字符串再写入 | String json = JSON.toJSONString(vo); set(k, json) |
W04 | 类型按 String → <未解析>+{} |
Redis 无 unwrapJsonStringVar(MQ 已有);应归 W01~W03,却被当成无结构 |
| S9 | 显式序列化标量 | set(k, JSON.toJSONString(someString)) |
W02 | 偶发低价值点 | 少见;unwrap 后仍是 String,应丢弃 |
| S10 | 本仓 VO 但字段全 transient / 全忽略注解 | set(k, emptyVo) |
W04 | 类型有名,骨架仍可能 {} |
有类型名,但「新增 {}」仍噪音 |
| S11 | 删除无结构写入点 | 删掉 S1 类代码 | WRITE_POINT_REMOVED | 「原结构 {}」 | 对称噪音,收敛规则应一并覆盖 |
3.3 低概率 / 已部分防护(记录边界)
| ID | 场景 | 现状 | 结论 |
|---|---|---|---|
| S12 | 字面量 "1" / UUID.toString() |
isTrivialValue 已忽略 |
一般不中招 |
| S13 | key 命中 *:lock / loginCount:* 等 |
ignore.key_patterns |
仅覆盖命名约定,挡不住 recording:todo:replace:* |
| S14 | MQ 直传 String / byte[] | isBareStringOrBytesPayload 已忽略 |
Redis 应对齐;MQ 数字变量仍可能漏(见下) |
| S15 | MQ 直传业务 VO | 正常监控 | 不属本问题 |
| S16 | Redis W01~W03 + 本仓 VO | 正常监控 | 不属本问题 |
3.4 MQ 侧对照(同类思想,程度不同)
| 点 | Redis(现状) | MQ(现状) |
|---|---|---|
| 裸 String / byte[] 变量 | 不忽略 → S1 | 忽略 |
| 数字 / 布尔变量 | 不忽略 → S3 | 不忽略(仅 trivial 字面量)→ MQ 也有 S3 风险 |
局部 toJSONString 再 send/set |
Redis 不 unwrap → S8 | 已 unwrap |
| 无法展开类型仍建点 | 会建点 → S6 | 会建点(直传对象时) |
| WRITE_POINT_ADDED 空 Schema 闸门 | 无 | 无(同样会「新增 {}」) |
结论:
- Redis 是重灾区;
- MQ 在 String 上已收敛,但 Integer/Long/Boolean 变量 与 空 Schema 仍告警 与 Redis 同类,应在统一规则里一并处理。
3.5 分析层放大效应(与模式无关)
SchemaCheckAnalyzer 在「新写入点、旧无配对」时:
fileChanged && oldWritePoint == null
→ 无条件 WRITE_POINT_ADDED
→ extract(type) 即使为空也 render → "{}"
→ 进入企微
对比「已有写入点」路径:differ.diff 空则 continue,不会为空 Schema 刷屏。
因此同类问题在 新增代码 时被放大;存量无结构写入若从未变更,不一定出告警。整改必须同时考虑:
- 检测层少建点(源头)
- 分析层对无结构新增/删除做闸门(兜底)
4. 收敛规则设计
4.1 原则
- 只监控有结构的序列化 value(本仓可展开的业务类型,或 unwrap 后得到的业务类型)。
- 宁漏勿滥:无法证明「有结构」则不告警。
- 检测层为主、分析层为辅:避免脏 WritePoint 进入报告链路。
- Redis / MQ 规则对齐,避免一边收敛一边漏。
4.2 规则表(建议落码)
| 规则 | 适用 | 行为 |
|---|---|---|
| R-A 标量类型忽略 | Redis W04/W05;MQ 直传 payload | value/payload 声明类型 ∈ {String, CharSequence, 原始/包装数字, Boolean, UUID, byte/Byte/byte[]} → 不建点 |
| R-B 本仓可展开 | Redis W04/W05;MQ 直传对象 | resolvedFqn == null 或 SourceIndex 无此类 → 不建点 |
| R-C 空 Schema 闸门 | Analyzer:ADDED / REMOVED | extract 后 schema.isEmpty() → 不产生 WRITE_POINT_ADDED/REMOVED(字段级 diff 路径保持:空 diff 已 skip) |
| R-D 局部 JSON unwrap | Redis(对齐 MQ) | String json = toJSONString(x); set/insert(k, json) → 按 W01~W03 建点,类型为 x;若 x 仍为标量 → 再走 R-A |
| R-E 方法返回值 | 与 R-A/R-B 相同 | set(k, obj.getFoo()) 按方法返回类型判定,不因「非字面量」直接放行 |
| R-F 集合 | W04/W05 / MQ | List<T> / Set<T>:元素 T 满足 R-B 才保留;List<String> 丢弃 |
4.3 明确保留(不应误杀)
| 写法 | 期望 |
|---|---|
set(k, JSON.toJSONString(vo)) / insert + 序列化 |
W01~W03,监控 VO |
redisTemplate.set(k, demoVo) 且 DemoVo 在本仓 |
W04,监控 |
opsForHash().put(k, f, demoVo) 本仓 VO |
W05,监控 |
MQ syncSend/asyncSend(topic, dto) 本仓 DTO |
监控 |
| 局部 JSON unwrap 后得到本仓 VO | 监控 |
4.4 配置策略
不新增任何 YAML / 检测开关。 R-A~R-F 均为工具内置默认行为,升级 jar 即生效。
业务侧若需临时止血,仍可沿用既有 ignore.key_patterns / ignore.writer_methods / suppressions,但不作为本问题根治手段。
5. 落地设计
5.1 推荐分层
┌─────────────────────────────────────────┐
│ RedisWritePointDetector / MqWritePointDetector
│ R-A 标量忽略
│ R-D Redis 局部 JSON unwrap
│ R-B 本仓类型门槛(直写对象)
│ R-E/R-F 返回值与集合元素
└──────────────────┬──────────────────────┘
▼
┌─────────────────────────────────────────┐
│ SchemaCheckAnalyzer
│ R-C 空 Schema → 跳过 ADDED/REMOVED
│ (字段级 diff 逻辑不变)
└─────────────────────────────────────────┘
5.2 涉及文件(实施时)
| 文件 | 变更要点 |
|---|---|
RedisWritePointDetector.java |
R-A / R-B / R-D / R-E / R-F |
MqWritePointDetector.java |
R-A 扩展数字/布尔;直传对象可加 R-B(与 Redis 对齐) |
SchemaCheckAnalyzer.java |
R-C 空 Schema 闸门 |
(可选)抽取 BareValueTypes 工具类 |
Redis/MQ 共用标量集合,防漂移 |
RecordingTodoReplaceScenarioTest |
修复后期望 0 写入点(或拆「修复前/后」) |
| 新增场景单测 | 覆盖 §3.1 / §3.2 表中 S2~S8、S11 |
docs/配置说明.md |
同步 W04/W05 与空 Schema 行为 |
5.3 实施顺序建议
本版本一次性落地 R-A~R-F(Redis + Analyzer + MQ 对齐),无分阶段开关、无灰度配置。
6. 测试与验收
6.1 复现用例(已有)
RecordingTodoReplaceScenarioTest:当前断言「会误报」。- 整改后应改为:检测结果为空,或 Analyzer 不产出 keyChanges。
6.2 建议补充用例
| 用例 | 期望(整改后) |
|---|---|
set(k, getContent()) 返回 String |
0 点 |
set(k, longVar) |
0 点 |
opsForHash().put(k, f, strVar) |
0 点 |
redisUtil.insert(k, tokenStr, ttl) |
0 点 |
set(k, externalDto) 不在 SourceIndex |
0 点 |
String json = toJSONString(vo); set(k, json) |
1 点,类型 = VO,W02/W01 |
set(k, demoVo) 本仓 VO |
仍 1 点 W04 |
| 删除 String 直写 | 不产生 WRITE_POINT_REMOVED |
MQ send(topic, longVar) |
0 点(Phase 4) |
6.3 回归
TenantScenarioTest、既有 W01~W05 / MQ fixture 全绿。- 业务仓抽样:RecordingTodo 类告警消失;真实 VO 包装层变更仍能检出。
7. 临时手段与风险
| 项 | 说明 |
|---|---|
| 无新配置 | 不增加 keep_empty_schema_* 等开关;行为固化在检测/分析逻辑 |
| 业务 ignore | 可对 recording:todo:replace:* 加 key 忽略,仅止血,不解决 S1~S7 类问题 |
| 误杀风险 | 依赖 jar 内 DTO 的 W04 将被忽略 → 可用既有 manual_mappings 或把类型源码纳入仓 |
| Object 擦除 | 可能漏检真实结构 → 接受宁漏勿滥;或要求业务写明 VO 类型 |
| 与「展示优化」关系 | 即使将来给新增 Key 染色,空 {} 仍无意义;必须先收敛再建点 |
8. 总结
| 问题 | 结论 |
|---|---|
| RecordingTodo 为何告警? | W04 把 String 原文当成对象写入;新增路径无空结构闸门 |
| 是否只有这一处? | 否。S2~S7 同形,S8 变形,S11 对称;MQ 在数字变量与空 Schema 上同类 |
| 根因一句话 | 新增写入检测缺少「有结构才告警」的收敛 |
| 整改抓手 | 检测层标量/本仓类型收敛 + Analyzer 空 Schema 闸门 + Redis JSON 局部 unwrap |
9. 检查表
- 认可 R-A~R-F 与「宁漏勿滥」、不新增配置
- 外部 jar DTO(S6)默认不监控
- 代码落地 +
RecordingTodoReplaceScenarioTest期望 0 点 - 同步
配置说明.md - 全量
mvn test通过