服务出问题时,日志是唯一能还原现场的东西;服务正常时,日志又往往是最大的性能开销与磁盘占用来源。这两个诉求相互冲突,导致日志系统的设计本质上是一系列取舍:写多少、写多快、丢了怎么办、格式给谁看。
C++ 没有标准日志库,std::cout 既不线程安全也不支持级别与轮转。生态里的选择从 header-only 的 spdlog 到重量级的 glog、Boost.Log,各有取舍。本文以 spdlog 为主线——它是目前 C++ 项目采用率最高的日志库——把架构、性能模型与结构化日志实践讲透,并给出与 OpenTelemetry 这类可观测性体系对接的路径。
为什么 std::cout 不能当日志
把日志当成「打印」是新手最常见的误区。生产环境的日志系统至少要满足六个要求,而 std::cout 一个都不满足:
| 要求 | std::cout | 日志库 |
|---|---|---|
| 线程安全 | 不保证(多线程交错输出) | 保证整条记录原子写入 |
| 日志级别 | 无 | trace/debug/info/warn/error/critical |
| 输出目标 | 仅 stdout | 文件、滚动文件、syslog、网络 |
| 轮转 | 无 | 按大小/时间滚动,保留 N 份 |
| 格式 | 手动拼 | pattern 统一控制 |
| 性能 | 同步阻塞 | 可异步、可编译期裁剪 |
最直接的伤害是多线程下日志行交错:两个线程各写一半,磁盘上出现「这条日志的前半句 + 那条日志的后半句」,排障时完全无法解读。std::cout 的单次 operator<< 只保证该次调用不被打断,一条完整日志由多次 << 组成,中间随时可能被其他线程插入。
第二个伤害是同步阻塞。std::cout 或同步文件写会阻塞业务线程直到数据进入内核缓冲。在高频路径上(如网络包处理、交易撮合),一次磁盘 IO 抖动就能拖垮整个线程的延迟分布。异步日志正是为解决这一点而生。
spdlog 架构:logger、sink、formatter 与 registry
spdlog 的核心是四个组件的组合,理解它们的分工才能正确配置。
#include <spdlog/spdlog.h>
#include <spdlog/sinks/rotating_file_sink.h>
#include <spdlog/sinks/stdout_color_sinks.h>
auto console = std::make_shared<spdlog::sinks::stdout_color_sink_mt>();
auto file = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
"logs/app.log", 1024 * 1024 * 64, 5); // 64MB × 5 份
std::vector<spdlog::sink_ptr> sinks{console, file};
auto logger = std::make_shared<spdlog::logger>("app", sinks.begin(), sinks.end());
spdlog::register_logger(logger);
spdlog::set_default_logger(logger);
四个组件的职责:
- logger:日志的入口,持有名称、级别、sink 列表与 formatter。
spdlog::get("app")按名字取回。 - sink:输出目标。一条日志会分发给 logger 的所有 sink,因此「同时写控制台与文件」只需挂两个 sink。
- formatter:把日志记录(时间、级别、线程 ID、消息、源位置)渲染成文本。
- registry:全局的 logger 注册表,
register_logger/get是它的 API。
sink 的 _mt 与 _st 后缀是必须记住的约定:_mt 是 multi-threaded(内部加锁),_st 是 single-threaded(不加锁,更快)。多线程程序误用 _st sink 会导致数据竞争与崩溃。判断标准很简单:只要可能被多个线程调用,就必须用 _mt。
pattern 与格式化
spdlog 的 pattern 用 % 占位符,与 printf 类似但语义不同:
spdlog::set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%^%l%$] [%t] %v");
| 占位符 | 含义 | 占位符 | 含义 |
|---|---|---|---|
%Y-%m-%d | 日期 | %l | 级别(info/warn/…) |
%H:%M:%S | 时间 | %t | 线程 ID |
%e | 毫秒 | %n | logger 名称 |
%^ %$ | 颜色起始/结束 | %v | 日志正文 |
%s | 源文件名 | %# | 源码行号 |
%! | 函数名 | %P | 进程 ID |
%s、%#、%! 需要编译期信息,只有调用带源位置的宏(SPDLOG_LOGGER_INFO 或 spdlog::info 在开启 SPDLOG_ACTIVE_LEVEL 时)才有效。默认的 spdlog::info(...) 在大多数构建里会带上源位置,但这有额外开销——记录源位置需要在调用点捕获 __FILE__ 与 __LINE__。
同步与异步:线程池与有界队列
异步日志的基本模型是「业务线程把日志消息塞进队列,后台线程从队列取出并写盘」。
#include <spdlog/async.h>
// 队列容量 8192,后台 1 个线程
spdlog::init_thread_pool(8192, 1);
auto logger = spdlog::create_async<spdlog::sinks::rotating_file_sink_mt>(
"async_app", "logs/app.log", 64 * 1024 * 1024, 5);
关键参数与行为:
| 参数 | 含义 | 取值建议 |
|---|---|---|
queue_size | 队列容量(条数) | 4096 ~ 65536,按日志峰值估算 |
n_threads | 后台写线程数 | 通常 1,多个 sink 目标可增加 |
overflow_policy | 队列满时的策略 | block(阻塞)或 overrun_oldest(丢弃最旧) |
flush_level | 触发 flush 的级别 | 通常 warn 或 err |
队列满怎么办是异步日志最关键的决策点,没有免费选项:
block(阻塞):业务线程等待队列腾出空间。不丢日志,但日志压力会反压到业务,可能造成延迟毛刺。overrun_oldest(丢弃最旧):不阻塞业务,但会丢日志。丢的是队列里最旧的那批,也就是「故障最初的那几条」——恰恰是排障最需要的。
生产系统的常见做法是分级处理:info 及以下允许丢弃,warn 及以上用独立通道保证不丢。这要求按级别路由到不同 sink 或不同 logger。
另一个必须理解的边界是 flush 语义。异步日志里,logger->info(...) 返回时消息只在队列中,尚未落盘。进程崩溃(如 SIGSEGV、SIGKILL)时队列里的日志会丢失。因此:
spdlog::flush_on(spdlog::level::err); // err 及以上立即刷盘
logger->flush(); // 或显式刷新
对于「崩溃前的现场」,依赖 flush 是不够的——崩溃可能发生在任何时刻。这正是 backtrace 环形缓冲的用武之地(见后文)。
日志库的性能哲学与通用 C++ 性能优化一脉相承:把开销从热路径挪到冷路径。异步日志把 IO 从业务线程挪到后台线程,代价是队列操作与内存分配。这与 https://plumephp.com/cpp-performance-optimization/ 里「减少热路径工作」的思路完全一致。
结构化日志:JSON sink 与字段设计
文本日志对人类友好,对机器不友好。当日志要进 ELK、Loki 或 ClickHouse 做检索与聚合时,结构化(通常是 JSON)是唯一可行的格式。
#include <spdlog/sinks/basic_file_sink.h>
// 或使用第三方 json sink,或自定义 formatter
一个可用的做法是自定义 formatter 输出 JSON,把结构化字段与消息正文分开:
class JsonFormatter : public spdlog::formatter {
public:
void format(const spdlog::details::log_msg& msg,
spdlog::memory_buf_t& dest) override {
// 手工拼 JSON,注意对字符串做转义
dest.append("{\"ts\":", ...);
dest.append("\"level\":\"");
dest.append(spdlog::level::to_string_view(msg.level));
dest.append("\",\"msg\":\"");
// 转义 msg.payload
dest.append("\"}\n");
}
std::unique_ptr<spdlog::formatter> clone() const override {
return std::make_unique<JsonFormatter>(*this);
}
};
JSON 转义是必须自己处理的:消息里出现 "、\、换行、控制字符会破坏 JSON 结构,下游解析器直接报错。别指望「日志内容不会有特殊字符」。
字段设计上有几条经验:
- 时间戳用 RFC 3339 / ISO 8601(
2026-10-07T21:00:00.123+08:00),带时区偏移,跨时区聚合无歧义;时区与格式化的通用原则见 时间与时区处理 。 - 固定的顶层字段:
ts、level、logger、msg、thread、trace_id、span_id。 - 业务字段放结构化对象:如
{"order_id": "A123", "amount": 99.5},便于按字段索引。 - 不要用字符串拼接做结构化:
"order=" + id + " amount=" + amt是文本日志,下游无法可靠解析。
结构化日志的真正价值在关联:有了 trace_id,可以把一次请求在多个服务、多个线程里的日志串起来。这正是 OpenTelemetry 这类标准的出发点。
上下文与 MDC
「每条日志都带上请求 ID」是排障刚需。手工在每条日志里传 trace_id 既繁琐又容易漏。spdlog 没有内置的 MDC(Mapped Diagnostic Context,映射诊断上下文),但可以用自定义 logger 封装:
class RequestLogger {
spdlog::logger& inner_;
public:
explicit RequestLogger(spdlog::logger& l) : inner_(l) {}
template <class... Args>
void info(std::string_view trace_id, fmt::format_string<Args...> f, Args&&... a) {
inner_.info("[trace={}] {}", trace_id, fmt::format(f, std::forward<Args>(a)...));
}
};
更彻底的方案是用 thread_local 存当前上下文,在 formatter 里自动读取:
thread_local std::string g_trace_id;
// 在 formatter::format 里读取 g_trace_id 并输出为字段
这样业务代码只写 spdlog::info("order created"),trace_id 自动附加。代价是 thread_local 访问在热路径上有开销,且异步日志下要小心「格式化发生在后台线程」——上下文必须在入队时就捕获,否则后台线程读到的 thread_local 是错的。这是异步日志 + MDC 的经典陷阱。
性能:编译期裁剪、backtrace 与队列开销
编译期级别裁剪
发布版本里,trace/debug 级别的日志调用应当完全不产生代码,包括参数求值。
#define SPDLOG_ACTIVE_LEVEL SPDLOG_LEVEL_INFO
#include <spdlog/spdlog.h>
设置后,SPDLOG_DEBUG(...) 会展开成空语句,连 fmt::format 的参数计算都不会发生。但注意:spdlog::debug(...) 与 SPDLOG_DEBUG(...) 不同,前者是函数调用,参数会先被求值再在运行时判断级别;后者是宏,编译期就裁掉了。
spdlog::debug("expensive: {}", compute_heavy()); // compute_heavy() 总会执行!
SPDLOG_DEBUG("expensive: {}", compute_heavy()); // 编译期裁掉,不执行
这是一个真实且高频的性能 bug:日志级别设成 warn 后性能没提升,因为参数求值仍在跑。
fmt 格式化与编译期检查
spdlog 底层用 fmt 库做格式化,它比 printf 与 iostream 都快,原因是编译期解析格式串并生成类型安全的代码路径。
spdlog::info("user={} id={} score={:.2f}", name, id, score);
{} 是自动占位符,{:.2f} 指定两位小数。fmt 在编译期检查参数个数与类型(C++20 的 consteval 使其成为编译错误),因此不会出现 printf 那种「格式串与参数不匹配导致崩溃」。这也是为什么 printf 风格在现代 C++ 日志里应被淘汰——它的类型安全依赖程序员而不是编译器。
格式化本身的成本不容忽视:把 double 转成十进制字符串涉及浮点运算与除法,比整数转换慢得多。热路径日志应尽量避免格式化浮点数,或把浮点字段留给结构化输出(下游用数值类型接收),而不是在应用层转成字符串。这与 https://plumephp.com/cpp-simd-vectorization-practice/ 里「把昂贵运算挪出热循环」是同一类思路。
backtrace 环形缓冲
spdlog 的 backtrace sink 在内存里保留最近 N 条 debug 日志,平时不输出;发生错误时调 dump_backtrace() 一次性倾倒。
auto bt = std::make_shared<spdlog::sinks::ringbuffer_sink_mt>(64);
logger->sinks().push_back(bt);
// ... 出问题时
logger->dump_backtrace(); // 输出最近 64 条 debug 日志
这解决了「debug 日志平时太多、出错时又太少」的矛盾:环形缓冲的内存占用固定(N × 单条大小),且写环形缓冲只是内存拷贝,不涉及 IO。代价是每条 debug 日志仍要格式化并占内存。
队列开销与采样
异步日志的入队需要一次内存分配 + 一次拷贝(消息进队列)。在每秒百万条的高频场景下,分配器会成为瓶颈。优化方向:
- 消息复用:spdlog 内部用内存池(
details::log_msg_buffer)减少分配。 - 采样:对高频同类日志按比例丢弃。
if (counter++ % 1000 == 0) spdlog::info(...)。 - 限制字段:源位置(
%s/%#)需要额外捕获,热路径日志可关掉。
采样要谨慎:对错误日志采样会漏掉关键事件,通常只对「高频但低价值」的日志(如健康检查、心跳)采样。这与 Go 结构化日志实践 里的取舍完全对应——语言不同,工程约束是相通的。
轮转、保留与磁盘治理
日志写得太快会撑爆磁盘,这是运维事故里最常见的一类。轮转(rotation)策略必须在上线前定好。
// 按大小轮转:单文件 64MB,保留 5 份
auto sink = std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
"logs/app.log", 64 * 1024 * 1024, 5);
// 按时间轮转:每天 02:30 切分,保留 30 天
auto daily = std::make_shared<spdlog::sinks::daily_file_sink_mt>(
"logs/app.log", 2, 30);
两种策略的取舍:
| 策略 | 优点 | 缺点 | 适用 |
|---|---|---|---|
按大小(rotating_file_sink) | 单文件大小可控,磁盘占用可算 | 时间边界不齐,跨天检索麻烦 | 高频、写入速率稳定 |
按时间(daily_file_sink) | 便于按天归档与删除 | 突发流量下单文件可能巨大 | 中低频、按天分析 |
| 按时间 + 大小 | 兼顾两者 | 需第三方 sink 或自定义 | 生产推荐 |
磁盘占用的估算公式是 单文件上限 × 份数 × sink 数。64MB × 5 = 320MB,加上控制台 sink 不占盘。多实例部署时还要乘以实例数——100 个 Pod 各写 320MB 就是 32GB,且是每台机器的量级。容器环境尤其危险:日志写进容器可写层会迅速撑爆节点磁盘,正确做法是写 stdout 交给容器运行时采集,或挂载独立的日志卷。
轮转的另一个陷阱是多进程写同一文件。rotating_file_sink 的轮转逻辑假设自己是唯一写者;两个进程同时轮转会把文件互相覆盖。多进程场景必须每个进程写独立文件(如 app-<pid>.log),或改用 syslog / 集中式日志服务。
保留策略上,除 sink 自带的份数上限外,还应有外部兜底:定时任务扫描日志目录,删除超过 N 天的文件。sink 的份数限制只在该 sink 生命周期内有效,进程重启或配置变更后旧文件不会被清理。
日志内容规范与安全
日志会流入各种下游(对象存储、日志服务、第三方 SaaS),内容规范不只是风格问题,也是合规问题。
不要记录敏感信息。 密码、令牌、身份证号、银行卡号、完整请求体都不该进日志。常见做法是脱敏:
std::string mask_token(std::string_view t) {
if (t.size() <= 8) return "***";
return std::string(t.substr(0, 4)) + "****" + std::string(t.substr(t.size() - 4));
}
注意日志注入(log injection)。 用户可控的字符串直接写进日志,若含换行符,攻击者可以伪造日志行,甚至注入 JSON 结构破坏下游解析。结构化日志必须对字符串做转义,文本日志至少要把 \n、\r 替换掉。
std::string escape(std::string_view s) {
std::string out;
out.reserve(s.size());
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
// 控制字符统一转义为 \u00XX
char buf[7];
std::snprintf(buf, sizeof buf, "\\u%04x", c);
out += buf;
} else {
out += c;
}
}
}
return out;
}
级别使用要有一致的语义,否则日志分级形同虚设。一套可执行的约定:
trace:逐函数/逐循环级别的细节,默认关闭。debug:开发与排障用的中间状态,生产默认关闭或走环形缓冲。info:状态变更、关键流程节点(服务启动、配置加载、任务完成)。warn:可恢复的异常、降级、重试(连接失败后重连成功)。error:需要人介入的失败,但进程仍可继续。critical:进程即将终止或数据可能损坏。
常见反模式是把所有异常都打成 error,导致真正的错误淹没在噪声里;或者把所有东西打成 info,让级别过滤失效。判断标准是「这条日志是否应该触发告警」——会告警的才是 error 以上。
与可观测性体系集成
日志只是可观测性三支柱(日志、指标、链路追踪)之一。孤立地优化日志,不如把它接入统一体系。
#include <opentelemetry/trace/provider.h>
// 在 formatter 中注入当前 span 的 trace_id / span_id
auto span = opentelemetry::trace::Tracer::GetCurrentSpan();
auto ctx = span->GetContext();
// ctx.trace_id() / ctx.span_id() 写入日志字段
集成的关键点:
- 统一时间基准:日志与追踪的时间戳都来自同一时钟源,否则无法按时间对齐。
- trace_id 贯穿:日志的
trace_id与追踪的trace_id必须同源,这是「从日志跳转到链路」的前提。 - 避免重复采集:日志与追踪都记录请求信息时,选一个作为主源,另一个只存引用。
- 采样协同:追踪采样率与日志采样率应协调,避免「有日志没链路」或反之。
OpenTelemetry 的 C++ SDK 提供了日志桥接(logs bridge),可以把 spdlog 的日志转发到 OTel 的日志管道,从而与追踪、指标统一导出。完整的体系设计可以参看 OpenTelemetry 完整指南 ,以及从另一门语言视角的 C# 日志与可观测性 。
在工程规范层面,日志的「级别使用约定」「字段命名规范」「保留周期」应当写进团队规范,与构建、依赖管理、测试一起构成工程实践的基线,这部分可以参考 https://plumephp.com/cpp-engineering-practices/。
实践建议
- 绝不裸用
std::cout:多线程下日志交错无法排障,且无法控制级别与轮转。 - 多线程一律用
_mtsink,单线程才用_st;拿不准就用_mt。 - 异步日志必须显式决定队列满的策略:要可用性选
overrun_oldest并接受丢日志,要完整性选block并接受延迟毛刺。 flush_on(level::err),让错误日志不因崩溃而丢失;关键路径手动flush()。- 热路径日志用宏形式(
SPDLOG_DEBUG)实现编译期裁剪,避免参数被提前求值。 - 结构化字段固定命名,时间戳用 ISO 8601 带时区,消息里的特殊字符必须做 JSON 转义。
- 异步 + MDC 时上下文要在入队前捕获,不要依赖后台线程的
thread_local。 - 日志量分级治理:debug 用环形缓冲按需倾倒,高频低价值日志采样,错误日志全量且独立通道。
- 让日志接入统一的 trace_id 体系,孤立日志的排障价值远低于可关联的日志。
日志系统的成熟度往往体现在「出事时能不能靠它定位」。一次线上故障里,真正有用的信息通常只有几十行,其余都是噪声。设计日志的目标不是「多记录」,而是在有限的磁盘与性能预算内,让关键事件可检索、可关联、可回溯。级别规范、结构化字段、trace_id 贯穿与环形缓冲,都是为这一个目标服务的。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。