多线程redis项目之aof

目录

疑惑

AOF作为文本文件,为啥它叫文本文件?而rdb是二进制文件

为什么有了rdb还需要aof,这两个是互补呢还是多余

源码分析(基于redis7.0源码)

我们怎么做

[1. always 模式(最高安全)](#1. always 模式(最高安全))

[2. everysec 模式](#2. everysec 模式)

[3. no 模式](#3. no 模式)

重写机制

整个流程框架

函数说明


Redis持久化策略与实战指南-CSDN博客

关于aof的初谈我已经在这篇文章讲过了,建议先看这篇文章,再看接下来的源码刨析

疑惑

AOF作为文本文件,为啥它叫文本文件?而rdb是二进制文件

因为aof本身是把命令按照resp协议写进缓冲区,后面刷新到磁盘的

  1. 可读性与调试

    • 运维人员可以直接查看 AOF 文件内容,确认执行了哪些命令,便于故障排查。

    • 在发生数据误操作时,可以手动编辑 AOF 文件(例如删除最后一条 DEL 命令),然后重启恢复,实现"后悔药"。

  2. 协议简单,跨平台

    • RESP 协议本身是文本协议,实现简单,且不受字节序影响,在任何平台上解析规则一致。

    • 追加操作只需将命令文本直接写入文件末尾,无需复杂的二进制编码。

  3. 重写机制

    • AOF 重写(BGREWRITEAOF)会生成一个最小化的命令集合(也是文本 RESP),替换旧文件。虽然重写过程涉及内存快照,但最终输出的仍是文本格式,保持一致性。(但现在有混合持久化)
  4. 可靠性

    • 文本格式便于在文件损坏时部分恢复(例如手动截断到最后一个完整命令)。

本质是因为:人类打开之后能够看得懂的就是文本,打开看不懂的就是二进制

aof采用resp序列化,resp协议当中都是人能懂的字符

rdb

  • 它使用单字节标记(如 0xFA0xFE0xFB)来表示不同类型的数据段。

  • 长度使用变长编码,一个长度可能占用 1、2、4 或 9 字节,这些字节可能是任意数值(包括不可打印的)。

  • 整数值直接以原始二进制存储(如 0x0001E240)。

  • 字符串虽然可以是文本,但也是按长度+内容存储,没有可读的分隔符。

为什么有了rdb还需要aof,这两个是互补呢还是多余

场景 RDB 的作用 AOF 的作用
数据安全要求高 ❌ 可能丢失较多数据 ✅ 可配置到秒级甚至命令级丢失
快速重启/故障恢复 ✅ 加载快 ❌ 重放慢
定期备份 ✅ 文件小,易于传输 ⚠️ 文件较大,但也可备份
误操作补救 ❌ 无法直接编辑 ✅ 可以手动删除错误命令
磁盘资源紧张 ✅ 占用空间小 ❌ 占用空间大

两者结合使用时:

  • 可以同时开启 RDB 和 AOF,此时 Redis 重启时会优先使用 AOF 恢复数据(因为 AOF 更完整)。

  • 如果 AOF 损坏,还可以用 RDB 作为备用。

  • 生产环境通常建议同时开启:RDB 用于冷备和快速恢复,AOF 保证最小数据丢失。

  • RDB:提供高效、紧凑的备份与快速恢复,适合冷备、灾难恢复。

  • AOF:提供更实时的数据持久化,适合追求高数据安全性的场景。

这样看两者都有优缺点,是互补型

源码分析(基于redis7.0源码)

cpp 复制代码
struct redisServer {
    // AOF 状态
    int aof_state;                /* AOF 是否开启: AOF_ON / AOF_OFF */
    int aof_fd;                   /* AOF 文件描述符 */
    char *aof_filename;           /* AOF 文件名,默认 "appendonly.aof" */
    off_t aof_current_size;       /* 当前 AOF 文件大小 */
    off_t aof_last_rewrite_size;  /* 上次重写时文件大小 */
    sds aof_buf;                  /* 写入缓冲区,积累命令后批量写入 */
    int aof_fsync;                /* 同步策略: AOF_FSYNC_ALWAYS, AOF_FSYNC_EVERYSEC, AOF_FSYNC_NO */
    time_t aof_last_fsync;        /* 上次 fsync 的时间(用于 everysec) */
    pid_t aof_child_pid;          /* 正在执行 AOF 重写的子进程 PID */
    // ...
};
  • aof_buf:一个 SDS 字符串,所有写命令先追加到这个缓冲区,达到一定条件后写入文件(避免频繁系统调用)。

  • aof_fsync :控制何时调用 fsync 将数据持久化到磁盘。

当用户执行写命令时,除了写入内存外,还要写入aof缓冲区,就会执行feedAppendOnlyFile

cpp 复制代码
void feedAppendOnlyFile(struct redisCommand *cmd, int dictid, robj **argv, int argc) {
    // 如果 AOF 未开启,直接返回
    if (server.aof_state == AOF_OFF) return;

    // 如果当前数据库不是上次记录的数据库,先写入 SELECT 命令切换数据库
    if (dictid != server.aof_selected_db) {
        // 构造 SELECT 命令的 RESP 表示
        char buf[64];
        int len = snprintf(buf, sizeof(buf), "*2\r\n$6\r\nSELECT\r\n$%d\r\n%d\r\n",
                           (dictid < 10 ? 1 : (dictid < 100 ? 2 : 3)), dictid);
        // 追加到 aof_buf
        server.aof_buf = sdscatlen(server.aof_buf, buf, len);
        server.aof_selected_db = dictid;
    }

    // 构造命令本身的 RESP 数组
    // 例如 "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n"
    robj *cmd_obj = createObject(OBJ_STRING, sdsnew(cmd->name));
    // 将参数转换为字符串并拼接
    // ... 省略具体构造细节
    server.aof_buf = sdscatlen(server.aof_buf, buf, len);
}

aof_buf 会在以下时机写入文件:(注意写入文件和落盘不一样,写入文件是写到内核缓存页)

  • 每次事件循环结束前beforeSleep 函数中调用 flushAppendOnlyFile

  • 当缓冲区大小超过 AOF_REWRITE_ITEMS_PER_CMD 阈值时(防止过大)。

  • 当 Redis 准备关闭或需要 fsync 时

cpp 复制代码
void flushAppendOnlyFile(int force) {
    ssize_t nwritten;
    if (sdslen(server.aof_buf) == 0) return;

    // 写入文件
    nwritten = write(server.aof_fd, server.aof_buf, sdslen(server.aof_buf));
    if (nwritten != (ssize_t)sdslen(server.aof_buf)) {
        // 写入失败处理
        return;
    }
    // 更新文件大小
    server.aof_current_size += nwritten;
    // 清空缓冲区
    sdsclear(server.aof_buf);

    // 根据同步策略决定是否 fsync
    if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
        // always: 立即 fsync
        aof_fsync(server.aof_fd);
        server.aof_last_fsync = time(NULL);
    } else if (server.aof_fsync == AOF_FSYNC_EVERYSEC) {
        // everysec: 如果距离上次 fsync 超过 1 秒,则后台线程或主线程执行 fsync
        if (time(NULL) - server.aof_last_fsync >= 1) {
            // 为了避免阻塞,Redis 会 fork 一个子线程做 fsync(实际使用 bgthread)
            // 这里简化:调用 aof_background_fsync
            aof_background_fsync(server.aof_fd);
            server.aof_last_fsync = time(NULL);
        }
    }
    // no: 不做任何 fsync,由操作系统决定
}
  • ALWAYS :每次写入后立即 fsync,最安全但性能最差。

  • EVERYSEC :每秒执行一次 fsync,由后台线程完成,避免阻塞主线程。

  • NO:依赖操作系统刷盘,性能最好但可能丢失最近的数据。

  • alwayseverysecno 都是 fsync(刷盘)策略 ,它们控制的是数据从操作系统内核缓冲区真正写入磁盘的时机 ,而不是控制 write 系统调用的时机(write 每次都会调用,但只把数据交给内核)。

重写时:会调用rewriteAppendOnlyFileBackground

cpp 复制代码
void rewriteAppendOnlyFileBackground(void) {
    if (server.aof_child_pid != -1 || server.rdb_child_pid != -1) {
        // 已有子进程,返回错误
        return;
    }
    if ((server.aof_child_pid = fork()) == 0) {
        // 子进程
        rewriteAppendOnlyFile(tmpfile);
        exitFromChild(0);
    } else {
        // 父进程:记录 PID,等待子进程结束
        server.aof_rewrite_scheduled = 0;
        server.aof_rewrite_time_start = time(NULL);
    }
}

int rewriteAppendOnlyFile(char *filename) {
    // 创建一个临时文件
    FILE *fp = fopen(filename, "w");
    // 初始化一个 rio 对象,绑定文件
    rio aof;
    rioInitWithFile(&aof, fp);

    // 遍历所有数据库
    for (int j = 0; j < server.dbnum; j++) {
        // 写入 SELECT 命令
        if (rioWrite(&aof, "*2\r\n$6\r\nSELECT\r\n", 17) == 0) goto werr;
        // 将数据库编号转为字符串并写入
        // ...

        // 遍历数据库的所有键
        dictEntry *de;
        while ((de = dictNext(di)) != NULL) {
            robj *key = dictGetKey(de);
            robj *val = dictGetVal(de);
            // 将键值对转换为 SET 或类似命令,写入 aof
            // 例如: "*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n"
            // 使用 rioWrite 写入
        }
    }
    // 完成
    fclose(fp);
    return C_OK;
}
  • AOF 重写也使用了**rio 抽象**,但后端是文件,没有压缩(因为 AOF 是文本)。

  • 子进程通过遍历内存数据库,将每个键值对转换成创建该键值对的命令(例如 SETHSET 等),形成新的 AOF 文件。

  • 在重写期间,父进程继续处理写命令,并将新命令追加到旧的 AOF 文件,同时记录这些新命令到 AOF 重写缓冲区server.aof_rewrite_buf_blocks)中,以便子进程结束后将这些增量命令追加到新文件。

当子进程重写完成后,父进程会通过 backgroundRewriteDoneHandler 处理:

  • 将重写期间累积在 aof_rewrite_buf_blocks 中的增量命令追加到新 AOF 文件。

  • 用新文件替换旧文件(rename)。

  • 更新 server.aof_fd 指向新文件,并清空缓冲区。

这样既保证了重写期间的命令不丢失,又使得新 AOF 文件体积最小。

我们怎么做

  • 用户态缓冲区 :例如我们的 _queue或 Redis 的 aof_buf,是 Redis 进程内部的内存缓冲区。

  • 内核页缓存 :通过 write 系统调用后,数据进入操作系统内核的页缓存。

  • 磁盘 :通过 fsync / fdatasync 强制将页缓存数据写入物理磁盘。

1. always 模式(最高安全)

标准 Redis 实现(无队列、无后台线程):

  • 每个命令追加到 aof_buf

  • 立即(或在事件循环末尾)调用 writeaof_buf 中的数据写入内核页缓存。

  • 立即调用 fsync,等待数据落盘。

  • 只有 fsync 成功后,才给客户端回复 OK

异步队列实现AofLogger):

  • 主线程将命令放入队列(用户态缓冲区),然后阻塞等待(通过 cv_commit)。

  • 后台线程从队列取出数据,调用 write,然后立即 fsync,最后更新 _last_synced_seq 唤醒主线程。

  • 效果相同:主线程最终仍等待落盘,但多了线程切换开销。

2. everysec 模式

标准 Redis

  • 主线程将命令追加到 aof_buf

  • 在事件循环末尾调用 write,将 aof_buf 数据写入内核页缓存(主线程执行 write)。

  • 一个后台线程每隔 1 秒 调用 fsync,将页缓存数据落盘。

  • 主线程从不等待 fsync

我的实现

  • 主线程将命令放入队列(用户态缓冲区),然后立即返回(不等待)。

  • 后台线程从队列取出数据,可以聚合多个命令 ,然后调用 write 写入内核页缓存。

  • 后台线程每隔 sync_interval_ms 调用 fsync

  • 区别write 也被移到后台线程,主线程完全无阻塞。

3. no 模式

  • 主线程只负责将命令入队(或追加到 aof_buf)。

  • 后台线程(或主线程)调用 write,但从不调用 fsync,完全依赖操作系统刷盘。

cpp 复制代码
 // 刷新方案
  enum class AofMode
  {
    kNo,          // NO:依赖操作系统刷盘,性能最好但可能丢失最近的数据。 
    kEverySec,    // 每秒执行一次 fsync,由后台线程完成,避免阻塞主线程。
    kAlways       // ALWAYS:每次写入后立即 fsync,最安全但性能最差。
  };
  //aof配置参数
  struct AofOptions
  {
    //基础配置
    bool enabled = false;                     //开启和关闭aof按钮
    AofMode mode = AofMode::kEverySec;        //默认是每s刷新
    std::string dir = "./data";               //aof文件目录
    std::string filename = "appendonly.aof";  //aof文件名

    //这里是内存缓冲区的参数,大小最大和时间最长,达到其中一个就要写入文件
    size_t batch_bytes = 256 * 1024;          // 每批聚合写入的目标字节数
    int batch_wait_us = 1500;                 // 聚合等待上限(微秒)

    //提前为 AOF 文件分配磁盘空间,避免后续写入时频繁扩展文件大小,减少碎片和性能开销。
    size_t prealloc_bytes = 64 * 1024 * 1024; // 初始预分配大小

    //同步周期在 everysec 模式下,后台 fsync 的实际执行间隔(毫秒)。默认为 1000ms,可微调以平衡延迟和稳定性。
    int sync_interval_ms = 1000;              // everysec 实际同步周期(毫秒),可调平滑尾延迟
    
    //  Linux 专用的性能调优(可选)
    bool use_sync_file_range = false;         // 写入后触发后台回写(SFR_WRITE)
    size_t sfr_min_bytes = 512 * 1024;        // 达到该批量再调用 sync_file_range,避免过于频繁
    bool fadvise_dontneed_after_sync = false; // 每次 fdatasync 后对已同步范围做 DONTNEED
  };

注意:sync_interval_ms

如果严格固定为 1000 毫秒,可能会出现以下问题:

  • 与业务负载周期重叠 :例如你的应用每隔恰好 1 秒会发起一波批量写请求,如果 fsync 也正好在这一时刻执行,那么 fsync 的磁盘 I/O 就会与业务写请求争抢资源,导致某几毫秒的延迟显著升高(即产生尾延迟尖峰)。

  • 多实例共振 :在同一台物理机上运行多个 Redis 实例,如果它们都采用固定的 1 秒同步,那么所有实例可能同时触发 fsync,造成磁盘 I/O 风暴,严重影响整体性能。

通过将同步间隔调整为略小于或大于 1000 毫秒(例如 950ms 或 1100ms),可以:

  • 打破与业务周期的同步性,分散负载。

  • 避免多个实例同时同步。

这种微调并不会显著降低数据安全性(因为仍然保证大约每秒一次同步),却能有效改善延迟分布的长尾部分------即最慢的那些请求的耗时。

redis官方并没有这么做,基本都是固定1s了,没有配置能够修改

  • Redis 官方 everysec 没有设计专门的平滑尾延迟优化,而是通过2 秒超时保护来限制延迟和数据丢失窗口
  • 刷新策略是 **"尽力每秒"**,而非绝对固定 1 秒,实际间隔受事件循环、磁盘 I/O 和线程调度影响
  • 这种设计是数据安全与性能的折中:正常情况每秒刷新,异常情况最多阻塞主线程 2 秒,避免数据丢失过多

那此时就会有人问batch_wait_us vs. sync_interval_ms``这两个不一样吗???

这两个参数是协同工作的,分别作用于数据持久化的两个不同阶段。batch_wait_us 负责"何时写",sync_interval_ms 负责"何时落盘"

阶段一:写入 (Write) - 由 batch_wait_usbatch_bytes 控制

当一个写命令(如 SET)执行后,数据首先被追加到内存中的 aof_buf 缓冲区。batch_wait_us 控制了主线程在将缓冲区数据写入操作系统内核缓冲区之前的最大等待时间。

  • 工作流程

    1. 主线程执行写命令。

    2. 将命令追加到 aof_buf

    3. 如果此时 aof_buf 的大小达到了 batch_bytes,或者自上次写入以来等待时间超过了 batch_wait_us 微秒,主线程就会调用 write() 系统调用,将 aof_buf 中的数据写入操作系统的 Page Cache(页缓存)

    4. 这一步执行速度很快,因为它只涉及内存操作,通常不会阻塞主线程太久。

阶段二:同步 (Sync/Fsync) - 由 sync_interval_ms 控制

write 调用只是把数据交给了操作系统,但此时数据可能还在内存中,尚未真正写入磁盘。fsync 系统调用的作用是强制操作系统将页缓存中的数据立即写入磁盘。

  • 工作流程

    1. appendfsync everysec 模式下,主线程完成 write 后便立即返回。

    2. 一个专门的后台线程会负责每隔 sync_interval_ms 毫秒执行一次 fsync 调用。

    3. fsync 调用会触发真正的磁盘 I/O,这个过程可能较慢,但因为是后台线程执行,通常不会阻塞主线程处理新的请求。

这两个参数的设计很精巧:batch_wait_us 像一个灵敏的"短跑选手",通过微小的延迟(微秒级)聚合更多写入请求,以此来提升吞吐量。而 sync_interval_ms 则是一个稳健的"长跑健将",它以一个相对固定的节奏(毫秒级),确保数据能被安全地落盘。

也就是一个是写入page(这个是攒够多次然后一次写),一个是真正落盘(这个是决定数据安全)

独属于Linux的优化点

**背景:**对于阻塞式IO大家在学习网络的时候都很清楚,比如read,只有底层socket的接收缓冲区有数据了才回返回,当调用write去写进内存的时候仅仅是写到了内核的页缓冲区,而不是真正的落盘,所以需要调用fsync函数或者fdatesync,内核才会强迫把 Page Cache 里的脏数据刷到物理磁盘上。

痛点1:

everysec 模式下,假设 1 秒内 Redis 狂写,在 Page Cache 里积攒了 10MB 的脏数据。

1 秒钟到了,后台线程调用 fdatasync()

这就好比大坝平时不泄洪,积攒了 10 米高的水位,突然开闸 。磁盘硬件必须一口气把这 10MB 写完,期间 fdatasync()同步阻塞 的,后台线程只能死等。

如果磁盘此时正忙(比如别的程序在读盘),这个等待可能从正常的 5ms 瞬间飙升到 500ms 甚至几秒!这就是 Redis 著名的尾延迟毛刺(延迟突然飙升)

本质就是因为fdatasync是一个同步阻塞的函数,那最好的就是做成异步的

解决方案:

sync_file_range 是 Linux 提供的一个异步冲刷 调用。它的核心作用是:把"通知内核刷盘"和"等待刷盘完成"解耦。

当内核缓存页到达sfr_min_bytes大小的时候(这里我们设为512KB),调用 sync_file_range(fd, offset, len, SYNC_FILE_RANGE_WRITE),就会异步通知内核,有空帮我把这些数据进行刷盘,然后后台线程就会返回,而不是阻塞等待,然后后面到了时间everysec之后还会调用fdatasync进行刷盘,保证数据安全,这是上一步在设置刷盘策略的时候进行的

本质:就是把本来要积攒1s的数据一次性刷盘,变成平摊成1s内多次刷盘,防止一次刷盘过多等待阻塞时间过长

痛点2:Linux 非常贪心,你有 64GB 内存,它绝不会让内存闲着。你 write() 写了 10GB 的 AOF,Linux 会把这 10GB 全留在 Page Cache 里,美其名曰"万一你等下要读呢,我给你缓存着"。

本来内存都很紧缺,redis本身也要占用,内存缓存页也要占用,当数据已经安安全全的落盘了,内存缓存页居然还不释放,这不是占用内存空间吗???

当内存快满时,Linux 才开始手忙脚乱地从 Page Cache 里踢 AOF 缓存,把内存让给 Redis。这个踢出和重新分配的过程,会导致内存分配卡顿,再次引发延迟毛刺。

复制代码
 bool fadvise_dontneed_after_sync = true;  // 每次 fdatasync 后对已同步范围做 DONT NEED

这个字段的配置就是后续调用posix_fadvise用的,就是跟内核承诺这块空间我不用了,内核立刻把这 1MB 占用的 Page Cache 标记为无效并释放。

它防止了无用的 AOF 历史数据污染 Page Cache,确保系统空闲内存始终充足,避免了内存紧缺时的回收卡顿,也把更多的缓存资源留给了 Redis 自身的键值读取。

  1. 生产:主线程狂写,数据进入 Page Cache。

  2. 异步搬运sync_file_range + sfr_min_bytes):每攒够 512KB,悄悄通知内核在后台搬数据到磁盘,不等它搬完。

  3. 安全确认 (1 秒到的 fdatasync):由于数据已经搬得差不多了,只需极短时间确认最后一点数据落盘。

  4. 打扫战场fadvise_dontneed):落盘后立刻大喊一声"这块内存我不需要了!",内核瞬间回收 Page Cache。

  5. 主线程 调用 appendCommand / appendRaw → 将 AofItem 放入 _queue_→ 唤醒 _writer_thread

  6. 后台线程 writerLoop 从队列中取出数据,调用 write,然后根据 _opts.mode 决定是否 fsync

  7. 如果模式是 kAlways,主线程会在入队后阻塞等待 ,直到 _writer_thread 通知该命令已落盘(通过 _last_synced_seq 和 _cv_commit)。

重写机制

  • 主进程继续正常处理客户端请求,并将新命令:

    • 写入旧的 AOF 文件(按照原有的 aof_bufwritefsync 流程)。

    • 同时 ,将新命令的 RESP 格式追加到一个特殊的重写缓冲区server.aof_rewrite_buf_blocks,也称为"AOF 重写缓冲区")。

  • 子进程不写旧 AOF 文件 ,它只负责读取当前数据库内存快照,将其转换为最小化的命令集合,写入一个临时新 AOF 文件 (例如 temp-rewrite.aof)。

  • 子进程生成临时文件后,向父进程发送信号(SIGCHLD)。

  • 父进程在信号处理函数中(或事件循环中)调用 backgroundRewriteDoneHandler

  • 父进程将重写期间积累的重写缓冲区中的所有命令,追加到临时新 AOF 文件的末尾。

  • 然后调用 rename 将临时文件原子替换为正式的 AOF 文件(例如 appendonly.aof)。

  • 更新文件描述符,关闭旧文件,后续写入指向新文件。

  • 释放重写缓冲区,清理状态。

对于redis是采用fork子进程

这里基本有4种思路:

1:就是其中某个线程进行重写的时候,先加全局锁,然后把数据拷贝一份先,然后释放锁,接着把数据写到新的文件当中,问题在于海量数据就会阻塞+内存OOM

2:直接fork,但是注意函数当中不能使用锁,一旦使用锁的函数就必须要处理死锁问题

3:MVCC多版本

4:forkless,这个是分段加锁+脏key+双写

写文章-CSDN创作中心https://mp.csdn.net/mp_blog/creation/editor?spm=1011.2124.3001.6217本篇文章对这几种方式进行了详细的阐述

整个流程框架

官方做法:

官方采用单进程,如果开启了aof策略,那就是有一个aof缓冲区,每次执行命令的时候,即需要修改内存当中的数据,又需要往aof缓冲区当中写,然后每次事件循环结束之前都会执行beforeSleep()进行write,除了这种情况,还有三种特殊情况

  1. serverCron 定时器(每秒一次)

如果上次 beforeSleep() 里的 write 失败(比如磁盘满、权限不足),serverCron 会每秒重试一次 write。

  1. 服务器关闭前(prepareForShutdown

Redis 准备关闭时,会强制调用 flushAppendOnlyFile(1),把最后残留的一点 aof_buf 写入内核。

  1. AOF 重写完成后

追加重写缓冲区 aof_rewrite_buf 到新 AOF 文件时,会执行一次 write。

write是写到内核缓存页,然后刷盘是根据刷盘策略进行刷盘,如果是always那就是每次write之后都会刷盘。

如果此时有bgrewrite命令来,那就需要利用重写缓冲区,此时命令不仅仅要写入旧的aof缓冲区,还需要写入重写缓冲区,因为要保证数据的安全

程序启动初始化初期就会绑定对应的SIGCHLD 信号和信号触发之后处理的函数,

  • 信号处理函数仅做一件事 :设置 server.aof_rewrite_signal = 1,然后立即返回。
    绝对不做任何文件 I/O(因为信号处理函数只能调用异步信号安全函数)。

  • 为什么会这样,这里需要涉及可重入函数(简单来说就是中断之后还能回到之前的函数处理步骤而不出错,这里不能IO,IO一般都不是可重复函数)

然后此时fork子进程,子进程去把内存当中的数据写入临时文件,如果此时临时文件写好了,那就去信号SIGCHLD通知父进程,父进程的中断的异步处理,然后把aof_rewrite_signal设置为1

aof缓冲区当中不一定为空,原因在于每次write是在beforesleep函数里,也就是事件循环结束之后,那可能在命令写入aof缓冲区期间和write之前,可能信号来临,所以缓冲区当中的内容不一定为空,那此时需要处理信号函数,信号函数就会把aof_rewrite_signal设为1,然后每次beforeSleep函数内部或者serverCron函数内部就会处理这个信号,如果发现为1,那就是子进程快照完毕,通知父进程要处理

cpp 复制代码
// 主事件循环
void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        // 1. 处理时间事件(包括 serverCron)
        processTimeEvents(eventLoop);
        
        // 【检查点 1】在 serverCron 中检测 aof_rewrite_signal
        // 如果检测到标志,调用 backgroundRewriteDoneHandler()
        
        // 2. 处理文件事件(客户端请求、网络 IO)
        aeApiPoll(eventLoop, NULL);
        
        // 3. 进入 beforeSleep()
        beforeSleep(eventLoop);
        
        // 【检查点 2】在 beforeSleep() 中检测 aof_rewrite_signal
        // 如果检测到标志,调用 backgroundRewriteDoneHandler()
    }
}

// serverCron:每 100ms 执行一次
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    // ... 其他定时任务 ...
    
    // 【关键】检测 AOF 重写完成信号
    if (server.aof_rewrite_signal) {
        backgroundRewriteDoneHandler(); // 执行实际的文件切换
        server.aof_rewrite_signal = 0; // 重置标志
    }
    
    // ... 其他定时任务 ...
    return 100; // 下次 100ms 后执行
}

// beforeSleep:每次事件循环结束后执行
void beforeSleep(struct aeEventLoop *eventLoop) {
    // ... 其他 beforeSleep 任务 ...
    
    // 【关键】检测 AOF 重写完成信号(更及时)
    if (server.aof_rewrite_signal) {
        backgroundRewriteDoneHandler(); // 执行实际的文件切换
        server.aof_rewrite_signal = 0; // 重置标志
    }
    
    // ... 其他 beforeSleep 任务 ...
}

backgroundRewriteDoneHandler 函数就会按照这样处理:特殊情况就是刚刚说的aof缓冲区不一定为空,这部分的数据在重写缓冲区当中也是存在的,我们先把aof缓冲区进行强制刷盘(防御性编程)那就是flushAppendOnlyFile传参为1,然后将aof_fd设为-1 ,此后正常的事件循环当中flushAppendOnlyFile 会因 aof_fd == -1 而跳过write,(本质理论上也要防止任何后台线程(如fsync线程)或未来未知的流程干扰我们的核心操作, 防止关闭旧 fd 后误写已关闭的文件描述符;2. 防止在重写切换期间新命令被误写旧文件。总而言之,将 aof_fd 暂时设置为 -1 是整个 AOF 重写流程中一个精心设计的、表现力极强的 "暂停信号" 。它不仅仅是为了清空 aof_buf,更是为了在替换文件的时刻,独占文件访问权,创造一个绝对安全的操作"安全窗口",防止各类并发写入。),接着将重写缓冲区当中的数据追加到新的文件当中,然后刷盘,确保数据落盘,接着原子替换文件,关闭之前的fd,然后把新的fd赋值给aof_fd,然后清空重写缓冲区,重置所有的标识

在这里如果旧的缓存页未刷盘是没关系的,就是后续系统刷盘也是刷到旧的文件,没关系,因为文件缓冲页和inode是一一对应的

cpp 复制代码
void backgroundRewriteDoneHandler() {
    // 【步骤 1】临时保存旧 AOF 的 fd
    int old_fd = server.aof_fd;

    // 【步骤 2】强制 flush aof_buf 残留数据到旧文件
    // 此时 aof_fd 还是有效的旧 fd
    // force=1,忽略任何判断,必须 write
    flushAppendOnlyFile(1);

    // 【步骤 3】临时禁用 AOF 写入
    // 后续新命令只追加到 aof_buf,不再执行 write
    server.aof_fd = -1;

    // 【步骤 4】打开子进程生成的新 AOF 临时文件
    // 文件名通常是 "appendonly.aof.new"
    int new_tmp_fd = open(server.aof_rewrite_file_name, O_WRONLY | O_APPEND);
    if (new_tmp_fd == -1) {
        // 错误处理:恢复旧 fd,记录日志
        server.aof_fd = old_fd;
        return;
    }

    // 【步骤 5】把 aof_rewrite_buf 追加到新临时文件
    if (server.aof_rewrite_buf.len > 0) {
        write(new_tmp_fd, server.aof_rewrite_buf.buf, server.aof_rewrite_buf.len);
    }

    // 【步骤 6】【关键】对新临时文件执行 fdatasync
    // 必须等刷盘完成才继续,保证新 AOF 数据永久不丢失
    fdatasync(new_tmp_fd);

    // 【步骤 7】关闭新临时文件的 fd
    close(new_tmp_fd);

    // 【步骤 8】【原子替换】rename 新临时文件为正式 AOF 文件
    // 这是一个原子操作,要么成功,要么失败,不会出现中间状态
    rename(server.aof_rewrite_file_name, server.aof_file_name);

    // 【步骤 9】关闭旧 AOF 的 fd
    close(old_fd);

    // 【步骤 10】重新打开新 AOF 文件(现在文件名是 "appendonly.aof")
    server.aof_fd = open(server.aof_file_name, O_WRONLY | O_APPEND | O_CREAT, 0644);
    if (server.aof_fd == -1) {
        // 错误处理:记录日志
        return;
    }

    // 【步骤 11】清理和重置状态
    server.aof_rewrite_buf.clear(); // 清空重写缓冲区
    server.is_rewriting = false;     // 重置重写状态
    server.aof_rewrite_signal = 0;   // 重置信号标志
}

结合redis官方的做法,我们和redis官方的不一样之处在于,我们在write和fsync实现处不一样,我们主要利用了生产者-消费者模型进行解耦,redis官方是每次循环结束都会调用一个beforeSleep进行write,而我们是写入_queue队列当中由后台线程进行处理,我们这里针对aof重写,实现的是forkLess方案,这里我们把我们的kvstore改成多少个数据类型就多少个分片,一般来说一个数据类型里面还可以有多个分片,一般一个分片就1000key左右,但是为了先验证开发结果,我们先采用每个数据类型一个分片

cpp 复制代码
1. 开始重写:后台线程标记所有分片的 in_snapshot=true,初始化脏 keyset
2. 分段拷贝:
   a. 遍历每个分片
   b. 加该分片的锁,拷贝该分片的当前数据,转换成最终值格式的命令写入新 AOF
   c. 立刻释放该分片的锁
   d. 其他分片的写操作完全不受影响,写的时候顺便记录脏 key
3. 原子补拷:
   a. 加极短的全局锁
   b. 遍历脏 keyset,把每个 key 的最新值转换成最终值格式的命令写入新 AOF
   c. 释放全局锁
4. 完成:
   a. 刷盘新 AOF 文件
   b. 原子 rename 替换旧 AOF 文件
   c. 重置所有分片的 in_snapshot=false,清空脏 keyset

处理脏key的同时需要处理队列,需要一个新旧队列,因为处理脏key的时候加的是全局锁,所有的写入线程暂时都写不了,此时我们需要处理队列,因为处理完脏key随后的所有命令都是新命令了 重写线程主函数,实现正确流程(分段拷贝- ->暂停 writerLoop → 加锁所有分片 → 交换队列 → 收集脏key → 设 _rewriting=false → 更新快照 → 释放锁 → 写临时文件 → 原子替换 → 恢复 writerLoop) 关于脏key的写入,我们放在命令分配器中提取脏key,然后再aof中提供函数写入set,因为对于不同的命令提取key是不一样的,如果放入aof中的appendCommand函数的话,aof这边还需要针对不同的命令进行提取key,这反而增加了耦合度,我们放在命令分配器中,命令分配器即需要入队列还需要入脏key,这需要命令分配器判断是否在重写,然后把key传给aof的记录脏key集合(简单来说命令分配器就是把key分离出来)

cpp 复制代码
客户端发来命令
      ↓
CommandDispatcher::dispatch()
      ↓
      ├── 解析命令类型(读 or 写)
      ├── 读命令(GET/HGET/ZSCORE...)→ 直接执行,返回结果,结束
      └── 写命令(SET/DEL/HSET/ZADD...)
              ↓
         执行命令(先改数据,保证数据先落地)
              ↓
         AOF 是否启用?
              ├── 否 → 返回结果,结束
              └── 是
                    ↓
               appendRaw(raw)  入队列
                    ↓
               是否正在重写?
                    ├── 否 → 返回结果,结束
                    └── 是 → recordDirtyKey(key)
                                  ↓
                             返回结果,结束

先执行命令再入队列,因为你要确保命令能够执行成功而不是失败了还入队列,在成功的基础上再入队列是安全且正确的做法

cpp 复制代码
dispatch()          → 统一处理 AOF 入队 + dirty_key 记录
handleXxx()         → 只负责执行命令,返回结果
AofLogger           → 提供 appendRaw / recordDirtyKey / isRewriting 接口
KeyValueStore       → 只管数据,不知道 AOF 的存在

函数说明

create_directorise():

cpp 复制代码
  bool create_directories(const path& __p);                  //抛异常
  bool create_directories(const path& __p, error_code& __ec);//不抛异常
  • 递归创建多级目录 比如你要创建 ./redis_data/aof/,如果 redis_data 文件夹都不存在,它会自动逐级创建所有父目录
  • 跨平台 Windows(\)、Linux(/)、macOS 通用,不用自己处理路径分隔符。
  • 幂等操作 目录已经存在时,不会报错,直接返回成功。

posix_fallocate():

  • 保证分配的空间已分配并置零(或标记为未写,但空间已保留)。

  • 在 Linux 上,普通 write 写入文件时,如果文件大小不足,内核需要分配新块、更新元数据(如 inode),可能产生额外开销和碎片。提前分配好连续空间,可以:

    • 减少文件扩展导致的延迟(每次扩展可能触发元数据更新)。

    • 降低磁盘碎片。

    • 在一些文件系统(如 XFS, ext4)上,能提升顺序写入性能。

fdatasync():

cpp 复制代码
int fdatasync(int fd);

fsync: 数据 + 文件元数据(大小、修改时间、权限)较慢需要完整文件一致性

fdatasync: 仅数据内容(AOF 日志正文)更快日志文件、数据库 WAL、AOF用

返回值:

0,刷盘成功

1,刷盘失败(磁盘故障、权限不足、文件已关闭),错误码存在 errno

关于原子操作std::atomic和线程库和条件变量去看我的篇章

C++11新特性全面解析(二):线程库+异常体系-CSDN博客

关于atomic,就两个基础函数

store:进行原子赋值,你直接用=也行,它内部已经重载了

load:进行原子取值

wait_for函数

cpp 复制代码
template<class Rep, class Period>
std::cv_status wait_for(std::unique_lock<std::mutex>& lock,
                        const std::chrono::duration<Rep, Period>& rel_time);

//带谓词版本
template<class Rep, class Period, class Predicate>
bool wait_for(std::unique_lock<std::mutex>& lock,
              const std::chrono::duration<Rep, Period>& rel_time,
              Predicate pred);
  • 原子地释放 lock 所持有的互斥锁,并阻塞当前线程。

  • 阻塞时长最多为 rel_time(例如 std::chrono::seconds(1)std::chrono::microseconds(1000))。

  • 在以下任一条件满足时返回:

    1. 被其他线程通过 notify_one()notify_all() 唤醒。

    2. 等待时间超过 rel_time

    3. 发生虚假唤醒(极少见)。

  • 返回前会重新获取互斥锁。

返回值

  • 不带谓词的版本:返回 std::cv_status::no_timeout 如果被唤醒;std::cv_status::timeout 如果超时。

  • 带谓词的版本:返回 pred() 的值(即条件是否满足),通常用来循环检查条件

关于锁和条件变量的底层实现,我将会出一期详细的讲解教程说明底层是如何去实现的,这里先粗略的理解:本质就是一个类似while循环,先加锁,加完锁之后判断谓词,如果谓词成功不进入wait语句等待,直接持有锁执行后面的,如果谓词不成功,即释放锁进入wait语句等待,如果有notity,那将先获取锁,然后判断谓词,如果成功则持有锁执行后面,如果不成功则接着释放锁进入wait语句,一直这样循环执行

那么底层锁:就是有一个变量,int之类的去维护,还有一个队列,队列维护有谁在等待锁,释放锁之后会从队列当中取某个线程来持有锁

  • notify_all() 会唤醒所有等待该条件变量的线程。

  • 但这些线程在 wait() 返回前必须重新获取关联的互斥锁。因此它们会一起竞争该互斥锁,只有一个能获得锁继续执行,其余线程会再次阻塞在互斥锁上(而不是条件变量上)。这可能导致短暂的惊群,但通常可接受。

如果没有条件变量,那将不会有惊群效应,系统只会取队头,或者取一个优先级最高的

writev函数

cpp 复制代码
struct iovec
  {
    void *iov_base;	/* Pointer to data.  */
    size_t iov_len;	/* Length of data.  */
  };
extern ssize_t writev (int __fd, const struct iovec *__iovec, int __count);

如果逐个调用 write,就需要多次系统调用。writev 允许你传递一个 iovec 数组,内核会依次写入每个 iov_base 指向的 iov_len 字节,就好像它们是一块连续的数据一样。

writev 就是:多块内存、一次性、连续写入、不拷贝、高性能

lseek函数

cpp 复制代码
off_t lseek(int fd, off_t offset, int whence);

将文件读写指针移动到文件末尾 ,并返回从文件开头到末尾的字节数 ,即当前文件的大小

注意:是获取内核页缓存中维护的文件逻辑大小

不是用户态缓冲区大小

不是直接读取物理磁盘的文件大小

是内核记录的、当前文件的最新总长度

sync_file_range

cpp 复制代码
int sync_file_range(int fd, off64_t offset, off64_t nbytes, unsigned int flags);

sync_file_range 是一个 Linux 特有的系统调用,用于对文件的一段指定范围发起异步写回操作

everysec 模式下,后台线程每秒调用一次 fdatasync,将内核页缓存中的脏数据强制落盘。如果过去一秒内写入的数据量很大(例如几十 MB),那么这些脏页会在 fdatasync 时刻集中写盘,造成一次磁盘 I/O 突发,导致 fdatasync 阻塞时间变长(可能几十到上百毫秒),这就是 尾部写放大

  • 这告诉内核:请开始将这段数据从页缓存写回磁盘,但调用立即返回,不等待完成。

  • 内核收到请求后,会异步地将指定范围的脏页写回磁盘。

  • 等到下一秒的 fdatasync 执行时,大部分数据可能已经被后台写回,只需等待剩余少量数据,因此阻塞时间大大缩短。

效果:将集中写盘的压力平摊到时间轴上,消除延迟尖峰,使尾延迟更平滑。

posix_fadvise函数

cpp 复制代码
(void)::posix_fadvise(_fd, 0, cur3, POSIX_FADV_DONTNEED);

posix_fadvise 是一个 Linux/Unix 系统调用,用于向内核提供关于文件访问模式的建议,以便内核优化缓存管理。

  • 这告诉内核:这些数据已经不再需要保留在页缓存中,可以释放。

  • 内核会尝试释放指定范围内的干净页(已经与磁盘同步的页),回收内存。

  • 如果某些页仍是脏页(未落盘),内核不会释放它们,因此安全。

效果:减少 Redis 的内存占用,避免 AOF 缓存挤占热数据内存。

相关推荐
Peter-OK10 小时前
Redis从3.x到8.4的核心新特性深度解析与实战学习指南
数据库·redis·缓存
文青小兵10 小时前
云计算Linux——数据库MySQL读写分离、数据库备份、恢复(十八)
linux·运维·服务器·数据库·mysql·云计算
@我漫长的孤独流浪10 小时前
SQL触发器实战:银行系统数据完整性控制
数据库·oracle
半夜修仙11 小时前
Redis中Set数据类型的常见命令
java·数据库·redis·笔记·学习
oradh11 小时前
Oracle逻辑存储结构概述
数据库·oracle·逻辑存储结构·oracle逻辑存储结构概述
廿一夏11 小时前
MySql视图触发器函数存储过程
数据库·sql·oracle
hikktn11 小时前
Oracle 行锁 ORA-00054 高效重试机制实战:MERGE 批量更新 + FOR UPDATE NOWAIT 完整方案
数据库·oracle
￰meteor11 小时前
【数据库导学】
数据库
zxrhhm11 小时前
Oracle检查点Checkpoint深度解析
数据库·oracle