恢复旧 Admin 缓存的代码,为什么会先抛出空指针?从重复添加 namespace 的缓存 key,到与生产格式不一致的测试数据,记录 ShenYu PR #7447 的修复、验证与 Review。
Gateway 手里的配置比某个 Admin 节点更新,按理说,Admin 应该先刷新自己的缓存,再判断要不要通知 Gateway 更新。但这次,代码还没执行到刷新,就先抛出了空指针。
Issue #7436 指出了一个很具体的原因:缓存 key 已经带了 namespace,恢复逻辑却又给它加了一次前缀。ns1_PLUGIN 变成了 ns1_ns1_PLUGIN,自然找不到原来的缓存。
我在 PR #7447 中修复了这个问题。生产代码的改动很小,但我还需要弄清楚两件事:为什么原来的测试没有发现它,以及这次空指针到了 Gateway 那边,到底会变成什么行为。
名字叫 group,里面存的却已经是完整 keyh2
ShenYu 的 HTTP 同步会比较 Gateway 和 Admin 的配置 MD5。两边相同,不需要更新;两边不同,再比较修改时间。如果 Gateway 的时间更新,就进入 Admin 本地缓存的恢复分支。
问题出在这个分支如何重新读取缓存。
AbstractDataChangedListener.updateCache() 在写入时,先把 namespace 和配置组拼成完整 key,例如 ns1_PLUGIN。这个值既是 CACHE 的索引,也被存进了 ConfigDataCache.group。
但恢复逻辑看到 getGroup(),又把它当成裸的配置组名,与 namespace 拼了一次:
| 位置 | 实际使用的值 |
|---|---|
| 配置组名 | PLUGIN |
| 生产写入的缓存 key | ns1_PLUGIN |
缓存对象的 group 字段 | ns1_PLUGIN |
| 原恢复逻辑重新构造的 key | ns1_ns1_PLUGIN |
也就是说,问题不是缓存没有写进去,而是读和写对同一个字段的理解不同。
错误 key 查不到对象后,代码会进入“缓存已经被其他线程替换”的判断,再访问 latest.getMd5()。此时 latest 是 null,刷新本地缓存的代码还没有机会执行。
这里还有一种比空指针更容易被忽略的情况:如果恰好存在另一个 namespace,名字就叫 ns1_ns1,那么 ns1_ns1_PLUGIN 可能真的对应一份缓存。程序不一定报错,却会用另一个 namespace 的 MD5 做判断。
因此,修复不能只让它“不抛异常”,还得确认它读的是谁的数据。
复用完整 key,不改变已有的缓存格式h2
这次我没有把所有缓存对象的 group 改回裸组名。现有生产写入已经使用完整 key,修改整个表示方式会扩大范围,也需要重新核对其他读取入口。
恢复分支直接使用已有的完整 key 就够了:
String configDataCacheKey = serverCache.getGroup();这个变量随后用于三次读取:加锁前、进入锁后,以及刷新缓存后。改正它的来源,三处就都能读到同一个 namespace 下的配置组,而不是只修掉第一次空指针。
原来的并发处理没有删除。加锁前后仍然检查缓存对象是否已经被替换;如果其他线程已经更新了它,就用最新 MD5 判断,不重复刷新。确实需要刷新时,仍然按 namespace 获取锁,并只刷新对应 namespace。
我在这里保留的是已有的缓存表示和锁范围,只纠正恢复入口对 key 的理解。代码旁也补了说明:group 已经是带 namespace 的完整 key,不能再拼一次。
测试数据和生产格式不同,会把错误逻辑变成“正确答案”h2
原来的恢复测试并不是没有覆盖这个分支。它们手工创建了 ConfigDataCache,但往 group 里放的是 PLUGIN,而不是生产实际保存的 ns1_PLUGIN。
在这样的测试数据下,再调用一次 buildCacheKey() 反而正好得到正确索引。于是,错误的生产逻辑通过了测试。
我先把已有用例中的缓存对象改成生产格式,再让新增回归用例通过真正的 updateCache() 创建缓存,而不是手工凑出一个看起来合理的对象。
测试中的 seedCache() 会构造带 namespace 的 PluginData,交给 updateCache() 序列化、计算 MD5 并写入缓存。恢复时则复用测试 listener,记录刷新了哪个 namespace,并写入指定内容,观察后续比较结果。
这里需要覆盖两种刷新结果:
- 刷新后的 MD5 与 Gateway 相同,说明两边已经一致,不应再通知更新。
- 刷新后的 MD5 仍与 Gateway 不同,应该返回需要更新的判断。
只检查“刷新被调用了”还不够。真正需要确认的是刷新以后,判断有没有基于正确的缓存继续完成。
namespace 隔离则单独安排了一个容易撞错 key 的场景:同时放入 ns1 和 ns1_ns1 两份缓存,并让后一份的 MD5 恰好与请求中的 MD5 相同。
如果恢复逻辑多拼一次前缀,就可能误以为数据已经一致,跳过 ns1 的刷新。用例因此同时检查:确实刷新了 ns1,返回了预期判断,而且另一份缓存对象没有被替换。
这比单纯断言“不出现空指针”更贴近这次 Bug。错误 key 不存在时会崩溃,错误 key 存在时则可能给出错误结果,两种都需要防住。
Admin 抛了异常,不代表 Gateway 会进入重试退避h2
另一个需要核对的地方,是故障发生后的请求行为。
Issue 中提到了 Gateway 的十次重试,以及失败后的五分钟退避。但继续看响应链路,这次空指针并不会直接触发那套逻辑。
Admin 的全局异常处理器会记录堆栈,并返回 HTTP 200 的错误结果,里面的 data 是 null。Gateway 的监听请求先检查 HTTP 状态,随后读取 data;遇到空数据时直接返回,没有把响应里的业务错误码转成异常。
对外层轮询任务来说,这次调用正常结束,于是退出当前重试循环,马上开始下一轮监听。原本需要异常才能进入的退避分支,在这条路径上没有生效。
因此,这个问题不只是某一次恢复失败,还可能让受影响的 Admin 持续收到请求、反复打印堆栈,直到其他缓存更新路径恢复了数据。
这次 PR 修复的是错误 key,没有顺手改 Gateway 的错误处理协议。我在 PR 说明中补清了这个影响,避免把“代码里有重试退避”直接当成“这次故障会退避”。
维护者在 Review 中确认了完整 key 的复用,以及同 namespace 恢复和前缀碰撞的测试覆盖。当时目标测试已在 Java 17 下通过,但页面上还有失败的 E2E 检查。
后来,我在跟进回复中补充了当前提交的成功运行链接:相关 CI 和 E2E 重试后通过,最后一次失败与成功运行之间没有修改代码或测试。根据当时的结果,我将先前失败描述为“看起来是间歇性的”,没有把原因说死。维护者随后批准了最终提交,PR 合并。
测试不只是覆盖分支,还要还原数据的来路h2
这次我意识到,读一个对象时,不能只凭字段名判断它的含义。group 看起来像配置组名,但真正决定它是什么的,是写入代码。先看它在哪里被构造、带着什么值进入缓存,再看读取逻辑,问题才容易对上。
测试数据也是一样。只检查“有没有走到这个分支”“最后返回的是不是预期值”,还不够。输入对象如果和生产格式不同,分支覆盖到了,也可能一直在验证一个现实中不存在的前提。
通过 updateCache() 创建测试缓存以后,写入方和读取方才真正被放在一起检查。重复前缀不再能靠手工数据蒙混过去,namespace 隔离也能用一个具体的碰撞场景来验证。
还有一点是对异常的理解。Admin 抛出了空指针,Gateway 却可以把这次请求当作正常结束。以后看重试或退避逻辑,我会继续确认错误经过响应包装、解析和返回之后,调用方究竟还能不能感知到失败,而不是看到一个 catch 就停下来。