日志写入流程:日志产生、缓冲、批量写入、异步写入等机制

日志写入这件事,看着简单,实际坑不少。很多系统上线后QPS突然掉下来,一查发现是日志写入扛不住了。我个人习惯把日志写入拆成四个阶段来设计:产生 → 缓冲 → 批量 → 异步。今天咱们就挨个聊透。

1. 日志产生——源头决定了后面的一切

日志不是凭空冒出来的。每个审计事件触发时,业务代码调用日志API生成一条记录。我见过不少团队在这里就出了岔子——日志格式不一致、字段缺失、时间戳精度不够。

一个规范的日志产生至少包含这些要素:

字段类型说明
event_idUUID全局唯一,方便去重和追踪
timestamp精确到毫秒或微秒建议用系统时钟而非业务时间
actorstring操作者标识
actionstring操作类型,如CREATE/UPDATE/DELETE
resourcestring被操作对象的ID或路径
detailJSON结构化上下文信息

有一点特别重要:日志产生阶段不要做任何IO操作。 只构建内存对象,直接扔到缓冲区里。我曾经有个项目,日志产生时顺便写了个本地文件,结果并发一上来,日志线程把业务线程都拖死了。

我的习惯: 把日志对象的构建放到业务线程之外,用一个专门的日志工厂类来处理。业务方只需要调用 log(actor, action, resource, detail) 即可。

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或者锁,保证并发安全。不然两个线程同时触发交换,数据就乱了。

踩坑提醒: 缓冲的容量不是越大越好。我见过一个团队设置了1GB的缓冲队列,结果内存溢出导致整个服务挂掉。建议按 最大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。

批量写入的瓶颈在哪? 网络往返和磁盘刷盘。如果要追求极致QPS,可以考虑先把日志写到WAL(Write-Ahead Log)里,然后再异步合并到主存储。这个后面章节会说到——不过今天先不展开。

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取决于最慢的那个环节。

审计日志写入流程 日志产生 内存缓冲 批量组装 异步写入 存储系统 说明 1. 产生阶段不碰IO 2. 缓冲采用双缓冲或环形队列 3. 批量按条数/时间双触发 关键指标 • 缓冲大小:最大QPS×峰值时长×1.5 • 批量大小:500~1000条为宜 • 写入线程数:建议CPU核数 • 数据丢失率:通过WAL降低

实际项目中的权衡

你可能要问:这么多环节,到底哪个最重要?我的经验是,缓冲区设计和批量策略是QPS的核心。日志产生阶段可以压到极低的损耗,异步写入只要线程数合理也不容易成为瓶颈。但是缓冲区如果设计不好,要么内存炸了,要么写入线程空转。

举个例子吧。我之前做一个电商平台的审计模块,日活2000万,高峰期QPS 8000。最开始我们用的是单缓冲区 + 定时刷盘,结果一到整点大促,日志延迟飙到5分钟,用户都翻页了日志还没写入。后来改成双缓冲 + 按条数/时间双触发 + 4个写入线程,延迟稳定在200ms以内。

所以呢,每个环节你都要考虑它的容量、延迟、安全性。日志写入不像业务查询那么灵活,它是一条单行道,设计时多花点心思,线上会省很多麻烦。

小技巧: 在压测阶段,我习惯在代码里暴露几个监控指标:缓冲区使用率、平均批量大小、单次写入耗时、队列积压深度。这样线上出问题时,一眼就能看出瓶颈在哪。