一位客户写信来反映了一个简单的观察,事实证明完全正确:从 HTTP 服务器提供文件服务会占用大量内存。如果 TsgcWebSocketHTTPServer 从 DocumentRoot 提供一个大文件,那么每个连接都会将整个文件读入 RAM。一百个客户端下载同一个 1 GB 的文件意味着大约需要 100 GB 的 RAM。这个版本修复了这个问题。现在文件从磁盘流式传输,无论文件多大、有多少客户端连接,服务器内存都保持平稳。该改进随 sgcWebSockets 2026.6.0 一同发布。
问题所在:每个连接一份完整副本
两条静态文件路径,即用于 HTTP/1.x 的 DoResponseHTTP 和用于 HTTP/2 的 DoHTTP2DocumentRoot,都以相同的方式构建响应。它们创建一个 TMemoryStream,调用 LoadFromFile,然后将该流交给响应。LoadFromFile 会一次性将整个文件读入内存,因此每个请求的开销就是整个文件的大小,而且每个并发下载都持有各自的副本。
// Old behaviour, simplified
oStream := TMemoryStream.Create;
oStream.LoadFromFile(vDocument); // entire file into RAM
AResponseInfo.ContentStream := oStream;
这使得峰值内存大致为 file size × concurrent connections。只要少数几个客户端拉取一个视频、一个安装程序、一个磁盘镜像或一个数据库备份,就足以将服务器逼入交换分区,或导致内存不足而崩溃。
慢速读取的放大效应
同一位客户描述了第二种场景,而它是一种真实的攻击:许多连接各自以非常慢的速度读取响应,大约每秒一个字节。这是一种慢速读取攻击,是 Slowloris 的一个变种。一个慢慢滴入读取的客户端会让连接保持很长时间,而在旧代码下,这些连接中的每一个还会占住该文件在内存中的一份完整副本。几千个对一个普通大小文件的慢速读取者,就可以在几乎不传输任何数据的情况下挟持数 GB 的内存。
我们是如何测量的
我们搭建了一个小型测试装置:一个带有 DocumentRoot 和一个大测试文件的服务器,外加一个具有两种模式的客户端。"快速"模式打开 N 个并发的正常下载,"慢速"模式打开 M 个原始连接,发送一个有效请求,然后以每秒一个字节的速度读取正文。我们用一个 100 MB 的文件采样了服务器的工作集,分别在修复之前和之后进行。
| 场景(100 MB 文件) | 修复前 | 修复后 |
|---|---|---|
| 5 个并发下载 | ~511 MB | ~11 MB |
| 10 个并发下载 | ~1,012 MB | ~11 MB |
| 20 个并发下载 | ~1.7 GB | ~12 MB |
| 100 个慢速读取(1 byte/sec)连接 | ~1.67 GB 持续保持 | ~19 MB |
| 文件完整性 | 不适用 | SHA-256 逐字节精确,Content-Length 正确 |
在修复之前,内存几乎精确地随文件大小乘以连接数而变化。修复之后,无论文件多大或有多少客户端连接,内存都保持在数十兆字节的范围内。
修复方法:从磁盘流式传输
现在文件改用一个只读、共享的 TFileStream 来提供,而不再使用 TMemoryStream。管线中的其他部分都无需改动,因为写入端本来就是流式的:HTTP/1.x 服务器以 32 KB 的分块发送内容流,而 HTTP/2 服务器以大约 64 KB 的 DATA 帧发送。因此服务器从磁盘读取一小块,发送出去,再继续下一块,这使得每个连接的内存开销从整个文件降到几十千字节。
// New behaviour, simplified
oStream := TFileStream.Create(vDocument, fmOpenRead or fmShareDenyWrite);
oStream.Position := 0;
AResponseInfo.ContentStream := oStream;
Content-Length 仍然正确,因为它来自 TFileStream.Size,并且该流在响应之后会像以前一样被释放。我们通过 SHA-256 比较验证了下载结果与源文件逐字节相同,并且响应头报告的长度正确。没有任何 API 变化,也无需配置:设置 DocumentRoot 仍然像一贯那样工作,只是它不再缓冲文件了。
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.Port := 80;
oServer.DocumentRoot := 'C:\inetpub\files'; // large files now stream from disk
oServer.Active := True;
.NET 版本会自动继承此修复,因为它的 DocumentRoot 文件服务运行在与 Delphi 组件相同的已编译核心中。
拖慢慢速读取者
从磁盘流式传输消除了内存放大效应,因此慢速读取者再也无法将整个文件占在 RAM 中。剩下的只是连接本身,而对此则有一个发送超时机制。Options.WriteTimeOut 为套接字写入设置了一个截止期限,因此停止接收数据的客户端会被断开,而不会永久阻塞一个服务器线程。在这个版本中,该超时机制在 Linux 和其他 POSIX 平台上也能正常工作,而不仅限于 Windows。POSIX 套接字 API 期望的是一个 timeval,而不是一个毫秒整数,现在这一点已被正确处理。
oServer := TsgcWebSocketHTTPServer.Create(nil);
oServer.DocumentRoot := 'C:\inetpub\files';
oServer.Options.WriteTimeOut := 15000; // drop a client that stalls for 15s, Windows and POSIX
oServer.Active := True;
对于公开的服务器,我们建议组合使用三个手段:设置 Options.WriteTimeOut 以便丢弃停滞的写入,使用防火墙的 RateLimit.MaxConnectionsPerIP 来限制单个地址能够打开的连接数,并考虑使用线程池调度器或 IOCP 和 EPOLL 处理器,这样大量慢速连接就无法各自耗尽一个线程。
升级
这个流式传输修复是即插即用的。一旦你更新,DocumentRoot 就会以平稳的内存从磁盘提供文件服务,无需任何代码改动。该改动已在 Delphi 7 至 13 上、在 HTTP/1.x 和 HTTP/2 路径上经过验证,.NET 构建通过共享核心获得该修复。如果你提供大型静态文件服务,这将显著减少内存占用,并是抵御慢速读取滥用的有用一步。
请从 sgcWebSockets 下载页面更新,或通过 GetIt 或你的注册账户获取。
有疑问、反馈或需要迁移帮助?联系我们,你会收到来自编写这些代码的人的回复。
