秒杀开始的第 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.hsrc/logging/AsyncLogger.hsrc/logging/AsyncLogger.ccsrc/logging/LogStream.h(流式宏),集成见 src/main.ccsrc/service/SeckillService.ccsrc/controllers/SeckillController.ccconfig.jsonCMakeLists.txtscripts/setup-wsl.sh

一、异步日志是什么、坑在哪、本质一句话

  • 是什么:把"打日志"从调用线程同步完成改成调用线程只入队、后台线程批量落盘。业务线程的日志成本从"一次文件 IO"降为"一次内存拷贝 + 一个锁"。
  • 坑在哪:同步日志的问题平时看不见——IO 线程写文件时磁盘抖动、日志量大、或落盘慢(fsync),任何一个都会把业务延迟直接放大一个量级。而且日志是慢变量的放大器:平时日志量小无所谓,秒杀洪峰日志量暴涨,它就在你最忙的时候卡你。
  • 本质一句话日志必须从"业务路径上的串行 IO"变成"业务路径之外的一条异步旁路"——队列是这条旁路的地基,队列设计决定峰值时是"丢日志保业务"还是"保日志卡业务"。

二、环形缓冲:异步日志的地基

环形缓冲(ring buffer)是一个固定大小的循环数组:生产者往"写指针"处放数据,消费者从"读指针"处取数据,指针到末尾绕回开头。相比无界队列,它有两个决定性优点:

  1. 内存上界固定——capacity 个槽位,启动时就分配好,绝不会因为日志洪峰把内存吃爆;
  2. 天然支持"满则丢"——数组满了就是满了,取舍明确,不需要在"分配内存"和"阻塞等待"之间纠结。

结构如下:

环形缓冲:生产者在写指针处入队,消费者在读指针处批量出队 生产者 Drogon IO 线程 / 协程 业务路径上只 push push 环形缓冲(capacity 槽位) 槽0 槽1 槽2 槽N 读指针 → 写指针 满则丢新(drop-newest) 批量取 消费者(后台线程) 一次取一批 → spdlog 落盘(file sink) 队列满的三种策略 丢新 / 丢旧 / 阻塞生产者 spdlog 负责的部分 格式化 + 轮转落盘(5MB × 3) 丢掉的日志去哪了? dropped() 计数 → 监控告警,不静默 分界:环形缓冲(排队 + 丢弃策略)自己实现;格式化与文件 IO(sink)复用 spdlog。 关键收益:业务线程永远不做文件 IO;日志洪峰被队列吸收,峰形被拉平。

三、功能抉择一:自实现环形缓冲 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
template <typename T, std::size_t Capacity>
class RingBuffer {
public:
// 满则丢(drop-newest):返回 false 表示本次被丢弃。
bool push(T item) {
std::lock_guard<std::mutex> lk(mu_);
if (count_ == Capacity) {
++dropped_;
return false;
}
buf_[(head_ + count_) % Capacity] = std::move(item);
++count_;
cv_.notify_one();
return true;
}

// 阻塞直到有数据;返回本次取出的条数(0 = 已 stop 且队列已空)。
std::size_t waitAndPopBatch(std::array<T, Capacity> &out) {
std::unique_lock<std::mutex> lk(mu_);
cv_.wait(lk, [this] { return count_ > 0 || stop_; });
std::size_t n = 0;
while (count_ > 0) {
out[n] = std::move(buf_[head_]);
head_ = (head_ + 1) % Capacity;
--count_;
++n;
}
return n;
}
// ... requestStop() / dropped() 略
private:
std::array<T, Capacity> buf_{};
std::size_t head_ = 0, count_ = 0, dropped_ = 0;
bool stop_ = false;
mutable std::mutex mu_;
std::condition_variable cv_;
};

两个细节值得说:

  • waitAndPopBatch 一次取空:消费者(后台线程)被唤醒一次就拿走全部,而不是逐条取。这是"批量"二字的来源——落盘以批为单位,写放大被均摊,线程切换次数被压到最低。
  • 元素用 std::move 搬移:环形缓冲里存的是 LogMessage{level, std::string},入队/出队全程零拷贝(只搬 std::string 指针)。

日志器本体(src/logging/AsyncLogger.h / .cc)的关键路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void AsyncLogger::start(const std::string &file, spdlog::level::level_enum level) {
if (running_.exchange(true)) return; // 幂等
try {
std::filesystem::create_directories("logs"); // file sink 不建目录,先补上
auto fileSink =
std::make_shared<spdlog::sinks::rotating_file_sink_mt>(
file, 5 * 1024 * 1024, 3); // 5MB × 3 轮转
fileLogger_ = std::make_shared<spdlog::logger>("seckill", std::move(fileSink));
fileLogger_->set_level(level);
fileLogger_->flush_on(spdlog::level::warn); // warn 及以上立即 flush
} catch (const spdlog::spdlog_ex &e) {
fileLogger_ = spdlog::stderr_color_mt("seckill_fallback"); // 降级不丢日志
fileLogger_->error("AsyncLogger: fallback to stderr, {}", e.what());
}
worker_ = std::thread([this] { workerLoop(); });
}

void AsyncLogger::workerLoop() {
std::array<LogMessage, kCapacity> batch;
for (;;) {
std::size_t n = buf_.waitAndPopBatch(batch);
if (n == 0) break; // stop 且排空
for (std::size_t i = 0; i < n; ++i) {
fileLogger_->log(batch[i].level, "{}", batch[i].msg); // "{}" 防花括号被当格式串
}
}
}

void AsyncLogger::log(spdlog::level::level_enum lvl, std::string msg) {
if (!running_.load(std::memory_order_relaxed)) return;
buf_.push(LogMessage{lvl, std::move(msg)}); // 满则丢,业务线程不阻塞
}

三个值得注意的工程细节:

  1. "{}" 显式占位logger->log(level, msg) 的第二个参数会被 spdlog 当格式串解析,消息里若含 {} 会触发 format error。写成 log(level, "{}", msg) 把消息当参数,彻底规避。
  2. flush_on(warn):warn 及以上立即 flush——关键时刻(售罄、重复下单、DB 异常)的日志不能留在缓冲区里等凑批,进程崩溃时就是"最后一条日志"。
  3. 降级不丢日志:打不开日志文件(目录无权限等)时退化为 stderr,绝不静默吞掉。

集成到启动流程(src/main.cc,配置走 Drogon 的 custom_config):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
drogon::app().loadConfigFile("./config.json");

// 异步日志配置来自 config.json 的 custom_config.async_log 段
{
const auto &cc = drogon::app().getCustomConfig();
std::string logFile = "./logs/seckill.log";
std::string logLevel = "warn";
if (cc.isMember("async_log")) {
const auto &al = cc["async_log"];
if (al.isMember("file")) logFile = al["file"].asString();
if (al.isMember("level")) logLevel = al["level"].asString();
}
seckill::log::AsyncLogger::instance().start(
logFile, spdlog::level::from_str(logLevel));
}

配套配置(config.json 新增段):

1
2
3
"custom_config": {
"async_log": { "file": "./logs/seckill.log", "level": "warn" }
}

构建接线:CMakeLists.txtfind_package(spdlog REQUIRED)src/CMakeLists.txtlogging/AsyncLogger.ccspdlog::spdlogscripts/setup-wsl.sh 的 apt 安装列表加 libspdlog-dev

业务调用层先补一层流式宏src/logging/LogStream.h),业务代码才从"拼字符串 + 调实例"两步,收敛成"一行流式宏":

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// src/logging/LogStream.h —— 一个轻量流式包装:析构即提交
namespace seckill::log {
class LogStream {
public:
explicit LogStream(spdlog::level::level_enum lvl) : lvl_(lvl) {}
~LogStream() {
// 析构即提交:临时对象在语句结束时析构,把拼好的字符串 push 进环形缓冲
AsyncLogger::instance().log(lvl_, oss_.str());
}
LogStream(const LogStream &) = delete; // 禁止拷贝/移动:必须是语句内临时量
template <typename T>
LogStream &operator<<(T &&v) { oss_ << std::forward<T>(v); return *this; }
private:
spdlog::level::level_enum lvl_;
std::ostringstream oss_;
};
}

// 宏:SK_ 前缀避 Drogon 自带 LOG_* 重定义;enabled() 先做级别短路
#define SK_LOG(level) \
if (!::seckill::log::AsyncLogger::instance().enabled(level)) { \
} else \
::seckill::log::LogStream(level)
#define SK_LOG_TRACE SK_LOG(spdlog::level::trace)
#define SK_LOG_DEBUG SK_LOG(spdlog::level::debug)
#define SK_LOG_INFO SK_LOG(spdlog::level::info)
#define SK_LOG_WARN SK_LOG(spdlog::level::warn)
#define SK_LOG_ERROR SK_LOG(spdlog::level::err)
#define SK_LOG_CRITICAL SK_LOG(spdlog::level::critical)

// 业务侧(src/service/SeckillService.cc)—— 流式、类型安全、不用 std::to_string
if (result.affectedRows() == 0) {
tx->rollback();
SK_LOG_WARN << "SOLD_OUT skuId=" << skuId; // 等价于旧的 instance().log(warn, ...),但更顺手
cb(false, "SOLD_OUT");
return;
}

两个关键设计,对应两个此前踩过的坑:

  1. SK_ 前缀避重定义:Drogon/trantor 已经提供全局的 LOG_INFO/LOG_WARN/LOG_ERROR同步宏,业务侧再定义同名宏会直接编译冲突。SK_ 前缀既避开冲突,又一眼能区分"走异步环形缓冲的业务日志"与"Drogon 框架自己的同步日志"。
  2. enabled() 级别短路AsyncLogger::enabled(lvl) 在拼字符串之前先问一句"这条要不要打"——不打就整条语句跳过,连字符串都不拼。秒杀洪峰期,如果每条 info/debug 都先拼完、再在后台被级别过滤掉,白白付出的 CPU 会直接吃业务线程的时间片;短路把这笔浪费在源头掐掉。(enabled() 用的是 atomic 读 level_,业务线程无锁。)

唯一例外是 main.ccgetDbClient 返回空的分支:那里刻意用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 1) 一次性装依赖(含 libspdlog-dev),然后编译
bash scripts/setup-wsl.sh
bash scripts/build-wsl.sh

# 2) 启动并确认异步日志器起来了
./build/src/seckill-cpp
# 启动日志应含: async logger started: file=./logs/seckill.log level=warn capacity=4096

# 3) 触发一次售罄,确认日志真的落盘
# 先把库存置 0:mysql -h127.0.0.1 -P3306 -useckill -pseckill seckill \
# -e "UPDATE seckill_sku SET stock=0 WHERE id=1;"
curl -s -X POST localhost:8080/api/seckill \
-H 'Content-Type: application/json' -d '{"userId":1001,"skuId":1}'
tail -f logs/seckill.log
# 期望看到: [2026-.. ..:..:..] [warning] SOLD_OUT skuId=1

# 4) 压测下验证"日志不拖业务":对比开/关异步日志的 RT 分布
bash scripts/smoke-seckill.sh 100 10 1 # 业务路径照常不超卖

验证重点不是"日志能打出来",而是 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++ 秒杀系统实战记录,所有方案、代码与踩坑均为原创。