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,我们只需要关注两个变量:

  1. 并发数 (Concurrency):有多少个虚拟用户同时在线。
  2. 单次请求耗时 (Time per Request):一个用户完成一次“发送消息”的动作平均需要多久。

单次请求耗时的组成

一个完整的请求周期包含两部分:

\[T_{\text{total}} = T_{\text{think}} + T_{\text{work}} \]

  • \(T_{\text{think}}\) (思考时间):用户在发下一条消息前“发呆”的时间。这是我们在配置文件中设定的。
  • \(T_{\text{work}}\) (工作耗时):代码执行耗时 + 网络传输耗时 + 服务器处理耗时。
    • 在理想的高性能压测中,如果服务器和网络没有达到瓶颈,\(T_{\text{work}}\) 通常极短(微秒级或极小的毫秒级),相对于 \(T_{\text{think}}\) 可以忽略不计。
    • 因此,理论计算通常简化为:\(T_{\text{total}} \approx T_{\text{think}}\)

通用公式

\[ \text{理论 TPS} = \frac{\text{并发数} \times 1000}{\text{平均请求耗时 (ms)}} \]


2. Step 1: Low Concurrency Baseline 计算

配置数据:

  • concurrency = 50
  • think_time_ms = [500, 1000]

计算步骤:

  1. 计算平均思考时间 (\(T_{\text{avg}}\))
    由于配置是一个范围 [min, max],且压测工具(你的 client.rs)是使用均匀分布随机生成的,所以平均值就是两者的中间值:

    \[T_{\text{avg}} = \frac{500 + 1000}{2} = 750 \text{ ms} \]

  2. 计算单个用户的频率 (\(f\))
    一个用户平均每 750 毫秒发送一条消息。

    \[f = \frac{1000 \text{ ms}}{750 \text{ ms}} \approx 1.333 \text{ 次/秒} \]

  3. 计算总 TPS
    50 个用户同时发:

    \[\text{TPS} = 50 \times 1.333... = 66.66... \]

理论 TPS 结果: 约 66.67


3. Step 2: High Throughput Test 计算

这是你主要关注的 4000 TPS 的来源。

配置数据:

  • concurrency = 500
  • think_time_ms = [50, 200]

计算步骤:

  1. 计算平均思考时间 (\(T_{\text{avg}}\))

    \[T_{\text{avg}} = \frac{50 + 200}{2} = 125 \text{ ms} \]

  2. 计算单个用户的频率 (\(f\))
    一个用户平均每 125 毫秒发送一条消息。

    \[f = \frac{1000 \text{ ms}}{125 \text{ ms}} = 8 \text{ 次/秒} \]

  3. 计算总 TPS
    500 个用户同时发:

    \[\text{TPS} = 500 \times 8 = 4000 \]

理论 TPS 结果: 4000

(注:你实际跑出的 3928 非常接近 4000,差距不到 2%。这微小的差距是由于 \(T_{\text{work}}\)(代码执行和网络IO)实际上不是 0,稍微占用了一点点时间。)


4. 总结流程图

为了让你更直观地理解,这是一个计算流程:

graph TD A[开始计算] --> B{获取配置}; B --> C[Step 1: 50人, 500-1000ms]; B --> D[Step 2: 500人, 50-200ms]; C --> E[计算平均耗时: (500+1000)/2 = 750ms]; D --> F[计算平均耗时: (50+200)/2 = 125ms]; E --> G[单人频率: 1000/750 = 1.33/s]; F --> H[单人频率: 1000/125 = 8/s]; G --> I[总TPS: 50 * 1.33 = 66.67]; H --> J[总TPS: 500 * 8 = 4000];

这就是为什么你在 Step 2 看到的数据总是围绕在 4000 左右,这个数字是由你的思考时间配置直接决定的。如果你想测试更高的 TPS,你需要:

  1. 增加并发数(例如改到 1000)。
  2. 或者 减小思考时间(例如改到 [10, 50])。

\[理论 \text{TPS}= \frac{并发数×1000}{ 平均请求耗时 (ms)} \]

原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)代码中:

  1. 发送流程
    • 调用 writer.write_u32(len).await:触发一次系统调用(write),仅写入 4 字节。
    • 调用 writer.write_all(body).await:触发第二次系统调用,写入消息体。
  2. 接收流程
    • 调用 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(())
}

解决了什么问题?

  1. 合并 Syscall:发送协议头(4字节)和协议体(N字节)现在合并在 Buffer 中,flush() 时只需调用 1 次 send 系统调用。在广播场景下,这可能减少了 50% 以上的系统开销。
  2. 减少上下文切换BufReader 会预读数据(例如一次读 4KB)。后续的 read_u32read_exact 直接从内存 Buffer 拿数据,不会因为 TCP 拆包而频繁返回 Pending,大幅减少了 Tokio 调度器的压力。

第三部分:数据分析与 TPS 详解

1. 为什么 Error Rate 之前很高,现在为 0?

现象:

  • 修复前:error_rate 约 38%,total_send_errors 数百。
  • 修复后:error_rate 0.0%,total_send_errors 0。

错误产生链条(修复前):

  1. Server 处理慢:由于 Syscall 和日志阻塞,Server 端读取数据的速度远低于 Client 发送的速度。
  2. TCP 窗口耗尽:Server 的操作系统内核发现应用层不取数据,导致内核接收缓冲区(Recv Buffer)被填满。
  3. 流量控制 (Flow Control):Server 向 Client 发送 TCP Zero Window 通告,告知“我满了,停止发送”。
  4. Client 写入失败:Client 的 OS 暂停发送。Client 端的 writer.write_all(...).await 长时间挂起。
  5. 超时/断连:由于长时间阻塞,Client 可能触发超时机制,或者 Server 因处理过慢被判定为死连接而断开。Rust 代码捕获到 IO Error,计数到 total_send_errors

修复后:
Server 处理速度大幅提升,能够及时清空内核缓冲区,TCP 窗口始终保持打开状态,因此 Client 发送顺畅,错误率为 0。

2. 4000 Send TPS 是如何计算得出的?

报告显示 Step 2 的 send_tps3928。这个数值完全符合你的配置预期。

计算依据:

  • 并发数 (Concurrency): 500
  • 思考时间 (Think Time): 配置为 [50ms, 200ms]
    • 这是一个均匀分布,平均思考时间 \(T_{think} = \frac{50 + 200}{2} = 125\) ms。

理论频率计算:
如果不考虑网络传输和代码执行的微小耗时:

\[\text{单个客户端频率} = \frac{1000 \text{ms}}{125 \text{ms}} = 8 \text{ 次/秒} \]

\[\text{理论总 TPS} = \text{并发数} \times \text{单客频率} = 500 \times 8 = 4000 \text{ TPS} \]

结论:
实测 3928 TPS 非常接近理论值 4000 TPS。这证明在加上 Buffer 后,Server 的接收能力和 Client 的发送能力都不再是瓶颈,系统如实地执行了压测脚本定义的负载。

3. 广播风暴与 Receive TPS 异常分析

现象:

  • Send TPS: 3928 (正常)
  • Receive TPS: 20,570 (严重偏低)
  • Packet Loss (应用层): 约 99%

数据计算:
在聊天室广播模型中,1 人发送消息,意味着房间内其他 \(N-1\) 人都应该收到消息。

\[\text{理论 Receive TPS} = \text{Send TPS} \times (\text{Concurrency} - 1) \]

\[\text{理论 Receive TPS} = 3928 \times 499 \approx 1,960,072 \]

实际运行情况:
Hub 作为单线程 Actor,每秒面临约 200 万次 的消息分发任务。
目前代码中:

  1. 内存瓶颈:每次分发都调用 server_msg.clone()。Rust 的 String Clone 涉及堆内存分配。每秒 200 万次堆分配会导致内存分配器饱和,极大拖慢 CPU。
  2. 反压丢弃:Hub 分发速度跟不上(受限于内存分配),或者 Client 发送任务的通道(Channel)被填满。代码中使用了 try_send,当通道满时直接丢弃消息。

结论:
Server 成功接收了 3928 TPS 的上行流量,但在试图将其放大为 200 万 TPS 的下行广播流量时,由于 Hub 的处理能力(主要是内存复制开销)不足,导致绝大多数消息被丢弃,仅处理了约 2 万 TPS。这也是 Latency 高达 400ms+ 的原因——未被丢弃的消息在通道中排队等待了很久。

posted @ 2025-11-26 18:11  phrink  阅读(9)  评论(0)    收藏  举报