Issue #4143 的核心是:Client Side Caching(CSC)的服务端反向索引在客户端断开后故意不做即时清理,导致内层表可无限膨胀。 下面按机制 → 内存布局 → 生命周期 → 缺口 来拆。
这个非常像Thesis-ZooKeeper中的客户端和server的watch机制。
问题背景 #
功能背景:Client Side Caching #
CLIENT TRACKING on 让客户端在本地缓存 key。
服务端不维护「客户端有哪些 key」的完整正向索引,而是维护反向索引:
某个 key 可能被哪些 client ID 缓存过?
key 被修改时, 服务端按这个表发 invalidation(RESP3 push / redirect pubsub), 所以这个就是主动推送? 客户端再丢本地缓存。
数据结构(内存视角) #
就是维护了一系列的反向索引,对于一个key,可能有哪些client持有过。
TrackingTable (全局 rax*)
├── key "user:1" → inner rax* (client ID 集合) ---> 里面的这个基数树没有上限
│ ├── 8-byte id → NULL
│ ├── 8-byte id → NULL
│ └── ...
├── key "user:2" → inner rax*
└── ...
是基数树。
/* The tracking table is constituted by a radix tree of keys, each pointing
* to a radix tree of client IDs, used to track the clients that may have
* certain keys in their local, client side, cache.
*/
rax *TrackingTable = NULL;
rax *PrefixTable = NULL;
uint64_t TrackingTableTotalItems = 0; /* Total number of IDs stored across
the whole tracking table. ... */
读路径插入树中:
for (int j = 0; j < numkeys; j++) {
int idx = keys[j].pos;
sds sdskey = objectGetVal(executing->argv[idx]);
void *result;
rax *ids;
if (!raxFind(TrackingTable, (unsigned char *)sdskey, sdslen(sdskey), &result)) {
ids = raxNew();
int inserted = raxTryInsert(TrackingTable, (unsigned char *)sdskey, sdslen(sdskey), ids, NULL);
serverAssert(inserted == 1);
} else {
ids = result;
}
if (raxTryInsert(ids, (unsigned char *)&tracking->id, sizeof(tracking->id), NULL, NULL))
TrackingTableTotalItems++;
}
要点:
| 层次 | 结构 | 限制 |
|---|---|---|
| 外层 | key → inner rax | tracking-table-max-keys(默认 1M),超限随机淘汰 key |
| 内层 | client ID(uint64_t,网络字节序存进 rax key)→ NULL |
无上限 |
| 全局计数 | TrackingTableTotalItems |
只统计 ID 条数,不限流 |
client ID 查找靠 server.clients_index(另一个 rax):
client *lookupClientByID(uint64_t id) {
id = htonu64(id);
void *c = NULL;
raxFind(server.clients_index, (unsigned char *)&id, sizeof(id), &c);
return c;
}
没有「client → keys」反向边,所以断开时没法 O(tracked_keys) 精确删除, 只能扫全表,复杂度是 O(外层 keys × 内层 ids)。
生命周期:为什么断开故意不清理 #
disableTracking() 注释写得很直白:
/* Remove the tracking state from the client 'c'. Note that there is not much
* to do for us here, if not to decrement the counter of the clients in
* tracking mode, because we just store the ID of the client in the tracking
* table, so we'll remove the ID reference in a lazy way. Otherwise when a
* client with many entries in the table is removed, it would cost a lot of
* time to do the cleanup. */
void disableTracking(client *c) {
/* BCAST 模式:会从 PrefixTable 里真正摘掉 client pointer */
...
/* 普通 tracking:只清 flag + tracking_clients--,不动 TrackingTable */
}
freeClient / clearClientConnectionState 路径会调 disableTracking(networking.c:2041),但 TrackingTable 里的死 ID 原样留下。
这是典型的 latency vs memory 权衡:
- 断开必须快(事件循环里不能扫百万级 key)
- 假设「key 迟早会被改 → invalidate 时整棵 inner rax 一起扔掉」
问题就出在这个假设不成立的时候。
死条目何时真正消失? #
内层 ID 只在「整 key 被 invalidate」时一起释放,所以说这个remove是lazy的,之前字节一面的时候被问到过.
void trackingInvalidateKey(...) {
...
while (raxNext(&ri)) {
client *target = lookupClientByID(id);
if (target == NULL || !(target->flag.tracking) || ...) {
continue; /* 死客户端:跳过发消息,但不从 ids 里删除! */
}
sendTrackingMessage(...);
}
/* 然后整棵 inner rax 删掉 */
TrackingTableTotalItems -= raxSize(ids);
raxFree(ids);
raxRemove(TrackingTable, key, keylen, NULL);
}
触发整 key 清理的路径:
- key 被修改/删除(
signalModifiedKey→trackingInvalidateKey) - 外层超限淘汰(
trackingLimitUsedSlots,按外层 key 数随机 walk) - FLUSHDB/FLUSHALL(整表重建)
注意 invalidate 遍历时:
lookupClientByID == NULL 只是 skip 发送,不会单独 raxRemove 那个死 ID。
只有整 key 被干掉时,死 ID 才一起消失。
也就是肯定不会单独删除client ids,而是等到整个key过期的时候才会释放。
因此复现路径就是 issue 说的:
大量 client TRACKING on → 读一批很少变更的 key → 断开
→ 外层 key 仍在(未改、未触顶淘汰)
→ 内层堆满 stale client ID
→ TrackingTableTotalItems / 堆内存持续涨
外层 tracking-table-max-keys 约束不了内层聚合大小:
100 个 key × 10 万死客户端 = 1000 万条目,外层仍只有 100。
Issue 期望的三件事 #
| 期望 | 含义 |
|---|---|
| 内层(或聚合)大小有上限 | 不能只限 raxSize(TrackingTable) |
| 死条目清理机制(sweeper) | 后台渐进扫描:lookupClientByID==NULL 则删;或断开时异步扫 |
| TrackingTable 纳入 defrag | 复用 defragRadixTree,外层 + 内层都搬 |