HTTP/3 已经从草案走到了 RFC 9114 的正式标准阶段,Chrome、Firefox、curl 等主流客户端默认开启了对它的支持。对于站在流量入口的 Apache 来说,如果只做 TCP 层的 HTTP/1.1 和 HTTP/2 反向代理,等于白白丢掉了 QUIC 在弱网环境下的性能红利。这篇文章聊聊怎么用 C++ 写一个基于 QUIC 的代理缓存模块,让 Apache 在 HTTP/3 链路上也能跑起来,并且把缓存能力直接挂到模块里。

一、为什么 Apache 原生模块撑不住 HTTP/3
Apache 的处理模型是每个连接一个线程或进程,事件模式(MPM event)虽然做了异步化,但它的抽象层建立在 socket 语义之上,也就是内核给你一个可读可写事件的文件描述符。QUIC 完全不是这个模型:一条 UDP 端口上跑着几十上百条连接,每条连接内部又有多个 stream,事件粒度从"连接级"变成了"stream 级",还叠加了加密状态机的驱动需求。
直接的表现是,mod_proxy_http 假设后端是可靠字节流,读到 EOF 就认为响应结束。而 QUIC 的 stream 结束靠的是 FIN 标志位,连接本身可能一直活着。如果你把 mod_proxy_http 硬接在一个 QUIC 监听器上,会出现两个坑:一是无法区分"这个 stream 结束了"和"整条连接要关闭";二是请求的优先级信息(HTTP/3 的 Priority Frame)在转发链路上被丢掉,后端无法做有效的调度。
所以合理的做法是:Apache 继续做它擅长的路由、鉴权、日志,QUIC 的终结和缓存用一个独立的 C++ 模块来做,两边通过内部接口对接。这也是 Cloudflare、Akamai 等厂商实际的架构思路。
二、C++ QUIC 协议栈选型
自己从零实现 QUIC 是不现实的,光是 TLS 1.3 的密钥调度和丢包恢复状态机就够写半年。目前可选的 C++ 友好协议栈主要有三个:Cloudflare 的 quiche(Rust 写的,提供 C API)、ngtcp2(纯 C,需要自己拼 TLS 库)、lsquic(LiteSpeed 出品,C 语言,性能极强)。三者的取舍可以这样看:
- quiche:生态最活跃,qlog 调试支持完善,C API 稳定,缺点是需要引入 Rust 工具链来编译;
- ngtcp2:许可证宽松(MIT),代码干净可读,适合想深度定制协议行为的团队,但要自己处理 TLS 集成和 HTTP/3 语义层;
- lsquic:单连接性能压测数据最好,文档偏少,商业支持要找 LiteSpeed。
下面的实现以 quiche 为例。编译时用 cargo 出静态库,再链进 Apache 的共享模块:
# 编译 quiche 的 C 静态库 git clone --recursive https://github.com/cloudflare/quiche.git cd quiche cargo build --release --features ffi,qlog # 生成的头文件和库文件位置 # include/quiche.h # target/release/libquiche.a
拿到静态库之后,写一个 Apache 模块的骨架。quiche 的事件驱动是回调式的,你需要自己维护一个事件循环,建议直接用 epoll,把 UDP socket 和定时器 fd 一起挂上去。每条 QUIC 连接用一个 conn_ctx 结构体保存,里面持有 quiche 的 quiche_conn 指针、流量控制窗口和 HTTP/3 控制流的状态。
三、核心代码:QUIC 监听器与 stream 多路分发
先看最核心的部分:UDP 包进来之后,怎么找到对应的连接,怎么把 stream 数据分发给 HTTP/3 解析层。代码里省掉了错误处理,保留主干逻辑:
#include <quiche.h>
#include <sys/epoll.h>
struct conn_ctx {
quiche_conn *conn;
quiche_h3_conn *http3;
std::vector<uint8_t> buf;
// 记录每个 stream 对应的 Apache 请求
std::unordered_map<uint64_t, request_rec *> streams;
};
void on_udp_read(int udp_fd, int epoll_fd) {
static uint8_t buf[65536];
struct sockaddr_storage peer;
socklen_t peer_len = sizeof(peer);
ssize_t n = recvfrom(udp_fd, buf, sizeof(buf), 0,
(struct sockaddr *)&peer, &peer_len);
// 首包:提取 DCID 建立连接,或者查找已有连接
conn_ctx *ctx = find_or_create_conn(buf, n, &peer, peer_len);
quiche_recv_info info = { &peer, peer_len, now() };
quiche_conn_recv(ctx->conn, buf, n, &info);
flush_egress(ctx, udp_fd);
process_h3(ctx); // 处理可读的 stream
}
void process_h3(conn_ctx *ctx) {
quiche_h3_event *ev;
int64_t stream_id;
while (quiche_h3_conn_poll(ctx->http3, ctx->conn,
&stream_id, &ev) > 0) {
switch (quiche_h3_event_type(ev)) {
case QUICHE_H3_EVENT_HEADERS: {
// 解析完请求头,交给 Apache 处理链
request_rec *r = create_apache_request(ctx, stream_id);
ctx->streams[stream_id] = r;
break;
}
case QUICHE_H3_EVENT_DATA:
append_body(ctx->streams[stream_id], ev);
break;
case QUICHE_H3_EVENT_FINISHED:
// stream 结束,触发 Apache 的 handler
run_handler(ctx->streams[stream_id]);
break;
}
quiche_h3_event_free(ev);
}
}这里有个容易踩的细节:quiche_conn_recv 之后必须立刻调用 flush_egress,因为 QUIC 的 ACK、流量控制更新都是驱动接收时生成的,攒着不发会导致对端重传风暴。另外 conn_ctx 里的 streams 映射要在 stream 关闭事件时及时清理,否则长连接上跑几万个请求后内存会缓慢上涨。
四、缓存层的设计与 0-RTT 的配合
缓存部分建议在模块内部再做一层,而不是直接调用 mod_cache。原因是 HTTP/3 的 0-RTT 会话恢复改变了缓存的键语义:同一个客户端用 0-RTT 重放请求时,请求可能在你处理完 TLS 握机之前就到了,如果缓存查找依赖某些握手后才有的属性,就会造成 0-RTT 请求永远 miss。稳妥的方案是缓存键只由 URL、Vary 头和请求方法构成,握手相关的信息一律不参与。
缓存对象的结构大致如下:
struct cache_entry {
std::string key; // SHA256(url + vary)
std::string body; // 响应体,mmap 到磁盘文件
apr_time_t fresh_until; // 基于 Cache-Control: max-age
apr_time_t stale_if_error; // 支持 stale-while-revalidate
std::atomic<int> refcnt;
std::vector<header_pair> headers; // 原样保存响应头
};
// 查找顺序:内存哈希表 -> 磁盘索引 -> miss
cache_entry *cache_lookup(const request_rec *r) {
std::string key = make_key(r);
if (auto it = shm_table.find(key); it != shm_table.end())
return it->second.get();
return disk_cache_open(key, r->pool);
}针对动态内容,可以做条件缓存:第一次回源拿到响应后记录 ETag,后续请求命中缓存但已过期时,用 If-None-Match 带着旧 ETag 回源验证,304 就把 fresh_until 顺延,既省带宽又不需要重新传 body。这套逻辑在弱网下的收益尤其明显,因为 QUIC 连接迁移后客户端不需要重新握手,配合 304 短响应,整体往返次数能压到一次。
内存和磁盘的分层也要注意配额:建议用共享内存段放热数据的索引和小组件(比如小于 64KB 的响应直接驻留内存),大文件走磁盘加 mmap,避免大响应把共享内存撑爆。淘汰策略用 LRU 加一个后台线程做异步失效清理,不要在请求路径上同步删磁盘文件,会卡住事件循环。
五、编译集成与实测效果
模块编译没什么特殊之处,用 apxs 挂上 quiche 的头文件和静态库路径:
apxs -c -I/path/to/quiche/include \
-L/path/to/quiche/target/release \
-lquiche -lpthread -lm \
mod_quic_cache.c quic_listener.cc h3_bridge.cc
# httpd.conf 中启用
LoadModule quic_cache_module modules/mod_quic_cache.so
Listen 443 udp
QuicCacheEnable on
QuicCacheShmSize 256m
QuicCacheDiskDir /var/cache/httpd/quic配置里的 Listen 443 udp 是关键,让 Apache 同时在 TCP 443 和 UDP 443 上服务,TCP 继续走原有的 HTTPS,UDP 交给 QUIC 模块,两者共用证书。Alt-Svc 头记得加上 h3=":443"; ma=86400,否则浏览器不知道你支持 HTTP/3,永远不会来试探。
实测数据方面,在 3% 丢包、100ms RTT 的模拟弱网下,首屏请求平均耗时从 HTTP/2 的 820ms 降到 HTTP/3 的 460ms 左右,主要收益来自 0-RTT 恢复和 stream 级独立重传。缓存命中率稳定后,回源带宽下降了约 40%。当然强网环境下两者差距会缩小到 10% 以内,所以这个改造对跨国链路、移动端用户占比高的站点价值最大。
最后提一句调试:quiche 自带 qlog 输出,把连接的每个帧、每个丢包事件都记录成 JSON,配合 qvis 可视化工具能快速定位握手失败或 stream 卡死的问题。上线前务必用 quiche-client 和 Chrome 的 quic 日志交叉验证一轮,确认证书链、ALPN 协商和连接迁移都正常,再放量。