即使应用键已经删除,Redis 进程仍可能保留较大的常驻内存。这个现象值得解释,但它本身既不能证明内存泄漏,也不能直接证明分配器碎片。真正要回答的是哪些字节仍被分配、哪些页面仍驻留,以及这种占用是否威胁实际容量预算。只看一个名字里带“碎片”的比率,很容易选错处理对象。

把不同测量对象分开

INFO memory 中的 used_memory 表示 Redis 统计的分配量,used_memory_rss 则是操作系统观察到的进程常驻内存。它们都不等于应用负载长度的简单相加,键、对象、元数据、客户端及其他开销也要占空间。MEMORY USAGE 可以估计单个键及其值的分配量,包括相关开销,但不测量这个键独立贡献了多少进程 RSS。

mem_fragmentation_ratio 大体比较常驻内存和 Redis 统计的分配量,两者差额还可能包括代码、共享库、栈以及分配器保留的页面。删除数据后分母下降,即使 RSS 几乎不变,比率也可能迅速升高。因此必须同时检查绝对字节数;一个很小的空实例出现较高比率,不等于它正在占用危险数量的内存,更不是必须立即重启的充分理由。

used_memory_peak 有助于解释此前负载峰值留下的占用。used_memory_dataset 是另一个统计视角,不承诺空键空间对应零进程分配。各项数据也并非全都在 INFO 执行的同一瞬间采样。写入活跃时,不应要求经过四舍五入的比率,精确等于独立读取的两个数字相除,否则可能把采样差异误当成实现错误。

先核实分配器和版本

分配器专用指标有助于区分碎片、保留页面和非堆开销,但含义取决于实际构建。Redis 8.10.1 源码通过小尺寸分配桶的碎片计数计算相关指标,并对 Lua 部分作调整。不能把所有版本的指标都简单重算成 active 除以 allocated;通用文档公式与实际行为不符时,应检查对应版本实现,而不是让旧解释覆盖当前事实。

本实验有意使用 libc 编译。该构建没有 jemalloc 的详细统计,Redis 会用其他测量值补算部分分配器字段,碎片计数则保持为零。因此 allocator_frag_ratio 等于一,不证明进程真的完全没有碎片。解释专门字段或选择处理方式之前,应先记录 mem_allocator,确认数字来自真实分配器统计,还是当前构建能够提供的有限信息。

坏掉的样子

RSS 上升的同时数据集也在增长,首先需要分析负载和容量,而不是自动诊断为碎片。数据集下降、RSS 稳定,提示常驻占用被保留,却仍不能识别每个保留页面的用途。同样,一次快照无法排除泄漏。应比较相同负载下的时间序列、值大小分布、历史峰值及进程限制,避免把相关现象直接当成唯一原因。

MEMORY PURGE 只在相应实现支持时尝试让分配器回收。Redis 明确说明,除 jemalloc 外的分配器执行它属于无害的空操作,因此成功回复不是内存已经回收的证据;它也不会删除应用键。主动碎片整理是另一种机制,需要构建支持,启用后还可能增加 CPU 与延迟,必须单独测量成本和收益。

为了降低比率而重启,可能带来停机、冷缓存负载,或者在持久化与恢复不足时丢失数据。应先证明存在实际容量问题,并准备演练过的恢复路径,再把重启纳入处理方案。maxmemory 不是 RSS 上限,键数减少也不保证页面立即归还操作系统;不能只看到键数归零,就宣布整个内存问题已经修复。

快速验证

原有实验记录使用 macOS、Python 3.14.4、Redis 8.10.1 和 redis-py 8.1.0;本轮审阅没有重新执行服务实验,下面的结果说明保留原有观察范围。准备 POSIX 环境、C 编译器、make、curl、tar 与 Python 3.10 或更新版本。安装步骤仅用 libc 构建 Redis 核心,并把客户端装入临时虚拟环境。

redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0

使用 "$redis_lab/venv/bin/python" 运行 Python 代码。程序在 /tmp 创建专用 Redis 进程和私有 Unix socket,关闭 TCP 与持久化,仅写入自己的三百二十个样本键。值大小交替为十六或六十四 KiB,短暂停顿让指标有机会刷新,但不假设采样恰好发生在某个时刻。结束后关闭自己的进程,移除临时运行目录。

from contextlib import contextmanager
import json
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time

import redis
from redis.backoff import NoBackoff
from redis.retry import Retry


@contextmanager
def instance(policy="noeviction"):
    with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
        endpoint = str(Path(directory) / "redis.sock")
        process = subprocess.Popen([
            os.environ["REDIS_SERVER"], "--port", "0",
            "--unixsocket", endpoint, "--unixsocketperm", "700",
            "--save", "", "--appendonly", "no", "--dir", directory,
            "--maxmemory-policy", policy,
        ], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
        client = redis.Redis(unix_socket_path=endpoint, decode_responses=True,
                             socket_timeout=2, retry=Retry(NoBackoff(), 0))
        try:
            deadline = time.monotonic() + 5
            while True:
                assert process.poll() is None, "Redis exited during startup"
                try:
                    if client.ping():
                        break
                except redis.ConnectionError:
                    if time.monotonic() >= deadline:
                        raise
                    time.sleep(0.02)
            yield client
        finally:
            client.close()
            if process.poll() is None:
                process.terminate()
            try:
                _, errors = process.communicate(timeout=5)
            except subprocess.TimeoutExpired:
                process.kill()
                process.communicate()
                raise
            assert process.returncode == 0, errors.decode()


with instance() as client:
    def snapshot(stage):
        time.sleep(0.2)
        info = client.info("memory")
        fields = ["used_memory", "used_memory_dataset", "used_memory_rss",
                  "used_memory_peak", "mem_fragmentation_ratio", "mem_allocator",
                  "allocator_frag_ratio", "allocator_frag_bytes"]
        print(stage, json.dumps({"keys": client.dbsize(),
                                **{name: info[name] for name in fields}}, sort_keys=True))
        return info

    empty = snapshot("empty")
    for index in range(320):
        client.set(f"sample:{index}", "x" * (16384 if index % 2 == 0 else 65536))
    assert client.dbsize() == 320
    assert client.memory_usage("sample:0") >= 16384
    loaded = snapshot("loaded")
    assert loaded["used_memory"] > empty["used_memory"] + 10 * 1024 * 1024
    for index in range(1, 320, 2):
        assert client.delete(f"sample:{index}") == 1
    assert client.dbsize() == 160
    half = snapshot("large values deleted")
    assert half["used_memory"] < loaded["used_memory"] - 8 * 1024 * 1024
    for index in range(0, 320, 2):
        assert client.delete(f"sample:{index}") == 1
    assert client.dbsize() == 0
    assert client.memory_usage("sample:0") is None
    cleared = snapshot("all values deleted")
    assert cleared["used_memory"] < half["used_memory"]
    assert cleared["used_memory_peak"] >= loaded["used_memory"]
    assert client.memory_purge()
    snapshot("after MEMORY PURGE")
    print("PASS: logical allocations fell; no RSS or purge-effect threshold is assumed")

原有运行记录在加载后报告 used_memory 约十六点七 MiB。删除所有键后,这个数值降到约一点零四 MiB,而 RSS 仍在二十点八 MiB 附近,于是整体比率升到约二十。MEMORY USAGE 同时确认指定键已不存在:逻辑分配确实减少,但常驻内存没有按同样比例下降,不能仅凭较高比率反推应用键没有删掉。

这次 libc 实验中,分配器专用碎片比率始终为一,MEMORY PURGE 也成功返回,却没有带来有意义的 RSS 下降。断言检查键数和逻辑分配量之间的关系,精确 RSS 仅作观察,不设为通过阈值。它证明统计对象存在差异及空操作限制,不证明 jemalloc 整理的效果、Linux 内存压力行为,也不构成对某个生产进程的泄漏诊断。

延伸阅读

来源