20. 消息持久化:文件存储、内存映射文件、日志结构存储、快照机制

消息队列跑在内存里,速度是快。但一断电,数据全丢。这谁受得了?

我早年做的一个嵌入式网关项目,就吃过这个亏。设备运行了三天三夜,突然掉电。重启后,所有未处理的消息全部消失。客户直接炸了。从那以后,消息持久化就成了我设计消息队列时的铁律。

说白了,持久化就是把内存里的消息,找个地方稳稳地存起来。存到哪?怎么存?存了怎么恢复?这就是今天要聊的核心。

20.1 文件存储:最朴素的方式

最简单的思路,就是把每条消息写成一行,追加到一个文件里。

// 伪代码:追加写消息到文件
FILE *fp = fopen("msg_queue.dat", "a");
if (fp) {
    fprintf(fp, "%ld|%s\n", msg->id, msg->payload);
    fclose(fp);
}

这种方式,我称之为「日志式追加写」。优点很明显:写操作极快,因为每次都是顺序写,不需要移动磁头。缺点呢?读的时候得从头扫到尾,效率感人。

核心要点:文件存储适合写多读少的场景。比如传感器数据采集,只管往文件里怼,查询是偶尔的事。

我在项目中遇到过一个问题:频繁的 fopen/fclose 会导致文件描述符泄漏。后来我改用 fwrite 配合缓冲区,定时 fflush,性能提升了不少。

注意:不要每条消息都 fflush。那会严重拖慢写入速度。建议攒够 4KB 或 8KB 再刷一次盘。

20.2 内存映射文件:速度与持久化的平衡

文件存储有个硬伤:每次读写都要经过系统调用。你想想看,fwrite 进去,内核把数据从用户态拷贝到内核态,再刷到磁盘。来回折腾,不慢才怪。

内存映射文件(mmap)就不一样了。它直接把磁盘文件映射到进程的虚拟地址空间。你操作内存,就等于操作文件。

#include <sys/mman.h>
#include <fcntl.h>

int fd = open("msg_queue.dat", O_RDWR | O_CREAT, 0644);
ftruncate(fd, FILE_SIZE);  // 预分配空间

void *map = mmap(NULL, FILE_SIZE, PROT_READ | PROT_WRITE,
                 MAP_SHARED, fd, 0);
// 现在 map 就是一块内存,直接读写即可
memcpy(map + offset, msg, msg_len);
msync(map, FILE_SIZE, MS_SYNC);  // 同步到磁盘

我个人习惯用 mmap 做消息队列的持久化。为什么?省去了用户态和内核态之间的数据拷贝。你直接写内存,内核在后台默默帮你刷盘。读写速度接近纯内存操作。

但要注意一点:mmap 的页大小通常是 4KB。如果你消息很小,比如几十个字节,会有空间浪费。我曾经踩过一个坑:大量小消息导致内存碎片,最后 mmap 区域不够用。解决方案是预分配大块空间,或者用 slab 分配器管理。

小技巧:嵌入式设备上,mmapMAP_SHARED 模式可以配合 MAP_POPULATE 标志,提前加载页面,避免访问时缺页中断。

20.3 日志结构存储:顺序写的极致

你有没有想过,为什么数据库的 WAL(Write-Ahead Log)那么快?答案就是:顺序写

日志结构存储(Log-Structured Storage)的核心思想很简单:所有写操作都追加到日志末尾。读操作呢?从日志里按顺序扫描,或者配合索引加速。

// 日志结构存储的写入格式
struct log_entry {
    uint32_t crc;       // 校验和
    uint32_t msg_id;    // 消息ID
    uint32_t payload_len;
    char     payload[0]; // 变长数据
};

// 追加写入
void log_append(int fd, struct log_entry *entry) {
    write(fd, entry, sizeof(*entry) + entry->payload_len);
    fsync(fd);  // 确保落盘
}

我在做一个工业控制项目时,消息量巨大,每秒几千条。用文件存储根本扛不住。后来改用日志结构存储,写入速度直接翻了三倍。为什么?因为磁盘的顺序写速度,比随机写快一个数量级。

但日志结构存储有个问题:日志会无限增长。你想想看,一直追加,磁盘迟早会满。怎么办?

解决方案:引入日志轮转(Log Rotation)。设置一个阈值,比如 100MB。日志满了就新建一个文件,旧的归档或删除。

20.4 快照机制:从日志中恢复状态

日志结构存储虽然快,但恢复起来很慢。假设你有 10GB 的日志,要恢复出当前的消息队列状态,得从头到尾扫一遍。这谁等得起?

快照(Snapshot)就是来解决这个问题的。它定期把内存中的完整状态,保存成一个文件。恢复时,直接加载最新的快照,再回放快照之后的少量日志即可。

// 快照数据结构
struct snapshot_header {
    uint32_t magic;         // 魔数,用于校验
    uint32_t version;       // 版本号
    uint64_t timestamp;     // 快照时间
    uint32_t msg_count;     // 消息总数
    uint32_t queue_count;   // 队列数量
};

// 生成快照
void take_snapshot(const char *path) {
    int fd = open(path, O_CREAT | O_WRONLY, 0644);
    struct snapshot_header hdr = { .magic = 0xDEADBEEF, ... };
    write(fd, &hdr, sizeof(hdr));
    // 遍历所有队列,写入消息
    for (每个队列) {
        for (每条消息) {
            write(fd, msg, msg->len);
        }
    }
    close(fd);
}

我建议快照的生成频率不要太高。比如每 5 分钟一次,或者每处理 10000 条消息一次。太频繁了,磁盘受不了。太稀疏了,恢复时回放日志太多。

避坑指南:我曾经在生成快照时,没有暂停消息处理。结果快照写到一半,消息队列变了,导致快照不一致。后来我加了读写锁,生成快照时禁止写入,才解决。

20.5 四种持久化方式对比

方式 写入速度 读取速度 恢复速度 适用场景
文件存储 中等 写多读少,数据量小
内存映射文件 高性能,中等数据量
日志结构存储 极快 慢(需配合快照) 高写入吞吐,数据量大
快照机制 中等 极快 需要快速恢复的场景

20.6 实战:组合使用

实际项目中,我很少只用一种方式。通常是日志结构存储 + 快照机制组合使用。

举个例子:

  1. 消息先写入日志文件(顺序写,快)
  2. 每 5 分钟生成一次快照(保存完整状态)
  3. 恢复时,加载最新快照,再回放快照之后的日志

这样做的好处是:写入性能接近磁盘极限,恢复时间可控。我做过测试,100 万条消息的恢复时间,从纯日志的 30 秒,降到了快照 + 日志的 2 秒。

个人经验:快照文件建议用 mmap 加载。恢复时直接映射到内存,省去一次文件读取。配合 MAP_POPULATE 标志,还能提前加载到物理内存。

20.7 知识体系图

下面这张图,帮你理清消息持久化的核心逻辑:

消息持久化核心逻辑 消息队列(内存) 文件存储 内存映射文件 日志结构存储 快照机制 恢复:加载快照 → 回放增量日志 四种持久化方式各有优劣,实际项目中常组合使用

嗯,持久化这块内容不少。但核心就一句话:选对方式,组合使用。文件存储简单但慢,mmap 快但要注意内存管理,日志结构存储适合高写入,快照机制解决恢复速度问题。你根据实际场景,灵活搭配就好。


公众号:蓝海资料掘金营,微信deep3321