Redis 持久化与内存管理一、为什么需要持久化Redis 默认将所有数据存储在内存Memory中这意味着一旦服务器断电、崩溃或重启内存中的数据将全部丢失。对于仅作为缓存Cache使用的场景数据丢失可以接受从数据源重新加载即可但当 Redis 承担会话存储、消息队列、业务数据存储等角色时数据的持久化Persistence就至关重要。持久化的核心目标将内存数据写入磁盘Disk保证 Redis 重启后可以通过磁盘文件恢复数据。Redis 支持以下 4 种持久化策略策略说明RDB按时间间隔生成时间点快照AOF记录每次写操作到日志文件RDB AOF混合使用兼具两者优势No Persistence完全禁用持久化纯内存模式二、RDBRedis Database Snapshot2.1 基本原理RDB 持久化通过在指定的时间间隔内对数据集进行时间点快照Point-in-time Snapshot生成一个紧凑的二进制文件默认文件名为dump.rdb。RDB 的触发方式有三种触发方式命令特点同步保存SAVE阻塞主进程期间无法处理任何请求生产环境严禁使用后台保存BGSAVEfork 子进程在后台执行主进程继续服务自动触发配置save规则满足条件时自动执行BGSAVE2.2 配置规则Redis 通过save配置项定义自动触发 RDB 快照的条件格式为save 秒数 修改次数save 900 1 # 900秒15分钟内至少有1次修改 save 300 10 # 300秒5分钟内至少有10次修改 save 60 10000 # 60秒1分钟内至少有10000次修改以上三个条件为或的关系满足任意一个即触发BGSAVE。RDB 自动保存触发逻辑的伪代码// Redis 内部维护的脏计数器 dirty 和最后保存时间 lastsave // 每次写操作SET/DEL/HSET 等都会使 dirty function checkSaveCondition(currentTime): for each rule in save_rules: // 遍历所有 save 配置规则 if dirty rule.changes // 修改次数是否达到阈值 and (currentTime - lastsave) rule.seconds: // 时间是否达到阈值 return true // 满足条件触发 BGSAVE return false // serverCron 每100ms调用一次 function serverCron(): if checkSaveCondition(now()): BGSAVE() // 后台执行快照 dirty 0 // 重置脏计数器 lastsave now() // 更新最后保存时间2.3 bgsave 的 fork 机制BGSAVE的核心在于利用操作系统的fork()系统调用创建子进程。子进程继承父进程的内存空间通过**写时复制COW, Copy-On-Write**机制实现快照期间主进程不被阻塞。COW 的原理fork()调用时子进程与父进程共享同一块物理内存页Page操作系统将内存页标记为只读主进程在快照期间仍然可以处理写请求当主进程试图修改某个内存页时触发页错误Page Fault操作系统将该页复制一份给主进程修改子进程读取的仍然是 fork 时刻的内存快照不受后续修改影响伪代码bgsave 的 COW 过程function BGSAVE(): child_pid fork() // 创建子进程 if child_pid 0: // 子进程 // 子进程看到的是 fork 瞬间的内存快照 rdb_file open(dump.rdb, w) for each database in server.db: for each key, value in database: write(rdb_file, key, value) // 写入 RDB 文件 close(rdb_file) exit(0) // 子进程完成退出 else: // 主进程 register_child_handler(child_pid) // 注册子进程结束回调 continue_serving_requests() // 继续处理客户端请求 // 主进程处理写请求时COW 的触发过程 function processWriteCommand(key, newValue): oldValue lookup(key) if is_shared_page(oldValue): // 该内存页与子进程共享 copy_page(oldValue) // 操作系统复制该页COW update(key, newValue) // 主进程在副本上修改 // 子进程仍然读取原始页面的数据2.4 RDB 文件结构RDB 文件采用二进制紧凑格式其结构如下-------------------------------------------------- | REDIS | 版本 | 数据库选择 | Key-Value| EOF | CRC64 | | 魔数 | 号 | (SELECTDB)| 对 | | 校验和 | --------------------------------------------------区域说明REDIS 魔数文件头5字节字符串 “REDIS”版本号RDB 格式版本如 “0011” 表示 RDB v11数据库选择SELECTDB标识 数据库编号Key-Value 对过期时间可选 类型标识 Key ValueEOF结束标记1字节CRC64 校验8字节校验和用于验证文件完整性可通过rdbchecksum配置关闭RDB 文件是二进制紧凑格式体积远小于等量的 AOF 文件非常适合备份Backup和灾难恢复Disaster Recovery。2.5 优缺点优点恢复速度快直接加载二进制文件到内存比 AOF 重放命令快得多文件紧凑二进制格式压缩率高适合备份、传输和灾难恢复对性能影响小由 fork 子进程完成主进程正常处理请求仅 COW 带来的少量内存开销缺点数据丢失风险可能丢失最后一次快照之后的所有修改数据fork 大内存实例可能卡顿虽然 COW 本身不需要复制全部内存但 fork 操作本身需要复制页表Page Table内存越大页表越大fork 越慢虚拟化环境中尤为明显2.6 适用场景灾难恢复定期将 RDB 文件传送到远程数据中心或对象存储定期备份按小时/天生成快照保留多个历史版本对数据丢失有一定容忍度的缓存场景如会话缓存丢失后可重建三、AOFAppend Only File3.1 基本原理AOFAppend Only File仅追加文件持久化方式记录服务器执行的每次写操作如 SET、DEL、HSET 等以 Redis 协议RESP, Redis Serialization Protocol格式追加到 AOF 文件末尾。恢复方式Redis 重启时重新执行ReplayAOF 文件中的所有命令即可重建数据集。AOF 文件是可读的文本文件例如*3 $3 SET $3 key $5 value3.2 AOF 同步策略AOF 的写入分为三步写入→同步。Redis 先将写命令写入 AOF 缓冲区AOF Buffer再根据同步策略决定何时调用fsync()将缓冲区数据刷到磁盘。策略行为安全性性能说明always每次写操作都fsync最高最慢最多丢失 0 条命令everysec每秒fsync一次高较好最多丢失约 1 秒数据生产环境最常用no由操作系统决定何时fsync最低最快可能丢失较长时间的数据配置项appendfsync everysec伪代码AOF 写入流程// 每次写命令执行后 function feedAppendOnlyFile(command, key, value): aof_buf.append(encode_resp(command, key, value)) // 追加到 AOF 缓冲区 // 事件循环中每次处理完事件后 function flushAppendOnlyFile(): if aof_buf is empty: return write(aof_fd, aof_buf) // 写入操作系统缓冲区Page Cache switch appendfsync: case always: fsync(aof_fd) // 立即刷盘确保数据落盘 aof_buf.clear() case everysec: if (now() - last_fsync_time) 1000ms: background_fsync(aof_fd) // 后台线程每秒 fsync 一次 last_fsync_time now() aof_buf.clear() case no: // 不主动 fsync交给操作系统决定 aof_buf.clear()3.3 AOF 重写Rewrite为什么需要重写AOF 文件记录的是每次写操作的命令随着时间推移文件会越来越大。例如对同一个 Key 执行了 100 次SETAOF 中记录了 100 条命令但当前值只需要最后 1 条对一个 List 执行了多次LPUSH和RPOP最终 List 只剩几个元素但 AOF 记录了所有历史操作重写原理AOF 重写的核心思想读取当前数据库的完整状态用最少的命令重新生成 AOF 文件。例如对同一个 Key 做了 100 次SET重写后只保留最后一次SET key final_value。触发方式触发方式命令/配置说明手动触发BGREWRITEAOF管理员主动执行自动触发auto-aof-rewrite-percentageAOF 文件大小相比上次重写后增长的百分比默认 100%即翻倍自动触发auto-aof-rewrite-min-size触发重写的 AOF 文件最小大小默认 64MB自动触发条件当前AOF文件大小 上次重写后大小 * (1 percentage) AND 当前AOF文件大小 min-size重写过程AOF 重写同样使用fork()创建子进程但需要解决一个关键问题重写期间主进程仍在处理新的写请求这些新命令不能丢失。解决方法AOF 重写缓冲区AOF Rewrite Buffer完整流程Redis 调用fork()创建子进程子进程根据当前内存数据生成新的 AOF 文件主进程同时将新的写命令追加到旧 AOF 文件和AOF 重写缓冲区子进程完成重写后通知主进程主进程将 AOF 重写缓冲区中的命令追加到新 AOF 文件末尾用新 AOF 文件替换旧 AOF 文件伪代码AOF 重写完整流程function BGREWRITEAOF(): // 1. 主进程 fork 子进程 child_pid fork() if child_pid 0: // 子进程 // 2. 遍历当前数据库生成最精简的写入命令 new_aof open(temp-rewriteaof.aof, w) for each database in server.db: write(new_aof, SELECT, database.id) for each key in database: value lookup(key) ttl get_ttl(key) if ttl 0: write(new_aof, EXPIREAT, key, ttl) write(new_aof, SET, key, value) // 用一条命令代替历史所有操作 write(new_aof, EOF) close(new_aof) notify_parent() // 通知主进程重写完成 exit(0) else: // 主进程 aof_rewrite_buffer Buffer() // 创建重写缓冲区 register_rewrite_handler(child_pid) // 3. 重写期间主进程处理新写命令 function processCommandDuringRewrite(command): execute(command) // 正常执行命令 append_to_aof(command) // 追加到旧 AOF 文件保证安全 aof_rewrite_buffer.append(command) // 同时追加到重写缓冲区 // 4. 子进程完成后的回调 function onRewriteComplete(): // 5. 将重写缓冲区中的命令追加到新 AOF 末尾 new_aof open(temp-rewriteaof.aof, a) for each cmd in aof_rewrite_buffer: write(new_aof, cmd) close(new_aof) // 6. 原子替换用新 AOF 替换旧 AOF rename(temp-rewriteaof.aof, appendonly_filename) aof_rewrite_buffer.clear()3.4 AOF 的 3 种文件格式Redis 7.0Redis 7.0 对 AOF 机制进行了重大重构引入了**多部分 AOFMulti Part AOF**机制将原本单一的 AOF 文件拆分为三种类型的文件文件类型说明数量基础 AOF 文件Base File记录重写后的完整数据快照通常只有一个最多 1 个增量 AOF 文件Incremental File记录自上次重写以来的增量写操作可有多个清单文件Manifest File管理和追踪所有基础 AOF 和增量 AOF 文件仅 1 个清单文件Manifest File的作用记录当前有哪些基础 AOF 和增量 AOF 文件记录每个文件的序号Sequence Number确保加载顺序正确文件名为appendonlydir/appendonly.aof.manifestMulti Part AOF 解决的问题避免重写期间的文件操作风险旧版 AOF 重写时需要操作单一 AOF 文件如果中途崩溃可能导致文件损坏Multi Part AOF 的重写只需生成新的基础 AOF不影响增量文件更快的恢复速度基础 AOF 包含完整快照增量 AOF 只需追加最近的变更更灵活的文件管理可以独立备份、删除旧的增量文件目录结构示例appendonlydir/ ├── appendonly.aof.manifest # 清单文件 ├── appendonly.aof.1.base.rdb # 基础AOFRDB格式前缀混合模式 ├── appendonly.aof.1.incr.aof # 增量AOF文件1 ├── appendonly.aof.2.incr.aof # 增量AOF文件23.5 优缺点优点数据安全性高everysec策略下最多丢失约 1 秒的数据可读性好AOF 是 RESP 文本格式可直接查看和手动修复redis-check-aof工具支持重写压缩通过重写机制控制文件大小缺点文件体积大同等数据量下AOF 文件远大于 RDB 文件恢复速度慢需要逐条重放命令比 RDB 加载慢得多写入性能略低每次写操作都需追加到 AOF 文件即使everysec也有额外开销四、RDB AOF 混合持久化4.1 为什么混合使用RDB 和 AOF 各有优缺点维度RDBAOF数据安全性可能丢失最后一次快照后的数据最多丢 1 秒恢复速度快直接加载二进制慢重放所有命令文件大小小紧凑二进制大文本格式混合策略的核心思路在 AOF 重写时先写入 RDB 格式的快照数据再追加增量的 AOF 命令。这样重写后的 AOF 文件兼具 RDB 的加载速度和 AOF 的数据完整性。配置项aof-use-rdb-preamble yesRedis 4.0 引入Redis 7.0 默认开启4.2 混合 AOF 文件结构混合持久化生成的 AOF 文件由两部分组成------------------------------------------------------ | 前半段RDB 格式 | 后半段AOF 格式 | | 紧凑的完整快照数据 | 快照之后的增量命令 | ------------------------------------------------------恢复过程先加载前半段的 RDB 部分直接读取二进制数据到内存速度极快再重放后半段的 AOF 部分补全快照之后发生的写操作伪代码混合 AOF 的恢复流程function loadMixedAOF(aof_file): header read_magic_header(aof_file) if header REDIS: // 检测到 RDB 格式前缀 // 第一阶段加载 RDB 部分 rdb_data read_until_rdb_eof(aof_file) load_rdb_data(rdb_data) // 快速加载快照数据到内存 // 第二阶段重放 AOF 增量部分 aof_commands read_remaining(aof_file) for each cmd in aof_commands: execute(cmd) // 重放增量命令 else: // 纯 AOF 格式全部重放 for each cmd in aof_file: execute(cmd)4.3 企业实践生产环境基本都使用 RDB AOF 混合模式兼顾数据安全性和恢复速度恢复时优先使用 AOF因为 AOF 的数据完整性高于 RDBAOF 记录的命令更全RDB 文件作为灾备手段定期将 RDB 文件复制到远程存储确保极端情况下可以恢复大部分数据Redis 7.0 的 Multi Part AOF 中基础 AOF 文件默认使用混合格式RDB 前缀 AOF 增量增量 AOF 文件为纯 AOF 格式五、持久化策略选择指南策略对比维度RDBAOFRDB AOFNo Persistence数据安全性可能丢失数分钟数据最多丢 1 秒最多丢 1 秒断电即丢失恢复速度快慢较快混合模式无需恢复文件大小小大中等无性能影响fork 时的短暂影响每秒 fsync 的 I/O两者之和无文件可读性二进制不可读RESP 文本可读部分可读无场景推荐场景推荐策略理由纯缓存数据可从源头重建No Persistence避免不必要的磁盘 I/O最大化性能缓存场景但需要快速恢复RDB快照恢复快减少冷启动时间数据不能丢失如会话、队列AOFeverysec保证最多丢 1 秒数据生产环境一般业务RDB AOF 混合兼顾安全性和恢复速度六、过期策略Expiration6.1 设置过期Redis 为 Key 设置过期时间的命令命令说明时间单位EXPIRE key seconds设置剩余生存时间秒PEXPIRE key milliseconds设置剩余生存时间毫秒EXPIREAT key timestamp设置过期时间戳秒级 Unix 时间戳PEXPIREAT key timestamp设置过期时间戳毫秒级 Unix 时间戳SETEX key seconds value设置值的同时指定过期秒PSETEX key milliseconds value设置值的同时指定过期毫秒Redis 内部通过redisDb.expires字典Dict存储 Key 与过期时间的映射关系。伪代码过期时间的内部存储// Redis 数据库的内部结构 struct redisDb: dict main_dict // 主字典Key → Value dict expires // 过期字典Key → 过期时间戳毫秒 function setKeyWithExpire(key, value, ttl_ms): main_dict[key] value expires[key] now() ttl_ms // 存储绝对过期时间戳 function setKeyWithoutExpire(key, value): main_dict[key] value expires.delete(key) // 无过期的 Key 不在 expires 字典中 function getRemainingTTL(key): if not expires.contains(key): return -1 // 表示永不过期 expire_time expires[key] remaining expire_time - now() if remaining 0: return -2 // 表示已过期 return remaining6.2 惰性删除Lazy Expiration惰性删除的核心思想不主动删除过期的 Key只在访问 Key 时才检查是否过期如果过期则删除。优点对 CPU 友好只在必要时才执行删除操作。缺点如果过期 Key 长期不被访问它们会一直占用内存造成内存泄漏Memory Leak。伪代码惰性删除逻辑function getCommand(key): if not main_dict.contains(key): return NULL // Key 不存在 if expires.contains(key): expire_time expires[key] if now() expire_time: // Key 已过期 main_dict.delete(key) // 删除 Key-Value expires.delete(key) // 删除过期记录 return NULL // 返回空表示 Key 不存在 return main_dict[key] // Key 存在且未过期返回值 // 所有读写命令在访问 Key 之前都会先执行惰性删除检查 function expireIfNeeded(key): if not expires.contains(key): return false // 无过期时间 expire_time expires[key] if now() expire_time: main_dict.delete(key) expires.delete(key) return true // 已删除过期 Key return false // 未过期6.3 定期删除Periodic Expiration为了弥补惰性删除的不足Redis 还实现了定期删除机制主动扫描并删除已过期的 Key。核心逻辑Redis 每 100ms 执行一次定期扫描由serverCron触发每次从expires字典中随机抽取一批设置了过期的 Key默认最多检查 20 个删除其中已过期的 Key如果本轮过期 Key 的比例超过 25%立即继续下一轮扫描如果不超过 25%停止本轮扫描等待下次定时触发伪代码定期删除的采样逻辑const EXPIRE_CHECKS_PER_CYCLE 20 // 每轮最多检查 20 个 Key const EXPIRE_RATIO_THRESHOLD 0.25 // 过期比例阈值 25% function activeExpireCycle(): expired_count 0 total_checked 0 // 从过期字典中随机抽取一批 Key 进行检查 sample_keys random_sample(expires, EXPIRE_CHECKS_PER_CYCLE) for each key in sample_keys: total_checked 1 expire_time expires[key] if now() expire_time: // Key 已过期 main_dict.delete(key) expires.delete(key) expired_count 1 // 如果过期比例超过阈值继续扫描 if total_checked 0 and (expired_count / total_checked) EXPIRE_RATIO_THRESHOLD: activeExpireCycle() // 递归继续扫描限时防止卡死主线程 // 注意实际 Redis 实现中有时间限制确保单次扫描不超过阈值时间 // 避免过多占用 CPU 时间影响主线程处理请求6.4 惰性 定期配合使用Redis 采用惰性删除 定期删除两种策略配合使用的方式为什么不能只用定期删除不可能每秒扫描全部 Key那样 CPU 开销太大会严重影响 Redis 的响应速度为什么不能只用惰性删除过期的 Key 如果不被访问永远占用内存导致内存泄漏两者配合惰性删除保证访问时一定删除过期 Key定期删除保证不常访问的过期 Key 也会被清理即便如此仍然可能有少量过期 Key 未被及时清理在两次定期扫描之间过期、且未被访问此时需要依赖**内存淘汰Eviction**机制兜底。七、内存淘汰Eviction7.1 maxmemory 配置Redis 通过maxmemory配置项设置最大内存限制。当 Redis 使用的内存超过此限制时根据maxmemory-policy配置的淘汰策略决定如何处理新的写入请求。maxmemory 4gb # 设置最大内存为 4GB maxmemory-policy allkeys-lfu # 设置淘汰策略如果未设置maxmemory64 位系统默认为 0即不限制Redis 不会主动淘汰数据但可能因系统内存不足而被操作系统 OOM Killer 杀掉。7.2 8 种淘汰策略详解策略淘汰范围淘汰依据说明noeviction--不淘汰任何 Key写入操作返回错误。默认策略allkeys-lru所有 Key最近最少使用LRU从所有 Key 中淘汰最久未被访问的volatile-lru设置了过期的 Key最近最少使用LRU仅在有过期时间的 Key 中淘汰allkeys-lfu所有 Key最不经常使用LFU从所有 Key 中淘汰访问频率最低的Redis 4.0volatile-lfu设置了过期的 Key最不经常使用LFU仅在有过期时间的 Key 中淘汰allkeys-random所有 Key随机从所有 Key 中随机淘汰volatile-random设置了过期的 Key随机从有过期时间的 Key 中随机淘汰volatile-ttl设置了过期的 KeyTTL 最小淘汰剩余生存时间最短的 Key注意volatile-*策略在所有 Key 都没有设置过期时间时退化为noeviction无法淘汰。7.3 LRU vs LFULRULeast Recently Used最近最少使用LRU 基于访问时间最近被访问的 Key 排在前面最久未被访问的排在后面淘汰时选择最久未访问的。Redis 的近似 LRURedis 并不维护全局的 LRU 链表内存开销大而是采用随机采样的方式每次需要淘汰时随机采样 N 个 Key默认maxmemory-samples为 5从这 N 个 Key 中选出最久未访问的进行淘汰采样数越大结果越接近理想 LRU但 CPU 消耗也越大LFULeast Frequently Used最不经常使用LFU 基于访问频率访问频率越低的 Key 越先被淘汰。LFU 比 LRU 更适合热点数据场景——一个偶尔被访问的老 Key 不会被 LRU 保留太久但在 LFU 中会被低频新 Key 先淘汰。Redis 的 LFU 使用计数器Counter来估算访问频率相关配置配置项默认值说明lfu-log-factor10计数器增长因子值越大计数增长越慢区分度越高lfu-decay-time1衰减时间分钟控制历史访问权重的衰减速度伪代码近似 LRU 采样淘汰逻辑function evictKeyIfNeeded(): if used_memory maxmemory: return // 内存未超限无需淘汰 sample_size maxmemory_samples // 采样数默认 5 pool [] // 候选淘汰池 // 随机采样 N 个 Key candidates random_sample(all_keys, sample_size) for each key in candidates: idle_time now() - key.last_access_time pool.append((idle_time, key)) // 选出最久未访问的 Key 进行淘汰 pool.sort_by_idle_time_desc() victim pool[0].key // idle_time 最大的 Key delete(victim) // LFU 计数器的更新逻辑 function updateLFUCounter(key): counter key.lfu_counter // 对数增长频率越高计数增长越慢 random_val random(0, 1) base lfu_log_factor // 默认 10 if random_val (1.0 / (counter * base 1)): key.lfu_counter 1 // LFU 计数器的衰减逻辑定期执行 function decayLFUCounters(): for each key in all_keys: elapsed_minutes (now() - key.lfu_decay_time) / 60000 if elapsed_minutes lfu_decay_time: // 默认 1 分钟 key.lfu_counter max(counter - elapsed_minutes, 0) key.lfu_decay_time now()7.4 淘汰策略选择指南场景推荐策略理由纯缓存allkeys-lfu或allkeys-lru缓存数据都可丢失优先保留热点数据既有缓存又有持久数据volatile-lru或volatile-lfu只淘汰可过期的缓存数据保护持久数据对象缓存 明确热点allkeys-lfu基于频率淘汰保留高频访问的热点对象数据全量重要、不能淘汰noeviction拒绝写入而非丢失数据需配合容量规划伪代码淘汰决策流程function processCommandWithEviction(command): // 1. 执行命令 result execute(command) // 2. 检查内存是否超限 if used_memory maxmemory: switch maxmemory_policy: case noeviction: return ERROR(OOM command not allowed when used memory maxmemory) case allkeys-lru: evict_by_lru(all_keys) case volatile-lru: if has_expired_keys(): evict_by_lru(expired_keys) else: return ERROR(no volatile keys to evict) case allkeys-lfu: evict_by_lfu(all_keys) case volatile-lfu: if has_expired_keys(): evict_by_lfu(expired_keys) else: return ERROR(no volatile keys to evict) case allkeys-random: evict_random(all_keys) case volatile-random: if has_expired_keys(): evict_random(expired_keys) else: return ERROR(no volatile keys to evict) case volatile-ttl: if has_expired_keys(): evict_min_ttl(expired_keys) else: return ERROR(no volatile keys to evict) return result function evict_by_lru(key_pool): // 近似 LRU随机采样 选最久未访问 candidates random_sample(key_pool, maxmemory_samples) victim find_max_idle_time(candidates) delete(victim) function evict_by_lfu(key_pool): // 近似 LFU随机采样 选频率最低 candidates random_sample(key_pool, maxmemory_samples) victim find_min_lfu_counter(candidates) delete(victim)