RDB全量快照备份

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;
}
相关推荐
LucianaiB12 小时前
我把刘备一生做成了可播放的高德地图,还把整套方法封装成了 Skill
后端
Reart12 小时前
操作系统实验哈工大lab2——系统调用
后端·操作系统
IT_陈寒12 小时前
React useEffect依赖数组中埋的坑,这次终于让我逮到了
前端·人工智能·后端
扎Zn了老Fe13 小时前
MyBatis-Plus必知必会:告别低效CRUD,高效开发持久层
后端·mybatis
用户4603812919413 小时前
终于知道AQS是啥东西了
后端
盐焗鹌鹑蛋13 小时前
【C++】C++11:列表初始化、声明、STL升级
c++
神奇小汤圆15 小时前
SpringBoot 相关的 Skills 全景指南
后端
ComputerInBook15 小时前
c 和 c++ 中的宏块(macro)
c语言·c++··宏块·宏指令
神奇小汤圆15 小时前
高性能进程内队列 BufferQueue 1.0 版本发布
后端