一台云服务器能服务多少辆车?答案不是 CPU,是带宽

0 阅读8分钟

cpu.png

"一台服务器能服务多少辆车?"

这个问题在系统设计阶段出现过好几次。每次讨论,工程师的第一反应都是往 CPU 和内存上算:30个进程,每进程 10MB RSS,合计 300MB,CPU 15%,8 vCPU 的机器完全够用——所以理论上可以服务更多辆车。

但实际部署选了 30 辆车,不是 50,不是 100。

真正的约束不是 CPU,不是内存,也不是 QUIC 连接数——是带宽

这台服务器不只跑 QUIC 控制信号网关,还承载了视频总结业务:每辆车的行车视频需要上传到云端做处理。视频上传的带宽,才是决定"一台服务器服务多少辆车"的硬约束。算清楚这笔账,30辆车这个数字就出来了。

这篇文章从这个带宽算术出发,讲清楚云控网关的部署规格是怎么定的,以及在这个规格下,"一进程一车"的架构为什么是最合适的选择——不是因为多线程方案会出 bug,而是因为在这个具体场景下,它在简单性、可观测性、故障隔离三个维度都是最优的。


第一章:带宽算术——30辆车这个数字怎么来的

先把账算清楚。

这台服务器上跑了什么

云控平台的一台服务器承载两类业务:

业务一:MPQUIC 控制信号网关

  • 每辆车一个 QUIC-Server 进程
  • 控制信号:< 100Kbps/辆(小包、低频)
  • 冗余发送:双路同发,实际带宽 ×2,约 200Kbps/辆
  • 30辆车合计:~6Mbps

控制信号的带宽极低。30辆车的控制信号加起来,连 10Mbps 都不到。

业务二:视频总结上传

  • 每辆车的行车视频(压缩后)上传到云端
  • 每辆车视频上传带宽:约 5-6Mbps(H.264,720p,压缩比较高)
  • 30辆车合计:约 150-180Mbps

视频上传才是带宽的主要消耗方。

带宽上限决定车辆数

这台服务器的带宽上限是 200Mbps。超过这个规格,云服务商的带宽费用会急剧上涨,不划算。

带宽分配:

200Mbps 总带宽
  ├── 视频上传:30辆 × 6Mbps = 180Mbps
  ├── 控制信号(冗余双发):30辆 × 200Kbps = 6Mbps
  └── 余量(OTA 更新、状态上报、突发):~14Mbps

30辆车 × 6Mbps/辆 = 180Mbps,加上控制信号的 6Mbps,合计 186Mbps,留 14Mbps 余量用于 OTA 更新和突发流量。

如果服务 40辆车:40 × 6Mbps = 240Mbps,超出 200Mbps 上限。

30辆车这个数字,是从带宽倒推出来的,不是从进程数或 CPU 推算出来的。

# 实时监控服务器的网络带宽使用
# 查看各业务的流量分布
iftop -i eth0 -n -P -t -s 5 2>/dev/null | head -30

# 或者用 nethogs 按进程查看带宽
nethogs eth0

# 用 nethogs 按进程实时查看带宽(推荐,真正的进程级统计)
nethogs eth0

# 或者用 ss 查看每个 QUIC-Server 进程的 socket 状态
for pid in $(pgrep quic-server); do
    vid=$(cat /proc/$pid/environ 2>/dev/null | tr '\0' '\n' \
          | grep VEHICLE_ID | cut -d= -f2)
    conn_count=$(ls /proc/$pid/fd 2>/dev/null | wc -l)
    echo "[$vid] PID=$pid fd_count=$conn_count"
done
# 注:/proc/$pid/net/dev 是 network namespace 视图,
# 同一 netns 下所有进程读到的结果相同,不能用于按进程统计流量

CPU 和内存:不是瓶颈

在带宽约束下,CPU 和内存的余量相当充裕:

资源30辆车实际使用服务器规格利用率
带宽~186Mbps200Mbps93%(瓶颈)
CPU~35%(控制信号 15% + 视频处理 20%)8 vCPU35%
内存~2.3GB(QUIC 进程 300MB + 视频缓冲 1.5GB + 系统 500MB)8GB29%

带宽是唯一接近上限的资源。CPU 和内存都有大量余量——这意味着从 CPU/内存角度看,服务器完全可以服务更多辆车,但带宽不允许。


第二章:一辆车,两个 IP,同一个 CID

带宽算出了服务器规格(30辆车),接下来的问题是:这30辆车的 QUIC 控制信号,用什么架构来接收?

先把 MPQUIC 在云端的特殊性说清楚。

T-Box 有两张 SIM 卡——移动 5G 和联通 4G。冗余发送的架构是:同一条控制信号,从两张 SIM 卡同时发出,云控网关取先到的那份,后到的去重丢弃(第8篇详细讲过这套去重机制)。

这个架构在 T-Box 侧很清晰:两个 socket,分别绑定 rmnet0(4G)和 rmnet1(5G),同一条 QUIC 连接的两条 Path。

但到了云控网关侧,有一个普通 QUIC 服务端没有的现象:同一辆车发来的两路包,来自两个完全不同的 IP 地址。

T-Box 4G 路径(移动 CGNAT 出口,rmnet0):
  src IP = 10.48.x.x,src port = 52341
  QUIC DCID = 0xAB34C7F2...

T-Box 5G 路径(联通 CGNAT 出口,rmnet1):
  src IP = 172.16.x.x,src port = 61872
  QUIC DCID = 0xAB34C7F2...(和 4G 路径相同!)

五元组完全不同,但两路包携带的 DCID 指向同一条 QUIC 连接。这是 QUIC 的核心设计——CID 解绑了连接与四元组的关系,让同一条连接可以跨越 IP 变化存活。

严格来说,MPQUIC 草案(RFC 9000 §9.5)要求多路径的每条路径使用不同的 CID,以防止路径关联追踪。云端在添加第二条路径时,会通过 NEW_CONNECTION_ID 帧为新路径分配独立的 CID。但在路径建立的初始阶段(PATH_CHALLENGE/RESPONSE 握手期间),两路包仍通过同一个连接的 CID 路由到同一个 Connection 对象——这就是云端能识别"两路包属于同一辆车"的机制。

# 在云控网关上抓包,验证同一辆车的双路包现象
tcpdump -i eth0 -n 'udp port 443' -w /tmp/gw_recv.pcap &
sleep 10 && kill %1

# 用 tshark 提取 CID,会看到相同 CID 来自两个不同 src IP
tshark -r /tmp/gw_recv.pcap \
    -Y 'quic' \
    -T fields -e ip.src -e udp.srcport -e quic.dcid \
    | sort -k3 | head -30
# 预期:同一个 DCID 值出现两行,对应两个不同的 src IP
#   10.48.3.21   52341   ab34c7f2...
#   172.16.8.94  61872   ab34c7f2...

MPQUIC 的第二条路径不是通过新的握手建立的——4G 握手完成后,云端服务器在同一条 QUIC 连接内调用 quic_conn_add_path() 添加 5G 路径,向 T-Box 发出 PATH_CHALLENGE 帧,T-Box 回复 PATH_RESPONSE,第二条路径验证完成,加入连接。整个过程在同一个 Connection 对象内完成,CID 不变。

这意味着:云端收到 5G 路径的第一个包时,CID 查表直接找到已有连接——两路包天然路由到同一个 Connection 对象,没有任何"识别问题"。


第三章:架构选择——多线程可以,但不值得

既然 CID 路由没有问题,为什么不用多线程 per-connection?

多线程方案可以正确工作。 这一点需要先说清楚——不是多线程方案"会出 bug",而是在这个具体场景下,它的复杂度远高于收益。

多线程方案的真实复杂性

标准的多线程 QUIC 服务端:CID 哈希到 Worker 线程,每个线程处理一批连接。同一辆车的两路 CID 相同,哈希到同一个线程,Connection 对象在该线程内处理,去重逻辑在 Connection 内部完成——理论上没有问题。

但 MPQUIC 场景引入了三个额外的复杂性:

复杂性1:CID 轮换(Connection ID Rotation)

QUIC 出于隐私保护,连接迁移时会轮换 CID(通过 NEW_CONNECTION_ID 帧分配新 CID,旧 CID 废弃)。新 CID 是随机生成的,hash(新CID) % num_workers 的结果可能落在不同的 Worker 线程。

这意味着:同一辆车的连接,在 CID 轮换后,后续的包可能被路由到新的 Worker 线程,而 Connection 对象还在旧线程里。需要额外的"连接重路由"机制——要么把 Connection 对象迁移到新线程(有竞争),要么维护一个全局的 CID→线程映射表(有锁)。

复杂性2:路径状态机的跨线程可见性

第四章会详细讲路径状态机(IDLE→PRIMARY_ONLY→READY→DEGRADED)。状态变化(比如 PATH_DEGRADED)需要立即影响调度器行为(切换到 MinRTT)。

在单线程里,状态变化是即时可见的。在多线程里,状态变化需要原子操作(atomic_store)或内存屏障(memory_barrier),才能保证其他线程立即看到新状态。

复杂性3:per-path 统计的并发更新

第四章的 path_loss_tracker_t 跟踪每条路径的丢包率。两路包在同一个线程里处理时,这个统计直接读写,无竞争。如果两路包在不同线程里处理(CID 轮换后),统计需要加锁或用原子操作。

为什么不值得

这三个复杂性都是可以解决的工程问题,不是不可逾越的障碍。但解决它们需要:

  • CID→线程映射表(全局锁或分片锁)
  • 连接重路由机制(CID 轮换时迁移连接)
  • 路径状态机的原子操作
  • per-path 统计的并发安全

每一项都增加代码量、增加 bug 风险、增加调试难度。

而这个场景的规模是:30辆车,每辆车 1-3 条 QUIC 连接,合计 30-90 条连接。

这个规模根本不需要多线程。8 vCPU 的服务器,30个进程各占一个核,每个进程处理 1-3 条连接,CPU 合计 15%,轻松跑完。多线程能带来的性能提升是 0——因为 CPU 本来就不是瓶颈(带宽才是)。

用多线程的复杂度,换来 0 的性能收益。这个交换不值得。

# 验证 CPU 不是瓶颈:查看各进程的 CPU 使用率
ps aux | grep '[q]uic-server' | \
    awk '{cpu+=$3; n++} END {
        printf "30个进程合计 CPU: %.1f%% (平均每进程 %.1f%%)\n",
        cpu, cpu/n
    }'
# 预期:合计 CPU ~15%,平均每进程 ~0.5%
# CPU 远未饱和,多线程带来的多核利用率提升毫无意义

三种方案的对比

逐一分析三种方案在 MPQUIC 控制信号场景的适用性:

方案A:单进程 epoll(全局 event loop)

所有连接在同一个 event loop 里,单线程处理。

  • 优点:CID 路由无竞争,路径状态机无锁,实现简单
  • 缺点:单核,无法利用多核;任一连接崩溃导致所有车辆断连
  • 关键说明:在控制信号场景(每辆车 < 100Kbps,小包低频),单核处理 30辆车的 event loop 迭代时间远低于 QUIC 超时阈值(25ms),性能本身不是问题。被排除的真正原因是故障隔离:一辆车的连接 bug 导致所有车辆断连,在云控场景不可接受。
  • 适用场景:测试/开发环境(不要求故障隔离)

方案B:多线程 per-connection(教科书方案)

CID 哈希到 Worker 线程,每个线程处理一批连接。

  • 优点:多核利用率高,理论上可线性扩展
  • 缺点:MPQUIC 场景有三个额外复杂性(CID 轮换重路由、状态机原子操作、per-path 统计并发安全),实现复杂度高
  • 关键说明:在 30辆车/CPU 35% 的场景,多线程带来的多核收益为零——高复杂度换 0 收益,不值得
  • 适用场景:大规模部署(> 500辆车),CPU 成为真正瓶颈时

方案C:多进程,每进程服务一辆车(最终选择)

每辆车一个独立进程,进程内单线程 event loop。

  • 优点:进程边界天然隔离,所有状态无竞争,故障域 = 1辆车,实现简单
  • 代价:进程数随车辆数线性增长(30辆车 = 30个进程,~300MB RSS)
  • 适用场景:云控典型规模(10-200辆车)
单进程 epoll多线程 per-conn多进程一辆车
多核利用率❌ 单核✅ 每进程一核
CID 轮换处理✅ 无需处理⚠️ 需要重路由✅ 无需处理
路径状态机✅ 单线程,无竞争⚠️ 需要原子操作✅ 单线程,无竞争
故障隔离❌ 全局崩溃❌ 全局崩溃✅ 单车隔离
实现复杂度
适用场景测试/开发环境>500辆车10-200辆车

在 30辆车的规模下,多进程方案在所有关键维度上要么最优,要么并列最优。唯一的"代价"——进程数随车辆数线性增长——在这个规模下根本不是问题。


第四章:一进程一车的核心实现

每个进程围绕一个核心对象运转:vehicle_ctx_t(车辆上下文)。它持有这辆车的全部状态,进程内单线程访问,无需任何锁。

vehicle_ctx_t {
    vehicle_id          // 车辆标识(VIN)
    tquic_conn_t        // QUIC 连接(含 4G/5G 两条 Path)
    path_state          // 路径状态机(IDLE / ADDING / READY / DEGRADED)
    loss_tracker[2]     // 两条路径的丢包率统计(EWMA,每 200ms 采样)
}

阶段一:连接建立(IDLE → READY)

T-Box 上线,4G 路径先完成握手。云端随即发起 PATH_CHALLENGE,探测 5G 路径是否可达。T-Box 回复 PATH_RESPONSE,5G 路径验证通过,状态进入 READY,REDUNDANT 调度器生效——控制信号开始在 4G 和 5G 上同时发送。

阶段二:稳定运行(READY)

双路冗余发送,云端取先到的那份,后到的丢弃。loss_tracker 每 200ms 采样一次,持续跟踪两条路径的丢包率。这是正常工作状态,也是第10篇前馈控制的数据来源。

阶段三:路径降级(READY → DEGRADED)

5G 丢包率持续上升,超过阈值,状态切换到 DEGRADED,调度器切换为 MinRTT——只走质量更好的那条路径。降级不等于断连,4G 路径继续承载控制信号。

阶段四:路径恢复(DEGRADED → READY)

每 30 秒重试一次 PATH_CHALLENGE,探测 5G 是否恢复。验证通过后,状态回到 READY,冗余发送重新生效。


第五章:故障隔离与可观测性

一进程一车在性能之外,还有两个在设计阶段容易被低估的好处。

故障域隔离

单进程方案(无论是单进程 epoll 还是多线程)里,任何一辆车的连接触发 bug——内存越界、TQUIC 内部状态机异常、空指针——整个进程崩溃,所有车辆同时断连

在云控场景,这不只是"服务短暂不可用",是"所有车辆同时失去控制信号"。

一进程一车方案,崩溃影响域是 1 辆车。其他 29 辆车的进程完全不受影响。

用 systemd 管理每辆车的进程,崩溃后自动重启,T-Box 端有重连机制,通常无感知:

# /etc/systemd/system/quic-server@.service
[Unit]
Description=QUIC Server for vehicle %i
After=network.target

[Service]
Environment=VEHICLE_ID=%i
ExecStart=/usr/bin/quic-server --vehicle-id=%i
Restart=always
RestartSec=1s
# 查看所有车辆进程的健康状态
systemctl list-units 'quic-server@*' --no-pager \
    | awk '{print $1, $3, $4}'

# 查看最近 10 分钟内有异常重启的进程
journalctl -u 'quic-server@*' --since "10 minutes ago" \
    | grep -E 'Failed|Stopped|Started' | tail -20

可观测性:精确到每辆车

多进程方案的每个进程独立暴露 metrics,告警可以精确到车辆粒度:

# Prometheus 格式的 per-vehicle metrics
curl -s http://localhost:9090/metrics | \
    grep -E 'quic_(path_loss|path_state|p99_latency)'
# 示例输出:
#   quic_path_loss_rate{vehicle="VIN_001",path="5g"} 0.003
#   quic_path_loss_rate{vehicle="VIN_001",path="4g"} 0.001
#   quic_path_state{vehicle="VIN_001"} 3          # 3=PATH_READY
#   quic_p99_latency_ms{vehicle="VIN_001"} 84.2
#
#   quic_path_loss_rate{vehicle="VIN_003",path="5g"} 0.081
#   quic_path_state{vehicle="VIN_003"} 4          # 4=PATH_DEGRADED
#   quic_p99_latency_ms{vehicle="VIN_003"} 178.6  # 降级后延迟上升

云控大屏上某辆车的延迟告警,直接对应到该车辆的进程日志——不需要在 30 辆车的混合日志里搜索。


第六章:完整的资源规划

把所有数字放在一起,形成一个完整的资源规划表。

单台服务器的容量模型

带宽约束(硬上限):
  视频上传:N辆 × 6Mbps + 控制信号 6Mbps ≤ 186Mbps(留 14Mbps 余量)
  → N × 6Mbps ≤ 180Mbps → N ≤ 30辆

CPU 约束(软上限,带宽先到):
  QUIC 控制信号:30进程 × ~0.5% = ~15%
  视频处理:~20%
  合计:~35%(8 vCPU 机器)

内存约束(软上限,带宽先到):
  QUIC 进程:30 × 10MB = 300MB
  视频缓冲:~1.5GB
  系统:~500MB
  合计:~2.3GB(8GB 机器)

扩容策略

当车辆数超过 30辆,不是升级单台服务器的带宽,而是横向扩容:新增一台服务器,每台服务器服务 30辆车。

这个扩容策略的前提是"一进程一车"的架构——每辆车的进程完全独立,没有跨服务器的共享状态,新增服务器不需要任何协调。

# 查看当前服务器的资源使用(判断是否需要扩容)
echo "=== 带宽使用(需两次采样做差,得到速率)==="
# /proc/net/dev 输出的是累计字节数,需差分才能得到速率
rx1=$(awk '/eth0/{print $2}' /proc/net/dev); tx1=$(awk '/eth0/{print $10}' /proc/net/dev)
sleep 1
rx2=$(awk '/eth0/{print $2}' /proc/net/dev); tx2=$(awk '/eth0/{print $10}' /proc/net/dev)
awk "BEGIN{printf \"RX: %.1f Mbps, TX: %.1f Mbps\n\", ($rx2-$rx1)*8/1000000, ($tx2-$tx1)*8/1000000}"

echo "=== 进程数(当前在线车辆数)==="
pgrep -c quic-server

echo "=== CPU 使用 ==="
ps aux | grep '[q]uic-server' | \
    awk '{sum+=$3} END {printf "QUIC进程合计: %.1f%%\n", sum}'

echo "=== 内存使用 ==="
ps aux | grep '[q]uic-server' | \
    awk '{sum+=$6} END {printf "QUIC进程合计: %.0f MB\n", sum/1024}'


总结:从带宽算术到架构选择

"一台服务器服务多少辆车"这个问题,答案不在 CPU 使用率或进程数限制里,而在带宽算术里:(200Mbps - 6Mbps控制信号) ÷ 6Mbps/辆视频 = 32.3辆,取整留 14Mbps 余量,得到 30辆。

在这个规模下,"一进程一车"是最合适的架构选择——不是因为多线程方案会出 bug,而是因为:

  1. CPU 不是瓶颈:带宽先到上限,CPU 还剩 65%。多线程能带来的多核利用率提升,在这里没有任何价值。
  2. 简单性:单进程内,CID 路由、路径状态机、per-path 统计,所有状态天然一致,没有任何同步问题。多线程方案需要处理 CID 轮换后的连接重路由、状态机原子操作、统计并发安全——这些复杂度换来 0 的性能收益。
  3. 故障隔离:崩溃影响域 1 辆车,不是 30 辆车。
  4. 可观测性:告警精确到车辆粒度,排查问题直接定位到对应进程。