RDB (Redis Database) 是 Redis 的核心持久化机制之一,它的核心思想是在指定的时间间隔,为内存中的数据生成一份时间点快照(Point-in-time Snapshot),并保存到一个紧凑的二进制文件中(默认为 dump.rdb)。它的优点和缺点都很鲜明:恢复速度快、对性能影响小 ,但也伴随着数据丢失风险高、生成时资源消耗大的不足。
核心机制:Copy-on-Write(写时复制)
1、父进程fork()子进程
当需要生成 RDB 时,Redis 主进程会调用 fork() 系统调用,创建一个子进程。在这个瞬间,子进程会复制父进程的页表,两者共享同一份物理内存数据。fork 完成后内存不是彻底隔离的,是"一开始共享,写时才复制。
cpp
// 假设 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()?
cpp
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:使用公平读写锁
cpp
#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)
cpp
// 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:读写分离(双缓冲)
cpp
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:限定写锁频率
cpp
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)
cpp
# 启用大页(2MB)
echo always > /sys/kernel/mm/transparent_hugepage/enabled
# 效果:
# 4GB 内存
# 4KB 页面:1M 个 PTE → fork 慢
# 2MB 页面:2K 个 PTE → fork 快 500 倍
2. 内核优化
cpp
// 使用 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;
}