秒杀系统高并发优化实战(C++ / Drogon):2.6 整合高性能异步日志(spdlog + 环形缓冲,替代 Log4j2+Disruptor)
秒杀开始的第 3 秒,接口 RT 突然从 8ms 涨到 200ms,CPU 没满、MySQL 没满——最后定位到是日志在拖后腿:log_level 一开高,每个请求都在 IO 线程上同步写文件,日志磁盘成了隐藏的串行点。Java 生态有 Log4j2 + Disruptor(无锁环形队列)这套成熟异步日志方案,C++ 这边我把等价物自己写了一遍:一个互斥锁版的环形缓冲负责排队,spdlog 负责格式化与落盘。本文讲清楚环形缓冲为什么是异步日志的地基、三个关键取舍,以及 seckill-cpp 里刚落地的真实实现。
配套仓库
seckill-cpp(GitHub: https://github.com/Hespethorn/seckill-cpp),本文对应 main 分支v0.1.1之后的新实现,代码见src/logging/ring_buffer.h、src/logging/AsyncLogger.h、src/logging/AsyncLogger.cc、src/logging/LogStream.h(流式宏),集成见src/main.cc、src/service/SeckillService.cc、src/controllers/SeckillController.cc、config.json、CMakeLists.txt、scripts/setup-wsl.sh。
一、异步日志是什么、坑在哪、本质一句话
- 是什么:把"打日志"从调用线程同步完成改成调用线程只入队、后台线程批量落盘。业务线程的日志成本从"一次文件 IO"降为"一次内存拷贝 + 一个锁"。
- 坑在哪:同步日志的问题平时看不见——IO 线程写文件时磁盘抖动、日志量大、或落盘慢(
fsync),任何一个都会把业务延迟直接放大一个量级。而且日志是慢变量的放大器:平时日志量小无所谓,秒杀洪峰日志量暴涨,它就在你最忙的时候卡你。 - 本质一句话:日志必须从"业务路径上的串行 IO"变成"业务路径之外的一条异步旁路"——队列是这条旁路的地基,队列设计决定峰值时是"丢日志保业务"还是"保日志卡业务"。
二、环形缓冲:异步日志的地基
环形缓冲(ring buffer)是一个固定大小的循环数组:生产者往"写指针"处放数据,消费者从"读指针"处取数据,指针到末尾绕回开头。相比无界队列,它有两个决定性优点:
- 内存上界固定——
capacity个槽位,启动时就分配好,绝不会因为日志洪峰把内存吃爆; - 天然支持"满则丢"——数组满了就是满了,取舍明确,不需要在"分配内存"和"阻塞等待"之间纠结。
结构如下:
三、功能抉择一:自实现环形缓冲 vs 直接用 spdlog 内置异步池
spdlog 本身就内置了异步模式(spdlog::create_async + 全局 thread_pool,内部也是一个 MPMC 环形缓冲 + 后台线程)。那为什么还要自己写一个?
| 方案 | 优点 | 代价 |
|---|---|---|
| A. 自实现环形缓冲 + spdlog 只做 sink(选定) | 丢弃策略可控(spdlog 内置只到队列深度,丢弃行为不透明);能讲透队列语义;与系列"自实现组件"主线一致 | 多维护一段并发代码(约 80 行 + 测试) |
B. 直接用 spdlog::create_async |
零代码,一行搞定 | 队列策略是黑盒;超过 max_queue_size 时 spdlog 的默认行为是阻塞生产者——这在秒杀洪峰下不可接受 |
选 A,放弃的代价是"维护成本":多一段要写测试的并发代码。但换来的是两个决定性收益——丢弃策略掌握在自己手里(下面一节),以及队列行为完全可解释(面试/排障时能讲清楚每一拍发生了什么)。spdlog 我们只用来干它最擅长的事:格式化 + 按大小轮转的文件 sink。
四、功能抉择二:队列满时,丢新 / 丢旧 / 阻塞?
这是异步日志最关键的一个决定,三条路:
| 策略 | 行为 | 适用 | 代价 |
|---|---|---|---|
| A. 丢新(drop-newest,选定) | 满则拒绝新日志,计数 | 业务吞吐优先的场景 | 峰值日志有洞,需靠计数监控 |
| B. 丢旧(drop-oldest) | 满则覆盖最老的日志 | 要"最新的现场" | 历史被覆盖,对账不可靠 |
| C. 阻塞生产者 | 满则等消费者 | 日志一条都不能少 | 业务线程被日志拖死,等于把雪崩引回业务 |
选 A,放弃的代价是"日志完整性":秒杀场景的日志不是审计账本,是监控信号——信号丢了可以靠计数感知"丢了多少",业务卡死就什么都感知不到了。所以:业务吞吐 > 日志完整性。配套动作是 dropped() 计数必须暴露给监控(/api/health 里可以带),让"丢日志"本身成为可告警的事件,而不是静默发生。
五、功能抉择三:互斥锁版 vs 无锁队列
Disruptor 的精髓是无锁(CAS + 屏障),spdlog 的 thread_pool 也是无锁环形队列。我这个版本用的是互斥锁:
| 维度 | 互斥锁版(选定) | 无锁版(Disruptor / spdlog pool) |
|---|---|---|
| 正确性 | 天然安全,逻辑直白 | 需要极小心地设计内存序(acquire/release)、ABA、伪共享 |
| 吞吐 | 受锁竞争限制 | 高一个量级(CAS 级) |
| 调试成本 | 低 | 高,出 bug 极难复现 |
| 适合 | 日志写入频率低(warn 级别) | 日志写入频率极高的场景 |
选互斥锁,放弃的代价是"理论吞吐上限"。判断依据很简单:日志级别默认 warn,秒杀热路径只在异常时打日志,写入频率低到锁竞争可以忽略;无锁队列在低竞争下的优势根本发挥不出来,却要把最难的并发正确性问题背在身上。等压测数据显示"日志成了瓶颈",再换无锁——这是系列一贯的路线:不预支复杂度,用数据决定要不要买。
六、真实实现(仓库 main 分支,日志调用宏化后)
三个文件,先看环形缓冲本体(src/logging/ring_buffer.h,模板类,核心语义):
1 | template <typename T, std::size_t Capacity> |
两个细节值得说:
waitAndPopBatch一次取空:消费者(后台线程)被唤醒一次就拿走全部,而不是逐条取。这是"批量"二字的来源——落盘以批为单位,写放大被均摊,线程切换次数被压到最低。- 元素用
std::move搬移:环形缓冲里存的是LogMessage{level, std::string},入队/出队全程零拷贝(只搬std::string指针)。
日志器本体(src/logging/AsyncLogger.h / .cc)的关键路径:
1 | void AsyncLogger::start(const std::string &file, spdlog::level::level_enum level) { |
三个值得注意的工程细节:
"{}"显式占位:logger->log(level, msg)的第二个参数会被 spdlog 当格式串解析,消息里若含{}会触发 format error。写成log(level, "{}", msg)把消息当参数,彻底规避。flush_on(warn):warn 及以上立即 flush——关键时刻(售罄、重复下单、DB 异常)的日志不能留在缓冲区里等凑批,进程崩溃时就是"最后一条日志"。- 降级不丢日志:打不开日志文件(目录无权限等)时退化为 stderr,绝不静默吞掉。
集成到启动流程(src/main.cc,配置走 Drogon 的 custom_config):
1 | drogon::app().loadConfigFile("./config.json"); |
配套配置(config.json 新增段):
1 | "custom_config": { |
构建接线:CMakeLists.txt 加 find_package(spdlog REQUIRED)、src/CMakeLists.txt 加 logging/AsyncLogger.cc 与 spdlog::spdlog,scripts/setup-wsl.sh 的 apt 安装列表加 libspdlog-dev。
业务调用层先补一层流式宏(src/logging/LogStream.h),业务代码才从"拼字符串 + 调实例"两步,收敛成"一行流式宏":
1 | // src/logging/LogStream.h —— 一个轻量流式包装:析构即提交 |
两个关键设计,对应两个此前踩过的坑:
SK_前缀避重定义:Drogon/trantor 已经提供全局的LOG_INFO/LOG_WARN/LOG_ERROR等同步宏,业务侧再定义同名宏会直接编译冲突。SK_前缀既避开冲突,又一眼能区分"走异步环形缓冲的业务日志"与"Drogon 框架自己的同步日志"。enabled()级别短路:AsyncLogger::enabled(lvl)在拼字符串之前先问一句"这条要不要打"——不打就整条语句跳过,连字符串都不拼。秒杀洪峰期,如果每条 info/debug 都先拼完、再在后台被级别过滤掉,白白付出的 CPU 会直接吃业务线程的时间片;短路把这笔浪费在源头掐掉。(enabled()用的是 atomic 读level_,业务线程无锁。)
唯一例外是
main.cc里getDbClient返回空的分支:那里刻意用 Drogon 的同步LOG_FATAL而非异步SK_LOG_*,因为进程马上exit,异步日志还躺在环形缓冲里来不及被后台线程取走,致命错误必须同步落盘,否则现场直接丢失。
真实调用点(src/service/SeckillService.cc + src/controllers/SeckillController.cc,秒杀热路径的业务告警统一走异步通道):
七、与 Log4j2 + Disruptor 对照(中性)
| 组件 | Java 生态(Log4j2 + Disruptor) | 本实现(C++) |
|---|---|---|
| 队列 | Disruptor 无锁环形缓冲(Sequence + 屏障,CAS) | 自实现互斥锁版环形缓冲(ring_buffer.h) |
| 落盘 | Log4j2 appender(异步批量) | spdlog rotating_file_sink(5MB × 3 轮转) |
| 满队列策略 | 可配(丢/阻塞/超时) | 丢新(drop-newest)+ dropped() 计数 |
| 核心取舍 | 无锁是亮点也是复杂度来源 | 低竞争场景锁足够,正确性优先 |
本质上是同一套异步日志范式(生产者入队 + 消费者批量落盘),差异在"无锁化"这一步做不做。秒杀阶段一不追极致日志吞吐,锁版完全够用;哪天日志真的成了瓶颈,把 RingBuffer 换成无锁实现、AsyncLogger 一行不用改——队列语义与落盘职责的分离,就是为了让这一步替换只发生在内部。
八、可运行验证步骤
1 | # 1) 一次性装依赖(含 libspdlog-dev),然后编译 |
验证重点不是"日志能打出来",而是 smoke-seckill.sh 100 并发抢 10 件依然不超卖、RT 没有因日志而抖动——异步日志的收益就在这条命令的结果里。
九、日志方案取舍总结
| 决策点 | 选定 | 放弃的代价 |
|---|---|---|
| 队列实现 | 自实现环形缓冲(mutex 版) | 多维护 ~80 行并发代码 + 测试 |
| 满队列策略 | 丢新(drop-newest) | 峰值日志有洞,靠计数监控补 |
| 落盘 | spdlog rotating_file_sink(5MB × 3) | 多一个 apt 依赖(libspdlog-dev) |
| 级别过滤 | 默认 warn + flush_on(warn) |
warn 以下消息只进 Drogon 内置日志通道 |
| 无锁化 | 暂不做,数据说话 | 理论上限低于 Disruptor 类方案 |
| 队列与落盘 | 职责分离(换实现不动上层) | 多一层抽象 |
一句话收尾:异步日志的本质是把"日志 IO"从业务路径上摘出去,环形缓冲负责排队与丢弃策略,spdlog 负责格式化与落盘——两者边界清晰,替换任一实现都不影响另一端。这套结构与 Log4j2+Disruptor 同源,但每一行取舍都是按秒杀场景自己的量级做的。
配套仓库:
https://github.com/Hespethorn/seckill-cpp(本文对应main分支最新实现,日志模块src/logging/(含ring_buffer.h+AsyncLogger.*+LogStream.h)、集成src/main.cc+src/service/SeckillService.cc+src/controllers/SeckillController.cc、配置config.json)。本系列是作者个人的 C++ 秒杀系统实战记录,所有方案、代码与踩坑均为原创。

