一、高性能并发日志系统:从需求到实战
日志系统这东西,说白了就是程序的「黑匣子」。我在做后台服务的时候,遇到过太多次线上问题排查全靠日志。要是日志系统本身拉胯——丢消息、写太慢、把磁盘撑爆——那真是雪上加霜。
今天咱们就手撸一个高性能的并发日志系统。嗯,我会把我在项目中踩过的坑、总结的经验都揉进去。你跟着走一遍,以后自己设计日志库心里就有底了。
1.1 日志系统的核心需求
先别急着写代码。咱们得想清楚:一个工业级的日志系统,到底要满足什么?
- 低延迟:打日志不能阻塞业务线程。你想想看,一个高并发的Web服务器,每条请求都等日志写完再返回,那性能直接崩了。
- 高吞吐:每秒能处理几十万条日志写入。我见过一个网关服务,高峰期每秒产生50万条访问日志,扛不住就丢数据。
- 线程安全:多线程同时写日志,不能出现乱序、数据错乱。
- 日志轮转:按时间或文件大小切分,防止磁盘写满。
- 异步写入:业务线程只管把日志丢到缓冲区,后台线程负责刷盘。
核心原则:日志系统的设计目标,就是「让业务线程几乎感觉不到日志的存在」。
1.2 多线程日志缓冲设计
我个人习惯用「双缓冲」方案。为什么?因为单缓冲加锁太频繁,性能瓶颈明显。
双缓冲的思路很简单:
- 两个缓冲区:
bufferA和bufferB - 业务线程往
bufferA写日志(无锁或轻量锁) - 后台线程把
bufferB刷到磁盘 - 当
bufferA满了,交换指针:业务线程写bufferB,后台线程刷bufferA
这样做的好处很明显:业务线程几乎不会被阻塞。我曾经在一个项目中用单缓冲,压测时发现日志写入占了20%的CPU时间。换成双缓冲后,直接降到3%以下。
来看核心数据结构:
// 日志缓冲区结构
typedef struct {
char *data; // 缓冲区数据
size_t size; // 缓冲区总大小
size_t write_pos; // 当前写入位置
pthread_mutex_t lock; // 互斥锁
} LogBuffer;
// 双缓冲管理器
typedef struct {
LogBuffer *current; // 当前写入缓冲区
LogBuffer *pending; // 待刷盘缓冲区
pthread_mutex_t swap_lock; // 交换锁
pthread_cond_t flush_cond; // 刷盘条件变量
} DoubleBuffer;
小技巧:缓冲区大小建议设为4KB的整数倍(比如64KB),因为磁盘块大小通常是4KB,对齐后写入效率更高。
1.3 异步日志写入机制
异步写入的核心,就是「生产者-消费者」模型。业务线程是生产者,后台刷盘线程是消费者。
我建议用条件变量来做同步,而不是忙等待。忙等待虽然简单,但会白白消耗CPU。你想想看,如果日志量不大,刷盘线程大部分时间都在空转,多浪费。
异步写入的流程:
- 业务线程调用
log_write(),将日志追加到当前缓冲区 - 如果缓冲区满了,触发交换信号
- 后台线程收到信号,交换缓冲区,开始刷盘
- 刷盘完成后,通知业务线程可以继续写入
这里有个坑:如果业务线程写入速度远大于刷盘速度,缓冲区会频繁交换,导致性能下降。我曾经遇到过这种情况,后来加了一个「水位线」机制——当缓冲区使用量超过80%时,提前触发交换,而不是等到满了再换。
// 异步写入核心逻辑
void log_write(Logger *logger, const char *msg) {
pthread_mutex_lock(&logger->buffer->lock);
// 检查剩余空间
if (logger->buffer->write_pos + len > logger->buffer->size) {
// 触发缓冲区交换
pthread_cond_signal(&logger->flush_cond);
// 等待刷盘完成
pthread_cond_wait(&logger->write_cond, &logger->buffer->lock);
}
// 写入日志
memcpy(logger->buffer->data + logger->buffer->write_pos, msg, len);
logger->buffer->write_pos += len;
pthread_mutex_unlock(&logger->buffer->lock);
}
注意:条件变量的使用一定要配合互斥锁,否则会出现「虚假唤醒」的问题。我刚开始写多线程代码时就吃过这个亏,debug了一整天才发现是条件变量没用对。
1.4 日志轮转策略
日志轮转,说白了就是「别让一个日志文件无限变大」。常见的策略有两种:
| 策略 | 触发条件 | 适用场景 |
|---|---|---|
| 按大小轮转 | 文件超过指定大小(如100MB) | 通用场景,控制磁盘占用 |
| 按时间轮转 | 到达指定时间点(如每天0点) | 需要按天归档的场景 |
| 混合轮转 | 大小或时间任一条件满足 | 生产环境推荐 |
我个人习惯用混合轮转。比如设置「每天轮转一次,且单个文件不超过200MB」。这样既方便按天查日志,又不会出现某个文件特别大的情况。
轮转时要注意:旧日志文件要重命名,不能直接覆盖。我见过一个系统,轮转时直接把旧文件删了,结果线上出问题想查昨天的日志,发现已经没了……
// 日志轮转实现
void log_rotate(Logger *logger) {
char old_path[256], new_path[256];
// 关闭当前文件
fclose(logger->file);
// 重命名:app.log -> app.2025-01-15.log
snprintf(new_path, sizeof(new_path), "%s.%s",
logger->base_path, get_current_date());
rename(logger->base_path, new_path);
// 创建新文件
logger->file = fopen(logger->base_path, "a");
// 检查旧文件数量,超过保留数则删除最旧的
clean_old_logs(logger->base_path, logger->max_files);
}
1.5 性能测试与调优
系统写完了,得测一测。我一般关注三个指标:
- 吞吐量:每秒能写入多少条日志
- 延迟:业务线程从调用
log_write到返回的时间 - CPU占用:日志系统消耗的CPU百分比
测试方法很简单:开N个线程,每个线程疯狂写日志,统计总耗时。
我记得有一次压测,发现吞吐量上不去。排查了半天,发现是 fwrite 调用太频繁。后来改成批量写入——攒够一批日志再一次性 fwrite,吞吐量直接翻了三倍。
性能数据参考(我的测试环境:4核8线程,SSD磁盘):
- 单线程同步写入:约 5万条/秒
- 双缓冲异步写入:约 50万条/秒
- 批量写入优化后:约 120万条/秒
你看,同样的硬件,不同的设计,性能差距能有20倍。这就是为什么我总说「架构设计决定性能上限」。
1.6 整体架构图
下面这张图展示了整个日志系统的核心流程。我特意把数据流和控制流分开画,方便你理解。
从图上你能看到,整个系统的核心就是「双缓冲 + 异步刷盘」。业务线程只管往缓冲区里写,后台线程负责刷盘和轮转。两者通过条件变量同步,互不阻塞。
我的建议:刚开始实现时,先跑通单线程版本,再扩展到多线程。每一步都做单元测试,别等到最后一起debug,那会非常痛苦。
好了,这一章的内容就到这里。日志系统的需求分析、缓冲设计、异步写入、轮转策略,咱们都过了一遍。下一章我会带你手写完整的代码实现,包括双缓冲的初始化、日志格式化、以及性能压测脚本。到时候咱们一起把代码跑起来,看看实际效果。