数据:8 线程自研 C++ 压测程序同机回环 → 48,765 QPS,p99 382μs(WSL2,4 cores @ 4.0 GHz) 代码:github.com/FSSLI/rpc_framework 协议:18 字节固定头 + 变长 body + CRC32(详见协议设计)
写完 RPC 框架第一版,我面临一个问题:怎么证明这玩意儿不是花架子?
光"我写了一个 RPC"太虚,得拿出数。所以我从第一版压测开始,记录每轮改了哪个文件、为什么这么改,迭代到 48,765 QPS。这篇文把整个过程写下来。
一、压测方案:用什么压
没用 wrk/ab,自己写了个 C++ 压测程序(仓库 tests/test_benchmark.cc)。理由:
- wrk 是 HTTP 压测器——我这协议是自定义 18 字节帧 + protobuf,得在 Lua 脚本里逐字节拼二进制报文,绕一层;而且 wrk 拆/拼 CRC 也得自己写
- 压测客户端直接复用框架自己的网络层和 codec——和真实调用走同一条路径(连接 → 编码 → 发送 → 解码 → 回调),不引入第三方客户端的实现差异
- 报告自己算:每个请求
std::chrono记耗时,结束后输出 QPS / p50 / p99 / 错误数,数字直接搬进 README 和简历
用法:
| |
1.1 环境
| |
跨机压测我也跑过,瓶颈在网卡(千兆 ≈ 8 Gbps,200K QPS 时打满),所以所有调优数据都是本机回环出来的——这反而更纯,排除网络抖动。
1.2 压测请求怎么构造
test_benchmark 启动 N 个线程,每线程从连接池拿一条长连接(per-thread 缓存),循环同步调用 EchoService.Echo("hello"),用 std::chrono 记每次往返耗时,5 万请求跑完统计 QPS / p50 / p99 / 错误数。
请求报文就是框架正常 Codec::encodeRequest 出来的 18 字节头 + protobuf body + CRC32——和真实调用零差别,测的就是 RPC 全路径。
二、第一版:7,800 QPS,p99 4.2ms
跑出来 7,800 QPS,p99 4.2ms——丢人。单核 QPS 才 2000。
口径说明:7,800 / 28,000 是开发机上逐版迭代的实测(每版代码不同,早期版本没留压测程序,现在不可复现);仓库
tests/test_benchmark.cc当前可复现的最终口径见第四节——direct 长连接 30,502 QPS → 池化 8 连接 48,765 QPS(+60%)。
2.1 火焰图找瓶颈
perf record -F 99 -p $(pgrep echo_server) -g -- sleep 30 抓下来,看 onMessage 路径:
| 函数 | 占比 | 说明 |
|---|---|---|
Buffer::readFd | 18% | readv 调度开销 |
TcpConnection::send | 12% | 写多遍(为啥?) |
Codec::decode | 25% | CRC32 校验,一字节一字节算 |
protobuf::parse | 22% | 多一次 string 拷贝 |
| 其他 | 23% | 业务逻辑 |
2.2 三个发现
A. CRC32 是热点——每次解码都跑一遍。改成查表法(256 项表)直接降到 6%。
B. 写多遍——原来 send() 里 writev 调两次,合并成一个 iovec。
C. protobuf 多一次 string 拷贝——decode 路径先把网络 buffer 拷进 std::string 再 parse,这一份内存多余。ParseFromArray 可以直接指着 buffer 解析,省掉。
三、第二版:ParseFromArray 省一次 string 拷贝
第一版 decode 路径里先把网络 buffer 拷进 std::string payload,再 req.ParseFromString(payload)——多了一次内存拷贝。
MessageLite::ParseFromArray(const void*, int) 可以直接指着网络 buffer 解析,省掉这次拷贝。
| |
QPS 从 7,800 干到 28,000——3.5x。
但这远不是终点。p99 2.1ms,主从 Reactor 的连接没充分利用。
四、第三版:连接池 + 连接数对齐
4.1 连接池
之前每个请求 connect() → 一次 RPC → close()。TCP 三次握手 + TLS(没有但) + close 每次都跑一遍。
改成 per-thread 连接池——每线程预连 N 个长连接,请求直接复用。
| |
4.2 连接数和线程数对齐
加池之后还卡了一下:8 个压测线程抢 1 条连接,锁竞争把 QPS 压在 30,502。把连接池开到 8 条、和线程数 1:1 对齐,每线程独占一条连接,锁竞争消失。
QPS 30,502 → 48,765(+60%),p99 382μs。这就是仓库 test_benchmark 当前能复现的最终数字。
五、最终调优清单(按影响排序)
| 优化项 | 改哪个文件 | 效果 |
|---|---|---|
| CRC32 改查表法(256 项表) | src/codec/rpc_codec.cc | perf 热点 25% → 6% |
protobuf 用 ParseFromArray 省一次拷贝 | src/codec/rpc_codec.cc | 7,800 → 28,000 QPS(早期迭代) |
| per-thread 连接池 | src/pool/connection_pool.cc | 消除 per-call connect 开销 |
| 连接数 = 线程数(8/8) | 压测参数调整 | 30,502 → 48,765 QPS(+60%) |
-DRPC_SILENT=ON 关闭日志 | CMakeLists.txt | 不关日志 QPS 衰减 30-50% |
| 合计 | 7,800 → 48,765(6.2x),p99 4.2ms → 382μs(11x) |
六、怎么读这份数据
秋招面试官常问"你怎么证明 48,765 QPS 不是花架子"——我的回答模板:
- 方法论:自研 C++ 压测程序
tests/test_benchmark.cc,和框架共用网络层/codec,同机回环排除网络抖动 - 对照:同版本代码下 direct 单连接 30,502 QPS vs 池化 8 连接 48,765 QPS——唯一变量是连接数
- 归因:每个优化对应火焰图上一个具体的热点(CRC 25% / 拷贝 22%)
- 可复现:
cmake .. -DRPC_SILENT=ON && make test_benchmark && ./test_benchmark 50000 8,clone 下来就能跑,数字对得上 README - 诚实:数据是 8 线程本机回环的,不能直接外推到生产——生产涉及网络/序列化/业务,实测 5k-10k QPS 才正常
七、未做完的事(留作下篇)
- 跨机压测:千兆网卡瓶颈,还要测一次万兆
- P99.9 / P99.99 尾部延迟:382μs 是 P99,99.9 可能在 1ms+,没分析
- CPU 380% 是否打满:还有 20% 没用,可能是锁竞争
如果你也在做 C++ 后端项目,这套压测流程可以照搬:自研压测客户端 + 火焰图 + 逐项归因 + 可复现脚本——比单纯报一个 QPS 数字有说服力得多。
相关阅读: