48,765 QPS 是怎么压出来的:一个 C++ RPC 框架的压测全流程

数据: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
2
cmake .. -DRPC_SILENT=ON && make -j$(nproc) test_benchmark
./test_benchmark 50000 8   # 5 万请求 × 8 线程

1.1 环境

1
2
3
4
5
CPU:    4 cores @ 4.0 GHz (VM, 不是物理机)
内存:    8 GB
OS:     Ubuntu 22.04, kernel 5.15
网络:   本机回环 (127.0.0.1),绕过网卡
压测:  `tests/test_benchmark 50000 8`(同机回环)

跨机压测我也跑过,瓶颈在网卡(千兆 ≈ 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::readFd18%readv 调度开销
TcpConnection::send12%写多遍(为啥?)
Codec::decode25%CRC32 校验,一字节一字节算
protobuf::parse22%多一次 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 解析,省掉这次拷贝

1
2
3
4
5
6
// before:一次拷贝 + parse
std::string payload(data + pos, payloadLen);
req.ParseFromString(payload);

// after:直接指着 buffer parse(改 src/codec/rpc_codec.cc 的 decode*)
req.ParseFromArray(data + pos, payloadLen);

QPS 从 7,800 干到 28,000——3.5x

但这远不是终点。p99 2.1ms,主从 Reactor 的连接没充分利用


四、第三版:连接池 + 连接数对齐

4.1 连接池

之前每个请求 connect() → 一次 RPC → close()。TCP 三次握手 + TLS(没有但) + close 每次都跑一遍

改成 per-thread 连接池——每线程预连 N 个长连接,请求直接复用。

1
2
3
4
5
6
7
8
9
// connection_pool.h (简化)
class ConnectionPool {
  std::vector<std::unique_ptr<TcpConnection>> pool_;
  std::mutex mu_;
  std::condition_variable cv_;

  TcpConnection* acquire(int timeout_ms);
  void release(TcpConnection* c, bool healthy);
};

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.ccperf 热点 25% → 6%
protobuf 用 ParseFromArray 省一次拷贝src/codec/rpc_codec.cc7,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 不是花架子"——我的回答模板:

  1. 方法论:自研 C++ 压测程序 tests/test_benchmark.cc,和框架共用网络层/codec,同机回环排除网络抖动
  2. 对照:同版本代码下 direct 单连接 30,502 QPS vs 池化 8 连接 48,765 QPS——唯一变量是连接数
  3. 归因:每个优化对应火焰图上一个具体的热点(CRC 25% / 拷贝 22%)
  4. 可复现:cmake .. -DRPC_SILENT=ON && make test_benchmark && ./test_benchmark 50000 8,clone 下来就能跑,数字对得上 README
  5. 诚实:数据是 8 线程本机回环的,不能直接外推到生产——生产涉及网络/序列化/业务,实测 5k-10k QPS 才正常

七、未做完的事(留作下篇)

  • 跨机压测:千兆网卡瓶颈,还要测一次万兆
  • P99.9 / P99.99 尾部延迟:382μs 是 P99,99.9 可能在 1ms+,没分析
  • CPU 380% 是否打满:还有 20% 没用,可能是锁竞争

如果你也在做 C++ 后端项目,这套压测流程可以照搬:自研压测客户端 + 火焰图 + 逐项归因 + 可复现脚本——比单纯报一个 QPS 数字有说服力得多。


相关阅读: