Redis(二):持久化、内存管理与过期淘汰
Redis(二):持久化、内存管理与过期淘汰
导语:内存数据库必须回答「重启后数据还在吗」和「内存满了怎么办」。本篇讲透持久化的三种形态、fork 与写时复制的真实代价、过期删除与八种淘汰策略,以及容易被忽略的「近似 LRU / LFU」实现细节,共 15 题。
一、持久化
1. Redis 有哪几种持久化方式?
答: 三种形态,实际只有两种机制:
| 方式 | 文件 | 本质 | 引入版本 |
|---|---|---|---|
| RDB(快照) | dump.rdb | 某一时刻的全量二进制快照 | 早期版本 |
| AOF(追加日志) | appendonly.aof | 记录所有写命令,重启时重放 | 1.1 |
| 混合持久化 | AOF 文件中前半是 RDB、后半是 AOF | 兼顾恢复速度与数据完整 | 4.0(aof-use-rdb-preamble) |
Redis 7.0 起 AOF 改为「多部分文件」结构(重要版本变化):
appendonlydir/ # 由 appenddirname 指定,默认 appendonlydir
├── appendonly.aof.1.base.rdb # 基准文件(RDB 或 AOF 格式)
├── appendonly.aof.1.incr.aof # 增量命令文件
└── appendonly.aof.manifest # 清单文件,记录各文件的用途与顺序好处是重写时不再需要「临时文件 + rename」的繁琐切换,基准文件与增量文件可以并存并原子切换,重写逻辑更清晰、更安全。
注意:
appendonly在 redis.conf 中默认是no(Redis 8 仍未改)。所以「Redis 默认开启持久化」这个说法是错的——默认只有 RDB 的save规则在跑。
2. RDB 的原理、优缺点?
答:
原理:把当前内存数据一次性序列化成紧凑的二进制文件。
| 命令 | 行为 |
|---|---|
SAVE | 主线程执行,期间完全阻塞,生产禁用 |
BGSAVE | fork 出子进程执行,主进程继续服务(默认方式) |
save 900 1 等配置 | 满足「900 秒内至少 1 次修改」等条件时自动触发 BGSAVE |
优点:
- 文件紧凑(内存中的二进制直接落盘,不含命令冗余);
- 恢复极快:加载 RDB 只需解析并重建内存,无需重放命令;
- 适合备份与灾难恢复(可定时归档到对象存储);
- 主进程开销小(子进程干活)。
缺点:
- 会丢数据:只能恢复到最近一次快照,最坏丢一个配置周期(如 15 分钟);
- fork 本身有停顿:数据量越大,复制页表越慢,此处是秒级停顿的来源;
- 写时复制会放大内存:见第 5 题。
3. AOF 的原理、优缺点?三种写回策略怎么选?
答: 原理:把每条写命令以 Redis 协议追加到 AOF 文件,重启时按顺序重放恢复数据。
三种 appendfsync 策略:
| 策略 | 行为 | 数据安全 | 性能 |
|---|---|---|---|
always | 每条命令都 fsync 到磁盘 | 最好,几乎不丢 | 最差,QPS 大幅下降 |
everysec(默认) | 后台线程每秒 fsync 一次 | 最多丢 1 秒 | 好,是安全与性能的平衡点 |
no | 交给操作系统决定何时落盘 | 最差,宕机可能丢较多 | 最好 |
优点:
- 数据更安全:默认最多丢 1 秒(RDB 可能丢十几分钟);
- 文件可读、可人工修复:文本形式的命令流,出问题可用
redis-check-aof --fix修复; - 体积可控:通过 AOF 重写压缩。
缺点:
- 文件通常比 RDB 大(记录的是命令而非最终状态);
- 恢复慢:要逐条重放,数据量大时可能耗时数分钟到数十分钟;
always对性能影响大,everysec在磁盘抖动时也可能引发主线程阻塞(fsync延迟);- 重写期间仍有额外开销(见第 4 题)。
生产实践:
everysec+no-appendfsync-on-rewrite yes(重写期间不做 fsync,避免 fsync 争抢导致主线程阻塞,代价是重写期间宕机可能多丢一点数据)。
4. AOF 重写是什么?为什么需要?重写缓冲区有什么用?
答: AOF 文件会随写命令不断增长而严重膨胀——比如同一个 key 被 INCR 了 100 万次,就有 100 万条命令,而重建它只需 SET key 1000000。
AOF 重写(BGREWRITEAOF):fork 一个子进程,读取当前内存中的数据,生成一份「能重建当前数据集的最小命令集合」的新文件。
关键问题:重写期间的新写命令怎么办? 答案是两个缓冲区:
写命令 --> aof_buf (追加到当前正在使用的 AOF 文件,保证不丢)
\-> aof_rewrite_buf(同时累积起来,重写完成后再追加到新文件末尾)重写子进程完成基准文件后,主进程把 aof_rewrite_buf 中的增量命令追加进去,新文件才完全等价于当前数据,然后原子切换。这样既不会丢数据,也不需要阻塞主线程。
触发方式:
- 手动:
BGREWRITEAOF; - 自动:
auto-aof-rewrite-percentage 100(比上次重写后体积增长 100%)+auto-aof-rewrite-min-size 64mb(且体积超过 64MB)。
Redis 7.0 的多部分 AOF 让「重写」变成了「生成新 base 文件」,增量文件继续累积,切换更平滑(见第 1 题)。
5. fork 与写时复制(COW)的细节与风险?
答: RDB 与 AOF 重写都靠 fork 子进程 + 写时复制(Copy-On-Write) 完成,这里藏着两个常被低估的风险。
机制:fork 之后,父子进程共享同一份物理内存页,且这些页被标记为只读。任何一方发生写操作时,内核才复制出该页的副本。
风险一:fork 本身的停顿
fork 不会立刻复制数据,但要复制页表。内存越大、页表越大,复制越慢:
- 10GB 数据在普通机器上 fork 停顿可达几十到几百毫秒;
- 若开启了 THP(透明大页),页表条目更大,fork 会更慢——生产建议关闭 THP;
- 需要确保
vm.overcommit_memory = 1,否则 fork 可能因「内存不足」直接失败(Can't fork)。
风险二:COW 导致的内存放大与「反噬」
- 最坏情况下(fork 期间所有页都被写到),额外内存占用接近翻倍——所以说「COW 会翻倍」只是最坏情况的边界,实际取决于写操作量;
- 更危险的是雪崩效应:写操作密集 → 大量页被复制 → 内存快速上涨 → 触发
maxmemory淘汰 → 淘汰本身又是写操作 → 复制更多页 → 内存继续涨……最终可能 OOM 被系统 kill; - 因此:预留物理内存要按「used_memory × 1.5 ~ 2」估算,或把持久化放到从节点执行。
其他要点:
fork期间主进程是完全阻塞的,但子进程只做「读 + 落盘」,不影响后续请求;- 子进程退出后(快照完成),COW 产生的额外内存才被释放;
- Redis 4.0 起支持
repl-diskless-sync(无盘复制,Redis 7.0 起默认开启),全量同步时不再落盘 RDB,直接通过网络发给从节点,适合磁盘 IO 紧张的场景。
6. 同时开启 RDB 和 AOF,重启时用哪个恢复?
答: 优先用 AOF 恢复,因为 AOF 通常更完整(最多丢 1 秒);只有 AOF 未开启、文件损坏且无法修复时,才回退到 RDB。
注意几个版本细节:
- Redis 4.0 前:AOF 未开启才用 RDB,两者都开时只认 AOF;
- 混合持久化 + Redis 7.0 多部分 AOF:AOF 文件本身前半段就是 RDB 格式的基准文件,所以恢复时是「先加载 base(快)+ 再重放 incr(全)」,兼具速度与完整性;
- 不要同时手动重启两个持久化:
BGSAVE与BGREWRITEAOF无法并行执行,后者会被推迟到前者完成。
7. 如何选择持久化方案?
答: 按「能丢多少数据」这个业务问题来选:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 纯缓存,丢了能从 DB 重建 | 关闭持久化(或只留 RDB 做冷备) | 避免无谓 CPU/IO/内存开销 |
| 能容忍分钟级丢失,追求恢复速度 | RDB | 文件紧凑、加载快,适合大数据量快速拉起 |
| 数据有价值,要求秒级丢失上限 | AOF everysec(推荐生产默认) | 平衡点最好 |
| 既要安全又要恢复快 | 混合持久化 / RDB + AOF 都开 | 生产最常见选择 |
生产配置建议:
- 主库以低延迟优先:可只开 RDB(在从库做 AOF),或 AOF
everysec+no-appendfsync-on-rewrite yes; - 持久化交给从节点是一种常见做法,把 fork 停顿与磁盘压力从主库剥离;
- 无论哪种方案,都要做「恢复演练」:没验证过恢复流程的备份不算备份。
8. 持久化对性能有什么影响?如何规避?
答:
| 影响点 | 说明 | 规避 |
|---|---|---|
| fork 停顿 | 复制页表,与数据量成正比 | 控制单实例内存(建议 ≤ 10~16GB);关闭 THP;把持久化放从库 |
| COW 内存放大 | 写密集时页复制,最坏接近翻倍 | 预留内存、低峰执行、避免与淘汰策略叠加 |
| fsync 阻塞 | always 或磁盘抖动时主线程被 write/fsync 拖住 | 用 everysec + no-appendfsync-on-rewrite yes;用 SSD |
| AOF 重写 CPU/IO | 子进程读写文件,与大 key 删除、主从全量同步叠加时更糟 | 错峰执行、限制 auto-aof-rewrite-* 频率 |
| 网络与磁盘带宽 | 全量同步传输大 RDB 会挤占带宽 | 用无盘复制 + 压缩,或错峰扩容 |
一句话原则:持久化的代价不是均摊的,而是集中在 fork 与重写的那几秒。所以监控上要盯
latest_fork_usec(最近一次 fork 耗时,INFO stats)这个指标,它比平均 QPS 更能预警风险。
二、过期删除与内存淘汰
9. Redis 的 key 过期删除策略有哪些?
答: Redis 采用「惰性删除 + 定期删除」组合,不是定时精确删除:
1)惰性删除(被动)
访问 key 时才检查是否过期,过期则删除并返回空。
- 优点:几乎不消耗 CPU,不访问的 key 不付出任何代价;
- 缺点:过期 key 若长期不被访问,就会一直占内存。
2)定期删除(主动)
由 serverCron 驱动的 activeExpireCycle 周期性抽样清理:
- 默认每秒执行 10 次(受
hz控制); - 每次从设置了过期时间的 key 中随机抽取 20 个检查,删除其中已过期的;
- 如果这 20 个里过期比例 > 25%,就认为「过期 key 还有很多」,继续抽样;
- 单次执行有时间上限(不超过 25% CPU 时间),避免阻塞。
这种「随机抽样 + 比例反馈 + 限时」的设计,让清理既不会漏太多、又不会因扫全表而卡死。
3)内存淘汰(兜底)
当内存达到 maxmemory 时按策略淘汰(见第 11 题)——注意淘汰与过期是两套独立机制,过期解决「该不该留」,淘汰解决「装不下怎么办」。
补充:
EXPIRE的实现:过期时间存放在独立的expires字典(key → 过期时间戳),因此给海量 key 设过期会额外消耗内存;而且SCAN遍历的是主字典,不会返回已过期但未删除的 key。
10. 主从/集群下过期键如何处理?
答: 这是高频深入题,核心是「删除权在主库」:
- 从库不主动删除过期 key:即使 key 已过期,从库也不会自己删(因为删除是有副作用的写操作,由主库统一决定,保证数据一致);
- 主库负责生成删除命令并传播:主库触发惰性/定期删除时,会生成
DEL(或UNLINK)命令同步给从库,从库照此删除; - 从库读取不会返回过期数据:Redis 3.2 起,从库对已过期的 key 返回空(逻辑上视为不存在),不会把脏数据给客户端;
- 主库未及时删的情况:如果从库长期读不到主库的删除命令(主库压力大、定期删除抽样没抽到),从库内存里会残留这些「逻辑已过期」的 key——这是主从实例内存使用不一致的常见原因之一;
- 集群下:每个主节点各自负责自己槽内的过期删除,规则与单机一致;
MOVED/ASK重定向不影响过期语义。
实践建议:不要依赖过期来精确释放内存。需要「定时清空」的场景,用定时任务显式
DEL/UNLINK,或给 key 设计合理的过期时间分布(避免同一秒集中过期)。
11. Redis 内存淘汰策略(maxmemory-policy)有哪些?
答: 共 8 种,可归为三类:
| 策略 | 淘汰范围 | 淘汰依据 |
|---|---|---|
noeviction(默认) | 不淘汰 | 内存满时写命令直接报错(读、删仍可用) |
allkeys-lru | 所有 key | 最近最少使用 |
allkeys-lfu(4.0+) | 所有 key | 访问频率最低 |
allkeys-random | 所有 key | 随机 |
volatile-lru | 仅设置了过期时间的 key | 最近最少使用 |
volatile-lfu(4.0+) | 仅设置了过期时间的 key | 访问频率最低 |
volatile-ttl | 仅设置了过期时间的 key | TTL 最小(即将过期的先淘汰) |
volatile-random | 仅设置了过期时间的 key | 随机 |
选型建议:
- 纯缓存场景用
allkeys-lru(或allkeys-lfu)——这是最省心的选择; volatile-*的陷阱:只淘汰「有 TTL 的 key」,如果很多 key 没设 TTL,内存一样会被打满,然后退化成noeviction报错;noeviction默认值很危险:线上不显式配置的话,内存满了会直接写失败;- LRU vs LFU:LRU 适合「访问模式随时间变化(有新热点)」;LFU 适合「热点长期稳定」,能避免偶发一次访问就把冷 key 「保鲜」。
淘汰流程:每个命令执行前检查 used_memory > maxmemory,超限则循环淘汰(每次采样一批)直到低于上限,然后才执行原命令——这会让该命令的延迟变高,是长尾延迟的常见来源。
生产必须同时配置
maxmemory(留出 20%~30% 余量给 COW、复制缓冲、客户端缓冲)+ 明确策略,不要依赖默认值。
12. Redis 的 LRU 是真 LRU 吗?LFU 又是怎么实现的?
答: 不是真正的 LRU,而是「近似 LRU」——这是最容易被追问的细节。
为什么不能做真 LRU:真 LRU 需要维护一条完整链表并在每次访问时移动节点,内存与操作开销都太高(每个对象都要一个双向链表指针),不符合 Redis「为内存斤斤计较」的设计。
近似 LRU 的做法:
redisObject.lru字段只有 24 位,存的是秒级访问时间戳;- 淘汰时随机采样
maxmemory-samples(默认 5)个 key,从中挑「最久未访问」的那个淘汰; - Redis 3.0 起引入淘汰侯选池(16 个条目),把采样到的候选中「最旧的」留在池里,下一次采样优于池中最差者才替换——用极小的内存代价把近似精度提升到接近真 LRU。
采样数越大越接近真 LRU,但 CPU 开销越大(最大可设 64)。
LFU 的实现(Redis 4.0+):
lru 这 24 位在 LFU 模式下被重新划分为:
[16 位:上次衰减时间(分钟级)] [8 位:访问计数 0~255]- 计数只有 8 位(最大 255),所以不能线性累加,而是用对数递增:计数越小增长越快、越大增长越慢——这样既能区分「访问 10 次」和「访问 100 次」,又不会被 255 上限卡死。增长率由
lfu-log-factor(默认 10)控制; - 计数会随时间衰减:距上次衰减超过
lfu-decay-time(默认 1 分钟)就减 1,避免历史上的热点永久占据位置; - 新对象初始计数是 5(不是 0),让它有机会被访问后被保留。
面试延伸:
redis-cli --hotkeys之所以要求开启 LFU,正是因为它直接读取lru字段里的这 8 位计数——这也是「发现热 key」的官方手段(见《Redis(一)》)。
13. Redis 内存耗尽会怎样?
答: 取决于 maxmemory-policy:
noeviction(默认):写命令返回错误OOM command not allowed when used memory > 'maxmemory';读命令和DEL仍然正常,所以线上表现常常是「查询正常、写入报错」,很有迷惑性;- 设了 LRU/LFU/RANDOM/TTL 策略:按规则淘汰以腾出空间,写命令正常;但如果淘汰速度跟不上写入速度,请求会变慢(每条命令前都要淘汰一轮);
- 没设
maxmemory或远超物理内存:Redis 会向系统申请更多内存,最终可能被 OOM Killer 干掉(dmesg里能看到 kill 记录),或被 swap 拖垮(mem_fragmentation_ratio < 1就是这个信号)。
排查与加固:
- 监控
used_memory、used_memory_rss、mem_fragmentation_ratio、evicted_keys(淘汰数量)、rejected_connections; - 配置
maxmemory(预留 20%~30%)、显式指定淘汰策略、开启activedefrag; - 容器环境要保证
limit大于maxmemory+ 复制/COW 缓冲,否则 JVM 式悲剧会在 Redis 上重演。
14. 如何设置、查询、取消 key 的过期时间?
答:
| 命令 | 说明 |
|---|---|
EXPIRE key 秒 / PEXPIRE key 毫秒 | 设置相对过期时间 |
EXPIREAT key 时间戳 / PEXPIREAT | 设置绝对过期时间(秒/毫秒时间戳) |
TTL key / PTTL key | 查询剩余时间;-1 表示永不过期,-2 表示 key 不存在 |
PERSIST key | 取消过期,变成永不过期(成功返回 1) |
SET key value EX 30 / SETEX | 写的时候直接带过期(原子,推荐) |
GETEX key EX 30 / EXPIRE ... NX/XX/GT/LT | 读时顺带改过期;条件式改过期(Redis 7.0+) |
易错点:
TTL的三种返回值:正数=剩余秒、-1=存在但无过期、-2=key 不存在。「TTL返回 -1」和「返回 -2」是完全不同的语义,面试常拿来区分是否真懂;SET会清掉原有 TTL:对已有 key 执行SET key value而不带EX,过期时间会被重置为永不过期——这是很隐蔽的 bug 来源(APPEND、INCR、HSET则不会清 TTL);- 过期时间精度:Redis 2.6 之前版本过期精度是毫秒级但可能因抽样延迟;实际「到期」不等于「删除」,会有延迟(见第 9 题);
- Redis 7.4 起支持 Hash 字段级过期:
HSETEX、HGETEX、HGETDEL可以为单个 field 设过期,这对「一个大 Hash 里各字段生命周期不同」的场景非常有用(也变相减少了 key 数量)。
15. Redis 内存如何优化?
答: 按「见效快 → 见效慢」的顺序:
1)数据结构层面(收益最大)
- 合理利用紧凑编码:调大
hash-max-listpack-entries、zset-max-listpack-entries等阈值,让小集合走 listpack(代价是 CPU:元素多时 listpack 的查找是 O(N) 遍历,需要压测权衡); - 用 Hash 分桶代替大量小 key:把
user:1、user:2… 合并到少数几个 Hash 里(userBucket:0的 field 是用户 ID)。收益是每个 key 的redisObject+ dictEntry + expires 条目开销被摊薄(这些元数据在小对象上往往是内存的主要占用); - 用 Bitmap 替代大 Set:签到、活跃标记类场景能省一到两个数量级;
- 用 HyperLogLog 替代精确去重 Set:UV 类统计固定 12KB;
- Redis 7.4+ 用 Hash 字段过期,把「一批小 key」合并为「一个 Hash + 字段 TTL」。
2)数据与 key 设计层面
- 缩短 key / field 名:
user:10086:profile:settings改成u:10086:s,海量 key 下省出的内存很可观; - 只存必要字段:避免整个大对象序列化后塞进去,用 protobuf/MessagePack 等紧凑格式替代 JSON;
- 压缩大 value:文本类 value 考虑 gzip/snappy(注意 CPU 成本);
- 避免 bigkey:既省内存也避免阻塞(见《Redis(一)》)。
3)实例与配置层面
- 32 位实例:指针只需 4 字节,小数据集可省大量内存;代价是单实例最大内存约 4GB,且不支持 RDB 超过该限制;
- 共享整数对象池:
0~9999的整数值 String 共享,避免重复分配(注意启用 LRU/LFU 时会自动禁用,见《Redis(一)》); maxmemory+ 合适淘汰策略:让内存可控,而不是无限增长到被 OOM Kill;activedefrag yes:整理碎片,回收「看得到用不上」的物理页。
衡量工具:用
MEMORY USAGE key看单 key、MEMORY STATISTICS(Redis 7.0+)看整体分布、redis-cli --bigkeys/--memkeys找大头。优化前后一定要用真实数据量对比,否则容易优化了个寂寞。
下一篇:《Redis(三)》讲高可用——主从复制的全量/增量同步与 PSYNC 机制、哨兵的客观下线与选主流程、Cluster 的哈希槽与故障转移,以及脑裂与集群使用限制。
