博客 / 詳情

返回

Redis 7.0 新特性之maxmemory-clients:限制客户端內存總使用量

背景

之前分享個 case(Redis 內存突增時,如何定量分析其內存使用情況),一個 Redis 實例的內存突增,used_memory最大時達到了 78.9G,而該實例的maxmemory配置卻只有 16G,最終導致實例中的數據被大量驅逐。

導致這個問題的一個常見原因是客户端佔用的內存過多。

Redis 中,客户端內存主要包括三部分:輸入緩衝區(暫存客户端命令)、輸出緩衝區(緩存發送給客户端的數據),以及客户端對象本身的開銷。

其中,輸入緩衝區可通過client-query-buffer-limit限制,輸出緩衝區可通過client-output-buffer-limit限制。

但這兩個參數只能限制單個客户端

在客户端數量較多的情況下,即使單個客户端佔用不大,客户端內存的總量仍可能失控。

為了解決這一問題,Redis 7.0 引入了maxmemory-clients,用於限制所有客户端可使用的內存總量。

下面看看具體的實現細節。

配置

standardConfig static_configs[] = {
...
    createSSizeTConfig("maxmemory-clients", NULL, MODIFIABLE_CONFIG, -100, SSIZE_MAX, server.maxmemory_clients, 0, MEMORY_CONFIG | PERCENT_CONFIG, NULL, applyClientMaxMemoryUsage),
...
};

maxmemory-clients 的默認值為 0,最小值為 -100,對應的內部變量是server.maxmemory_clients

該參數既可以設置為正數,也可以設置為負數:

  • 正數:表示客户端內存總使用量的上限。因為該參數的類型是 MEMORY_CONFIG,所以可以指定 kb/mb/gb 之類的單位。不指定,則默認是字節。

  • 負數:表示按 maxmemory  的百分比限制客户端內存。例如:maxmemory-clients = -50表示客户端內存總量不得超過 maxmemory 的 50%。

這一點是在getClientEvictionLimit函數中實現的。

size_t getClientEvictionLimit(void) {
    size_t maxmemory_clients_actual = SIZE_MAX;

    if (server.maxmemory_clients < 0 && server.maxmemory > 0) {
        unsignedlonglong maxmemory_clients_bytes = (unsignedlonglong)((double)server.maxmemory * -(double) server.maxmemory_clients / 100);
        if (maxmemory_clients_bytes <= SIZE_MAX)
            maxmemory_clients_actual = maxmemory_clients_bytes;
    }
    elseif (server.maxmemory_clients > 0)
        maxmemory_clients_actual = server.maxmemory_clients;
    else
        return0;

    /* Don't allow a too small maxmemory-clients to avoid cases where we can't communicate
     * at all with the server because of bad configuration */
    if (maxmemory_clients_actual < 1024*128)
        maxmemory_clients_actual = 1024*128;

    return maxmemory_clients_actual;
}

實現細節

當通過CONFIG SET命令調整maxmemory-clients的值時,會調用applyClientMaxMemoryUsage函數進行處理。

static int applyClientMaxMemoryUsage(const char **err) {
    ...
    if (server.maxmemory_clients != 0)
        initServerClientMemUsageBuckets();
    ...
    if (server.maxmemory_clients == 0)
        freeServerClientMemUsageBuckets();
    return 1;
}

可以看到,當server.maxmemory_clients的值不為 0,會調用initServerClientMemUsageBuckets()

void initServerClientMemUsageBuckets() {
    if (server.client_mem_usage_buckets)
        return;
    server.client_mem_usage_buckets = zmalloc(sizeof(clientMemUsageBucket)*CLIENT_MEM_USAGE_BUCKETS);
    for (int j = 0; j < CLIENT_MEM_USAGE_BUCKETS; j++) {
        server.client_mem_usage_buckets[j].mem_usage_sum = 0;
        server.client_mem_usage_buckets[j].clients = listCreate();
    }
}

該函數用於初始化server.client_mem_usage_buckets數組,數組長度由宏CLIENT_MEM_USAGE_BUCKETS決定,默認 19。

每個元素表示一個桶(bucket)。 每個桶維護兩類信息:

  • mem_usage_sum:該桶內所有客户端的內存佔用總和。
  • clients:屬於該桶的客户端列表。

當客户端內存發生變化時,Redis 會通過updateClientMemUsageAndBucket更新該客户端的內存使用情況(客户端使用的內存,對應client list輸出中的tot-mem),並根據內存大小將客户端分配到對應的桶中:

  • 小於 32KB 的客户端進入 0 號桶;
  • 32KB~64KB 的客户端進入 1 號桶;
  • 之後每個桶的範圍按 2 倍遞增;
  • ≥ 4GB 的客户端進入 18 號桶。

此外,Redis 還會通過clientsCron()週期性地更新部分客户端的內存使用情況。 clientsCron() 的執行頻率由server.hz控制,默認每秒 10 次。

適用的客户端類型

需要注意的是,並非所有客户端都會被統計內存並參與驅逐。具體判斷邏輯如下:

int clientEvictionAllowed(client *c) {
    if (server.maxmemory_clients == 0 || c->flags & CLIENT_NO_EVICT) {
        return 0;
    }
    int type = getClientType(c);
    return (type == CLIENT_TYPE_NORMAL || type == CLIENT_TYPE_PUBSUB);
}

可以看到,只有滿足以下條件的客户端才會被統計內存並參與驅逐:

  1. maxmemory_clients不為 0。
  2. 客户端未設置 CLIENT_NO_EVICT,在 Redis 7.0 中,支持通過CLIENT NO-EVICT ON命令顯式關閉驅逐。
  3. 客户端類型為 NORMAL 或 PUBSUB。也就是説,複製相關客户端不會被驅逐。

客户端驅逐細節

客户端驅逐是在evictClients函數中實現的。

void evictClients(void) {
    // 如果 client_mem_usage_buckets 沒被初始化,則直接返回
    if (!server.client_mem_usage_buckets)
        return;
    // 從最大客户端內存桶開始驅逐
    int curr_bucket = CLIENT_MEM_USAGE_BUCKETS-1;
    listIter bucket_iter;
    listRewind(server.client_mem_usage_buckets[curr_bucket].clients, &bucket_iter);
    // 獲取客户端允許使用的最大內存
    size_t client_eviction_limit = getClientEvictionLimit();
    if (client_eviction_limit == 0)
        return;
    // 循環驅逐,直到客户端總內存降到閾值以下或所有可驅逐客户端已釋放
    while (server.stat_clients_type_memory[CLIENT_TYPE_NORMAL] +
           server.stat_clients_type_memory[CLIENT_TYPE_PUBSUB] >= client_eviction_limit) {
        // 獲取當前桶的下一個客户端
        listNode *ln = listNext(&bucket_iter);
        if (ln) {
            client *c = ln->value;
            // 生成客户端信息字符串,用於日誌
            sds ci = catClientInfoString(sdsempty(),c);
            serverLog(LL_NOTICE, "Evicting client: %s", ci);
            // 釋放客户端佔用的資源
            freeClient(c);
            sdsfree(ci);
            // stat_evictedclients對應的是info stats中的evicted_clients
            server.stat_evictedclients++;
        } else {
            // 當前桶已空,切換到下一個較小客户端桶
            curr_bucket--;
            // 所有桶都已經遍歷完,但內存仍超過閾值,記錄警告
            if (curr_bucket < 0) {
                serverLog(LL_WARNING, "Over client maxmemory after evicting all evictable clients");
                break;
            }
            listRewind(server.client_mem_usage_buckets[curr_bucket].clients, &bucket_iter);
        }
    }
}

可以看到,該函數從最大內存桶開始驅逐,優先淘汰佔用內存最多的客户端。

對於被驅逐的客户端,會在日誌中打印以下內容。

* Evicting client: id=993566 addr=243.247.151.0:46084 laddr=172.17.0.2:7379 fd=774 name= age=6 idle=1 flags=N db=0 sub=0 psub=0 ssub=0 multi=-1 qbuf=0 qbuf-free=20474 argv-mem=0 multi-mem=0 rbs=4096 rbp=0 obl=0 oll=1 omem=3145752 tot-mem=3171096 events=rw cmd=get user=default redir=-1 resp=2

該函數的調用場景主要有兩個:

  1. beforeSleep:在處理完本輪所有命令、即將進入下一輪事件循環阻塞前執行。該階段會處理客户端讀寫與阻塞狀態、集羣與複製維護、Key 過期、AOF 刷盤、異步釋放客户端,以及客户端內存驅逐等操作。
  2. processCommand(client *c):在完整讀取並解析一條客户端命令後調用,是所有命令的必經路徑,用於執行命令合法性校驗、ACL 權限檢查、集羣重定向判斷、客户端內存限制、服務器內存淘汰、只讀從庫校驗等一系列前置檢查。
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.