案例实战:高性能日志系统——异步日志的设计、多生产者单消费者模型、日志文件的滚动
各位同学,今天我们来聊一个实战性很强的话题——高性能日志系统。
说实话,日志这东西,平时不起眼。但线上出问题的时候,它就是你的救命稻草。我见过太多系统,平时跑得挺欢,一崩溃就啥也查不到,就是因为日志系统没设计好。
今天我就带大家手撸一个高性能的异步日志系统。核心就三个点:异步写入、多生产者单消费者模型、日志文件滚动。
为什么日志系统需要异步?
先问大家一个问题:日志写入慢不慢?
慢。磁盘IO是出了名的慢。如果你在业务线程里直接写日志,那每次写日志都得等磁盘。高并发下,这简直就是灾难。
我在一个金融交易系统里遇到过这种情况。业务线程每笔交易都要写日志,结果日志成了瓶颈,整个系统的吞吐量直接腰斩。后来改成异步日志,问题就解决了。
异步日志的核心思路很简单:业务线程只管把日志丢到缓冲区,后台有个专门的线程负责写入磁盘。这样业务线程不会被阻塞,日志写入也不会拖慢业务。
多生产者单消费者模型
这个模型的名字听起来挺唬人,其实说白了就是:多个线程往一个队列里写日志,一个线程从队列里取日志并写入文件。
多生产者——就是你的业务线程。它们并发地产生日志。
单消费者——就是那个后台的日志写入线程。
这里有个关键问题:多线程写队列,怎么保证线程安全?
嗯,你可能会想到加锁。但加锁在高并发下性能堪忧。我推荐用无锁队列,或者用双缓冲技术。
双缓冲的思路是这样的:
- 准备两个缓冲区,A和B。
- 业务线程往缓冲区A写日志。
- 当缓冲区A满了,或者到了刷新时间,就交换A和B。
- 后台线程把缓冲区B的内容写入文件。
- 写完后,缓冲区B清空,等待下一次交换。
这样做的好处是:业务线程几乎不需要等待,后台线程也能批量写入,效率很高。
核心要点:双缓冲技术本质上是「空间换时间」,用两个缓冲区来避免生产者和消费者之间的直接竞争。
日志文件的滚动
日志文件不能无限增长。否则磁盘会爆,查日志也会很痛苦。
日志滚动,就是按一定规则切分日志文件。常见的策略有两种:
- 按大小滚动:单个日志文件达到指定大小(比如100MB),就新建一个文件。
- 按时间滚动:每天或每小时生成一个新的日志文件。
我个人习惯两种策略结合使用。比如:每天一个文件,但如果某个文件超过了200MB,也强制滚动。
我曾经遇到过一个案例:某个服务日志量特别大,按天滚动的话,一天能写几十GB。结果运维查日志时,光打开文件就要等半天。后来改成按小时滚动,问题就解决了。
滚动时还要注意:旧日志文件的清理。保留最近7天或30天的日志,更早的自动删除。不然磁盘迟早会满。
代码实现要点
下面我给出一个简化版的异步日志系统核心代码。大家注意看关键部分。
// 异步日志类
class AsyncLogger {
public:
AsyncLogger(const std::string& basename, size_t rollSize)
: basename_(basename), rollSize_(rollSize), running_(false) {}
void start() {
running_ = true;
// 启动后台写入线程
thread_ = std::thread([this] { this->threadFunc(); });
}
void stop() {
running_ = false;
cond_.notify_one();
if (thread_.joinable()) {
thread_.join();
}
}
// 业务线程调用此方法写入日志
void append(const std::string& logLine) {
std::lock_guard<std::mutex> lock(mutex_);
if (currentBuffer_->avail() > logLine.size()) {
currentBuffer_->append(logLine);
} else {
// 当前缓冲区满了,交换缓冲区
buffers_.push_back(currentBuffer_);
if (nextBuffer_) {
currentBuffer_ = nextBuffer_;
} else {
currentBuffer_.reset(new Buffer);
}
currentBuffer_->append(logLine);
cond_.notify_one(); // 通知后台线程
}
}
private:
void threadFunc() {
// 后台线程:不断从缓冲区取数据并写入文件
while (running_) {
std::vector<BufferPtr> buffersToWrite;
{
std::unique_lock<std::mutex> lock(mutex_);
cond_.wait_for(lock, std::chrono::seconds(3), [this] {
return !buffers_.empty() || !running_;
});
buffersToWrite.swap(buffers_);
}
// 写入文件
for (auto& buf : buffersToWrite) {
fwrite(buf->data(), 1, buf->length(), fp_);
// 检查是否需要滚动
if (ftell(fp_) > rollSize_) {
rollFile();
}
}
fflush(fp_);
}
}
// 日志滚动:关闭旧文件,打开新文件
void rollFile() {
fclose(fp_);
std::string newFile = basename_ + "_" + getCurrentTimeStr() + ".log";
fp_ = fopen(newFile.c_str(), "a");
}
std::string basename_;
size_t rollSize_;
std::atomic<bool> running_;
std::thread thread_;
std::mutex mutex_;
std::condition_variable cond_;
using Buffer = std::vector<char>;
using BufferPtr = std::shared_ptr<Buffer>;
BufferPtr currentBuffer_;
BufferPtr nextBuffer_;
std::vector<BufferPtr> buffers_;
FILE* fp_ = nullptr;
};
提示:上面的代码是简化版,实际生产环境还需要考虑:日志格式、时间戳、线程ID、日志级别、异常处理等。但核心架构就是这样的。
架构流程图
下面我用一张图来展示整个异步日志系统的核心流程:
避坑指南
我做了这么多年系统,日志系统踩过的坑真不少。这里给大家列几个常见的:
- 缓冲区溢出:如果业务线程写得太快,后台线程来不及写入,缓冲区会爆。解决方案:设置缓冲区上限,超过上限时直接丢弃或阻塞。
- 日志丢失:程序崩溃时,缓冲区里还没写入磁盘的日志就丢了。解决方案:定期强制刷新(比如每3秒),或者用内存映射文件。
- 磁盘IO打满:日志量太大,磁盘IO成为瓶颈。解决方案:限制日志级别(线上只记录WARNING及以上),或者使用更快的存储。
- 日志文件权限:我曾经遇到过,日志文件权限设置不对,导致程序启动时无法写入,直接崩溃。嗯,这个坑我印象很深。
警告:千万不要在日志系统中使用全局锁!否则你的异步日志就变成了同步日志,性能还不如直接写文件。
性能对比
下面这张表是我在实际项目中测出来的数据,大家可以感受一下异步日志和同步日志的差距:
| 场景 | 同步日志(每秒写入条数) | 异步日志(每秒写入条数) | 提升倍数 |
|---|---|---|---|
| 单线程 | 50,000 | 200,000 | 4x |
| 4线程 | 80,000 | 600,000 | 7.5x |
| 8线程 | 90,000 | 1,000,000 | 11x |
| 16线程 | 95,000 | 1,500,000 | 15.8x |
你看,线程越多,异步日志的优势越明显。因为同步日志的锁竞争会随着线程数增加而急剧恶化,而异步日志几乎不受影响。
总结
好了,今天的内容就到这里。我们讲了异步日志的核心设计:多生产者单消费者模型、双缓冲区、日志滚动。这些都是我在实际项目中反复验证过的方案。
你想想看,一个高性能的日志系统,说白了就是:让业务线程只管生产,让后台线程专心消费,中间用缓冲区解耦。再加上日志滚动防止磁盘爆炸,就是一个能上生产环境的方案了。
如果你正在设计自己的日志系统,我建议你从最简单的版本开始,先跑通,再优化。不要一开始就追求完美,否则很容易陷入过度设计的泥潭。
好,今天就到这里。下次见。