秒杀系统高并发优化实战(C++ / Drogon):接入 MySQL 的第一个坑——getDbClient 为什么返回空
服务正常启动,日志干干净净地打出 starting on :8080,然后第一个请求进来,进程当场 SIGSEGV。gdb 抓到的栈只有函数名、没有行号,SeckillService::doSeckill 里的 db_->newTransactionAsync(...) 一声不吭地踩在空指针上——数据库客户端是空的,代码却把它当成已经连上数据库在用。
这不是什么玄学段错误,而是 Drogon 接入 MySQL 时几乎人人都会踩、却极少被写进教程的一个启动期大坑。本文把完整排查链展开:为什么服务能起来、为什么偏偏在请求时才崩、以及官方文档里那句被大多数人忽略的话到底是什么意思。
本文是「秒杀系统(C++ / Drogon)」系列的实战篇。配套仓库
seckill-cpp(GitHub: https://github.com/Hespethorn/seckill-cpp),修复涉及src/main.cc与src/service/SeckillService.cc。真实编译在 WSL 完成,MySQL 走sql/schema.sql建库建表。
一、是什么:Drogon 里获取 DB 客户端只有一条正路
Drogon 的 ORM 把数据库连接池托管在框架内部,业务代码不自己管理连接,而是通过框架接口拿客户端:
1 | // 1. 从 config.json 的 db_clients 段按名字取(最常见的用法) |
config.json 里声明好连接参数,框架负责建池、断线重连、按需分配连接:
1 | { |
看起来平平无奇:加载配置 → 取客户端 → 用。但这条链路里藏着一个时序陷阱,正是它让服务"启动成功、一请求就崩"。
二、坑在哪:服务能起来,第一个请求才崩
我把秒杀服务的依赖注入写成"标准"样子——启动时取好客户端,传给业务层:
1 | int main() { |
SeckillService::doSeckill 里第一件事就是开事务:
1 | void SeckillService::doSeckill(int64_t userId, int64_t skuId, |
现象链条:
| 阶段 | 表现 | 为什么 |
|---|---|---|
| 启动 | 正常打印 starting on :8080 |
loadConfigFile 只"注册配置",不建连接池,也没人碰那个空指针 |
| 首个请求 | Thread "DrogonIoLoop" received SIGSEGV |
handler 执行 → doSeckill → db_->... 空指针解引用 |
| gdb bt | 只有 SeckillService::doSeckill(...) (),没有行号 |
Release -O3 -DNDEBUG 构建没带 -g,符号表只有函数名 |
| 玄学感 | 崩在"业务第一行",跟配置、MySQL 都无关 | 根因在启动时那行"看起来没问题"的 getDbClient |
三、本质一句话
getDbClient()返回的不是"配置里声明的客户端",而是"已经创建好的客户端对象";而这个对象要等app.run()启动事件循环时才真正创建。在此之前调用,拿到的永远是空shared_ptr。
配置注册(loadConfigFile)和对象创建(run())是两个阶段。把 getDbClient 放在 run() 之前,等于在"注册表"里查一个还没实例化的条目——框架为了不抛异常,选择返回空指针。代码不崩在启动(因为没人用它),崩在第一个请求(因为业务真的去调它了)。
四、为什么会这样:Drogon 的启动生命周期
用一张图看清 Drogon 从 main 到服务可用的完整时间线:
Drogon 这样设计是刻意的:连接池要挂在事件循环(EventLoop)上,而事件循环是 run() 才启动的。在循环还没跑起来时建连接池,要么阻塞、要么拿到半成品,所以框架干脆把实例化推迟到 run(),并在 run() 之前对 getDbClient 一律返回空。
五、官方文档里那句话,值得加粗
Drogon 官方 Wiki 在 getDbClient 条目下有一句很容易被划过去的话:
"This method cannot be called before running app.run(), otherwise the user will get an empty shared_ptr."
——getDbClient()不能在app.run()之前调用,否则你会拿到一个空的shared_ptr。
中文直译就是:别在 run() 之前调,否则拿到空指针。这句话和"服务能启动、请求才崩"的症状严丝合缝——空指针不立刻爆,是因为没人立即用它。
六、修复:把依赖注入推迟到第一个请求
保持"Controller 依赖 Service、Service 依赖 DbClient"的注入结构不变,只是把取客户端的时机挪到 handler 里——handler 执行必然在 run() 之后,此时客户端一定就绪。用 static 局部变量保证只初始化一次,且 C++11 的 magic static 保证多线程安全:
1 | // main.cc:延迟初始化,绕开 run() 之前的空指针陷阱 |
三处关键改动:
getDbClient移进 lambda:执行时机从"启动时"变为"第一个请求时",必然在run()之后。static局部变量:整个进程生命周期只组装一次 Controller,避免每个请求重复创建 Service/连接句柄。- 防御性判空保留:即使未来有人把这段代码挪回启动阶段,也会得到明确的
LOG_FATAL而不是神秘的 SIGSEGV。
七、可运行验证步骤
在 WSL(Ubuntu)里完整复现并验证修复:
1 | # 1. 复现崩溃(修复前代码):服务起来,curl 一发就崩 |
八、排错方法论:这次是怎么定位的
这次排查的每一步都值得记下来,下次遇到"启动正常、请求崩溃"可以直接套用:
| 步骤 | 动作 | 结论 |
|---|---|---|
| 1 | gdb -batch -ex run -ex bt |
确认崩在 SeckillService::doSeckill,但 Release 无 -g 拿不到行号 |
| 2 | 用 -DCMAKE_BUILD_TYPE=Debug 重编 + ASan |
直接报 SeckillService.cc:26: member access within null pointer——空指针实锤 |
| 3 | 查 Drogon 库是否带 MySQL:`nm libdrogon.a | grep MysqlClient` |
| 4 | 查 config 是否被解析:打印 getCustomConfig() |
返回 null 会误判为"config 没加载"——注意 getCustomConfig() 只返回 custom_config 子段,不是整个文件 |
| 5 | 查文档:getDbClient 调用时机 |
命中:必须在 app.run() 之后调用 |
第 4 步是个容易误导人的点:getCustomConfig() 返回的是配置文件里 custom_config 字段(用户自定义区),如果没写这个字段就返回 null——它不能用来判断整个 config.json 是否加载成功。判断加载成功与否,直接看 loadConfigFile 有没有抛异常即可(它失败会 LOG_FATAL 并退出)。
九、设计取舍总结
| 决策点 | 选定方案 | 放弃方案 | 放弃的代价 |
|---|---|---|---|
| DB 客户端获取时机 | 延迟到 handler(run() 之后) | 启动时在 main() 里取 | 首次请求多一次 static 初始化(可忽略) |
| 依赖注入方式 | static 延迟单例 + 注入 Service | 全局裸指针 / 每次请求现取 | 全局单例状态,测试时需重置 |
| 空指针防御 | 判空 + LOG_FATAL |
不判空裸用 | 无(纯收益,把神秘 SIGSEGV 变明确报错) |
| 构建类型 | Debug(-g)用于排错 |
只跑 Release | Debug 性能略低,但排错必须有符号 |
| ORM 选型 | Drogon 内置 orm DbClient |
规划中的 mysql-connector-cpp + 自管连接池 | 框架绑定;换来连接池/重连/异步 API 全托管 |
一句话收尾:Drogon 的 getDbClient() 是一条"只有 run() 之后才开门"的通道,启动阶段调用不是"可能出错",而是"必然拿到空指针"。把依赖注入推迟到 handler,用 static 保证只初始化一次,既保住了注入结构,又踩在了框架的正确时机上——这是阶段一里第一次尝到"Drogon 有自己的生命周期,不能按普通 C++ 类的直觉来"的滋味,后面接入 Redis、MQ 时还会反复遇到同样的思维转变。
配套仓库:
https://github.com/Hespethorn/seckill-cpp(本文对应修复后的src/main.cc延迟初始化写法,扣减逻辑见src/service/SeckillService.cc)。本系列是作者个人的 C++ 秒杀系统实战记录,所有方案、代码与压测数据均为原创。

