跳过正文

Client Side Caching-服务端反向索引在客户端断开之后没有做及时清理导致无限膨胀的问题

作者
杨全烨
系统软件:操作系统、网络与分布式系统。
目录

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 路径会调 disableTrackingnetworking.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 清理的路径:

  1. key 被修改/删除(signalModifiedKey → trackingInvalidateKey
  2. 外层超限淘汰(trackingLimitUsedSlots,按外层 key 数随机 walk)
  3. 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,外层 + 内层都搬