【vemory】高性能KV存储AOF方案设计

一、背景

vemory 是我自研的一个高性能 KV 存储项目,本系列博客谈及的源码主要是是纯 C 实现的极致优化版,然而,本项目的 C++ 版全部开源(结合 cursor AI 进行的迁移版),感兴趣的小伙伴可以查看Github - Vemory

性能参考
实现 AOF SET 吞吐 (req/s) SET 平均延迟 (ms)
Vemory 关闭 2144.13 0.719
Vemory 开启 2057.23 0.740
Redis 关闭 2076.33 0.736
Redis 开启 1990.96 0.757

内存数据库的持久化

KV 存储是一种内存数据库,正如 Redis 这类 NoSQL 数据库一样,其高性能得益于内存的访问速度,那么,如何在最小的性能损失下将数据引擎中的数据从内存中保存到磁盘上,就是这类数据库必须直面的一个问题。

博客内容

本博客包含了 vemory 项目增量持久化(Append Only File,AOF)部分的设计思路、技术权衡、难点剖析,以及部分的 C 源码展示。

二、增量持久化

什么是增量持久化 AOF

增量持久化 AOF------所谓增量,即用户输入的变更数据 的指令(如SET,MOD,DEL等),增量持久化即对变更数据的指令的持久化,所谓变更数据,那也就意味着不包含 PING、SAVE 这类指令,最重要的是------AOF 不包含 GET,这种持久化是日志式的,就像流水账一样,记录的是变更。

2.1. AOF 功能入口与设计

AOF 功能入口本身并不复杂,将序列化格式的数据反序列化(vemory 采用了 RESP 格式)之后,拿到具体命令的字符串 tokens(cmd,key,value),以 cmd 字符串进行 switch 分发,在 SET/DEL/MOD 这类变更指令的分支,挂 AOF 的入口,参数就是 tokens。

设计思路

入口函数kvs_aof_append():总体的设计是将 tokens 序列化后写入 .aof 文件并进行刷盘,但是如果在主线程中写 .aof 文件,又刷盘,那么这种阻塞式的设计将给 vemory 的吞吐量带来巨大灾难。

所以我们采用了后台刷盘线程的设计,避免阻塞主线程。

技术权衡

我是这样分析的:首先,我们的网络层采用了单主线程的设计,即进程中会变更数据的线程只有一个,刷盘线程也只有一个,这显然是一个 SPSC 模型的问题,所以可以大胆地采用无锁设计 ,某些应避免竞态的地方使用原子操作来同步,这么一来,可以避免一切锁导竞争致的开销。

RingBuffer 设计

主线程找到 AOF 入口函数kvs_aof_append(),然后函数内部只考虑将序列化后的数据调buffer_add()抛给无锁队列 RingBuffer,抛出后立刻走。

c 复制代码
void kvs_aof_append(const char *cmd, const char *key, const char *value) {
	// 对于正在加载 AOF 的情况,不要继续
    if (g_is_loading || !g_aof_buffer) return;

    char msg[KVS_MAX_MSG_LEN]; //序列化后的数据存放于此
    
    // ...
    // 省略中间的序列化编码
    // ...

    // ringbuf 满时背压等待,不能静默丢失 AOF 命令。
    while (!atomic_load_explicit(&g_aof_worker_failed, memory_order_acquire) &&
           !atomic_load_explicit(&g_aof_stopping, memory_order_acquire) &&
           buffer_add(g_aof_buffer, msg, (uint32_t)len) != 0) {
        sched_yield(); //背压时出让CPU
    }
}

在 RingBuffer 的另一端,刷盘线程不断从队列中取出数据,并按照配置好的 always、everysec 两种策略执行刷盘。

说明:

  • fflush(fp)后,数据此时可能仍在内核的 page cache 中,并不代表已经落盘。
  • fsync() 才是请求内核将对应文件的修改同步到持久化存储;但设备、文件系统和硬件缓存的具体持久化保证仍是更底层的话题。
c 复制代码
void *kvs_aof_flush_loop(void *arg) {
    buffer_t *buffer = arg;
    FILE *fp = fopen(AOF_FILE, "ab");
    char data[AOF_BUFFER_SIZE];
    struct timespec last_sync = {0, 0};

    // 刷盘线程持续消费 ringbuf,进程退出前会先排空队列。
    if (!fp) {
        atomic_store_explicit(&g_aof_worker_failed, true, memory_order_release);
        // ... 打印错误日志 ...
        return NULL;
    }

    while (1) { // loop
        uint32_t available = buffer_len(buffer);
        if (available > 0) {
            int len = buffer_remove(buffer, data,
                                    available > sizeof(data) ? sizeof(data) : available);
            if (len > 0) {
                if (fwrite(data, 1, (size_t)len, fp) != (size_t)len) {
                    //... 打印错误日志 ...
                }
                fflush(fp);

                // always 每批同步;everysec 按单调时钟最多每秒同步一次。
                bool should_sync = g_persist_config.aof.fsync_policy ==
                                   KVS_AOF_FSYNC_ALWAYS;
                // ...
                // 省略一些通过时钟进行频率控制的逻辑
                // .. 
                if (should_sync && fsync(fileno(fp)) != 0) {
                    // ... 打印错误日志 ...
                }
            }
        } else if (!atomic_load_explicit(&g_aof_stopping, memory_order_acquire)) {
            sched_yield();
        }

        if (atomic_load_explicit(&g_aof_stopping, memory_order_acquire) &&
            buffer_len(buffer) == 0) break;
    }

    fflush(fp);
    fsync(fileno(fp));
    fclose(fp);
    return NULL;
}

2.2. AOF 加载

当然了,AOF 有落盘就有加载,加载这部分主要的思路就是打开 .aof 文件到内存,然后读取日志,由于 .aof 里存放的本来就是可被解析的(序列化格式的)更改命令,重放过程可以直接复用协议解析(反序列化)的代码。

三、难点剖析:优雅关闭

按下 Ctrl-C,关闭服务器,进程发送 SIGINT 信号,如果没有优雅退出设计,进程里的主线程、刷盘线程会被同时关闭 ,进程直接退出:如果这时候恰好在进行 AOF ,将导致 RingBuffer 中残留数据丢失 的问题,这就是为什么我们要做排空机制 :那么在主线程结束数据推送后,刷盘线程不能同时被杀死,需要等待 RingBuffer 内的数据排空后,再释放 RingBuffer、关闭刷盘线程,理解主线程关闭和刷盘线程关闭之间的时间差是理解优雅退出的关键。

信号处理

按下 Ctrl-C 主动关闭,发送 SIGINT,此时主线程 Reactor 在关闭之前阻塞在 epoll_wait 当中,关闭时无法执行 aof 的关闭函数(shutdown),也无法 return 解决的方案是:捕获sigint/sigterm的时候,通过信号处理器将 shutdown 置位,然后利用一个定时器来定时检测 shutdown 标志位g_aof_shutdown_requested,通过定时器回调来正确执行 shutdown。

c 复制代码
// 唯一一个响应sigint信号的函数:设置shutdown标志
static void kvs_aof_signal_handler(int signal_number) {
    (void)signal_number;
    g_aof_shutdown_requested = 1; //将shutsdown标志位置位
}

// 安装信号处理器
static void kvs_aof_install_signal_handlers(void) {
// ...
// 省略具体实现
// ...
}

定时器回调优雅关闭

我们通过信号处理器来进行 shutdown 标志位的置位,用定时器轮询的方式检测 标志位,可以复用我们控制 AOF 刷盘策略的同一个定时器实例。

定时器回调 shutdown,优雅关闭:

主线程 Reactor 运行 epoll_wait 循环,它会阻塞直到有网络事件。如果线程完成了排空,但主线程还在 epoll_wait 中,main() 函数根本走不到 return,所以,排空完成时,必须 _exit/exit 退出 vemory 进程

c 复制代码
static void kvs_aof_shutdown_from_timer(void) {
    atomic_store_explicit(&g_aof_stopping, true, memory_order_release); //AOF状态更新
    kvs_aof_wait_for_appenders(); //等待排空
    pthread_join(g_aof_thread, NULL); //回收刷盘线程
    atomic_store_explicit(&g_aof_thread_started, false, memory_order_release); //AOF状态更新
    buffer_free(g_aof_buffer); //释放RingBuffer
    g_aof_buffer = NULL;
    _exit(0); //退出vemory进程
}
相关推荐
三言老师2 小时前
Rocky Linux 8.6 整机系统备份与迁移方案文档文档用途
linux·运维·服务器·网络
雾时之林2 小时前
Linux----防火墙
linux·运维·网络
LuminousCPP2 小时前
数据结构基础篇(二):顺序表与链表全方位对比|从内存布局到 CPU 缓存理解底层差异
c语言·数据结构·经验分享·链表·缓存
王老师青少年编程3 小时前
2021年CSP-J初赛真题及答案解析(完善程序2)
c++·真题·csp-j·答案·csp·初赛·信奥赛
有点。3 小时前
C++二叉树(一)
开发语言·数据结构·c++
Lazionr3 小时前
stack与queue:底层实现与容器适配器
开发语言·数据结构·c++
我星期八休息4 小时前
网络编程—NAT、代理服务与内网穿透
linux·服务器·网络·网络协议
豆沙沙包?4 小时前
C++~~~vector容器(p31-P40)
开发语言·c++
小小de风呀4 小时前
de风——【从零开始学习C++】(十八):AVL树——让二叉搜索树自己“站起来“的自平衡黑科技
c++·科技·学习