过期与逐出都可能让一次成功查找变成未命中,但它们回答的是不同问题。过期服从键的截止时间,逐出则在内存压力下移除符合条件的键。延长 TTL 不能阻止容量驱动的逐出,扩大内存也不能纠正应用写错的截止时间。必须先确定实际发生了哪种变化,才能判断应该检查写入协议、调整容量,还是重新设计缓存用途。

时间与容量是两个独立维度

带有过期设置的键在 Redis 中称为 volatile。Redis 会在访问和主动清理过程中检查过期键,但这不承诺键一定存活到截止时间。显式删除、替换以及适用的逐出策略,都可能提前改变结果。因此,不管最初设定的期限还有多久,缓存客户端都必须能处理未命中,不能把较长 TTL 当作某条数据不会丢失的保证。

maxmemory 是 Redis 进行内存管理决策时使用的统计上限,不是操作系统 RSS 的硬上限。复制与追加日志缓冲区可能包含不计入逐出判断的内存,进程开销、分配器行为和持久化任务也需要容量余量。大型命令还可能短暂超过配置值。规划时既要考虑缓存预算,也要考虑运行整个进程的环境,不能把两者设成同一个数字就认为足够。

先确定候选范围,再讨论排序

allkeys 类策略不管键有没有 TTL,都会把它纳入候选;volatile 类策略则只考虑带过期设置的键。在各自候选范围内,LRU 偏向最久未访问的键,LFU 使用访问频率信息,random 类策略随机选择。Redis 的 LRU、LFU 是近似算法,不保证全局精确的受害键顺序。volatile-ttl 偏向剩余寿命较短的条目,也不是只清除已经到期的数据。

noeviction 在无法满足内存限制时拒绝增长内存的命令,而不是为了腾空间丢键;已有数据仍可能正常读取。但它不是持久性保证,普通过期、显式删除、进程故障和持久化配置仍然分别起作用。应检查当前实例实际配置,不能根据某种部署形式或集群名称,推断所有环境都使用相同默认策略。

volatile 策略如果找不到带 TTL 的候选键,在这种场景下就表现得像 noeviction。选了 volatile-lru,不会自动把持久键变成可丢弃数据。反过来,把会话或权威记录与可重建缓存混放在 allkeys 策略之下,它们也会成为候选。键名前缀只是命名约定,不会给逐出算法建立独立的数据保护边界。

坏掉的样子

未命中突然增加,可能来自过期、逐出、主动失效或键构造变化。需要把 expired_keys、evicted_keys 的增量,与同期请求负载和写命令结果一起看。这些是累计计数,应比较有限时间区间,并考虑重启或重置;仅看到逐出数上升,还不能知道哪个应用功能失去了哪些数据。

除了读取,也要监控被拒绝的写入。热缓存可能继续返回看似合理的值,而写入方已经不断收到内存错误。忽略这些错误,会把容量问题变成应用一致性缺陷。此时延长 TTL 可能只会让旧值保留更久,却不能恢复替换能力;重试策略也不能把内存压力进一步放大成无上限的请求风暴。

将权威状态和可丢弃缓存分开,是架构选择,不是事故中可以直接执行的数据迁移命令。改变现有实例策略之前,应确认可恢复性、持久化和数据归属。下面的实验只修改自己创建的进程,用三个独立候选策略验证行为,不切换生产实例的配置,也不操作现有键空间。

快速验证

准备 POSIX 环境、C 编译器、make、curl、tar 与 Python 3.10 或更新版本。本次在 macOS 上使用 Python 3.14.4、Redis 8.10.1 和 redis-py 8.1.0。使用 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 代码。每种策略都创建自己的 Unix socket、空数据集和进程,并关闭 TCP 及持久化。内存限制设置为测得的初始分配量再加两个 MiB,有限次写入使用六十四 KiB 的值,启用 TTL 的分支统一设置一小时。结束时关闭每个实验进程,仅清理自己的临时运行目录。

from contextlib import contextmanager
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()


for policy, expiring in [("allkeys-lru", True), ("noeviction", True),
                         ("volatile-lru", False)]:
    with instance(policy) as client:
        baseline = client.info("memory")["used_memory"]
        client.config_set("maxmemory", baseline + 2 * 1024 * 1024)
        payload = "x" * 65536
        written, rejected = 0, False
        for index in range(128):
            try:
                client.set(f"item:{index}", payload, ex=3600 if expiring else None)
            except redis.exceptions.OutOfMemoryError:
                rejected = True
                break
            written += 1
        stats = client.info("stats")
        remaining = client.dbsize()
        assert stats["expired_keys"] == 0
        if policy == "allkeys-lru":
            assert written == 128 and not rejected
            assert stats["evicted_keys"] > 0 and remaining < written
            assert client.ttl("item:127") > 0
        else:
            assert rejected and 0 < written < 128
            assert stats["evicted_keys"] == 0 and remaining == written
            assert client.get("item:0") == payload
            if not expiring:
                assert client.ttl("item:0") == -1
        print(policy, {"written": written, "remaining": remaining,
                       "evicted": stats["evicted_keys"],
                       "expired": stats["expired_keys"], "rejected": rejected})

本次 allkeys-lru 接受一百二十八次写入,留下二十三个键,报告一百零五次逐出和零次过期;最新保留键仍有正的 TTL。这说明丢失发生在期限到来之前,原因是容量逐出,但不把这次观察夸大成对精确 LRU 受害顺序的证明,也不要求另一台机器得到相同的键数。

noeviction 接受二十三次写入后拒绝下一次分配,原有键仍能读取,逐出计数保持为零。没有任何带期限键的 volatile-lru 也出现同类拒绝。实际数量受分配开销和构建影响,因此断言检查的是关系,不是强制匹配这组数字。它不是吞吐基准,也不覆盖复制、故障切换、持久化峰值或操作系统级内存限制。

延伸阅读

来源

  • Redis 键逐出,maxmemory 统计、候选策略与近似排序,已核验 2026-09-08。
  • Redis EXPIRE,键级截止时间及过期行为,已核验 2026-09-08。
  • Redis INFO,内存统计及逐出、过期计数,已核验 2026-09-08。
  • Redis 8.10.1 编译说明,独立核心构建与分配器选择,已核验 2026-09-08。