从"它工作"到"它扩展"
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)配对。两条规则:
- 使用粘性会话——WebSocket 帧不是幂等的,不能在对话中重新路由。
- 转发原始的
X-Forwarded-For和 TLS 终止标头,以便您的应用程序看到真实的客户端 IP。
参考基准
来自单个 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 -L 和 set_irq_affinity.sh 是您的朋友。在 Windows 上,在 NIC 属性中将 RSS Profile 设置为 NUMAScaling,并使用 Get-NetAdapterRss 验证。仅当您的监控告诉您内核在 softirq 或 DPC 中花费实际时间时才值得调优。
剖析引导的调优循环
调优是迭代的。从默认值开始,运行代表性负载,查看:每工作器 CPU、GC / 分配率、扇出下的 p99 延迟以及 OS 级连接计数器。更改一件事,重新运行,比较。最常见的惊讶:
- 对已经压缩的负载启用压缩 → CPU 峰值,零带宽收益。
OnMessage内的同步 DB 调用 → 在 <1% CPU 下工作器池饱和。- 没有广播批处理 → 在市场开盘峰值期间出现队头阻塞。
- 64 核机器上的默认线程池 → 将工作序列化到 64 个工作器,而 256 个将解锁 4 倍吞吐量。
进一步阅读
如果您还没有选择正确的服务器类,请从哪个版本开始。然后跳到负载均衡器组件进行多机扩展。库的新手?开始使用中心会在五分钟内引导您完成安装。