C++ 日志与结构化可观测性:spdlog 与异步日志

日志既是排障的第一现场,也是性能的隐形杀手。本文拆解 spdlog 的 logger/sink/formatter 架构,讲清同步与异步日志的队列模型与丢日志边界,演示结构化 JSON 日志与字段设计,并给出编译期级别裁剪、backtrace 环形缓冲与 OpenTelemetry 集成的落地方案。

服务出问题时,日志是唯一能还原现场的东西;服务正常时,日志又往往是最大的性能开销与磁盘占用来源。这两个诉求相互冲突,导致日志系统的设计本质上是一系列取舍:写多少、写多快、丢了怎么办、格式给谁看。

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毫秒%nlogger 名称
%^ %$颜色起始/结束%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 日志仍要格式化并占内存。

队列开销与采样

异步日志的入队需要一次内存分配 + 一次拷贝(消息进队列)。在每秒百万条的高频场景下,分配器会成为瓶颈。优化方向:

  1. 消息复用:spdlog 内部用内存池(details::log_msg_buffer)减少分配。
  2. 采样:对高频同类日志按比例丢弃。if (counter++ % 1000 == 0) spdlog::info(...)。
  3. 限制字段:源位置(%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/。

实践建议

  1. 绝不裸用 std::cout:多线程下日志交错无法排障,且无法控制级别与轮转。
  2. 多线程一律用 _mt sink,单线程才用 _st;拿不准就用 _mt。
  3. 异步日志必须显式决定队列满的策略:要可用性选 overrun_oldest 并接受丢日志,要完整性选 block 并接受延迟毛刺。
  4. flush_on(level::err),让错误日志不因崩溃而丢失;关键路径手动 flush()。
  5. 热路径日志用宏形式(SPDLOG_DEBUG)实现编译期裁剪,避免参数被提前求值。
  6. 结构化字段固定命名,时间戳用 ISO 8601 带时区,消息里的特殊字符必须做 JSON 转义。
  7. 异步 + MDC 时上下文要在入队前捕获,不要依赖后台线程的 thread_local。
  8. 日志量分级治理:debug 用环形缓冲按需倾倒,高频低价值日志采样,错误日志全量且独立通道。
  9. 让日志接入统一的 trace_id 体系,孤立日志的排障价值远低于可关联的日志。

日志系统的成熟度往往体现在「出事时能不能靠它定位」。一次线上故障里,真正有用的信息通常只有几十行,其余都是噪声。设计日志的目标不是「多记录」,而是在有限的磁盘与性能预算内,让关键事件可检索、可关联、可回溯。级别规范、结构化字段、trace_id 贯穿与环形缓冲,都是为这一个目标服务的。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. C++ Unicode 与文本处理:编码转换与高性能字符串
  2. C++ 数值计算与线性代数:Eigen 与表达式模板
  3. C++ 静态分析与代码质量工具链:clang-tidy 与 Clang Static Analyzer