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 分配器管理。
小技巧:嵌入式设备上,mmap 的 MAP_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 实战:组合使用
实际项目中,我很少只用一种方式。通常是日志结构存储 + 快照机制组合使用。
举个例子:
- 消息先写入日志文件(顺序写,快)
- 每 5 分钟生成一次快照(保存完整状态)
- 恢复时,加载最新快照,再回放快照之后的日志
这样做的好处是:写入性能接近磁盘极限,恢复时间可控。我做过测试,100 万条消息的恢复时间,从纯日志的 30 秒,降到了快照 + 日志的 2 秒。
个人经验:快照文件建议用 mmap 加载。恢复时直接映射到内存,省去一次文件读取。配合 MAP_POPULATE 标志,还能提前加载到物理内存。
20.7 知识体系图
下面这张图,帮你理清消息持久化的核心逻辑:
嗯,持久化这块内容不少。但核心就一句话:选对方式,组合使用。文件存储简单但慢,mmap 快但要注意内存管理,日志结构存储适合高写入,快照机制解决恢复速度问题。你根据实际场景,灵活搭配就好。
公众号:蓝海资料掘金营,微信deep3321