tps
TCP 服务器性能瓶颈与架构分析报告
一个取消使用缓存存取消息后带来的问题
并发congif
[[steps]]
name = "Step 1: Low Concurrency Baseline"
concurrency = 50 # 并发客户端数量
duration_secs = 20 # 测试持续时间
# 每个客户端在每次动作前,会随机等待 think_time_ms 范围内的时间 (min, max)
think_time_ms = [500, 1000]
[[steps]]
name = "Step 2: High Throughput Test"
concurrency = 500
duration_secs = 30
think_time_ms = [50, 200]
1.计算公式
要计算 TPS,我们只需要关注两个变量:
- 并发数 (Concurrency):有多少个虚拟用户同时在线。
- 单次请求耗时 (Time per Request):一个用户完成一次“发送消息”的动作平均需要多久。
单次请求耗时的组成
一个完整的请求周期包含两部分:
- \(T_{\text{think}}\) (思考时间):用户在发下一条消息前“发呆”的时间。这是我们在配置文件中设定的。
- \(T_{\text{work}}\) (工作耗时):代码执行耗时 + 网络传输耗时 + 服务器处理耗时。
- 在理想的高性能压测中,如果服务器和网络没有达到瓶颈,\(T_{\text{work}}\) 通常极短(微秒级或极小的毫秒级),相对于 \(T_{\text{think}}\) 可以忽略不计。
- 因此,理论计算通常简化为:\(T_{\text{total}} \approx T_{\text{think}}\)。
通用公式
2. Step 1: Low Concurrency Baseline 计算
配置数据:
concurrency= 50think_time_ms=[500, 1000]
计算步骤:
-
计算平均思考时间 (\(T_{\text{avg}}\))
由于配置是一个范围[min, max],且压测工具(你的client.rs)是使用均匀分布随机生成的,所以平均值就是两者的中间值:\[T_{\text{avg}} = \frac{500 + 1000}{2} = 750 \text{ ms} \] -
计算单个用户的频率 (\(f\))
一个用户平均每 750 毫秒发送一条消息。\[f = \frac{1000 \text{ ms}}{750 \text{ ms}} \approx 1.333 \text{ 次/秒} \] -
计算总 TPS
50 个用户同时发:\[\text{TPS} = 50 \times 1.333... = 66.66... \]
理论 TPS 结果: 约 66.67
3. Step 2: High Throughput Test 计算
这是你主要关注的 4000 TPS 的来源。
配置数据:
concurrency= 500think_time_ms=[50, 200]
计算步骤:
-
计算平均思考时间 (\(T_{\text{avg}}\))
\[T_{\text{avg}} = \frac{50 + 200}{2} = 125 \text{ ms} \] -
计算单个用户的频率 (\(f\))
一个用户平均每 125 毫秒发送一条消息。\[f = \frac{1000 \text{ ms}}{125 \text{ ms}} = 8 \text{ 次/秒} \] -
计算总 TPS
500 个用户同时发:\[\text{TPS} = 500 \times 8 = 4000 \]
理论 TPS 结果: 4000
(注:你实际跑出的 3928 非常接近 4000,差距不到 2%。这微小的差距是由于 \(T_{\text{work}}\)(代码执行和网络IO)实际上不是 0,稍微占用了一点点时间。)
4. 总结流程图
为了让你更直观地理解,这是一个计算流程:
这就是为什么你在 Step 2 看到的数据总是围绕在 4000 左右,这个数字是由你的思考时间配置直接决定的。如果你想测试更高的 TPS,你需要:
- 增加并发数(例如改到 1000)。
- 或者 减小思考时间(例如改到
[10, 50])。
原tps压测报告
{
"target_server": "127.0.0.1:8080",
"steps": [
{
"step_name": "Step 1: Low Concurrency Baseline",
"test_duration_secs": 20.855596348,
"concurrency": 50,
"total_sent": 1351,
"total_received": 1351,
"send_tps": 64.77877579988537,
"receive_tps": 64.77877579988537,
"total_login_failures": 0,
"total_send_errors": 0,
"error_rate": 0.0,
"success_rate": 1.0,
"latency": {
"min_ms": 0.003,
"mean_ms": 0.03695632864544781,
"std_dev_ms": 0.21845766548401757,
"p50_ms": 0.026,
"p90_ms": 0.053,
"p95_ms": 0.062,
"p99_ms": 0.113,
"max_ms": 8.019
}
},
{
"step_name": "Step 2: High Throughput Test",
"test_duration_secs": 30.342326256,
"concurrency": 500,
"total_sent": 119260,
"total_received": 119260,
"send_tps": 3930.4830814155885,
"receive_tps": 3930.4830814155885,
"total_login_failures": 0,
"total_send_errors": 0,
"error_rate": 0.0,
"success_rate": 1.0,
"latency": {
"min_ms": 0.002,
"mean_ms": 0.013452691598188828,
"std_dev_ms": 0.0190725729638132,
"p50_ms": 0.008,
"p90_ms": 0.026,
"p95_ms": 0.033,
"p99_ms": 0.062,
"max_ms": 4.263
}
}
],
"status": "COMPLETED",
"start_time_utc": "2025-11-23T14:16:44.792672586+00:00",
"end_time_utc": "2025-11-23T14:17:35.992976700+00:00",
"total_duration_secs": 51.200305245
}
可以看见tps是64和4000左右
第一部分:原始代码的性能缺陷分析 (Unbuffered IO)
在最初的实现中,代码移除了 read_line(自带缓冲),转而直接在 TcpStream 上使用 bincode 进行读写。虽然架构切换为 Actor 模型消除了锁竞争,但 IO 处理方式引入了更为底层的系统级瓶颈。
1. 系统调用 (Syscall) 雪崩
现象:
在 Step 1(50 并发)中,TPS 仅为 9,平均延迟高达 119ms。
原理分析:
TcpStream 代表了一个网络套接字资源,对其进行读写必须通过操作系统内核(Kernel)完成。你的协议格式为 [Length (4 bytes)] + [Body (N bytes)]。
在无缓冲(No Buffer)代码中:
- 发送流程:
- 调用
writer.write_u32(len).await:触发一次系统调用(write),仅写入 4 字节。 - 调用
writer.write_all(body).await:触发第二次系统调用,写入消息体。
- 调用
- 接收流程:
- 调用
reader.read_u32().await:触发一次系统调用(read),请求 4 字节。 - 调用
reader.read_exact(buf).await:触发第二次系统调用,读取消息体。
- 调用
计算开销:
假设并发 500 用户,每秒每人收发 1 条消息。
- 应用层请求量:500 ops/sec。
- 内核层负载:每次交互至少产生 4 次 Syscall(2读 + 2写)。
- 总计 Syscall:\(500 \times 4 = 2000\) 次/秒。如果算上广播(1发 N收),Hub 向 500 个客户端转发消息,Syscall 数量将爆炸式增长至 \(500 \times 500 \times 2 = 500,000\) 次/秒。
每次系统调用都需要 CPU 保存现场、从用户态(User Mode)切换到内核态(Kernel Mode)、执行内核代码、再恢复现场切回用户态。这种频繁的边界穿越消耗了绝大部分 CPU 时间,导致业务逻辑几乎无法执行。
2. 上下文切换 (Context Switch) 与调度抖动
原理分析:
Tokio 是基于协作式调度的异步运行时。await 点意味着任务可能挂起(Yield)。
在无缓冲读取中:
- 当调用
read_u32()时,如果网卡缓冲区只有 2 个字节(TCP 拆包是常态),Future 返回Pending。 - Tokio 调度器将当前 Client Task 挂起(Park),切换去执行其他 Task。
- 当剩余字节到达,Task 被唤醒(Wake),再次执行。
后果:
由于直接操作裸 Socket,应用层对 TCP 数据流的任何碎片化都极其敏感。在高并发下,Tokio Worker 线程陷入了极高频的“挂起-唤醒-切换”循环。这导致 CPU 指令缓存(L1/L2 Cache)失效,大幅降低了处理效率。
3. 同步日志阻塞
原理分析:
使用了默认的 FmtSubscriber 且级别为 INFO。在 Hub 的广播循环中,每转发一条消息就打印一条日志。
标准输出(Stdout)通常是同步且带锁的。当 500 个客户端并发广播时,成千上万次的 println! 或 write 操作会竞争同一个锁,直接导致处理网络 IO 的 Worker 线程被阻塞在写日志上。
第二部分:修复方案与代码解析
修复的核心在于引入 用户态缓冲区(User Space Buffering),将多次微小的系统调用合并为一次大的内存操作。
核心代码修改展示
src/client.rs
// 引入缓冲包装器
use tokio::io::{BufReader, BufWriter};
pub async fn handle_connection(...) -> Result<()> {
let (reader, writer) = socket.into_split();
// 关键修复:
// 在 TCP Stream 之上包裹 8KB (默认) 的内存缓冲区。
// read/write 操作现在变成对内存数组的 memcpy,不再直接触发 syscall。
let mut reader = BufReader::new(reader);
let mut writer = BufWriter::new(writer);
// ... (中间逻辑) ...
}
async fn write_framed_message(writer: &mut BufWriter<OwnedWriteHalf>, msg: ServerMessage) -> Result<()> {
let msg_bytes = bincode::serialize(&msg)?;
// 这里的 write 只是将数据写入 writer 内部的 Vec<u8>,耗时为纳秒级
writer.write_u32(msg_bytes.len() as u32).await?;
writer.write_all(&msg_bytes).await?;
// 关键修复:
// 显式调用 flush,只有此时才会真正发起 1 次系统调用,将缓冲区内的数据一次性发送给内核。
writer.flush().await?;
Ok(())
}
解决了什么问题?
- 合并 Syscall:发送协议头(4字节)和协议体(N字节)现在合并在 Buffer 中,
flush()时只需调用 1 次send系统调用。在广播场景下,这可能减少了 50% 以上的系统开销。 - 减少上下文切换:
BufReader会预读数据(例如一次读 4KB)。后续的read_u32和read_exact直接从内存 Buffer 拿数据,不会因为 TCP 拆包而频繁返回Pending,大幅减少了 Tokio 调度器的压力。
第三部分:数据分析与 TPS 详解
1. 为什么 Error Rate 之前很高,现在为 0?
现象:
- 修复前:
error_rate约 38%,total_send_errors数百。 - 修复后:
error_rate0.0%,total_send_errors0。
错误产生链条(修复前):
- Server 处理慢:由于 Syscall 和日志阻塞,Server 端读取数据的速度远低于 Client 发送的速度。
- TCP 窗口耗尽:Server 的操作系统内核发现应用层不取数据,导致内核接收缓冲区(Recv Buffer)被填满。
- 流量控制 (Flow Control):Server 向 Client 发送 TCP Zero Window 通告,告知“我满了,停止发送”。
- Client 写入失败:Client 的 OS 暂停发送。Client 端的
writer.write_all(...).await长时间挂起。 - 超时/断连:由于长时间阻塞,Client 可能触发超时机制,或者 Server 因处理过慢被判定为死连接而断开。Rust 代码捕获到 IO Error,计数到
total_send_errors。
修复后:
Server 处理速度大幅提升,能够及时清空内核缓冲区,TCP 窗口始终保持打开状态,因此 Client 发送顺畅,错误率为 0。
2. 4000 Send TPS 是如何计算得出的?
报告显示 Step 2 的 send_tps 为 3928。这个数值完全符合你的配置预期。
计算依据:
- 并发数 (Concurrency): 500
- 思考时间 (Think Time): 配置为
[50ms, 200ms]。- 这是一个均匀分布,平均思考时间 \(T_{think} = \frac{50 + 200}{2} = 125\) ms。
理论频率计算:
如果不考虑网络传输和代码执行的微小耗时:
结论:
实测 3928 TPS 非常接近理论值 4000 TPS。这证明在加上 Buffer 后,Server 的接收能力和 Client 的发送能力都不再是瓶颈,系统如实地执行了压测脚本定义的负载。
3. 广播风暴与 Receive TPS 异常分析
现象:
- Send TPS: 3928 (正常)
- Receive TPS: 20,570 (严重偏低)
- Packet Loss (应用层): 约 99%
数据计算:
在聊天室广播模型中,1 人发送消息,意味着房间内其他 \(N-1\) 人都应该收到消息。
实际运行情况:
Hub 作为单线程 Actor,每秒面临约 200 万次 的消息分发任务。
目前代码中:
- 内存瓶颈:每次分发都调用
server_msg.clone()。Rust 的 String Clone 涉及堆内存分配。每秒 200 万次堆分配会导致内存分配器饱和,极大拖慢 CPU。 - 反压丢弃:Hub 分发速度跟不上(受限于内存分配),或者 Client 发送任务的通道(Channel)被填满。代码中使用了
try_send,当通道满时直接丢弃消息。
结论:
Server 成功接收了 3928 TPS 的上行流量,但在试图将其放大为 200 万 TPS 的下行广播流量时,由于 Hub 的处理能力(主要是内存复制开销)不足,导致绝大多数消息被丢弃,仅处理了约 2 万 TPS。这也是 Latency 高达 400ms+ 的原因——未被丢弃的消息在通道中排队等待了很久。

浙公网安备 33010602011771号