qinghuan
← 数据系统 / 全部笔记

单元 47 / 数据系统

P0 · 必会主线L1 · 基础

Redis 的键为什么消失:过期、淘汰与 TTL

分清到期消失和内存淘汰,再观察覆盖写入怎样影响 TTL,为缓存和登录状态选择合适的失效规则。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能复现 TTL 被覆盖的情况,按数据用途解释内存满时应淘汰还是拒绝写入。

学习理由、准备要求与知识点

为什么学它

键过期与内存淘汰是两种独立机制,混淆它们容易造成会话丢失或缓存永不过期。

先读这些内容

知识点清单

  • TTL 与 PTTL
  • 惰性和主动过期
  • SET 覆盖 TTL
  • HSET 更新
  • KEEPTTL
  • maxmemory
  • 淘汰策略
  • 缓存 TTL 抖动

运行前先确认Redis7.4、Java17;Jedis源码v5.2.0。算法以单调逻辑秒和最小堆演示到期事件,不是Redis真实过期实现;不模拟maxmemory淘汰。

01先弄懂它是什么

Redis 中的键为什么会消失,要分两种情况:过期是有效时间到了,淘汰是内存紧张时按策略让出空间。还剩一分钟 TTL 的键也可能提前被淘汰;有些 allkeys 策略甚至会移除没有 TTL 的键。缓存通常能重建,但登录态、去重记录和锁提前丢失的影响不同,不能只因为都在 Redis 中就使用相同容量安排。

查看剩余有效期用 TTL:-2 表示键不存在,-1 表示键存在但没设有效期,非负数是剩余秒数;PTTL 返回毫秒粒度。这个数只是查询时看到的剩余时间,不保证键一定活到那一刻,因为它还可能被删除或淘汰。两次读数也不能直接证明客户端间隔和服务端计时完全相同。

普通SET覆盖键通常清除原有效期;显式EX/PX设置新期限,KEEPTTL保留已有期限。HSET修改现有Hash字段通常保留Key的TTL,但新建Hash不会自动拥有业务要求的期限。覆盖、修改内部值和重命名等命令的影响不同,应查具体命令,不能用“写入都刷新TTL”概括。

文中这几个词,先认一认

过期
达到规定的到期时间后,键失效,和当前内存是否紧张是两回事。
淘汰
内存压力下按策略移除键以释放空间,即使这个键尚未到期也可能发生。
noeviction
内存超限时拒绝部分需要增加内存的写操作;不是内存无限,也不是所有命令都停止。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

在隔离实例交互执行SET study:ttl v EX 60,观察TTL;再SET study:ttl newer,TTL应显示无期限。重新带EX写入后用SET study:ttl newest KEEPTTL观察保留期限。每一步记录键是否存在与TTL,不仅看SET返回OK。实验只操作练习前缀,实际键策略应由业务决定。

Redis 既会主动检查一部分键,也会在访问键时检查它是否过期;不是每个键都配一个 Java 定时线程。服务器记录的是到期时刻,保存和恢复数据时也要考虑中间已经过去了多久。本文的最小堆程序只演示一件事:键被更新后,旧的到期任务不能误删新值。它没有复现 Redis 内部怎样存储和调度过期任务,也不能用来判断真实删除延迟。

选择内存策略前,先确认哪些数据允许提前丢失。allkeys 策略可以从全部键中选,volatile 策略只从设置了过期时间的键中选;可选的键不够,写入仍可能被拒绝。LRU 主要参考最近是否访问,LFU 主要参考访问频率,但 Redis 用的是近似判断,不会给全部键维护一份绝对精确的完整排名。监控时应一起看内存用了多少、多少键被淘汰或过期,以及多少写入被拒绝。

正在加载流程图…

过期与淘汰是两条不同原因链,统计和业务恢复也需要分别处理。
查看流程图源码(Mermaid)
flowchart TD
 A["一次读取或写入"] --> B["检查键是否到期"]
 B --> C["按当前版本处理失效"]
 A --> D["检查内存与淘汰策略"]
 D --> E{"是否有可淘汰候选"}
 E -->|"有"| F["按策略移除候选"]
 E -->|"无或禁止"| G["相关写入可能被拒绝"]
 C --> H["返回值及TTL观察"]
 F --> H

按顺序看,每一步做了什么

  1. 用TTL区分不存在、持久键和有期限键。
  2. 逐命令复现SET覆盖、显式EX与KEEPTTL。
  3. 检查缓存与关键状态是否应分开容量策略。
  4. 在隔离测试中观察内存、淘汰和拒绝写指标。
  5. 为提前失效与写入失败分别定义业务行为。

03对照实现代码

SetParams如何选择单一过期模式 (新标签页)↗

Jedis v5.2.0 · SetParams#expiration、#ex、#keepTtl、#addParams

原创参数状态模型,非源码节选。只表达一次选择的过期模式,真实过期和内存淘汰由Redis服务端实现。

核心机制代码 · java
import java.util.*;
class SourceModel {
    enum Mode { NONE, EX, KEEP }
    static class Options {
        Mode mode=Mode.NONE;
        long seconds;
        Options ex(long value) {
            if (value<=0) throw new IllegalArgumentException("ttl");
            mode=Mode.EX; seconds=value; // 明确选择新期限。
            return this;
        }
        Options keep() { mode=Mode.KEEP; return this; } // 覆盖先前模式。
        List<String> arguments() {
            // 只输出当前模式,不同时发送互斥选项。
            return switch(mode) {
                case NONE -> List.of();
                case EX -> List.of("EX",Long.toString(seconds));
                case KEEP -> List.of("KEEPTTL");
            };
        }
    }
}

真实expiration方法保存类型与值,ex与keepTtl都通过它更新同一组选项。

链式调用并非不断累积所有模式,后设的过期选项会替换状态,代码审查应看最终参数。

KEEPTTL不带新的过期数值,它保留已有期限;不存在的键没有旧期限可继承。

客户端参数构造与Redis过期、淘汰、持久化是不同层,Java源码不能当作Redis服务端内核证据。

代码先调用谁,再调用谁

  1. 创建SetParams
  2. 选择EX或KEEPTTL
  3. 更新参数状态
  4. addParams输出当前选项
  5. 服务端按SET语义执行

04动手写一个例子

代际标识防止旧过期事件删除新值

实现单线程键值模型,支持普通SET、带TTL覆盖、KEEPTTL和TTL查询。过期事件进入最小堆,每次写分配新代际;旧事件只能删除同一代际的键。时间使用单调逻辑秒,TTL正整数。

输入 1
k在0写入TTL5,2时普通SET覆盖,6时查询
输出 1
新值仍在,TTL=-1
输入 2
k截止10,8时KEEPTTL覆盖
输出 2
截止仍10

最小堆先取出最早到期的任务,但任务等待期间,同名键可能已被改成新值。因此每次写入都带一个新的版本编号,也叫代际;到期任务记住它负责的那个编号。任务执行时,只有编号仍与键的当前编号相同,才允许删除,不能仅凭键名相同就动手。

在本模型中,普通 SET 没有指定 TTL 时,新值就没有到期时间;这里说的是不会按期限过期,不是已经保存到磁盘。KEEPTTL 则沿用当前有效旧值的到期时刻;若旧键已不存在,就没有期限可继承。写入前先清掉已经到期的条目,才能避免把失效的期限重新带给新值。

旧事件采用懒删除,简化更新,但队列空间随未出堆的历史事件增长,不能只按当前键数估计内存。生产定时器需要压缩、索引更新或容量限制,这是模型明确的代价。

为什么这样写是对的每个有效键只有一个当前代际;任何旧代际事件都不能删除它;TTL查询前已处理所有到期事件。

时间 写入O(log H),清理E个事件O(E log H)额外空间 O(K+H)

K为当前键数,H为尚在堆中的历史事件,E为本次清理数;懒删除可能使H大于K。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 58 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    record Value(String text,Long deadline,long generation) {}
    record Event(String key,long deadline,long generation) {}
    static class Store {
        final Map<String,Value> values=new HashMap<>();
        final PriorityQueue<Event> events=new PriorityQueue<>(Comparator.comparingLong(Event::deadline));
        long generation,last=-1;
        void advance(long now) {
            if (now<0||now<last) throw new IllegalArgumentException("clock");
            last=now;
            while (!events.isEmpty()&&events.peek().deadline()<=now) {
                Event event=events.remove();
                Value current=values.get(event.key());
                // 旧定时事件不能影响覆盖后产生的新代际。
                if (current!=null&&current.generation()==event.generation())
                    values.remove(event.key());
            }
        }
        void set(String key,String text,Long ttl,boolean keep,long now) {
            if (ttl!=null&&(ttl<=0||keep)) throw new IllegalArgumentException("options");
            advance(now);
            Value old=values.get(key);
            Long deadline;
            if (keep) deadline=old==null?null:old.deadline();
            else deadline=ttl==null?null:Math.addExact(now,ttl);
            generation=Math.addExact(generation,1);
            // 普通覆盖没有TTL时,新代际明确不关联期限。
            Value value=new Value(text,deadline,generation);
            values.put(key,value);
            if (deadline!=null) events.add(new Event(key,deadline,generation));
        }
        long ttl(String key,long now) {
            advance(now);
            Value value=values.get(key);
            // 缺失与持久键使用不同特殊值。
            return value==null?-2:value.deadline()==null?-1:value.deadline()-now;
        }
    }
    static void eq(long expected,long actual) {
        if (expected!=actual) throw new AssertionError(actual);
    }
    public static void main(String[] args) {
        Store s=new Store();
        s.set("k","a",5L,false,0); eq(5,s.ttl("k",0));
        s.set("k","b",null,false,2); eq(-1,s.ttl("k",6));
        s.set("j","a",5L,false,6);
        s.set("j","b",null,true,8); eq(3,s.ttl("j",8)); eq(-2,s.ttl("j",11));
        s.set("r","a",10L,false,11);
        s.set("r","b",5L,false,12); eq(-2,s.ttl("r",17));
        s.set("x","a",1L,false,17);
        s.set("x","b",null,true,20); eq(-1,s.ttl("x",20));
        try { s.set("bad","a",0L,false,20); throw new AssertionError("ttl"); }
        catch (IllegalArgumentException expected) {}
        eq(-2,s.ttl("bad",20));
        System.out.println("PASS 8 expiration generation tests");
    }
}

这些特殊情况也要试一试

  • 恰好到期视为失效;KEEPTTL作用于已过期键时不能复活旧期限。
  • 普通覆盖使旧事件失效,即使事件稍后从堆弹出也不能删除新值。
  • 逻辑时钟不允许倒退,期限与代际加法都检测溢出。
进阶选读 · 压缩定时事件与近似淘汰协同

场景热点键频繁改TTL,历史堆事件远多于活跃键,定时器内存增长。

做法当历史事件与活跃键比例超限时按当前有效期限重建堆,或采用可索引堆更新;另设独立容量策略,不让过期调度承担全部内存控制。

复杂度重建可O(K),索引堆更新O(log K),但需要额外索引维护。

好处与代价压缩降低空间却引入突发维护成本,近似淘汰降低排序成本但不能保证严格LRU次序。

05项目里怎么用

缓存、会话和可靠去重数据可能需要不同实例或容量政策。某些状态提前淘汰只会回源,另一些会造成重新登录或重复执行业务。noeviction减少主动淘汰,却把问题转为相关写入拒绝,应用必须处理错误而不是忽略返回值。

maxmemory 不能简单当成 Redis 进程全部内存的上限。复制、持久化和缓冲还需要空间,应同时观察进程实际驻留内存 RSS、碎片和相关缓冲。删除大键、很多键一起到期,也可能集中增加工作量,需要按数据规模安排到期时间和操作速度。

过期观察要说明单位和时点,持久化恢复后TTL还应检查剩余期限。读取到-2不一定证明自然到期,也可能被显式删除、覆盖重建或淘汰。定位原因需结合命令路径与指标,单次TTL不足以解释全部生命周期。

容易踩的坑

  • 把过期和淘汰合并成同一种失效原因。
  • SET覆盖后以为旧TTL自动保留。
  • noeviction被当作内存问题自动解决方案。
  • 懒删除定时器按活跃键数估算内存,忽略历史事件。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
allkeys淘汰候选广,适合可重建缓存。无TTL键也可能提前丢失。可接受缓存丢失的实例。
volatile淘汰限定带TTL键作为候选。候选不足仍可能拒绝写入。明确区分可淘汰键且预算充足的场景。
noeviction避免因该策略主动淘汰已有键。内存超限写入会失败。更重视保留状态且能处理拒绝的系统。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01TTL=-1和-2分别是什么?

展开参考答案与推导

-1表示存在但无过期,-2表示键不存在。

02有TTL就能保证有效期内一直存在吗?

展开参考答案与推导

不能,还可能被删除、覆盖或淘汰。

03Redis LRU是精确完整排序吗?

展开参考答案与推导

不是,使用近似采样等机制以降低维护成本。

换个情况,还会处理吗

01更新缓存后TTL变-1如何排查?

展开参考答案与推导

检查是否普通SET覆盖而没有显式期限或KEEPTTL。

02设置volatile策略后仍拒绝写入为什么?

展开参考答案与推导

可能可淘汰候选不足,需检查键TTL分布、预算和策略。

03登录态提前消失如何定位?

展开参考答案与推导

分别检查过期、显式删除、淘汰和重建路径,不能只读一次TTL下结论。

写代码试一试

01为何到期事件要保存代际?

输入 / 条件
旧值到期前被新值覆盖
输出 / 预期
旧事件不删除新值

提示 比对当前身份。

展开参考答案与推导

只有事件代际等于当前条目代际才能执行删除。

02已过期键KEEPTTL应怎样处理?

输入 / 条件
旧期限18,20时覆盖
输出 / 预期
新键无可继承旧期限

提示 写前处理到期。

展开参考答案与推导

先advance移除旧键,再按不存在分支创建持久条目。

03怎样控制历史事件增长?

输入 / 条件
一个键更新十万次TTL
输出 / 预期
定时器空间受控

提示 不能只懒删除。

展开参考答案与推导

按比例重建堆或用可索引堆原地更新时间,并说明维护复杂度。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能复现 TTL 被覆盖的情况,按数据用途解释内存满时应淘汰还是拒绝写入。

本篇重点加练

保留有效期和内存淘汰的作用层不同,必须把业务可用期与缓存驻留时间分开验证。

  • KEEPTTL
  • 淘汰策略

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01排错实验

    在独立本地 Redis 设置三种短 TTL 键,间隔观测 TTL、PTTL 和读取结果。解释计时精度及未主动读取的键可以怎样被处理。

    提交内容
    命令及带时刻的观测表
    怎样算完成
    • 区分逻辑过期与内存回收
    • 测量误差有说明

    练到的知识点TTL 与 PTTL · 惰性和主动过期

  2. 作业 02代码推演

    同一资料键先写入带 TTL,再分别执行覆盖 SET、Hash 字段更新和保留 TTL 的写入。为每类合法类型准备独立样本,先预测再运行。

    提交内容
    预测表和实测记录
    怎样算完成
    • 不混淆键类型
    • 每次写入前后都检查有效期

    练到的知识点SET 覆盖 TTL · HSET 更新 · KEEPTTL

  3. 作业 03代码推演

    后台预热程序读取旧 TTL 后更新值,期间键可能已经到期并被另一个请求重建。审查客户端自行恢复 TTL 与服务端保留 TTL 语义的时点差异。

    提交内容
    交错与有效期契约
    怎样算完成
    • 区分键的不同代次
    • 包含到期后重建

    练到的知识点KEEPTTL

综合应用

  1. 作业 04原理问答

    本地测试实例内存上限很小,混有永久键和过期键。评审三类淘汰策略的候选范围及写入失败时应用应提供的信息。

    提交内容
    策略比较与故障契约
    怎样算完成
    • 区分过期与内存淘汰
    • 列出不可丢的数据类别

    练到的知识点maxmemory · 淘汰策略

  2. 作业 05设计评审

    1000 家商户缓存由批任务集中预热,要求有效期分散但不能早于最短业务期限。设计 TTL 取值约束及分布检验。

    提交内容
    采样规则与分布报告计划
    怎样算完成
    • 给出上下界和单位
    • 说明随机种子对可复现实验的作用

    练到的知识点缓存 TTL 抖动

  3. 作业 06编程实践

    编写本地 Redis 缓存写入实验,固定数据量与内存预算,记录有效期变化、淘汰统计及应用命中率;实验实例与其他数据隔离。

    提交内容
    程序、配置及报告
    怎样算完成
    • 资源和运行时间有上限
    • 不把单次淘汰顺序当作算法保证

    练到的知识点TTL 与 PTTL · SET 覆盖 TTL · HSET 更新 · KEEPTTL · maxmemory · 淘汰策略 · 缓存 TTL 抖动

  4. 作业 07设计评审

    同一实例存放少量大值和大量小值,业务希望保留“重建最昂贵”的键。评审常见淘汰策略的观测信息是否足以表达该成本。

    提交内容
    策略目标与信息需求
    怎样算完成
    • 区分访问频率、时间和重建成本
    • 不假设策略具有业务知识

    练到的知识点淘汰策略

  5. 作业 08排错实验

    在独立本地 Redis 用小内存上限建立两类缓存,持续更新其中一类并记录 TTL、驻留和淘汰统计,测量不超过固定时长。

    提交内容
    配置、脚本和时间记录
    怎样算完成
    • 不推断单键必然存活
    • 区分保留 TTL 和避免淘汰

    练到的知识点KEEPTTL · 淘汰策略

进阶挑战 · 掌握前置后选做

  1. 作业 09面试场景

    服务会话与可重建缓存共用实例,缓存增长后用户集中掉线。提出验证性调查和资源分配评审,给出风险可观察的迁移验收条件。

    提交内容
    调查计划与迁移矩阵
    怎样算完成
    • 先确认丢失原因再调整
    • 明确哪些数据不能依赖缓存可用期

    练到的知识点TTL 与 PTTL · 惰性和主动过期 · maxmemory · 淘汰策略 · 缓存 TTL 抖动

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 反例构造

    研究“缓存寿命越长就越节省后端成本”是否总成立,将内存占用、更新频率和陈旧错误代价加入模型并设计反例。

    提交内容
    成本模型与反例
    怎样算完成
    • 各成本单位明确
    • 不只用一次命中率比较下结论

    练到的知识点KEEPTTL · 淘汰策略

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗