RDB (Redis Database) 是 Redis 的核心持久化机制之一,它的核心思想是在指定的时间间隔,为内存中的数据生成一份时间点快照(Point-in-time Snapshot),并保存到一个紧凑的二进制文件中(默认为 dump.rdb)。它的优点和缺点都很鲜明:恢复速度快、对性能影响小,但也伴随着数据丢失风险高、生成时资源消耗大的不足。
核心机制:Copy-on-Write(写时复制)
1、父进程fork()子进程
当需要生成 RDB 时,Redis 主进程会调用fork()系统调用,创建一个子进程。在这个瞬间,子进程会复制父进程的页表,两者共享同一份物理内存数据。fork 完成后内存不是彻底隔离的,是“一开始共享,写时才复制。
// 假设 Redis 有 4GB 内存数据 // fork 之前 父进程: [数据页1][数据页2][数据页3]...[数据页N] 物理内存 4GB // 执行 fork() 瞬间 1. 操作系统创建子进程 2. 复制父进程的页表(不是数据!) 3. 父子进程的页表指向同一物理内存 父进程页表: [页1→物理地址A][页2→物理地址B][页3→物理地址C] 子进程页表: [页1→物理地址A][页2→物理地址B][页3→物理地址C] ↓ 同一块物理内存 (4GB) 用户程序调用 fork() ↓ 陷入内核态(系统调用) ↓ 1. 复制进程描述符(task_struct) ↓ 2. 复制内存描述符(mm_struct) ↓ 3. 复制页表(共享物理内存) ↓ 4. 复制文件描述符表 ↓ 5. 复制信号处理表 ↓ 6. 分配新的 PID ↓ 7. 子进程加入调度队列 ↓ 返回用户态(父进程返回 PID,子进程返回 0)父进程修改数据/新增数据
父进程要修改物理页时,会先检查该页是否被共享(物理页引用计数),如果该页被共享会执行写时复制:(1)分配新的物理页给父进程(2)操作系统复制旧的物理页内存到新的物理页上(3)父进程页表指向新的物理页,子进程页表仍指向旧的物理页(4)旧的物理页减少引用计数。如果子进程要修改数据也是一样,修改完后两个物理页彻底隔离。
2、子进程"读"、"写磁盘
子进程负责将共享内存中的所有数据,序列化并写入一个临时的 RDB 文件中。由于它持有的是 fork 时刻的内存快照,所以看到的数据是一致且不变的。
3、父进程继续处理新请求
父进程不受干扰,继续处理客户端请求。当有写请求发生时,操作系统会为被修改的内存页创建一个副本,父进程在副本上修改,而子进程依然读取原始的内存页来生成快照。
4、原子替换
当子进程完成临时文件的写入后,会用这个临时文件原子地替换旧的 RDB 文件,完成一次持久化操作。
这个机制确保了 Redis 主进程在备份期间几乎不会被阻塞,性能影响极小。
RDB文件结构
文件头(Header)固定以 "REDIS" 5 个字节开头,用于快速校验文件类型。紧接着是 4 个字节的版本号(如0007),以字符串形式存储。文件的主体,包含了实际的数据。如果某个数据库为空,则不会在此部分出现。每个非空数据库部分都以一个SELECTDB操作码开头,随后紧跟数据库编号(如0,1),最后是具体的键值对(Key-Value Pairs)数据。文件尾是一个特定的字节EOF(0xFF),标记键值对数据的结束,最后加上一个 8 字节的 CRC64 校验和,用于在加载时验证文件是否完整或损坏。
RDB 的优缺点
优点:(1)恢复速度快:RDB 是数据的二进制快照,恢复时直接加载到内存,比 AOF 逐条重放命令要快得多。(2)文件紧凑,便于备份:生成的dump.rdb文件体积小,非常适合做冷备或异地容灾。(3)对性能影响小:持久化过程主要消耗在子进程,父进程几乎无额外 I/O 开销。
缺点:(1)数据丢失风险:由于是间隔性备份,如果 Redis 在两次快照之间意外宕机,最后一次快照之后的所有修改都将丢失。(2)fork 资源消耗:当数据集非常大时,fork()子进程本身会消耗一定时间并导致内存占用瞬时翻倍(页表复制),在高负载场景下可能引发延迟。
总的来说,RDB 是一种非常适合备份和灾难恢复的机制,它的高性能和快速恢复特性使其在生产环境中备受青睐。但为了平衡数据安全性与性能,官方建议将 RDB 和 AOF 两种持久化方式结合使用。如果同时启用,Redis 在重启时会优先使用 AOF 文件来恢复数据,因为它通常能提供更完整的数据。
为什么不使用读写锁而使用fork()?
pthread_rwlock_t config_lock; // 读线程:频繁读取配置 void* worker_thread(void* arg) { while (1) { pthread_rwlock_rdlock(&config_lock); int timeout = g_config.timeout; // 多个线程可并发读 int max_conn = g_config.max_connections; pthread_rwlock_unlock(&config_lock); // 使用配置工作... do_work(timeout, max_conn); } } // 管理线程:偶尔更新配置 void* config_reloader(void* arg) { while (1) { sleep(60); // 每分钟重载一次 Config new_config; load_from_file(&new_config); pthread_rwlock_wrlock(&config_lock); // 独占锁 g_config = new_config; // 更新配置 pthread_rwlock_unlock(&config_lock); } }读写锁可以允许多个线程同时获取读锁,适用于读多写少的场景。而redis的使用场景读写基本不固定,出现大量读操作时会出现大量的锁竞争,造成性能延时抖动。
读写锁使用场景:(1)配置管理,配置多是读多写少。(2)缓存系统,读多写少。(3)路由表、DNS缓存。(4)数据库连接池,读多写少。(5)黑白名单。
读写操作通过同一把锁对象(如std::shared_mutex)竞争,在某些场景下可能出现写锁饥饿或读锁延迟过高的问题,性能不稳定。
| 问题 | 表现 | 原因 |
|---|---|---|
| 写锁饥饿 | 写线程长时间无法获得锁 | 新读锁不断插队 |
| 读锁延迟 | 读线程在有写等待时被阻塞 | 某些实现的写优先策略 |
| 性能抖动 | 吞吐量忽高忽低 | 读/写模式切换 |
读写锁优化方案
方案1:使用公平读写锁
#include <mutex> #include <condition_variable> class FairRWLock { std::mutex mtx; std::condition_variable cv; int readers = 0; bool writing = false; int waiting_writers = 0; public: void read_lock() { std::unique_lock lock(mtx); // 如果有人在等写锁,读也要等(公平) cv.wait(lock, [this] { return !writing && waiting_writers == 0; }); readers++; } void write_lock() { std::unique_lock lock(mtx); waiting_writers++; cv.wait(lock, [this] { return !writing && readers == 0; }); waiting_writers--; writing = true; } void unlock() { std::unique_lock lock(mtx); if (writing) { writing = false; } else { readers--; } cv.notify_all(); } };方案2:减少锁竞争(RCU)
// Read-Copy-Update 思想 template<typename T> class RCUProtected { std::atomic<T*> ptr; std::shared_mutex update_mtx; public: T* read() { return ptr.load(); // 无锁读 } void update(T* new_ptr) { std::unique_lock lock(update_mtx); // 只有更新需要锁 T* old = ptr.exchange(new_ptr); // 等待所有读完成(RCU 宽限期) std::this_thread::sleep_for(std::chrono::milliseconds(100)); delete old; } };方案3:读写分离(双缓冲)
template<typename T> class DoubleBuffer { T buffer[2]; std::atomic<int> read_index{0}; std::mutex write_mtx; public: T read() { return buffer[read_index.load()]; // 无锁读 } void write(const T& new_value) { std::lock_guard lock(write_mtx); int write_index = 1 - read_index.load(); buffer[write_index] = new_value; read_index.store(write_index); // 原子切换 } };方案4:限定写锁频率
class BatchingRWLock { std::shared_mutex mtx; std::atomic<int> pending_writes{0}; const int BATCH_SIZE = 100; public: void write() { if (pending_writes++ % BATCH_SIZE == 0) { std::unique_lock lock(mtx); // 实际写操作 } else { // 累积批量写 } } void read() { std::shared_lock lock(mtx); // 读操作 } };现代优化:写时复制 + 大页内存
1.透明大页(THP)
# 启用大页(2MB) echo always > /sys/kernel/mm/transparent_hugepage/enabled # 效果: # 4GB 内存 # 4KB 页面:1M 个 PTE → fork 慢 # 2MB 页面:2K 个 PTE → fork 快 500 倍2.内核优化
// 使用 RCU(Read-Copy-Update) // 某些场景避免复制整个 mm_struct // 使用引用计数共享 // 直到写时才复制(Lazy Copy) #include <stdio.h> #include <unistd.h> #include <sys/resource.h> int main() { // 获取页大小 long page_size = sysconf(_SC_PAGESIZE); printf("Page size: %ld bytes\n", page_size); // 分配内存 size_t size = 100 * 1024 * 1024; // 100MB char *data = malloc(size); memset(data, 0xAA, size); // 查看内存映射 printf("Before fork:\n"); char cmd[100]; snprintf(cmd, sizeof(cmd), "cat /proc/%d/smaps | grep -E '^(Size|Pss|Rss)'", getpid()); system(cmd); pid_t pid = fork(); if (pid == 0) { // 子进程 printf("\nChild process (PID %d):\n", getpid()); printf("Child sees same memory (COW)\n"); // 修改内存触发 COW data[0] = 0xBB; printf("After child modifies 1 byte:\n"); system(cmd); sleep(1); } else { // 父进程 sleep(2); printf("\nParent process (PID %d):\n", getpid()); printf("Parent's memory unchanged:\n"); system(cmd); } return 0; }