Drogon 性能很高,为什么一个慢请求仍能拖垮服务?异步 C++ Web 实战
Drogon 是支持 C++14/17/20 的异步 Web 框架,提供 HTTP、WebSocket、数据库、Redis、过滤器、中间件、文件处理与协程等能力。它能用少量 I/O 线程承载大量连接,但“使用高性能框架”不等于业务代码自动非阻塞。
如果 handler 中执行同步模型推理、读取大文件、等待没有 timeout 的上游请求,event loop 仍会被占住;一个慢请求就可能让同一线程上的其他连接一起变慢。
本文围绕这个问题讲清:
- Drogon 的 callback 异步模型怎样工作;
- 如何写可编译、可测试的 JSON API;
- 数据库、Redis、HTTP 上游和 CPU 重活应放在哪里;
- 做 AI 网关时如何处理超时、上传、SSE 与客户端断连。
一、先理解 Drogon 为什么快
典型请求链路是:
1 | socket event |
核心不是 C++ 语法,而是 handler 等待 I/O 时不占住线程。官方接口把 response callback 作为参数,就是让函数可以在异步操作完成后再响应。
三条必须遵守的规则
- callback 最终应被调用一次;
- 发起异步 I/O 后立即返回,不在 event loop 忙等;
- callback 捕获的对象必须活到异步完成,避免悬空引用。
调用两次 callback 可能产生重复响应;忘记调用会让连接一直等待;用 [&] 捕获局部变量,函数返回后异步回调再访问,就会触发未定义行为。
二、最小可运行项目
沿用 CMake,不要求全局安装新工具。若项目已经通过 vcpkg、Conan、系统包或源码子模块提供 Drogon,应继续使用原方式。
1 | drogon_demo/ |
CMakeLists.txt
1 | cmake_minimum_required(VERSION 3.16) |
main.cc
1 |
|
这个示例刻意做了几件工程化小事:
- 默认只监听回环地址,避免学习服务意外暴露到局域网;
- 使用
std::from_chars严格校验整数,12x不会被当成 12; - 所有错误采用同一 JSON 结构;
- 提前返回保证错误分支不会继续调用 callback;
- 编译器警告保持开启。
配置、构建和运行:
1 | cmake -S . -B build -DCMAKE_BUILD_TYPE=Release |
最小验证:
1 | curl -i http://127.0.0.1:8080/health |
构建命令限制并行度,避免在小机器上无界并行。若 Drogon 不在默认 CMake 搜索路径,应按项目现有包管理器传 toolchain/prefix,不要直接修改系统目录。
三、直接 handler 还是 Controller
registerHandler() 适合健康检查和很小的 demo。接口增多后使用 Controller 能把路由与实现分开。
HttpSimpleController
适合单个或少量固定路径:
1 |
|
HttpController
适合一组 REST 接口:
1 |
|
Controller 只应负责 HTTP 解析、调用 service、映射结果。SQL、上游重试、复杂事务和模型逻辑堆进 Controller,会让测试与错误处理迅速失控。
一个够用的目录结构是:
1 | src/ |
不要为了形式提前制造接口、工厂和适配器。只有业务确实变复杂时再分层。
四、什么代码会阻塞 event loop
下面的 handler 看起来简单,却会让当前 I/O 线程停 5 秒:
1 | app().registerHandler( |
同类阻塞还包括:
- 同步 HTTP/SDK 调用;
- 大文件同步读写;
- 同步数据库驱动;
.get()等待 future;- 大量压缩、图片解码和 PDF 解析;
- 本地模型推理;
- 长循环或锁竞争。
处理方式按任务类型选择:
- 数据库:用 Drogon async/coroutine API;
- HTTP 上游:用异步
HttpClient; - 小型 CPU 工作:放到有界工作线程池,完成后回调;
- 重型/长任务:交给独立 worker 或模型服务,返回 202 + job ID;
- 交互式 LLM:调用支持动态 batching 与取消的专用推理服务。
把线程数从 4 调到 100 只会掩盖阻塞,还会增加内存与上下文切换,不是根治。
五、JSON 请求体必须做字段级校验
getJsonObject() 成功只代表 JSON 能解析,不代表业务合法:
1 | void createDocument( |
这里按 byte 限制只是示例。若产品要求“200 个 Unicode 字符”,需要明确 UTF-8 code point 或 grapheme 语义,不能把 std::string::size() 当成人类字符数。
还应限制 body 大小、数组长度、嵌套深度和未知字段策略。反序列化之后才拒绝超大请求,可能已经消耗大量内存,网关/框架层要先设置总体上限。
六、数据库查询应保持参数化和异步
不要拼接用户输入:
1 | const auto sql = "SELECT id, name FROM users WHERE id = " + userInput; |
使用参数占位和 async callback:
1 | auto database = drogon::app().getDbClient(); |
生产响应不应把 error.base().what() 原样返回给客户端,避免泄露 SQL、表结构和连接信息;详细错误写结构化日志,客户端拿稳定错误码和 request ID。
MySQL/MariaDB 与 PostgreSQL 的占位符风格可能不同,应按当前 Drogon database backend 文档使用。事务中的每一步也要使用同一 client/transaction 对象,不能在异步回调中随意换连接。
协程并不会让阻塞代码自动异步
C++20 Task<> 与 execSqlCoro() 能把回调写成顺序控制流,可读性更好。但在协程中调用同步文件 I/O 或 sleep_for,仍然会阻塞执行它的线程。协程是组织异步的语法,不是线程池。
七、异步代理上游服务
Drogon 做网关时,可用异步 HttpClient 调用 FastAPI 或模型服务:
1 | void proxyRequest( |
sendRequest 最后的 timeout 及具体重载应按项目锁定的 Drogon 版本核对。上游连接失败通常映射 502,网关等待超时映射 504;上游返回的 4xx/5xx 是否透传,应由明确协议决定。
代理时不能无脑复制一切
- 不转发 hop-by-hop header;
- 重新生成或传递受控的 request/trace ID;
- 只向可信上游传递必要身份信息;
- 限制请求/响应体;
- 设连接、读取和总 deadline;
- 重试只用于幂等请求或带幂等键的操作;
- 不把内部错误栈直接暴露给外部。
若网关、FastAPI 和模型服务各自都重试三次,一次请求可能放大为 27 次调用。应统一 retry budget,并把总 deadline 向下游传递。
八、上传文件最大的风险不是解析失败
使用 MultiPartParser 前先设计:
- 总 body 和单文件大小上限;
- 文件数量上限;
- 允许的 MIME/扩展名组合;
- 服务端生成的 object key;
- 路径规范化,禁止
../; - 内容哈希与幂等键;
- 恶意文件扫描和隔离区;
- 上传中断后的临时文件清理;
- 谁能读取、删除和重新处理。
不要直接用 getFileName() 作为保存路径。原始文件名只适合经过转义后作为展示元数据。
AI 文档处理推荐流程:
1 | Drogon 接收并流式保存 |
大 PDF 解析和 embedding 不应在上传 handler 里完成。
九、Filter、Middleware 与业务鉴权的边界
Filter 适合路由前拒绝请求,Middleware 适合围绕处理链统一观察或修改。常见职责:
- 身份认证;
- request ID 与结构化访问日志;
- CORS 与安全 header;
- body/速率限制;
- 统一错误映射;
- 延迟指标。
不要把 API key 写成源码常量。鉴权代码应读取安全配置,并使用项目的安全比较函数:
1 | const auto providedKey = request->getHeader("x-api-key"); |
凭证应来自安全配置,日志中脱敏,比较方式与轮换策略按鉴权方案实现。Filter 通过后,业务层仍要做对象级授权,例如 tenant A 不能凭一个合法 token 读取 tenant B 的文档。
Session 依赖 Cookie,适合传统浏览器会话;水平扩容时要确认 session store、Cookie 的 Secure/HttpOnly/SameSite、过期和密钥轮换。无状态 API 常使用经过验证的 token,但同样需要撤销、权限与过期策略。
十、Redis 适合缓存,不应成为所有状态的替代品
Drogon 的异步 Redis client 可用于缓存、限流和短期状态。需要注意:
- key 要包含租户和版本;
- 设置 TTL 并定义缓存失效;
- cache miss 时防止大量请求击穿后端;
- 多命令原子性需要事务或 Lua;
- Redis 失败时明确 fail-open 还是 fail-closed;
- 不把长期审计和唯一事实只放缓存。
回调中同样不能执行重 CPU 工作。Redis 快不代表网络永远不超时,也应有错误指标和降级策略。
十一、SSE 代理为什么比普通 HTTP 代理难
LLM 常返回:
1 | data: {"delta":"hello"} |
网关若先聚合完整 response 再返回,用户会一直看不到 token,还会让网关为每个请求缓存整段输出。
正确的流式代理需要:
- 尽早返回
text/event-streamheader; - 上游每到一块就写入下游并 flush;
- 不对 SSE 做普通响应缓存或整包压缩缓冲;
- 正确处理不完整 UTF-8、SSE 行与 JSON 跨 chunk;
- 客户端断开时取消上游生成;
- 设置 idle timeout、总 deadline 和心跳;
- 处理背压,慢客户端不能无限积压内存。
Drogon 的 streaming API 在版本间可能变化,必须按项目当前版本的 response stream 示例实现并测试生命周期。不要把普通 sendRequest() 的完整响应回调误当作增量上游流。
TCP chunk 不等于 SSE event
一次网络回调可能只含半个 data: 行,也可能包含多个 event。代理若需要解析或审计 SSE,必须维护增量 buffer;若只是透明转发,也要确保不会破坏字节顺序和取消语义。
十二、客户端断连后为什么必须取消上游
浏览器关闭页面后,如果网关仍让模型生成 2000 token:
- GPU 继续计费和占 KV Cache;
- 网关继续接收数据;
- 最终写入一个无人读取的连接;
- 高并发下形成“幽灵请求”。
应把下游连接生命周期与上游请求/任务的 cancellation 绑定。取消是协作式的:网关停止读取不一定让模型立即停止,内部协议要支持 request ID 与 cancel endpoint/信号。
十三、配置文件与敏感信息
配置文件适合 listeners、线程数、日志、连接池名称和非敏感上限:
1 | { |
数据库密码、Redis 凭证和 API key 不应提交进仓库。如何把秘密注入 Drogon 要遵循项目部署系统;不要为了方便把 .env 内容复制到 config.json。
配置加载失败应让服务启动失败,而不是使用不安全默认值。启动时记录配置版本和非敏感摘要,不打印凭证。
十四、Drogon 与 FastAPI/Celery 怎样分工
不必为了性能强行增加 Drogon。一个清晰的决策是:
| 场景 | 更自然的选择 |
|---|---|
| Python 模型/数据原型和内部 API | FastAPI |
| 长耗时、可重试后台任务 | Celery 或专用 worker |
| C++ 业务已有大量库、低延迟入口 | Drogon |
| 动态 batching 的 LLM 推理 | vLLM/SGLang 等专用服务 |
| 网关已有成熟 Envoy/Nginx/API Gateway | 优先复用,不必重写 |
当确实采用混合架构时:
1 | Client |
每多一层都会增加延迟、故障点、协议和部署成本。只有职责与收益清晰时才引入,不要因为“C++ 更快”把简单系统拆成三套框架。
十五、错误码与 deadline 应端到端一致
网关常见映射:
| 情况 | HTTP 状态 |
|---|---|
| 身份缺失/无效 | 401 |
| 已认证但无权限 | 403 |
| 请求体或参数非法 | 400 / 422 |
| body 超限 | 413 |
| 限流 | 429 |
| 上游连接/协议错误 | 502 |
| 上游超过 deadline | 504 |
| 服务主动过载 | 503 |
错误响应保持稳定:
1 | { |
总 deadline 应由入口创建并逐层缩短。网关 5 秒、FastAPI 10 秒、模型服务 30 秒会导致网关放弃后下游仍做无用工作。
十六、测试不应只访问 /health
最小测试矩阵:
- 正常与非法路径/查询/JSON;
- 超大 body、多个文件和中断上传;
- 数据库/Redis 连接失败;
- 上游连接拒绝、超时、慢响应和非法响应;
- callback 各分支恰好返回一次;
- 客户端断连是否取消上游;
- SSE 半行、多事件、慢客户端和
[DONE]; - 目标并发下 P50/P95/P99、错误率和内存;
- 服务停止时连接 drain 与未完成请求。
压测要同时观察 event loop 延迟、CPU、连接池等待和上游耗时。总延迟高不一定是 Drogon 慢,可能是连接池耗尽或上游排队。
十七、常见症状与排查
并发一高,所有接口一起慢
检查 handler/filter/middleware 是否有同步 I/O、CPU 重活、锁或 .get();再看数据库/HTTP 连接池等待。不要先无脑加线程。
偶发崩溃,栈在异步 callback
检查捕获的局部引用、this 生命周期、callback move/copy 和跨线程对象安全。优先按值捕获小对象、用智能指针表达共享生命周期。
客户端一直 pending
检查某个错误分支是否忘记 callback、异常是否越过响应逻辑,以及上游请求是否没有 timeout。
返回过两次或出现奇怪连接错误
检查成功/失败回调是否都可能触发,是否调用 callback 后缺少 return,以及 timeout 与完成回调是否竞态。
上传把磁盘写满
检查 body/file 限制、临时文件清理、配额、对象存储失败补偿和无人认领文件扫描。
SSE 首 token 很慢
确认网关没有缓冲完整响应,代理/压缩层未聚合,header 已及时发送;同时拆分上游 TTFT 与网关排队。
十八、一条务实的学习与落地路线
- 用本文最小项目跑通 CMake、路由和 JSON;
- 熟悉 Controller、参数校验和统一错误;
- 用 async DbClient/HttpClient,禁止 event loop 阻塞;
- 加 request ID、结构化日志、deadline 和指标;
- 做上传大小/路径/权限安全;
- 用故障注入验证上游超时和 callback 生命周期;
- 真有流式需求再实现 SSE,并测试断连与背压;
- 根据压测决定线程、连接池和是否需要 C++ 网关;
- 重任务交给 worker,模型推理交给专用服务;
- 保持架构最小,收益不足就不要增加一层。
官方资料
- Drogon 官方仓库与 Quick Start
- Drogon Wiki
- Drogon:Installation
- Drogon:HttpController
- Drogon:Middleware and Filter
- Drogon:DbClient
- Drogon:Coroutines
- Drogon:Redis
总结
Drogon 的高性能建立在异步 I/O 和 event loop 不被阻塞的前提上。真正可靠的 C++ Web 服务,不是把所有逻辑塞进 handler,而是严格控制 callback 生命周期、参数与 body、数据库和上游 deadline、上传安全、流式背压与客户端取消。
在 AI 系统中,Drogon 适合承担确有价值的 C++ 网络入口;文档处理交给后台 worker,在线模型交给专用 serving,Python 业务保留在 Python 生态。
一句话记住:
框架负责让等待变得异步,业务代码负责不把阻塞、失控生命周期和无界资源重新带回 event loop。