日志写入流程:日志产生、缓冲、批量写入、异步写入等机制
日志写入这件事,看着简单,实际坑不少。很多系统上线后QPS突然掉下来,一查发现是日志写入扛不住了。我个人习惯把日志写入拆成四个阶段来设计:产生 → 缓冲 → 批量 → 异步。今天咱们就挨个聊透。
1. 日志产生——源头决定了后面的一切
日志不是凭空冒出来的。每个审计事件触发时,业务代码调用日志API生成一条记录。我见过不少团队在这里就出了岔子——日志格式不一致、字段缺失、时间戳精度不够。
一个规范的日志产生至少包含这些要素:
| 字段 | 类型 | 说明 |
|---|---|---|
| event_id | UUID | 全局唯一,方便去重和追踪 |
| timestamp | 精确到毫秒或微秒 | 建议用系统时钟而非业务时间 |
| actor | string | 操作者标识 |
| action | string | 操作类型,如CREATE/UPDATE/DELETE |
| resource | string | 被操作对象的ID或路径 |
| detail | JSON | 结构化上下文信息 |
有一点特别重要:日志产生阶段不要做任何IO操作。 只构建内存对象,直接扔到缓冲区里。我曾经有个项目,日志产生时顺便写了个本地文件,结果并发一上来,日志线程把业务线程都拖死了。
2. 缓冲——给写入留个喘气的空间
为什么需要缓冲?说白了就是削峰填谷。业务高峰期QPS飙到1万,但磁盘写入能力可能只有2000条/秒。没有缓冲区,日志直接丢失或阻塞业务。
常见的缓冲策略有:
- 内存队列:环形缓冲区,写指针追读指针,超过阈值触发丢弃策略
- 阻塞队列:生产者-消费者模式,队列满了就阻塞生产者
- 双缓冲:两个缓冲区交替使用,一个在写入,一个在收集
我参与过一个金融审计系统,用的是双缓冲 + 内存池。大概的逻辑是这样的:
// 伪代码 - 双缓冲示意
Buffer current = pool.get(); // 当前收集缓冲区
Buffer pending = pool.get(); // 待写入缓冲区
// 日志产生线程
void writeLog(LogEntry entry) {
current.push(entry);
if (current.isFull()) {
swap(current, pending); // 原子交换
current = pool.get(); // 拿一个空buffer继续收
asyncFlush(pending); // 异步刷盘
}
}
这里有个关键点:swap操作用CAS或者锁,保证并发安全。不然两个线程同时触发交换,数据就乱了。
最大QPS × 峰值持续时长 × 日志单条大小 估算,再乘以1.5倍的安全系数。
3. 批量写入——把多条合并,减少IO次数
现在缓冲区里攒了一堆日志条目,怎么写到磁盘或数据库?一条一条写?那IO次数太多,QPS直接崩掉。批量写入的核心思路:攒够一批,一次性发出去。
批量写入一般有两种触发条件:
- 按条数触发:比如攒够500条,立刻刷一次
- 按时间触发:比如每隔200毫秒,强制刷一次
两种结合效果最好。你想想看,如果只有按条数触发,低峰期日志永远写不出去;只有按时间触发,高峰期可能一次写入几千条导致延迟抖动。我建议这样:
| 场景 | 批量上限 | 时间窗口 |
|---|---|---|
| 低QPS(<500/s) | 200条 | 500ms |
| 中QPS(500~5000/s) | 500条 | 200ms |
| 高QPS(>5000/s) | 1000条 | 100ms |
数据写入时,最好用预编译SQL或者批量插入接口。拿MySQL举例:
INSERT INTO audit_log (event_id, ts, actor, action, resource, detail)
VALUES (?, ?, ?, ?, ?, ?),
(?, ?, ?, ?, ?, ?),
... // 一次插入多条
一条SQL带1000行,比1000条SQL快两个数量级。我实际测过,单条插入大概2ms/条,批量插入1000条只要10~20ms,平均每条0.01~0.02ms。
4. 异步写入——别让日志拖慢主流程
好,现在日志攒够了批量,该往存储写了。如果业务线程自己等IO完成,那就变成同步写了——业务线程必须卡在那里。异步写入的意思:业务线程把日志扔进缓冲区就返回,由后台线程或线程池负责实际的IO操作。
同步 vs 异步的对比:
| 对比项 | 同步写入 | 异步写入 |
|---|---|---|
| 业务线程等待时间 | 等IO完成,约1~5ms | 仅入队,约0.001ms |
| 最大QPS(理论) | 受限于IO延迟 | 受限于CPU和缓冲区容量 |
| 数据丢失风险 | 低(每次写入确认) | 高(缓冲区未刷盘时崩溃) |
| 推荐场景 | 金融核心、强一致性 | 审计日志、监控、流量大 |
异步写入的经典实现是生产者-消费者队列。我通常用Java里的 LinkedBlockingQueue 或 Disruptor。Disruptor 吞吐量更高,但调试起来麻烦一点。
这里有个容易被忽略的点:异步写入的线程数怎么设? 设少了,队列积压;设多了,上下文切换频繁。我个人的经验公式:
写入线程数 = 磁盘IOPS / 每次批量写入耗时(秒)
// 例如磁盘IOPS=3000,每次批量耗时20ms(即0.02s)
// 写入线程数 = 3000 / 50 = 60? 不对,实际取CPU核数的一半左右
// 更保守的做法:设为核心数,然后压测调优
- 使用WAL预写日志,内存缓冲的同时写本地文件,崩溃后可以重放
- 或者接受一定程度的丢失(比如审计日志允许丢0.1%,但不允许丢关键操作)
- 我曾在某项目中用双缓冲 + 本地文件预写,把丢失率压到了百万分之一以下
整体写入流程——一张图说清楚
下面这张图把整个过程串起来了。记住,每个环节都有各自的优化点,但整体QPS取决于最慢的那个环节。
实际项目中的权衡
你可能要问:这么多环节,到底哪个最重要?我的经验是,缓冲区设计和批量策略是QPS的核心。日志产生阶段可以压到极低的损耗,异步写入只要线程数合理也不容易成为瓶颈。但是缓冲区如果设计不好,要么内存炸了,要么写入线程空转。
举个例子吧。我之前做一个电商平台的审计模块,日活2000万,高峰期QPS 8000。最开始我们用的是单缓冲区 + 定时刷盘,结果一到整点大促,日志延迟飙到5分钟,用户都翻页了日志还没写入。后来改成双缓冲 + 按条数/时间双触发 + 4个写入线程,延迟稳定在200ms以内。
所以呢,每个环节你都要考虑它的容量、延迟、安全性。日志写入不像业务查询那么灵活,它是一条单行道,设计时多花点心思,线上会省很多麻烦。