秒杀接口上线后的第一个周末,运维问了我三个问题:端口在哪个文件改?请求失败到底是"抢光了"还是"系统错了"?出问题的时候日志在哪看?——三个问题我愣了一分钟。功能写完了,但**配置、日志、异常这三件"看不见的基础设施"**决定了一个系统能不能被运维、被排查、被信任。本文讲 seckill-cpp 在这三件事上的落地:config.json 怎么组织、Drogon 内置日志怎么用、错误如何从 SeckillService 一路映射成客户端看得懂的 HTTP 语义。

配套仓库 seckill-cpp(GitHub: https://github.com/Hespethorn/seckill-cpp),本文对应阶段一 v0.1.x,代码见 config.jsonsrc/main.ccsrc/controllers/SeckillController.ccsrc/service/SeckillService.ccsrc/controllers/HealthController.cc

一、基础设施是什么、坑在哪、本质一句话

  • 是什么:三个横切关注点——配置(环境相关的参数不硬编码)、日志(出问题时能还原现场)、异常(错误以统一的语义传回调用方)。
  • 坑在哪:基础设施的坑都是"平时看不见、出事才咬人"。配置放错位置 → 换环境部署时改代码;日志打太细 → 秒杀高峰期 IO 被日志拖垮;异常处理不一 → 客户端把"售罄"当成"系统错误"疯狂重试,把故障放大成雪崩。
  • 本质一句话基础设施的价值是"出事时的可运维性",而不是"正常运行时的功能"——它所有的设计取舍,都要以"真出问题时能不能快速定位"为标尺。

二、配置:config.json 的三段式组织

seckill-cpp 的配置全部外置到 config.json,三段各管一件事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"listeners": [{
"address": "0.0.0.0",
"port": 8080,
"https": false
}],
"app": {
"threads_num": 4,
"log_path": "./logs",
"log_level": "warn",
"run_as_daemon": false
},
"db_clients": [{
"name": "default",
"rdbms": "mysql",
"host": "127.0.0.1",
"port": 3306,
"dbname": "seckill",
"user": "seckill",
"passwd": "seckill",
"connection_number": 10
}]
}
  • listeners:监听端口与协议。https: false 是阶段一的选择——本地压测不需要 TLS 开销,代价是流量明文,部署到公网前必须开 HTTPS(见 2.2 接口设计的讨论)。
  • app:进程级参数。threads_num: 4 决定 Drogon 起几个 IO 线程(等于压测时的并发处理能力);log_path / log_level 控制日志去哪、打多细(下一节展开);run_as_daemon: false 让程序前台跑,方便 systemd 托管和调试。
  • db_clientsDrogon 内置连接池的声明。这里写的只是"配置",真正创建连接池对象要等 run()(详见 2.4 的 getDbClient 时序坑)。connection_number: 10 是连接池大小——它和阶段一 ~371 QPS 的瓶颈直接相关:10 条连接 × 每条持行锁到事务提交,就是你能同时处理的上限。

加载方式在 main.cc

1
drogon::app().loadConfigFile("./config.json");

一个容易误会的点:loadConfigFile 返回的是 HttpAppFramework&(链式调用),不是 bool。配置解析失败不是返回 false,而是抛异常——ConfigLoader 构造时 throw runtime_error,程序根本起不来。换句话说:能正常启动 = 配置必然加载成功,不需要在启动日志里找"配置加载失败"这种永远看不到的话。

三、日志:Drogon 内置日志够用在哪,能力边界在哪

Drogon 内置了基于 trantor 的日志(LOG_INFO / LOG_WARN / LOG_ERROR / LOG_FATAL),直接可用:

1
2
3
4
5
6
// src/main.cc
LOG_INFO << "seckill-cpp starting on :8080 (stage-1: DB-direct)";

// getSeckillController() 判空失败时
LOG_FATAL << "getDbClient(\"default\") returned null after run(): "
"check db_clients in config.json and Drogon MySQL support.";
  • LOG_FATAL 输出后直接终止进程——用在"配置正确性不可挽回"的场合(db 客户端为空意味着任何请求都会 SIGSEGV,与其崩得莫名其妙,不如带诊断信息退出)。
  • 日志去往 log_path./logs),级别由 log_level 控制。

功能抉择:为什么阶段一选内置日志,而不是立刻上重型异步日志方案?

方案 优点 代价
A. Drogon 内置(选定) 零额外依赖、API 极简、和 Drogon 生命周期天然对齐 同步落盘、无环形缓冲、无滚动策略细粒度控制
B. 异步日志(spdlog + 环形缓冲) 日志 IO 不阻塞业务线程、吞吐高几个数量级 多一个依赖 + 配置 + 队列丢弃策略要自己定

选 A,放弃的代价是"高峰期日志吞吐":同步落盘意味着每条日志都要等文件 IO 完成,日志打得多会直接拖慢 IO 线程。所以配套决定是日志级别默认 warn——秒杀高峰期把 info 全关掉,只留警告和错误。这是典型的"靠配置保命":代码里 LOG_INFO 照写,部署时用 log_level 决定开多少。等压测数据显示"日志本身成了瓶颈"的那一天,再换异步方案,代价是换来(换走)什么到时候用数据说话。

四、全局异常:从 SQL 异常到 HTTP 语义的一条链路

异常处理最容易做坏的地方是"每一层各自为政"。seckill-cpp 的做法是错误语义在服务层定死,状态码在控制器层统一映射

服务层 SeckillService(bool ok, std::string msg) 回调收口,语义在头文件里写死:

1
2
3
4
5
6
7
8
// 回调语义:
// (true, "OK") 下单成功
// (false, "SOLD_OUT") 库存不足
// (false, "DUPLICATE_ORDER") 重复下单(同一 user_id+sku_id 已存在)
// (false, "DB_ERROR: ...") 系统异常
void doSeckill(int64_t userId,
int64_t skuId,
std::function<void(bool, const std::string &)> &&callback);

SQL 层的异常在两处被转译:execSqlAsync异常回调拿到 std::exception_ptr,用 std::rethrow_exception 取消息,统一加工成 "DB_ERROR: " + what()

1
2
3
4
5
6
7
8
[tx, cb](const std::exception_ptr &eptr) {
tx->rollback();
try {
std::rethrow_exception(eptr);
} catch (const std::exception &ex) {
cb(false, std::string("DB_ERROR: ") + ex.what());
}
}

控制器层 SeckillController 再把业务语义映射成 HTTP 状态码:

1
2
3
4
5
6
7
8
9
10
11
12
root["code"] = ok ? 0 : 1;
root["msg"] = ok ? "success" : msg;

auto resp = drogon::HttpResponse::newHttpJsonResponse(root);
if (!ok) {
// 业务拒绝(售罄 / 重复下单)客户端不该重试;系统错误才值得重试。
if (msg == "SOLD_OUT" || msg == "DUPLICATE_ORDER") {
resp->setStatusCode(drogon::k409Conflict);
} else {
resp->setStatusCode(drogon::k500InternalServerError);
}
}

功能抉择:为什么用 409 而不是笼统的 400/500?

场景 HTTP body 客户端语义
下单成功 200 {"code":0,"msg":"success"} 完成
库存不足 409 {"code":1,"msg":"SOLD_OUT"} 不该重试,抢光了
重复下单 409 {"code":1,"msg":"DUPLICATE_ORDER"} 不该重试,已有订单
参数缺失 400 {"code":400,"msg":"missing or invalid userId/skuId"} 请求本身错了
DB 异常 500 {"code":1,"msg":"DB_ERROR: ..."} 可以重试,系统问题

选 409 的原因:它是"资源当前状态冲突"的标准语义(RFC 7231),比 400 更能表达"你的请求没问题、是资源状态不对"。这个区分直接关系到雪崩——如果售罄也返回 500,客户端重试逻辑会把流量持续打在已售罄的接口上;而 409 让客户端知道"别试了"。错误语义如果不在源头定死,一定会在某个中间层被吞掉或误分类。

入参校验放在控制器层第一道(缺 userId/skuId 直接 400,不进服务层):

1
2
3
4
5
6
7
8
9
10
const auto &json = req->getJsonObject();
if (!json || !json->isMember("userId") || !json->isMember("skuId")) {
Json::Value err;
err["code"] = 400;
err["msg"] = "missing or invalid userId/skuId";
auto resp = drogon::HttpResponse::newHttpJsonResponse(err);
resp->setStatusCode(drogon::k400BadRequest);
callback(resp);
return;
}

五、健康检查与排错脚本:可运维性的另一半

基础设施不只是代码,还有探活排错工具

  • GET /api/healthHealthController,无依赖、不碰 DB):给网关/探活/K8s liveness 用。秒杀服务挂了,负载均衡器靠它摘流量。
  • scripts/debug-wsl.sh 三件套:check(MySQL 连通 + 表数据 + Drogon 是否真带 MySQL 后端的 7 步探测)/ asan(ASan/UBSan 构建精确定位崩溃行)/ gdb(Debug 构建带行号 bt)。
  • scripts/smoke-seckill.sh:并发抢购 + MySQL 核对不超卖的回归验证——每次改完代码跑一遍,等于把"不超卖"这条底线变成自动化检查。

这三个加在一起,才让"出问题时能快速定位"成立:探活告诉你在哪台机器挂了,排错脚本告诉你为什么挂,冒烟脚本告诉你改完有没有破坏底线。

一次秒杀请求的完整路径:校验 / 业务 / 异常三层,各司其职 客户端 POST /api/seckill {"userId","skuId"} ① 控制器·校验 缺参 → 400 直接返回 不进入服务层 ② 服务层·业务 事务化原子扣减 回调 (ok, msg) MySQL ③ 控制器·统一映射 HTTP 状态码 SOLD_OUT / DUPLICATE → 409(客户端不该重试)| DB_ERROR → 500(值得重试) ④ 旁路基础设施 config.json(端口/线程/日志/连接池)· log_level=warn 保命 · /api/health 探活 · debug/smoke 脚本定位回归 原则:错误语义在服务层定死(ok,msg),状态码在控制器层统一映射,层与层之间不做二次解释。

六、可运行验证步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1) 启动服务(前台,方便看日志)
./build/src/seckill-cpp

# 2) 探活(健康检查不碰 DB,任何情况下都应 200)
curl -s localhost:8080/api/health
# 期望: {"status":"UP","service":"seckill-cpp","stage":"1-db-direct"}

# 3) 三种错误语义各打一发,确认状态码区分
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8080/api/seckill \
-H 'Content-Type: application/json' -d '{}'
# 期望: 400(缺参)
curl -s -X POST localhost:8080/api/seckill \
-H 'Content-Type: application/json' -d '{"userId":999999,"skuId":1}'
# 期望: 409 + {"code":1,"msg":"SOLD_OUT"}(库存耗尽场景)
curl -s -X POST localhost:8080/api/seckill \
-H 'Content-Type: application/json' -d '{"userId":1,"skuId":1}'
# 期望: 200 或 409 DUPLICATE_ORDER(重复场景)

# 4) 看日志去哪了
ls logs/ # log_path=./logs 下的日志文件
# 5) 回归底线(见脚本章节)
bash scripts/smoke-seckill.sh 100 10 1

七、基础设施取舍总结

决策点 选定 放弃的代价
配置 全部外置 config.json(三段式) 多一层文件管理;改端口/线程要改文件
日志 Drogon 内置 + log_level=warn 默认 无异步落盘/环形缓冲,高峰期日志吞吐受限
错误语义 服务层 (ok, msg) 定死 枚举/异常式表达更类型安全,字符串有拼写风险
状态码 业务拒绝 409 / 参数 400 / 系统 500 客户端需按文档区分,不能只认 200
连接池 connection_number: 10 池小限并发,池大耗内存;需按压测调
探活/排错 health + debug 三件套 + smoke 脚本 维护成本,但换排查效率

一句话收尾:配置让系统可部署,日志让事故可还原,异常语义让故障不扩散——三件套在功能跑通之后补上,才算是"能上线"而不是"能跑"。

配套仓库:https://github.com/Hespethorn/seckill-cpp(本文对应 v0.1.x,配置 config.json、错误链路 src/controllers/SeckillController.cc + src/service/SeckillService.cc、探活 src/controllers/HealthController.cc、排错 scripts/debug-wsl.sh、回归 scripts/smoke-seckill.sh)。本系列是作者个人的 C++ 秒杀系统实战记录,所有方案、代码与踩坑均为原创。