Skip to content

第 5 章 过期与内存淘汰

学习目标:把「Key 怎么过期」和「内存满了 Redis 怎么挑 Key 踢」这两件事彻底搞清楚。看完之后能解释「为什么 Redis 不维护一张完整 LRU 链表」「allkeys-lru 和 volatile-lru 选哪个」「为什么从节点不主动删过期 Key」这些大厂高频追问。


5.1 概览:内存吃紧时 Redis 怎么办

Redis 是纯内存数据库,内存就是它的命根子。当数据越塞越多,迟早有两种情况会发生:

  1. 某些 Key 到期了——业务设了 EXPIRE,时间一到这些 Key 就该消失。
  2. 内存写满了——maxmemory 上限被触达,再写入就要么报错要么挑老的踢出。

这两件事看起来都是「删 Key」,但触发时机、删除目标、执行策略完全不同

                      ┌─────────────────────────────────────┐
                      │       Redis 内存治理两条线           │
                      └────────────┬────────────────────────┘

              ┌────────────────────┼────────────────────┐
              │                                         │
   ┌──────────▼──────────┐                  ┌──────────▼──────────┐
   │   ① 过期机制         │                  │  ② 内存淘汰策略       │
   │   Expiration         │                  │   Eviction           │
   ├──────────────────────┤                  ├──────────────────────┤
   │ 触发:用户设了 TTL    │                  │ 触发:used_memory     │
   │       且时间到了      │                  │       ≥ maxmemory     │
   ├──────────────────────┤                  ├──────────────────────┤
   │ 目标:仅带 TTL 的 Key │                  │ 目标:按策略选 Key     │
   ├──────────────────────┤                  ├──────────────────────┤
   │ 策略:惰性 + 定期      │                  │ 策略:8 种可配          │
   │       (没有定时!)   │                  │  noeviction / lru …  │
   └──────────────────────┘                  └──────────────────────┘

🍱 生活类比:把 Redis 想象成一台冰箱。

  • 过期机制 = 牛奶盒上的「保质期」:到期了就该扔,但你不会守着冰箱看时间,而是「拿出来喝的时候顺手看一眼」(惰性)+「每天巡一次冰箱」(定期)。
  • 内存淘汰 = 冰箱塞不下了:要往里塞新菜,得先决定扔掉哪盘旧菜,是按「最近一次吃的时间」(LRU)还是「最常吃的频次」(LFU)。

本章我们就把这两条线一一拆开。


5.2 Key 过期机制

5.2.1 三种删除策略:定时、惰性、定期

讲过期机制之前,先把课本上的「三种删除策略」摆出来:

┌───────────────────┬────────────────────────────────┬───────────────┬───────────┐
│ 策略              │ 工作方式                         │ 优点           │ 缺点       │
├───────────────────┼────────────────────────────────┼───────────────┼───────────┤
│ ① 定时删除         │ 每个 Key 设 TTL 时挂一个定时器,  │ 内存友好       │ CPU 爆炸   │
│   (Timer-based)   │ 时间到立刻 DEL                   │ 立刻释放       │ 定时器开销大│
├───────────────────┼────────────────────────────────┼───────────────┼───────────┤
│ ② 惰性删除         │ 访问 Key 时才检查是否过期,      │ CPU 最省       │ 内存浪费   │
│   (Lazy)          │ 过期了顺手 DEL                   │               │ 不访问就不删│
├───────────────────┼────────────────────────────────┼───────────────┼───────────┤
│ ③ 定期删除         │ 后台每秒跑 N 次,每次随机抽样     │ CPU/内存折中   │ 不及时     │
│   (Active)        │ 检查并删过期 Key                  │               │ 仍有浪费   │
└───────────────────┴────────────────────────────────┴───────────────┴───────────┘

⚠️ 网上一个超常见的误传:很多文章会说「Redis 同时使用了三种删除策略」。

真相:Redis 从来没有实现「定时删除(Timer-based)」!源码里压根就没有为每个过期 Key 挂定时器。Redis 实际只用了惰性删除 + 定期删除这两种组合。

为什么不用定时?想象一下 100 万个 Key 都设了 TTL,那就是 100 万个定时器排队,CPU 直接被定时器调度搞崩。

所以,Redis 的真实组合是:

        ┌──────────────────────────────────────┐
        │       Redis 真实的删除策略             │
        ├──────────────────────────────────────┤
        │                                       │
        │  访问时 → 惰性删除 (expireIfNeeded)   │
        │              +                        │
        │  后台 hz=10 → 定期删除 (activeExpire) │
        │                                       │
        │  ❌ 没有「定时删除」                    │
        │                                       │
        └──────────────────────────────────────┘

5.2.2 惰性删除(Lazy Expiration)

🥛 生活类比:你打开冰箱拿牛奶喝,瞄一眼保质期才发现过期了——这才把它扔掉。如果你不去拿,它就永远躺在冰箱里。

Redis 的所有数据访问命令(GET / HGET / LPOP / ...)在真正读写数据之前,都会先调一个函数:

c
// Redis 源码 src/expire.c(简化版)
int expireIfNeeded(redisDb *db, robj *key) {
    if (!keyIsExpired(db, key)) return 0;        // 没过期 → 直接返回

    if (server.masterhost != NULL) return 1;     // 从节点不主动删,等主节点 DEL 同步

    // 主节点:真删
    server.stat_expiredkeys++;                   // expired_keys 计数 +1
    propagateExpire(db, key, server.lazyfree_lazy_expire);  // 写入 AOF / 同步从
    notifyKeyspaceEvent(NOTIFY_EXPIRED, "expired", key, db->id);
    return server.lazyfree_lazy_expire ?
        dbAsyncDelete(db, key) :                 // 异步删
        dbSyncDelete(db, key);                   // 同步删
}

调用时机:

用户命令


┌─────────────────────────┐
│ lookupKeyRead/Write()   │
│   ├─ expireIfNeeded()  ─┼─→ 过期?→ DEL it,返回 nil
│   └─ 真正读/写            │
└─────────────────────────┘

redis-cli 实测一下

bash
127.0.0.1:6379> SET k1 hello EX 2          # 设个 2 秒就过期
OK
127.0.0.1:6379> INFO stats | grep expired_keys
expired_keys:0
# ⏳ 等待 5 秒...
127.0.0.1:6379> INFO stats | grep expired_keys
expired_keys:0                              # 没访问 → 没触发惰性删除
127.0.0.1:6379> GET k1                      # 访问的瞬间,惰性删除被触发
(nil)
127.0.0.1:6379> INFO stats | grep expired_keys
expired_keys:1                              # 计数 +1

优缺点

✅ 优点:CPU 几乎零开销,删除均摊到正常请求中
❌ 缺点:如果 Key 从此再无人访问,它会"僵尸般"占用内存
        ↑ 所以必须配合「定期删除」兜底

5.2.3 定期删除(Active Expiration)

惰性删除有个致命缺陷:冷数据永远不会被清理。比如你给一批日志 Key 设了 1 天 TTL,但这些 Key 写完之后再也没人 GET 过,那它们就躺在内存里直到 Redis 重启。

为了兜住这个漏洞,Redis 还有一个后台任务,每秒跑 hz 次(默认 10 次,可调),主动扫描带 TTL 的 Key:

c
// Redis 源码 src/expire.c —— activeExpireCycle 简化逻辑
void activeExpireCycle(int type) {
    // 每个 db 循环
    for (db_idx in dbs) {
        do {
            // 1. 从 db->expires 字典里随机抽 20 个 key
            sample = randomSample(db->expires, ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP);
            // 默认 ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP = 20

            // 2. 检查并删除已过期
            int expired = 0;
            for (k in sample) {
                if (isExpired(k)) { dbDelete(k); expired++; }
            }

            // 3. 如果一轮里过期比例 ≥ 25%,再来一轮
        } while (expired > sample.size * 0.25);
        // 4. 单次总耗时不超过 25 ms(避免阻塞)
    }
}

用图把流程画出来

                每秒 hz=10 次(默认每 100ms 一次)


            ┌──────────────────────────────────┐
            │  从 db->expires 哈希表抽 20 个 Key │
            └──────────────┬───────────────────┘

                ┌──────────┴──────────┐
                ▼                     ▼
        没过期 → 跳过           过期了 → DEL
                │                     │
                └──────────┬──────────┘

            ┌──────────────────────────────────┐
            │  统计:本轮过期比例 = 过期数 / 20   │
            └──────────────┬───────────────────┘

                  ≥ 25%?  ◇
                  ┌────────┴────────┐
                 是                  否
                  │                  │
                  ▼                  ▼
          再来一轮(继续抽)        本次结束
          (直到 25ms 总耗时上限)

💡 为什么是 25% 这个阈值? 这是 Redis 作者基于经验值定的——如果一批抽样里超过 1/4 都过期了,说明这个 db 的过期 Key 还很多,趁热打铁;否则就放过这一轮。

几个关键参数(写在 redis.conf 里):

bash
hz 10                          # 后台任务频率,每秒 10 次
                              # 调高(如 100)→ 过期更及时但 CPU 占用高
                              # 一般保持默认 10 即可

active-expire-effort 1         # Redis 6+ 新增,1~10
                              # 越大 → 单次过期扫描越积极(最多 10)

实测「定期删除」生效

bash
# 写 1000 个 1 秒过期的 Key
127.0.0.1:6379> EVAL "for i=1,1000 do redis.call('SET','tmp:'..i,'v','EX',1) end" 0
127.0.0.1:6379> DBSIZE
(integer) 1000

# 不去访问任何一个 Key,等 30 秒
127.0.0.1:6379> DBSIZE         # 30 秒后再看
(integer) 0                    # 已经被定期删除清完了
127.0.0.1:6379> INFO stats | grep expired_keys
expired_keys:1000              # 全都计入

5.2.4 主从复制下的过期:从节点不主动删

这是个大厂超爱问的点

:主从架构下,一个带 TTL 的 Key 在从节点上会不会自动过期?

不会。从节点不会主动删除任何过期 Key,必须等主节点先删,再通过复制同步 DEL 命令到从节点。

为什么这么设计?

        ┌─────────── 反例:如果从节点也主动删 ───────────┐
        │                                              │
   主节点                                          从节点
   ─────                                         ──────
   t=0:  SET k v EX 10        ─── 同步 ──→        SET k v EX 10
   t=8:  在主节点上看到 GET k → "v"
                              (主节点 OK,因为还没到 10 秒)

   t=11: GET k → (nil)         (主节点惰性删除)

                              ┆  但因主从有 ms 级时钟漂移,
                              ┆  从节点在 t=9.95 时本地时间已 ≥ 10s
                              ┆  → 假如从节点主动删了,
                              ┆  → 主节点上 GET 还能读到,从节点上读不到
                              ┆  → 主从数据不一致!
        └────────────────────────────────────────────┘

为了避免主从数据不一致,Redis 强制:

┌──────────────────────────────────────────────────┐
│ 从节点的过期处理规则:                              │
│                                                    │
│  ① 从节点本地不主动检测过期 → 即使本地时间过了也保留 │
│  ② 等主节点删了 Key 之后,会向所有从节点广播 DEL    │
│  ③ 客户端读从节点时,旧版本会返回脏数据             │
│     —— 3.2 之后改为「读到过期 Key 直接返回 nil」     │
│        (但内存还没释放,等主节点 DEL 同步过来)     │
└──────────────────────────────────────────────────┘

面试加分点:3.2 起,从节点上的读操作即使发现 Key 在本地已经过期,也只会返回 nil而不会真的删除——这样既保证用户感知正确,又保证主从数据一致性的最终归属权完全在主节点手里。


5.3 内存淘汰策略(8 种)

5.3.0 触发时机

        每次客户端写入命令前


   ┌──────────────────────────────┐
   │ used_memory ≥ maxmemory ?    │
   └──────────────┬───────────────┘

        否 ──→ 直接执行写入



   ┌──────────────────────────────┐
   │  按 maxmemory-policy 淘汰     │
   │  腾出空间后再执行写入          │
   └──────────────────────────────┘

⚠️ 注意maxmemory 0 表示无上限(默认)。生产环境必须设,否则一旦内存吃满,操作系统会 OOM Kill 整个 Redis 进程。

5.3.1 noeviction(默认)

┌──────────────────────────────────────────┐
│ noeviction:内存满了直接拒绝写入          │
│                                            │
│  写入命令 → (error) OOM command not       │
│             allowed when used memory      │
│             > 'maxmemory'.                 │
│                                            │
│  读命令 / DEL 仍然正常                     │
└──────────────────────────────────────────┘

适用场景:数据不能丢的纯持久化场景(不应该用 Redis,但偶尔会有人这么用)。绝大多数场景下不推荐——你的应用会因为写报错而崩溃。

5.3.2 volatile-lru / allkeys-lru

volatile-lru  →  仅在「设了 TTL 的 Key」中淘汰最久未访问的
allkeys-lru   →  在所有 Key 中淘汰最久未访问的

📚 LRU = Least Recently Used,「最近最久未使用」。

        访问时间轴(左边老,右边新)
        老 ─────────────────────────→ 新
        K1   K3   K2   K5   K4
         ↑                       ↑
        最久未访问            最近访问
        ↑ 淘汰它

5.3.3 volatile-lfu / allkeys-lfu

volatile-lfu  →  仅在「设了 TTL 的 Key」中淘汰访问频次最低的
allkeys-lfu   →  在所有 Key 中淘汰访问频次最低的

📊 LFU = Least Frequently Used,「最近最少使用次数」。

        访问频次统计
        K1: ▇▇▇▇▇▇▇  (7 次)
        K2: ▇        (1 次)  ← 淘汰它(虽然刚刚才访问,但总频次最低)
        K3: ▇▇▇▇▇    (5 次)
        K4: ▇▇       (2 次)

5.3.4 volatile-random / allkeys-random

volatile-random  →  仅在「设了 TTL 的 Key」中随机踢
allkeys-random   →  所有 Key 里随机踢

最简单粗暴。适用于业务对「淘汰精度」无所谓的场景,比如完全均匀分布的访问(理论上很少见)。

5.3.5 volatile-ttl

volatile-ttl  →  在「设了 TTL 的 Key」中,挑「剩余存活时间最短」的踢
        K1: TTL=300s
        K2: TTL=10s   ← 淘汰它(反正马上就要死了)
        K3: TTL=600s
        K4: 没设 TTL  ← 不参与

5.3.6 8 种策略全景图

┌─────────────────────┬───────────────────┬─────────────────────────┐
│  策略名              │  作用范围           │  挑选规则                │
├─────────────────────┼───────────────────┼─────────────────────────┤
│  noeviction          │  ——              │  不淘汰,写入报 OOM       │
│  allkeys-lru         │  所有 Key         │  最近最久未使用            │
│  volatile-lru        │  仅 TTL Key       │  最近最久未使用            │
│  allkeys-lfu         │  所有 Key         │  访问频次最低              │
│  volatile-lfu        │  仅 TTL Key       │  访问频次最低              │
│  allkeys-random      │  所有 Key         │  随机                     │
│  volatile-random     │  仅 TTL Key       │  随机                     │
│  volatile-ttl        │  仅 TTL Key       │  剩余 TTL 最小             │
└─────────────────────┴───────────────────┴─────────────────────────┘

5.3.7 决策树:到底选哪个?

                     业务用 Redis 的目的?

            ┌───────────────┼───────────────┐
            │                               │
        纯缓存                          缓存 + 持久数据混合
        (丢了能从 DB 重建)            (部分 Key 不能丢)
            │                               │
            ▼                               ▼
     在意访问频率分布吗?              所有「可淘汰 Key」都设 TTL
            │                               │
   ┌────────┴────────┐                      ▼
   是                否                   选 volatile-* 系列
   │                │                      │
   ▼                ▼                      │
 有明显热点?     近期访问 ≈ 总频率           ▼
   │                │                  在意频率?
 ┌─┴─┐              │                   │
 是   否             ▼                ┌──┴──┐
 │   │           allkeys-lru          是   否
 ▼   ▼                                │   │
allkeys-lfu                           ▼   ▼
(推荐!)                          volatile-lfu  volatile-lru

          特殊场景:
            - 访问完全均匀 → allkeys-random(极少见)
            - 想优先清快过期的 → volatile-ttl
            - 数据绝对不能丢 → noeviction(但要监控内存!)

🎯 80% 的缓存场景推荐allkeys-lruallkeys-lfu。 后者在「热点 Key 长期高频」时表现更稳定。


5.4 LRU 近似实现

5.4.1 真实 LRU 链表的内存代价

学校里教 LRU,老师都会画一张「双向链表」:

   头部(最近访问)                                    尾部(最久未用)
       │                                                    │
       ▼                                                    ▼
   ┌────┐  ┌────┐  ┌────┐  ┌────┐  ┌────┐  ┌────┐  ┌────┐
   │ K1 │⇄│ K3 │⇄│ K7 │⇄│ K2 │⇄│ K9 │⇄│ K5 │⇄│ K4 │  ← 淘汰
   └────┘  └────┘  └────┘  └────┘  └────┘  └────┘  └────┘

每次访问 K3 → 把 K3 摘出来移到头部,O(1) 操作。淘汰 → 直接从尾部摘,O(1) 操作。完美。

但这个完美方案在 Redis 里是个奢侈品

代价分析(假设 Redis 里有 1 亿个 Key):
  - 每个 Key 要多 2 个指针(prev, next)
  - 64 位机器:2 × 8 = 16 字节 / Key
  - 1 亿 × 16 字节 = 1.6 GB 纯指针开销!
  - 链表节点本身还要至少 32~48 字节的元数据

而且,每次访问都要做链表节点的「摘除 + 头插」,在多线程或多核场景下链表头部就是热点锁,极易成为瓶颈。

5.4.2 Redis 的近似 LRU

Redis 的方案:给每个 redisObject 加一个 24 bit 的 lru 字段,记录这个 Key 最后一次被访问时的「时钟」。淘汰时不维护链表,而是:

struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:24;        // ★ LRU 时钟(24 bit ≈ 194 天循环)
    int refcount;
    void *ptr;
};

访问时:把 obj->lru 设为当前 server 的 LRU clock(每秒更新一次)。

淘汰时

        随机采样池(默认 maxmemory-samples = 5)


   ┌─────────────────────────────────────┐
   │  从 db->dict(或 db->expires)        │
   │  随机抽 5 个 Key                       │
   └──────────────┬──────────────────────┘


   ┌─────────────────────────────────────┐
   │  根据 lru 字段算「空闲时间」           │
   │  idle = LRU_clock - obj->lru         │
   └──────────────┬──────────────────────┘


   ┌─────────────────────────────────────┐
   │  把「空闲时间最长的」放进淘汰池        │
   │  (池子大小 16,按 idle 排序)         │
   └──────────────┬──────────────────────┘


   ┌─────────────────────────────────────┐
   │  淘汰池满了?→ 弹出 idle 最大的 Key 删 │
   │  没满?→ 下一轮再抽 5 个继续填          │
   └─────────────────────────────────────┘

画一下采样池的演化

   假设 db 里有 K1~K10,真实 idle 时长(数字越大越久未访问):
   K1=100, K2=300, K3=50, K4=800, K5=200, K6=600, K7=20, K8=400, K9=900, K10=10

   ┌─────────────── 第一次采样池(容量 16) ───────────────┐
   │  随机抽 5 个:K3(50)  K7(20)  K2(300) K8(400) K4(800)  │
   │  排序后池子(按 idle 升序):                          │
   │  K7(20) K3(50) K2(300) K8(400) K4(800)                │
   └────────────────────────────────────────────────────────┘

                              ▼ 第二次采样
   ┌─────────────── 池子继续填(保持 idle 排序) ───────────┐
   │  又随机抽 5 个:K1(100) K5(200) K6(600) K9(900) K10(10)│
   │  合并入池后(仍升序,最多 16 个):                     │
   │  K10(10) K7(20) K3(50) K1(100) K5(200) K2(300)        │
   │  K8(400) K6(600) K4(800) K9(900)                      │
   └────────────────────────────────────────────────────────┘

                              ▼ 淘汰
   ┌─────────────── 弹出 idle 最大的 ───────────────────────┐
   │  K9(900) ← 被踢出!                                    │
   └────────────────────────────────────────────────────────┘

采样数 = 精度 vs CPU 的取舍

bash
# redis.conf 配置
maxmemory-samples 5         # 默认 5:CPU 友好,精度约 90%
                            #     10:精度 ~99%,CPU 多花一点
                            #      3:CPU 最省,精度下降

💡 Redis 作者 antirez 实测过:采样 5 已经能逼近真实 LRU 95%+ 的精度,10 几乎和真实 LRU 无法区分。所以默认 5 是个「省 CPU + 够用」的甜点。

5.4.3 「采样池」是 3.0 后的优化

3.0 之前 Redis 是「随机抽 N 个直接淘汰最老的」,效果不够好。3.0 引入了淘汰池(eviction pool)——一个全局的、按 idle 时长排序的固定大小(16)的池子。每次采样新抽出的 Key 都尝试合并进池,淘汰时只挑池里最老的。这个改进让 Redis 的近似 LRU 几乎等同于真实 LRU。


5.5 LFU 近似实现

5.5.1 LRU vs LFU 的本质区别

     时间轴 ──────────────────────────────────→
     K1: ▇    ▇                              ▇   (3 次, 最近访问)
     K2: ▇▇▇▇▇▇▇▇▇▇                         (10 次, 但很久前)
     K3:                              ▇       (1 次, 最近访问)

   ┌──────────────────────────────────────────┐
   │  LRU 视角:「最近一次什么时候用?」          │
   │  → K2 最久没碰,淘汰 K2                    │
   │                                            │
   │  LFU 视角:「累计访问了多少次?」            │
   │  → K3 只访问 1 次,淘汰 K3                 │
   └──────────────────────────────────────────┘

典型场景对比

场景LRU 表现LFU 表现
持续高频热点✅ 好✅ 更好(不会被偶发冷数据冲掉)
突发流量(短时段大量一次性访问)❌ 把真热点冲走✅ 不受影响
周期性变化(早晚活跃用户不同)✅ 跟得上❌ 历史频次会拖累新热点

5.5.2 8 bit 计数器:怎么估算 ~100 万次访问?

把每个 Key 都搞个 32 bit 计数器太占内存。Redis 复用了 LRU 的那 24 bit 字段,拆成两半

Redis 4.0 LFU 模式下 redisObject.lru 字段(24 bit 复用):

  ┌──────────────────┬──────────────────────────────┐
  │   16 bit         │   8 bit                       │
  │   ldt(上次衰减时间)│   counter(访问频次估计)       │
  │   分钟为单位       │   0~255                       │
  └──────────────────┴──────────────────────────────┘

8 bit 最大 255,哪能表示 100 万访问?答案是对数计数器(Morris 概率计数)

算法:每次访问 Key,不一定让 counter +1,而是有概率才加:

c
// 来自 src/evict.c(简化)
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255;
    double r = rand()/RAND_MAX;                       // 随机数 [0,1)
    double baseval = counter - LFU_INIT_VAL;          // 减去初始值 5
    if (baseval < 0) baseval = 0;
    double p = 1.0 / (baseval * server.lfu_log_factor + 1);
    if (r < p) counter++;
    return counter;
}

直观理解:counter 越大,下一次 +1 的概率越小。这样:

  • counter = 0 时:几乎每次访问都 +1
  • counter = 100 时:每一千次访问才加 1
  • counter = 200 时:每百万次访问才加 1

实测数值lfu-log-factor=10,默认):

  访问次数      counter 估计值
  ───────       ─────────
  10           1
  100          5
  1,000        10
  10,000       18
  100,000      142
  1,000,000    255 (饱和)

8 bit 就这样表达了 6 个数量级的访问频次。

5.5.3 概率衰减:让历史不要拖累现在

光涨不降的话,热门一阵子的 Key 就永远是热门了。Redis 引入时间衰减

每次访问 Key 时:
  Δt = 当前分钟 - 上次衰减分钟(ldt 字段)
  decrement = Δt / lfu-decay-time      # decay-time 默认 1 分钟
  counter = max(0, counter - decrement)
  ldt = 当前分钟
  示例(lfu-decay-time=1):
  K1 初始 counter=100, ldt=10:00
  K1 在 10:30 时检查:Δt = 30 分钟 → counter -= 30 → counter=70
  这样过了 100 分钟没访问,counter 自然归零,K1 就成了候选淘汰目标

两个关键参数

bash
lfu-log-factor 10        # 计数器增长速度(越大涨越慢,能区分更高的频次范围)
                         # 默认 10,可调 0~∞
lfu-decay-time 1         # 衰减周期(分钟)
                         # 0 表示永不衰减
                         # 默认 1,可调

5.5.4 调试用:OBJECT FREQ

bash
# 必须先开 LFU 策略
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lfu

127.0.0.1:6379> SET k1 v
127.0.0.1:6379> GET k1
127.0.0.1:6379> GET k1
127.0.0.1:6379> GET k1
127.0.0.1:6379> OBJECT FREQ k1
(integer) 6                        # 初始 5 + 几次 GET 涨上来的

⚠️ OBJECT FREQ 只在 LFU 策略下能用,其他策略会报错。


5.6 maxmemory 配置实战

5.6.1 配置示例

bash
# redis.conf
maxmemory 4gb                       # 上限 4GB
maxmemory-policy allkeys-lru        # 推荐缓存场景
maxmemory-samples 5                 # LRU/LFU 采样数

# LFU 模式才生效
lfu-log-factor 10
lfu-decay-time 1

也可以运行时动态设置(重启失效):

bash
127.0.0.1:6379> CONFIG SET maxmemory 4gb
OK
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lfu
OK

5.6.2 怎么估算合理的 maxmemory

   ┌──────────────────────────────────────────────────────┐
   │  服务器总物理内存                                      │
   ├────────────┬────────────┬───────────────────────────┤
   │  操作系统    │  其他进程    │  Redis 进程                │
   │  ~10%       │  ~10%       │  ~80%                     │
   └────────────┴────────────┴────────────┬──────────────┘

                  ┌───────────────────────┴────────────────────┐
                  │                                            │
              maxmemory                              fork 期间额外内存
              (读写正常用)                          (RDB/AOF 重写时复制页)
                ~70%                                    ~30%(最坏情况翻倍!)

经验公式

maxmemory ≈ (机器物理内存 × 0.6) ~ (机器物理内存 × 0.7)

🚨 为什么要留 30%?Redis 的 RDB / AOF 重写靠 fork(2) 实现 COW(Copy-On-Write)。如果父进程在 fork 期间被大量写入,修改过的内存页会被全部复制一份——最坏情况下,子进程的内存几乎等于父进程当时的全部 used_memory。如果不留余量,机器直接 OOM Kill。

5.6.3 监控关键指标

bash
127.0.0.1:6379> INFO memory
used_memory:1234567890               # 已用字节数
used_memory_human:1.15G
used_memory_peak_human:1.20G          # 历史峰值
used_memory_rss:1300000000           # 操作系统视角的 RSS(含碎片)
mem_fragmentation_ratio:1.05         # rss / used_memory,>1.5 就该重启
maxmemory:4294967296
maxmemory_policy:allkeys-lru

127.0.0.1:6379> INFO stats
expired_keys:1024                    # 累计被过期删除的 Key
evicted_keys:5012                    # 累计被淘汰策略踢出的 Key
keyspace_hits:1000000                # 命中次数
keyspace_misses:5000                 # 未命中次数 → 算命中率

报警建议

┌──────────────────────────┬──────────────────────────────┐
│  指标                     │  报警条件                     │
├──────────────────────────┼──────────────────────────────┤
│ used_memory / maxmemory   │  > 80%                       │
│ evicted_keys / 秒         │  > 1000(持续触发淘汰,热数据被冲走)│
│ mem_fragmentation_ratio   │  > 1.5(碎片严重)            │
│ keyspace_misses 突增      │  缓存命中率下降               │
└──────────────────────────┴──────────────────────────────┘

5.7 实操:跑一遍配套代码 + demo

实战代码见 05_expire_eviction/code/

  • 01_expire_strategies.py:观察惰性删除定期删除的实际行为
  • 02_lru_simulator.py:手写采样 LRU 与真 LRU 的命中率对比
  • 03_eviction_demo.py:通过 CONFIG SET maxmemory 触发 8 种淘汰策略

浏览器演示见 05_expire_eviction/demo.html

  • 过期机制可视化:键空间网格 + 倒计时,对比惰性 vs 定期
  • LRU vs LFU 对比:相同访问序列,两种算法的淘汰差异动画
  • 8 种淘汰策略沙盒:交互式选择策略,观察踢出哪个 Key

5.8 本章小结

┌────────────────────────────────────────────────────────┐
│                     本章核心要点                          │
├────────────────────────────────────────────────────────┤
│                                                          │
│  ① 过期机制 = 惰性删除 + 定期删除                          │
│     ❌ Redis 没有「定时删除」(网传的误区)                 │
│                                                          │
│  ② 惰性删除:访问 Key 时调 expireIfNeeded                 │
│     定期删除:hz=10 次/s,每次抽 20 个,过期≥25% 再来一轮   │
│                                                          │
│  ③ 主从复制:从节点不主动删,等主节点 DEL 同步             │
│     —— 3.2+ 从节点读到过期 Key 直接返回 nil               │
│                                                          │
│  ④ 内存淘汰策略 8 种:                                    │
│     noeviction / {allkeys,volatile} × {lru,lfu,random}  │
│     + volatile-ttl                                       │
│                                                          │
│  ⑤ Redis 用「采样近似 LRU」:每个 obj 24 bit lru 字段     │
│     淘汰时随机抽 5 个 + 维护 16 容量的淘汰池                │
│                                                          │
│  ⑥ LFU = Morris 概率计数器(8 bit 估算 6 个数量级)        │
│     + 时间衰减(lfu-decay-time)                          │
│                                                          │
│  ⑦ maxmemory 留 30% 给 fork(COW 最坏翻倍)               │
│                                                          │
│  ⑧ 必看监控:evicted_keys, expired_keys, fragmentation   │
│                                                          │
└────────────────────────────────────────────────────────┘

5.9 面试高频题

Q1:Redis 怎么删除过期 Key?为什么用「惰性 + 定期」组合?

考察点:过期机制的整体设计哲学。

标准答案

Redis 实际只用了两种删除策略:

  1. 惰性删除(Lazy):客户端访问 Key 时,Redis 在 lookupKeyRead/Write 中调用 expireIfNeeded,发现过期就 DEL 并返回 nil。优点是 CPU 几乎零开销;缺点是冷 Key 永远不删。
  2. 定期删除(Active):后台 serverCron 任务每秒触发 hz 次(默认 10 次),每次从 db->expires 字典里随机抽 20 个 Key 检查过期,如果一轮中过期比例 ≥ 25% 就再来一轮,单次最长跑 25 ms。优点是兜底冷 Key;缺点是仍有内存延迟释放。

为什么不用「定时删除」

  • 给每个带 TTL 的 Key 挂一个定时器,会把 CPU 耗在定时器调度上。
  • Redis 是单线程模型,CPU 资源极其宝贵,把 CPU 留给业务请求更划算。

加分项

  • 提到 hz 参数和 active-expire-effort(6.0+)可调过期回收力度。
  • 主从架构下从节点不主动删除,避免主从数据不一致。

易错点

  • 千万别说「Redis 用了三种删除策略」——这是网上传烂了的误区,源码里压根没有定时删除。

Q2:8 种内存淘汰策略分别什么时候用?

考察点:场景匹配能力。

标准答案(按业务场景分类):

┌─────────────────────┬──────────────────────────────────┐
│  策略                │  典型场景                          │
├─────────────────────┼──────────────────────────────────┤
│ noeviction          │ 数据绝对不能丢,宁可写报错         │
│ allkeys-lru         │ 通用缓存(最常用!80% 场景的默认)  │
│ allkeys-lfu         │ 有持续高频热点(比如商品详情、首页)│
│ allkeys-random      │ 访问完全均匀(少见)                │
│ volatile-lru        │ 缓存 + 持久数据混存,缓存全设 TTL   │
│ volatile-lfu        │ 同上,且热点稳定                    │
│ volatile-random     │ 同上,但不在意精度                  │
│ volatile-ttl        │ 设了 TTL 的临时数据,优先清快过期的  │
└─────────────────────┴──────────────────────────────────┘

加分项

  • allkeys-lfu 在突发流量场景下不会被「秒杀型一次性访问」冲掉真热点。
  • volatile-* 的隐性约束:所有「能淘汰的 Key」必须设 TTL,否则全是「不可淘汰」会触发 OOM 写报错。
  • Redis 4.0 才引入 LFU,在此之前生产基本都是 allkeys-lru

易错点

  • volatile-lru 不会动没设 TTL 的 Key——如果你的「持久数据」没设 TTL 而想用 LRU 淘汰,应该选 allkeys-lru

Q3:LRU 和 LFU 的区别?Redis 怎么近似实现 LRU 的?

考察点:算法理解 + 工程实现取舍。

标准答案

算法本质区别

  • LRU 关注「最近一次访问的时间」,把最久没碰的踢出。
  • LFU 关注「累计访问的频次」,把总频次最低的踢出。

LFU 优于 LRU 的场景:突发一次性访问(比如恶意爬虫扫一遍冷数据)会把 LRU 的真热点全部冲走,LFU 不会,因为偶发访问的 counter 涨不上来。

Redis 的近似 LRU 实现

  1. 每个 redisObject 有 24 bit 的 lru 字段,记录最后一次访问时的全局 LRU clock(每秒更新)。
  2. 不维护任何链表,淘汰时随机抽 maxmemory-samples(默认 5)个 Key。
  3. 把这些 Key 按「空闲时长」放进一个全局淘汰池(容量 16,按 idle 升序)。
  4. 淘汰池满 → 弹出 idle 最大的 Key 删掉,腾出空间。

精度:实测采样 5 已经能达到真实 LRU 95%+ 的命中率,采样 10 几乎与真实 LRU 无法区分。

加分项

  • 提到 3.0 引入的「淘汰池」是关键优化,3.0 之前是「直接踢抽样里最老的」,效果差很多。
  • LFU 的实现细节:8 bit 计数器 + Morris 概率对数计数 + 时间衰减(lfu-decay-time)。
  • LFU 模式下 24 bit 字段拆为「16 bit ldt + 8 bit counter」。

易错点

  • 别说 Redis 维护了 LRU 链表——它没有,链表内存代价太大。

Q4:为什么 Redis 不维护一个真正的 LRU 链表?

考察点:对内存与 CPU 取舍的理解。

标准答案

  1. 内存代价巨大:双向链表每个节点至少 2 个指针,64 位机上 16 字节/Key。1 亿 Key = 1.6 GB 纯指针开销。
  2. 维护代价高:每次访问都要把节点摘除并重新插到头部,多线程下链表头是热点,单线程下也要 O(1) 但常数不小。
  3. 精度收益不显著:实测采样 5~10 个就已经能达到真实 LRU 95~99% 的精度,工程上完全够用。
  4. Redis 是性能优先的内存数据库:「省内存」和「短关键路径」比「算法理论最优」更重要。

加分项

  • 提到 antirez 在博客里专门撰文解释过这个设计取舍。
  • 真正的 LRU 链表在多核 / 并发场景下会成为瓶颈,采样法天然无锁。

Q5:主从复制下,从节点会主动删除过期 Key 吗?

考察点:主从数据一致性细节。

标准答案

不会。从节点不会主动检测和删除任何过期 Key,必须等待主节点删除后通过复制流广播 DEL 命令同步过来。

为什么这么设计

  • 主从之间存在毫秒级时钟漂移,如果各自独立判断过期,会出现「主节点上 Key 还有效,从节点上已经被删」的不一致
  • 一旦客户端读从节点拿到 nil,但读主节点拿到值,业务逻辑就错乱了。

Redis 3.2 之前的坑

  • 那时从节点读到本地已过期的 Key 会返回脏数据(还没等到主节点 DEL 同步过来)。
  • 3.2 起改为「读到过期 Key 直接返回 nil」——既保证用户感知正确,又不真删(删除权完全归主节点)。

加分项

  • replica-read-only yes(默认开启)禁止在从节点写数据,否则会出现更严重的不一致。
  • 主节点删除 Key 时会写入 DEL 到 AOF 和 replication backlog,从节点严格按主节点节奏走。

易错点

  • 不要说「从节点也会按 hz 跑定期删除」——它不会对带 TTL 的 Key 跑 activeExpireCycle

Q6:缓存场景该用 allkeys-lru 还是 volatile-lru?为什么?

考察点:策略选择的工程判断。

标准答案

绝大多数纯缓存场景推荐 allkeys-lru。原因:

  1. 简单一致:缓存就是「随时可重建」的数据,没必要给每个 Key 都设 TTL,让 LRU 自动管理冷热即可。
  2. 避免 OOM 写报错:用 volatile-lru 时,如果你忘了给某个 Key 设 TTL,它就成了「不可淘汰」。当所有可淘汰 Key 都被踢光后,新写入会触发 OOM 报错。这个坑我们排查过线上事故。
  3. LRU 本身就解决「过期」问题:冷数据自然被踢,热数据留着——TTL 在缓存场景反而是冗余。

什么时候用 volatile-lru

  • Redis 实例同时存「缓存」和「业务持久数据」(比如 Session、配置)。
  • 持久数据不能被淘汰,缓存可以。
  • 此时给所有缓存设 TTL,淘汰范围限定在 volatile-*

加分项

  • 实际更推荐:两套数据物理隔离(不同 Redis 实例 / 不同 db)。混存容易出错。
  • 进一步追问 LFU vs LRU:有稳定高频热点选 LFU,否则 LRU 就够。

易错点

  • volatile-lru 但忘了所有 Key 都设 TTL,一上线就 OOM 写报错。

📌 下一章预告:第 6 章我们看 Redis 的「持久化」——RDB 快照、AOF 写后日志、4.0 的混合持久化。看完之后能解释「为什么 BGSAVE 用 fork」「AOF 重写到底重写了什么」。

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗

💻 示例代码

python
"""
Ch5 配套代码 1 / 3 —— 过期机制实测

演示:
  1. 惰性删除:写入 TTL=2s 的 Key,sleep 3s 后不主动触发,
                直到 GET 才发现过期被删,expired_keys 才 +1
  2. 定期删除:批量写入大量短 TTL Key,不去访问,观察后台
                activeExpireCycle 自动清理(DBSIZE 自然下降)
"""

import time
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)


def section(title: str) -> None:
    print("\n" + "=" * 60)
    print(title)
    print("=" * 60)


def get_expired_count() -> int:
    return int(r.info("stats")["expired_keys"])


def demo_lazy_expiration() -> None:
    section("Demo 1: 惰性删除 —— 不访问就不触发")

    key = "demo:lazy:k1"
    r.delete(key)
    base = get_expired_count()

    r.set(key, "hello", ex=2)
    print(f"  写入 {key} TTL=2s,当前 expired_keys = {base}")

    print("  ⏳ sleep 3s,期间不访问 Key...")
    time.sleep(3)

    after_sleep = get_expired_count()
    print(f"  sleep 后 expired_keys = {after_sleep}(无变化 → 惰性删除未触发)")

    val = r.get(key)
    print(f"  GET {key}{val!r}")

    after_get = get_expired_count()
    print(f"  GET 之后 expired_keys = {after_get}{'+1' if after_get > after_sleep else '无变化'} → 惰性删除被触发)")


def demo_active_expiration(n: int = 2000) -> None:
    section(f"Demo 2: 定期删除 —— 后台自动清理 {n} 个短 TTL Key")

    base = get_expired_count()
    base_dbsize = r.dbsize()

    pipe = r.pipeline(transaction=False)
    for i in range(n):
        pipe.set(f"demo:active:k{i}", "v", ex=1)
    pipe.execute()
    print(f"  写入 {n} 个 TTL=1s 的 Key,当前 DBSIZE={r.dbsize()}, expired={get_expired_count() - base}")

    print("  ⏳ 不访问任何 Key,等定期任务慢慢清...\n")
    print(f"  {'秒':>4} | {'DBSIZE':>8} | {'本轮新增 expired':>18}")
    print("  " + "-" * 40)

    last_expired = get_expired_count()
    for sec in range(1, 21):
        time.sleep(1)
        cur_expired = get_expired_count()
        cur_size = r.dbsize()
        print(f"  {sec:>4} | {cur_size:>8} | {cur_expired - last_expired:>18}")
        last_expired = cur_expired
        if cur_size == base_dbsize:
            print(f"  ✅ 全部清理完毕(共 {cur_expired - base} 个被定期删除)")
            break

    for i in range(n):
        r.delete(f"demo:active:k{i}")


if __name__ == "__main__":
    try:
        demo_lazy_expiration()
        demo_active_expiration()
    except redis.ConnectionError as e:
        print(f"❌ Redis 连接失败: {e}")
python
"""
Ch5 配套代码 2 / 3 —— 采样 LRU vs 真 LRU 命中率对比

模拟一个有 1000 个 Key 的工作集,按 80/20 法则访问(80% 请求集中在 20% 热点 Key),
缓存容量 200,分别用:
  - 真 LRU(OrderedDict)
  - 采样 LRU(每次淘汰随机抽 N 个,挑最久未访问的)
比较两者命中率差异。复现 Redis 作者博客中的「采样 5 ≈ 真 LRU」结论。
"""

import random
import time
from collections import OrderedDict

import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)


class TrueLRU:
    def __init__(self, capacity: int):
        self.cap = capacity
        self.data: "OrderedDict[str, int]" = OrderedDict()
        self.hits = 0
        self.misses = 0

    def access(self, key: str) -> None:
        if key in self.data:
            self.data.move_to_end(key)
            self.hits += 1
        else:
            self.misses += 1
            self.data[key] = 1
            if len(self.data) > self.cap:
                self.data.popitem(last=False)


class SampledLRU:
    """模拟 Redis 的近似 LRU:access 时只更新时间戳,淘汰时随机采样。"""

    def __init__(self, capacity: int, samples: int):
        self.cap = capacity
        self.samples = samples
        self.data: dict[str, int] = {}
        self.tick = 0
        self.hits = 0
        self.misses = 0

    def access(self, key: str) -> None:
        self.tick += 1
        if key in self.data:
            self.data[key] = self.tick
            self.hits += 1
        else:
            self.misses += 1
            if len(self.data) >= self.cap:
                pool = random.sample(list(self.data.keys()), min(self.samples, len(self.data)))
                victim = min(pool, key=lambda k: self.data[k])
                del self.data[victim]
            self.data[key] = self.tick


def make_workload(n_requests: int = 50_000, n_keys: int = 1000) -> list[str]:
    """80% 请求落在 20% 的 Key 上"""
    hot = [f"hot:{i}" for i in range(n_keys // 5)]
    cold = [f"cold:{i}" for i in range(n_keys - len(hot))]
    seq = []
    for _ in range(n_requests):
        if random.random() < 0.8:
            seq.append(random.choice(hot))
        else:
            seq.append(random.choice(cold))
    return seq


def hit_rate(c) -> float:
    total = c.hits + c.misses
    return c.hits / total if total else 0.0


def run_simulation() -> None:
    print("=" * 60)
    print("采样 LRU vs 真 LRU 命中率对比(80/20 工作集)")
    print("=" * 60)

    workload = make_workload(n_requests=50_000, n_keys=1000)
    capacity = 200

    true_lru = TrueLRU(capacity)
    for k in workload:
        true_lru.access(k)

    print(f"\n  缓存容量={capacity}, 工作集={1000}, 请求数={len(workload)}\n")
    print(f"  {'方案':<22} {'命中率':>10} {'相对真LRU':>14}")
    print("  " + "-" * 50)
    base = hit_rate(true_lru)
    print(f"  {'真 LRU (基准)':<22} {base*100:>9.2f}% {'100.00%':>14}")

    for samples in [3, 5, 10, 20]:
        s_lru = SampledLRU(capacity, samples=samples)
        for k in workload:
            s_lru.access(k)
        rate = hit_rate(s_lru)
        print(f"  {'采样 LRU (N=' + str(samples) + ')':<22} {rate*100:>9.2f}% {rate/base*100:>13.2f}%")

    print("\n  💡 结论:采样 5 已经能逼近真 LRU 95%+ 精度,10 几乎无法区分。")
    print("     这就是 Redis 默认 maxmemory-samples=5 的原因。")


def verify_redis_alive() -> None:
    """顺便确认本机 Redis 可达,避免误以为脚本完全是离线的。"""
    pong = r.ping()
    print(f"\n  [check] Redis ping → {pong}(脚本本身是离线模拟,不写真 Redis)")


if __name__ == "__main__":
    try:
        random.seed(42)
        verify_redis_alive()
        t0 = time.time()
        run_simulation()
        print(f"\n  耗时 {time.time() - t0:.2f}s")
    except redis.ConnectionError as e:
        print(f"❌ Redis 连接失败: {e}")
python
"""
Ch5 配套代码 3 / 3 —— 8 种淘汰策略实测

通过 CONFIG SET 把 maxmemory 调小,写入超量数据,观察不同策略下哪些 Key 被踢出。
对比 4 种典型策略:noeviction / allkeys-lru / allkeys-lfu / volatile-ttl。
"""

import time
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

VALUE = "x" * 1024
PREFIX = "evict:"
N_KEYS = 200
KEEP_HOT = ("evict:hot:1", "evict:hot:2", "evict:hot:3")
ORIGINAL = {}


def section(t: str) -> None:
    print("\n" + "=" * 60)
    print(t)
    print("=" * 60)


def save_original_config() -> None:
    ORIGINAL["maxmemory"] = r.config_get("maxmemory")["maxmemory"]
    ORIGINAL["maxmemory-policy"] = r.config_get("maxmemory-policy")["maxmemory-policy"]


def restore_config() -> None:
    r.config_set("maxmemory", ORIGINAL.get("maxmemory", "0"))
    r.config_set("maxmemory-policy", ORIGINAL.get("maxmemory-policy", "noeviction"))


def cleanup() -> None:
    keys = r.keys(PREFIX + "*")
    if keys:
        r.delete(*keys)


def fill_and_observe(policy: str, with_ttl: bool = False) -> None:
    cleanup()
    r.config_set("maxmemory", 0)
    r.config_set("maxmemory-policy", policy)

    for i in range(20):
        key = f"{PREFIX}cold:{i}"
        if with_ttl:
            r.set(key, VALUE, ex=60)
        else:
            r.set(key, VALUE)
    for hot in KEEP_HOT:
        r.set(hot, VALUE)
        for _ in range(50):
            r.get(hot)

    used = int(r.info("memory")["used_memory"])
    target = used + 100 * 1024
    r.config_set("maxmemory", target)
    print(f"  策略={policy:<18} 容量上限={target} bytes (~{target//1024}KB)")

    evicted_before = int(r.info("stats")["evicted_keys"])

    fail_count = 0
    for i in range(N_KEYS):
        try:
            key = f"{PREFIX}new:{i}"
            if with_ttl:
                r.set(key, VALUE, ex=120)
            else:
                r.set(key, VALUE)
        except redis.ResponseError as e:
            fail_count += 1
            if fail_count <= 1:
                print(f"  ⚠ 写入报错(OOM 写报错预期出现 in noeviction): {e}")

    time.sleep(0.1)
    evicted_after = int(r.info("stats")["evicted_keys"])
    cold_remain = sum(1 for i in range(20) if r.exists(f"{PREFIX}cold:{i}"))
    hot_remain = sum(1 for k in KEEP_HOT if r.exists(k))
    new_remain = sum(1 for i in range(N_KEYS) if r.exists(f"{PREFIX}new:{i}"))

    print(f"     淘汰数 = {evicted_after - evicted_before:>4} | "
          f"冷数据剩 {cold_remain:>2}/20 | 热点剩 {hot_remain}/3 | 新写入剩 {new_remain}/{N_KEYS} | OOM 报错 {fail_count}")


def main() -> None:
    section("8 种淘汰策略对比(重点看 4 种)")
    save_original_config()

    try:
        print("\n  [测试 1] noeviction:内存满时直接拒写")
        fill_and_observe("noeviction", with_ttl=False)

        print("\n  [测试 2] allkeys-lru:踢最久未访问,热点应保留")
        fill_and_observe("allkeys-lru", with_ttl=False)

        print("\n  [测试 3] allkeys-lfu:踢访问频次低的,热点应保留")
        fill_and_observe("allkeys-lfu", with_ttl=False)

        print("\n  [测试 4] volatile-ttl:仅淘汰带 TTL 且 TTL 最小的")
        fill_and_observe("volatile-ttl", with_ttl=True)

        print("\n  💡 观察要点:")
        print("     - noeviction 下「新写入剩 N」会很少(因为大量被拒)")
        print("     - allkeys-lru / lfu 下「热点剩 3/3」(被反复 GET 过)")
        print("     - volatile-ttl 下淘汰的是 TTL 较短的,hot 没设 TTL 不参与")
    finally:
        cleanup()
        restore_config()
        print("\n  已恢复 maxmemory 与 maxmemory-policy 原始值")


if __name__ == "__main__":
    try:
        main()
    except redis.ConnectionError as e:
        print(f"❌ Redis 连接失败: {e}")

01_expire_strategies.py ↗ · 02_lru_simulator.py ↗ · 03_eviction_demo.py ↗