sgcWebSockets 性能调优——扩展到 10 万个连接

· 功能

从"它工作"到"它扩展"

sgcWebSockets 开箱即用处理几千个连接,无需调优。在单个机器上推过 50,000 或 100,000 个并发套接字也是可能的——但需要选择正确的服务器类、根据工作负载调整线程池大小、选择正确的压缩策略并调整操作系统。本文按您应该拉动的顺序逐个介绍每个杠杆,并提供来自运行 Windows Server 2025 和 Ubuntu 24.04 的 Hetzner AX102(Ryzen 9 7950X3D,128 GB RAM,1 Gbps NIC)的基准数字。

在开始之前,关于方法论的一句话。您下面看到的每个数字都来自合成回显工作负载——100 字节负载,10 条消息/秒/客户端,无业务逻辑。真实应用程序将根据您的 OnMessage 处理程序实际所做的内容落在这些数字和慢一个数量级之间。将这些视为上限,而不是承诺。重点是展示扩展曲线的形状以及悬崖在哪里,而不是为任意工作负载提供保证。

另外:先调优最接近用户的东西。如果您的 TLS 握手需要 80 毫秒,从广播循环中削减 200 微秒毫无意义。剖析、找到瓶颈、修复、重复。下面的列表大致按影响-努力比排序,但每个工作负载都不同。

1. 选择正确的 I/O 模型

sgcWebSockets 提供两个服务器系列:基于 Indy 的 TsgcWebSocketServer(每连接一个线程)和 IOCP/epoll 支持的 TsgcWebSocketHTTPServer(事件驱动)。超过约 5,000 个并发连接,您需要第二个。句号。

服务器 I/O 模型 甜蜜点 硬天花板
TsgcWebSocketServer (Indy) 每连接一个线程 <5,000 ~10,000(线程栈耗尽)
TsgcWebSocketHTTPServer Win IOCP 10,000–100,000 ~250,000(文件描述符)
TsgcWebSocketHTTPServer Lin epoll 10,000–100,000 ~1,000,000(调优后)
TsgcWebSocketHTTPServer Mac kqueue 10,000–50,000 ~100,000

2. 调整线程池大小

IOCP/epoll 服务器使用固定大小的工作池。默认值是 CPUCount。对于纯回显/扇出工作负载保持小(每核 2–4 个)。对于在 OnMessage 内访问数据库或调用外部 API 的工作负载,增加它——否则一个慢请求会阻塞 N 个对等方。

oServer := TsgcWebSocketHTTPServer.Create(Self);
oServer.Port := 443;

// Tune the worker pool
oServer.ThreadPool.PoolSize       := CPUCount * 2;   // CPU-bound
// oServer.ThreadPool.PoolSize    := 128;            // DB-bound (e.g. 16 cores)
oServer.ThreadPool.QueueSize      := 4096;
oServer.ThreadPool.MaxConnections := 100000;

oServer.Active := True;

经验法则:PoolSize = CPUCount + (average_blocking_ms / average_cpu_ms) * CPUCount。如果您发现 Application Insights / 您的 APM 报告队列深度增长,将池加倍。内核调度程序设置了硬上限——Windows 上数万个 OS 线程不好玩——但对于合理的工作负载,您通常远没有那么近。超过 256 个工作器,考虑将阻塞工作卸载到专用工作器池。

最佳的架构决策是保持 OnMessage 非阻塞,并将每个实际工作单元推送到由单独的线程池服务的专用队列。这将 I/O 线程(必须保持快速)与业务线程(可以慢而无后果)解耦。它还使您可观察:队列深度成为"我们即将崩溃"的领先指标。

3. 压缩:per-message-deflate

WebSocket 压缩 (RFC 7692) 修剪 JSON 或文本负载的 60–90%。它也是 CPU 重的。仅当您的工作负载是文本重的且 CPU 有余量时才全局启用它。对于二进制或已经压缩的负载(JPEG、MP4、gzip 压缩的日志),它是纯开销。

oServer.Extensions.PerMessage_Deflate.Enabled         := True;
oServer.Extensions.PerMessage_Deflate.ServerMaxWindow := 15; // default
oServer.Extensions.PerMessage_Deflate.MemLevel        := 8;
oServer.Extensions.PerMessage_Deflate.Threshold       := 256; // skip tiny msgs

设置 Threshold,以便小于标头开销的消息不被压缩。跳过 60 字节心跳的 deflate 比花费更多的 CPU 节省。

一个微妙的陷阱:per-message-deflate 使用一个跨同一连接上的消息保留状态的滑动窗口。该状态是每连接内存。使用 ServerMaxWindow=15(默认值),每个连接持有大约 32 KB 的字典。乘以 100,000 个连接,您只为压缩状态有 3 GB 的 RAM。如果您受内存限制,将 ServerMaxWindow 降到 10 或 11——您损失几个百分点的压缩比,换取大约 8 倍少的每连接内存。

4. 分片

大帧(>1 MB)在完整消息重新组装之前持有工作线程。分片传出消息以保持延迟平滑并为其他对等方释放工作器。

oClient.WriteOptions.FragmentEnabled := True;
oClient.WriteOptions.FragmentSize    := 65536;  // 64 KB chunks

在服务器端,将 ReadOptions.MaxFrameSize 设置为合理的上限(我们使用 4 MB)以保护免受试图分配千兆字节缓冲区的恶意对等方的影响。

5. 广播优化

向每个已连接客户端发送相同的消息是聊天/交易/发布订阅服务器的 #1 瓶颈。天真的循环 for each client: client.Send(msg) 序列化和压缩相同的负载 N 次。使用内置的广播,它序列化一次并重用编码的帧。

// Slow: N encodes, N compresses
for i := 0 to oServer.Connections.Count - 1 do
  oServer.Connections[i].WriteData(vJSON);

// Fast: 1 encode, 1 compress, N writes
oServer.Broadcast(vJSON);

// Fastest for fan-out >10k: pre-encoded buffer
vFrame := oServer.EncodeFrame(vJSON);
oServer.BroadcastEncoded(vFrame);

在 16 核机器上,对于 50,000 客户端扇出,天真循环与 BroadcastEncoded 之间的差异是 12 秒 vs 380 毫秒。同样的原则适用于通道——预编码帧,然后遍历订阅者列表。如果您的订阅者跨许多通道拆分,每通道编码一次并在内广播。在该代码路径中过早悲观将拖垮原本快速的服务器。

6. OS 级调优

内核施加的硬限制远早于组件。在您责备库之前调优这些。

设置 Linux Windows 建议
文件描述符限制 ulimit -n HKLM — MaxUserPort 预期连接的 2 倍
TCP backlog net.core.somaxconn TcpMaxConnectResponseRetransmissions 4096+
TIME_WAIT 重用 tcp_tw_reuse=1 TcpTimedWaitDelay=30 减少端口耗尽
SO_REUSEPORT 内核 ≥3.9 不适用 多进程接受器
临时端口范围 net.ipv4.ip_local_port_range MaxUserPort 10000–65535

7. 心跳和空闲检测

移动客户端一直在掉线。没有心跳,您的服务器保持套接字打开直到 TCP keepalive 计时器触发(通常为 2 小时)。配置短心跳和死对等方检测。

oServer.HeartBeat.Enabled  := True;
oServer.HeartBeat.Interval := 30;     // seconds
oServer.HeartBeat.Timeout  := 90;     // close if no pong within this

这在 90 秒内捕获半开连接,而不是两小时,在繁忙的服务器上释放数千个陈旧套接字。

8. 负载均衡器配对

如果您需要扩展到单个机器之外,将 sgcWebSockets 与我们的 TsgcWebSocketHTTPServer_LoadBalancer 或外部 L7 LB(HAProxy、nginx、AWS ALB)配对。两条规则:

参考基准

来自单个 AX102 机器(16 核 / 32 线程,128 GB)的数字,运行带 100 字节负载的回显服务器,每客户端 10 条消息/秒。

并发客户端 吞吐量 (msg/s) p50 延迟 p99 延迟 CPU 使用率 RSS
10,000 100,000 0.8 ms 3.2 ms 14% 0.9 GB
50,000 500,000 1.1 ms 5.4 ms 38% 3.8 GB
100,000 1,000,000 1.7 ms 9.8 ms 71% 7.2 GB
250,000 2,500,000 3.4 ms 22 ms 96% 17.8 GB

9. TLS 终止

TLS 握手是 CPU 昂贵的。如果您每秒服务数千个新连接,在 Delphi 进程中终止 TLS 可以使核心饱和于做加密而不是提供帧。对于高流失工作负载,我们在 sgcWebSockets 服务器前面的 nginx 或 HAProxy 中终止 TLS,并在纯 HTTP 中运行后端。前端免费获得硬件 AES 加速、会话恢复和 OCSP 装订,Delphi 进程可以将 100% 的 CPU 花在应用逻辑上。

对于具有持久长期连接的工作负载(典型的聊天/交易场景),进程内 TLS 没问题,因为握手在数小时或数天内摊销。对于连接-断开-重连突发(在不稳定网络上的移动客户端),在前面放一个反向代理。

10. NIC 和网络调优

在 1 Gbps 下,您不太可能饱和 NIC。超过 10 Gbps,您必须考虑中断合并、接收端缩放 (RSS) 以及将 sgcWebSockets 工作线程固定到 NUMA 本地核心。在 Linux 上,ethtool -Lset_irq_affinity.sh 是您的朋友。在 Windows 上,在 NIC 属性中将 RSS Profile 设置为 NUMAScaling,并使用 Get-NetAdapterRss 验证。仅当您的监控告诉您内核在 softirq 或 DPC 中花费实际时间时才值得调优。

剖析引导的调优循环

调优是迭代的。从默认值开始,运行代表性负载,查看:每工作器 CPU、GC / 分配率、扇出下的 p99 延迟以及 OS 级连接计数器。更改一件事,重新运行,比较。最常见的惊讶:

进一步阅读

如果您还没有选择正确的服务器类,请从哪个版本开始。然后跳到负载均衡器组件进行多机扩展。库的新手?开始使用中心会在五分钟内引导您完成安装。