C++项目规模一旦上来,日志和异常处理就不再是“顺手写个printf”或者“随手throw一个”能糊弄过去的事情了。这两个模块承担着系统可观测性和故障恢复的核心职责,设计不当会带来日志刷爆磁盘、异常吞掉错误、多线程崩溃等一连串问题。本文从架构设计的角度,剖析C++框架中日志系统与异常处理机制的实现要点。

一、日志系统的架构分层与核心设计
一个成熟的日志模块通常分为三层:前端API层、核心分发层和后端输出层。前端API层暴露给业务代码,例如LOG_INFO("user {} login", uid)这样的调用;核心分发层负责日志级别过滤、格式化以及向多个sink的投递;后端输出层则是具体的输出目的地,包括控制台、文件、网络、数据库等。分层的意义在于解耦:业务代码不关心日志写到哪里,新增一个输出目的地不需要改动任何调用方代码。
级别过滤建议在最前端完成,也就是在宏或者内联函数入口处就判断级别,避免无效的格式化开销。很多框架的性能问题出在这里:即使日志级别设置为ERROR,调试日志的字符串拼接照样执行。正确的做法是用宏把格式化延迟到级别判断之后:
#define LOG_DEBUG(...) \
do { \
if (logger.level() <= LogLevel::DEBUG) { \
logger.log(LogLevel::DEBUG, __VA_ARGS__); \
} \
} while (0)
多目的地分发需要支持每级别独立配置,比如ERROR以上级别同时输出到控制台和告警文件,INFO级别只写滚动文件。下面是一个典型的多sink设计:
auto consoleSink = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
auto fileSink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
"app.log", 1024 * 1024 * 5, 3); // 单文件5MB,保留3个备份
spdlog::logger logger("main", {consoleSink, fileSink});
fileSink->set_level(spdlog::level::info); // 文件只记info以上
consoleSink->set_level(spdlog::level::err); // 控制台只打error
二、异步日志与线程安全的实现细节
多线程环境下日志写入是竞争焦点。如果每条日志都直接加锁写文件,高并发场景下锁竞争会成为瓶颈。主流方案是异步日志:前端线程只负责把日志消息拷贝进一个无锁队列或带锁的环形缓冲区,由单独的后台线程负责实际的格式化和落盘。这样前端的开销被压缩到一次内存拷贝加一次原子操作,性能可以提升一个数量级。
spdlog的异步模式使用起来很简单,但有几个坑需要注意。队列满时的阻塞策略要选对:生产环境建议用block_on_overflow风格的策略,宁可慢也不能丢日志;而队列大小要根据峰值QPS估算,太小会频繁阻塞,太大则占用内存。另外异步模式下进程退出时要记得flush,否则最后一批日志可能残留在队列里没写出去:
spdlog::init_thread_pool(8192, 1); // 队列8192条,1个后台线程
auto asyncLogger = std::make_shared<spdlog::async_logger>(
"async",
std::make_shared<spdlog::sinks::basic_file_sink_mt>("async.log"),
spdlog::thread_pool(),
spdlog::async_overflow_policy::block);
spdlog::register_logger(asyncLogger);
// 进程退出前强制刷新
std::atexit([]() { spdlog::default_logger()->flush(); });
还有一个容易被忽略的点是崩溃日志。程序发生段错误时,常规日志机制已经不可靠,需要借助信号处理器把崩溃时的栈信息写到独立文件。可以注册SIGSEGV、SIGABRT等信号,在处理器中调用backtrace系列函数输出调用栈,再配合addr2line工具还原源码位置,这对定位线上问题非常关键。
三、异常处理的等级划分与RAII配合
C++异常处理的核心不是throw和try-catch的语法,而是异常安全保证。业界通常划分为三个等级:基本保证(异常抛出后对象处于有效但未指定状态,无资源泄漏)、强保证(操作失败则回滚到调用前状态,类似事务)、不抛保证(操作绝不抛出异常,用noexcept标记)。STL容器大多提供强保证,例如vector::push_back在元素拷贝构造抛异常时会保持原容器不变。
实现异常安全的主力工具是RAII。资源在构造函数中获取、在析构函数中释放,栈展开时析构函数自动执行,从根上杜绝了泄漏。所有资源——内存、文件句柄、锁、数据库连接——都应该用RAII对象管理,而不是手动配对调用:
// 错误示范:手动管理,异常抛出时file忘记关闭
void badDemo() {
FILE* file = fopen("data.txt", "r");
processFile(file); // 如果这里抛异常,文件句柄泄漏
fclose(file);
}
// 正确做法:RAII封装
struct FileGuard {
FILE* fp;
explicit FileGuard(const char* path) : fp(fopen(path, "r")) {
if (!fp) throw std::runtime_error("open failed");
}
~FileGuard() { if (fp) fclose(fp); }
FileGuard(const FileGuard&) = delete;
FileGuard& operator=(const FileGuard&) = delete;
};
noexcept的使用时机也需要谨慎。析构函数默认就是noexcept的,不要在其中抛出异常,否则栈展开期间再抛异常会直接触发std::terminate。移动构造函数、swap函数应尽量标记为noexcept,这直接决定了容器在扩容时是走移动还是拷贝路径,对性能影响显著。反过来,不要为了“看起来安全”随意给函数加noexcept——一旦函数真的抛了异常,程序会直接终止,比异常传播的后果更严重。
四、日志与异常的协同设计
日志和异常不是孤立的两个模块,它们需要在框架层面协同工作。一个常见的反模式是在每层都catch异常并记录日志,导致同一个错误被重复记录四五次,排查时干扰极大。推荐的实践是“捕获-转换-上抛”原则:底层捕获具体异常后记录详细信息并转换为业务异常继续上抛,中间层不记录只转发,最顶层统一捕获并做最终处理。这样每条错误只有一个权威记录点。
异常信息中携带上下文是另一个关键点。裸抛std::runtime_error("failed")这类异常几乎无法排查问题。可以利用继承自定义异常体系,把错误码、模块名、关键参数打包进异常对象,顶层捕获时一次性格式化到日志:
class AppException : public std::runtime_error {
public:
AppException(const std::string& module, int code, const std::string& msg)
: std::runtime_error(msg), module_(module), code_(code) {}
const std::string& module() const { return module_; }
int code() const { return code_; }
private:
std::string module_;
int code_;
};
// 顶层统一处理
int main() {
try {
runService();
} catch (const AppException& e) {
spdlog::error("module={} code={} what={}", e.module(), e.code(), e.what());
return 1;
} catch (const std::exception& e) {
spdlog::critical("unexpected: {}", e.what());
std::abort(); // 未知异常不应继续运行
}
return 0;
}
另外建议区分“可恢复错误”与“不可恢复错误”。前者用异常或错误码(如std::expected风格)表达,允许调用方处理后续;后者例如不变量被破坏,应记录致命日志后立即终止进程,交给上层编排系统重启,带病运行往往比崩溃危害更大。
总结来说,日志系统重在分层解耦与异步化,异常处理重在RAII资源管理和安全等级的自觉遵守,而两者的协同则需要一套清晰的异常传播与记录规范。把这些基础打牢,框架的稳定性就有了坚实的底座。