维护另一个 PR 的间隙,我遇到了一个很小的缓存问题。顺着空列表往下看,才发现“没有实例”和“没有更新”其实是两回事。
当时我正在维护另一个 ShenYu PR #7289。维护者让我处理 merge conflicts,在等待和重新跑测试的间隙,我又翻了一下项目里的 Issue,看到了 #6526。
问题第一眼看起来很简单:ZooKeeper Watcher 收到空的子节点列表时,没有更新本地缓存。最后一个实例已经删掉了,再查一次,返回的却还是旧实例。
和之前的 TCP 问题相比,这次没有复杂的并发设计,也不用沿着很多模块找调用链。最后的生产代码修改,确实只是去掉一个判断。但看进去以后,我觉得它有一个很值得记下来的地方:空列表是在说“没有数据需要处理”,还是在说“最新状态就是没有数据”?
这个区别想清楚以后,修改就不难了。修复最终通过 PR #7372 合并。下面想记录的,是我怎么理解这个空状态,以及为什么测试没有停在“删干净了”这一步。
最后一个实例被删了,缓存为什么还在?h2
ZookeeperInstanceRegisterRepository.selectInstances() 会读取 ZooKeeper 中的实例节点,把结果保存在 watcherInstanceRegisterMap 里,并注册一个 Watcher 来监听后续变化。
假设 service-a 下原来有两个实例。删除其中一个以后,Watcher 重新读取子节点列表,用剩下的实例更新缓存,这时一切正常。真正出问题的是再把最后一个实例删掉:ZooKeeper 返回 [],缓存却没有跟着变空。
原来的 Watcher 回调里,有这样一段逻辑:
// 原有回调中的关键片段List<String> childrenList = StringUtils.isNotBlank(path) ? client.subscribeChildrenChanges(path, this) : Collections.emptyList();
if (!childrenList.isEmpty()) { watcherInstanceRegisterMap.put( selectKey, getInstanceRegisterFun.apply(childrenList) );}只要列表为空,就跳过更新。于是 ZooKeeper 已经没有实例,本地缓存里却还留着最后一个实例;后续查询命中缓存,读到的自然还是旧结果。
第一次看 if (!childrenList.isEmpty()) 时,“有数据才更新”其实很容易让人觉得合理。但这里接收的是当前子节点列表,不是一批只需要追加的数据。Watcher 已经告诉我们最新结果是空,这本身就是一次更新。
我后来意识到,问题并不是没收到通知,而是收到了通知,却因为结果为空,把这次状态变化忽略了。
为什么保存空列表,而不是删掉缓存?h2
最直接的修复,就是去掉非空判断,让每一次读取到的列表都能更新缓存:
// 修复后的缓存更新,childrenList 也可以是空列表watcherInstanceRegisterMap.put( selectKey, getInstanceRegisterFun.apply(childrenList));getInstanceRegisterFun 会把子节点转换成实例列表。输入为空时,得到的也是空列表,因此缓存中会保留 service-a -> []。
看到这里,也很容易想到另一种做法:既然已经没有实例了,直接 remove(selectKey) 不就行了吗?我继续看了 selectInstances() 读取缓存的部分:
final List<InstanceEntity> cachedInstances = watcherInstanceRegisterMap.get(selectKey);
if (Objects.nonNull(cachedInstances)) { return cachedInstances;}它判断的是有没有缓存结果,而不是结果里有没有实例。[] 是一个有效的命中,下一次查询可以直接返回;如果把整个 entry 删掉,查询就会走到后面的订阅和初始化流程。
这时我才把两件事分开:没有实例,不代表没有缓存结果。 我们已经知道这个服务当前没有实例,没必要仅仅因为数量是零,就把它当作一次缓存未命中。
| 这里读到的结果 | 在这段逻辑中的含义 | 后续处理 |
|---|---|---|
| 缓存为非空列表 | 已有当前实例快照 | 直接返回缓存 |
缓存为 [] | 已有快照,当前没有实例 | 同样直接返回缓存 |
Map 查询返回 null | 没有这个 key 的缓存结果 | 进入订阅和初始化流程 |
在这段代码里,null 是没查到缓存,[] 是查到了一个空结果。两者的后续处理不同,不能因为“都没有实例”就混在一起。
缓存变空以后,还能继续更新吗?h2
接着我又想到一个问题:如果 selectInstances() 以后都直接返回缓存里的 [],那服务重新注册实例的时候,怎么知道它又有数据了?
关键是,查询命中空缓存,不等于 Watcher 停止工作。
Watcher 回调仍然会调用 client.subscribeChildrenChanges(path, this),重新读取子节点并续订监听。之后有新实例出现,回调再把新的实例列表写入同一个缓存 entry。空列表只是这段变化中的一个正常快照,不是终点。
所以这里不是“把缓存清空以后就不管了”,而是继续用同一套 Watcher 更新当前快照。把读取路径和更新路径放在一起看,这个选择就更容易理解了。
测试为什么没有停在“已经变空”?h2
这次我觉得测试比生产代码的修改更值得展开一点。原来的测试主要检查有实例时能正常读取,我把它补成了 1 → 0 → 1:先有一个实例,删除最后一个,再让实例重新出现。
测试用一个简单状态变量控制模拟的 ZooKeeper 返回值:
final boolean[] hasInstance = {true};
// 根据当前模拟状态,返回一个子节点或空列表when(mock.subscribeChildrenChanges(anyString(), any(CuratorWatcher.class))) .thenAnswer(invocation -> { watcherArr[0] = (CuratorWatcher) invocation.getArguments()[1]; return hasInstance[0] ? Collections.singletonList("shenyu-test") : Collections.emptyList(); });接着,主动触发捕获到的 Watcher 回调,检查每一次状态变化后的查询结果:
// 测试中的关键步骤,省略 repository 和 mockEvent 的初始化assertEquals(1, repository.selectInstances(selectKey).size());
hasInstance[0] = false;watcherArr[0].process(mockEvent);assertTrue(repository.selectInstances(selectKey).isEmpty());
hasInstance[0] = true;watcherArr[0].process(mockEvent);assertEquals(1, repository.selectInstances(selectKey).size());如果测试只停在中间一步,能检查旧实例有没有残留,但看不到后续变化还能不能生效。再往后走一步,就把“从有到无”和“从无到有”连起来了。
PR 里还记录了一个对我很有帮助的检查:先加上空列表的断言,它在旧代码下失败;去掉判断以后,再运行通过。这样能确认,新测试确实抓住了这次修复的问题,而不只是给测试多加了几行代码。
和之前的空快照问题,原来有同一个盲点h2
做这个 Issue 时,我想起了第一次处理的 HTTP 空快照问题。那次是收到空的 ProxySelector 快照以后,刷新链路没有把旧的 TCP Server 清理掉;这次是 ZooKeeper 返回空子节点列表以后,旧实例缓存没有被覆盖。
模块不同,后果也不同,但我在两次排查里碰到了一个相似的盲点:看到列表为空,很容易顺手把它理解成“这次没东西需要做”。
但对这些全量快照来说,空数据可能恰恰在说:之前存在的东西,现在已经全部没有了。 继续保留旧状态,反而和这次更新的含义相反。
我以前更习惯关注“数据来了以后怎么处理”,这两次之后,开始会多问一句:数据从有变成没有的时候,代码会走哪条分支?旧状态会不会还留在那里?
改动很小,但我想记下的不只是那两行代码h2
和前两个 PR 相比,这次修复简单很多。最后也没有做什么很大的改造,就是删掉一个非空判断,补上状态变化的测试。
不过我并不觉得它只能写成一句“修复缓存残留”。对我来说,这次有意思的地方,是沿着一个看起来很合理的判断往下读,发现它其实混淆了两种状态:没有缓存结果,和已经知道结果为空。
也让我对测试有了一点新的想法。除了检查某个时刻返回什么,还可以把前后变化连起来看:先有数据,后来没有,再后来又有。很多问题正好藏在这些过渡里,而不是某一个静态结果里。
这次记下的东西很简单:“没有实例”不是“没有状态”,它本身就是当前状态。 以后再遇到 Watcher、全量同步或者快照式缓存,我想自己会更留意那个容易被直接跳过的空列表。