63、实战:日志系统(单例+多线程安全+文件滚动)
日志系统,说白了就是给程序装个「黑匣子」。
我做了这么多年C++开发,几乎每个项目都离不开它。调试时靠它定位问题,线上运行时靠它排查故障。但很多人写日志就是简单fprintf一下,结果多线程一跑就乱码,日志文件几天就撑爆磁盘。
嗯,今天我们就手撸一个靠谱的日志系统。它要满足三个硬指标:单例模式、多线程安全、文件滚动。
一、整体架构设计
先画张图,让你对整个系统有个直观认识。
从图里能看出来,所有线程都通过同一个Logger实例写日志。这个实例内部用互斥锁保护,保证同一时刻只有一个线程在写文件。同时,它还会检查当前文件大小,超过阈值就自动滚动。
二、单例模式实现
单例模式,说白了就是整个进程里只存在一个Logger对象。C++11之后实现起来很简单,用static local变量就行——它天然是线程安全的。
核心要点:不要自己加双重检查锁,C++11标准已经保证了static局部变量的初始化是线程安全的。
// Logger.h
#pragma once
#include <string>
#include <mutex>
#include <fstream>
#include <memory>
class Logger {
public:
static Logger& instance() {
static Logger inst;
return inst;
}
// 禁止拷贝和赋值
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
void setLogLevel(int level);
void setLogFile(const std::string& filename, size_t maxFileSize = 10*1024*1024);
void log(int level, const char* file, int line, const char* fmt, ...);
private:
Logger() = default;
~Logger();
void rollFile();
void writeLog(const std::string& msg);
std::mutex mtx_;
std::unique_ptr<std::ofstream> file_;
std::string filename_;
size_t maxFileSize_;
size_t currentSize_{0};
int logLevel_{0};
};
我个人习惯把构造函数设为私有,然后通过instance()静态方法获取唯一实例。这样外部想new一个Logger?门儿都没有。
三、多线程安全设计
多线程安全的核心就一句话:所有写操作都加锁。
但这里有个坑——日志系统本身不能死锁。比如你在日志输出函数里又调用了日志输出,那就完蛋了。我曾经在一个项目里见过这种递归锁导致的死锁,排查了整整两天。
避坑指南:日志输出函数内部不要调用任何可能触发日志输出的代码。比如不要在日志格式化时调用复杂的业务逻辑。
具体实现上,我用了std::lock_guard,RAII风格,异常安全。
void Logger::log(int level, const char* file, int line, const char* fmt, ...) {
if (level < logLevel_) return; // 级别过滤,不加锁
char buf[4096];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
// 格式化时间戳
char timeBuf[64];
auto now = std::chrono::system_clock::now();
auto t = std::chrono::system_clock::to_time_t(now);
std::strftime(timeBuf, sizeof(timeBuf), "%Y-%m-%d %H:%M:%S", std::localtime(&t));
// 拼装完整日志行
std::string msg = std::string(timeBuf) + " [" + levelToString(level) + "] "
+ file + ":" + std::to_string(line) + " - " + buf + "\n";
// 加锁写文件
{
std::lock_guard<std::mutex> lock(mtx_);
writeLog(msg);
}
}
void Logger::writeLog(const std::string& msg) {
if (!file_ || !file_->is_open()) return;
*file_ << msg;
file_->flush(); // 确保立即写入磁盘
currentSize_ += msg.size();
if (currentSize_ >= maxFileSize_) {
rollFile();
}
}
你想想看,如果这里不加锁,两个线程同时写文件,那日志行就会互相穿插,读起来根本没法看。加锁之后,每个线程的日志行都是完整的。
四、文件滚动机制
文件滚动,说白了就是日志文件太大时自动切分。我见过有人把日志写到几十GB,打开文件都能把编辑器卡死。
滚动策略一般有两种:
- 按大小滚动:文件超过10MB就切分
- 按日期滚动:每天生成一个新文件
我这里实现的是按大小滚动,同时保留最近N个历史文件。
void Logger::rollFile() {
// 关闭当前文件
file_->close();
// 重命名:app.log -> app.1.log, app.1.log -> app.2.log, ...
// 保留最多10个历史文件
const int maxBackup = 10;
for (int i = maxBackup - 1; i >= 1; --i) {
std::string oldName = filename_ + "." + std::to_string(i) + ".log";
std::string newName = filename_ + "." + std::to_string(i + 1) + ".log";
if (std::filesystem::exists(oldName)) {
std::filesystem::rename(oldName, newName);
}
}
// 把当前文件重命名为 .1.log
std::string backupName = filename_ + ".1.log";
if (std::filesystem::exists(filename_)) {
std::filesystem::rename(filename_, backupName);
}
// 重新创建新文件
file_ = std::make_unique<std::ofstream>(filename_, std::ios::app);
currentSize_ = 0;
}
小技巧:滚动操作本身也放在锁里面,因为涉及文件重命名和创建,必须保证原子性。另外,std::filesystem是C++17的特性,如果你的编译器不支持,可以用rename()和remove()等C函数替代。
五、宏定义与使用示例
为了方便调用,我通常会定义几个宏。这样写起来就像printf一样简单。
#define LOG_TRACE(fmt, ...) Logger::instance().log(0, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_DEBUG(fmt, ...) Logger::instance().log(1, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_INFO(fmt, ...) Logger::instance().log(2, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_WARN(fmt, ...) Logger::instance().log(3, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_ERROR(fmt, ...) Logger::instance().log(4, __FILE__, __LINE__, fmt, ##__VA_ARGS__)
使用起来非常直观:
int main() {
Logger::instance().setLogFile("app.log", 5*1024*1024); // 5MB滚动
Logger::instance().setLogLevel(1); // 只输出DEBUG及以上级别
LOG_INFO("系统启动,进程ID: %d", getpid());
for (int i = 0; i < 1000; ++i) {
LOG_DEBUG("处理第 %d 条数据", i);
// 模拟业务处理...
}
LOG_WARN("内存使用率超过80%%");
LOG_ERROR("数据库连接失败,错误码: %d", -1);
return 0;
}
六、性能与注意事项
| 关注点 | 说明 | 我的建议 |
|---|---|---|
| 锁粒度 | 整个写操作加锁,包括格式化 | 如果性能要求极高,可以用双缓冲队列,但实现复杂度大增 |
| 磁盘IO | 每次写日志都flush | 调试阶段可以flush,线上可以去掉flush提升性能 |
| 日志级别 | 先判断级别再格式化 | 避免不必要的字符串拼接,尤其是TRACE级别 |
| 文件句柄 | 滚动时关闭旧文件,打开新文件 | 注意异常情况下的文件句柄泄漏 |
| 线程安全 | 所有public方法都加锁 | 包括setLogLevel和setLogFile,防止配置变更时竞态 |
核心总结:一个工业级的日志系统,单例保证全局唯一,互斥锁保证线程安全,文件滚动防止磁盘爆炸。这三者缺一不可。
嗯,这套代码我用了好几年,从Windows到Linux,从单线程到上百线程,从来没出过问题。你拿去改改就能用在自己的项目里。
公众号:蓝海资料掘金营,微信deep3321