重启一台挂着一万个客户端的 WebSocket 服务器,会发生一些很不愉快的事情。每个客户端几乎在同一时刻察觉到断开,每个客户端都等待同样固定的秒数,然后每个客户端都在同一个时间点拨号回来。服务器刚起来,立刻就被一万个同时发起的 TLS 握手砸中。它要么崩溃,要么开始限流,然后客户端又整齐划一地再次重试。这就是惊群效应,而固定的重连间隔必然导致它。
sgcWebSockets 2026.7 针对客户端猛捶一台本已陷入困境的服务器的两个场景做了改进。WatchDog 现在可以用指数退避加抖动来重连,而不再是固定间隔;HTTP 客户端可以自行重试失败的请求,遵守服务器的 Retry-After 提示,并使用同样的退避算法。两者都是可选启用且默认关闭的,所以在你启用之前,现有应用的行为与以往完全一致。
固定重连间隔的问题
WatchDog 一直是保持客户端连接的标准做法。你设置 Enabled,设置一个以秒为单位的 Interval,当连接断开时,组件每隔 Interval 秒重试一次,直到连接恢复。
这对于一台连着不稳定 Wi-Fi 的客户端来说没问题。但对于一个客户端集群来说,这是错误的行为。使用固定间隔时,集群中的每个客户端都是一个以相同频率滴答的节拍器,而一次服务器重启会让它们全部同步:它们在同一瞬间断开,于是它们就在同一瞬间重连,永远如此。更糟的是,这个间隔不会自适应。如果服务器宕机十分钟,一个 Interval = 1 的客户端会发起六百次毫无意义的连接尝试,其他每个客户端也都一样。
你想要的恰恰相反。第一次快速重试,因为大多数断连都是瞬时的,快速重连对用户来说是无感的。然后逐步退避,这样一台真正宕机的服务器就不会被反复猛击。同时把客户端分散开,这样它们就不会全都同时到达。
WatchDog 上的指数退避
WatchDog 选项对象新增了四个属性。类型定义位于 sgcTCP_Classes:
type
TsgcWatchDogBackoff = (wdbFixed, wdbExponential);
在任何 WebSocket 组件上,这些选项都通过 Client.WatchDog 访问,退避相关的设置如下:
uses
sgcWebSocket, sgcTCP_Classes;
begin
sgcWebSocketClient1.WatchDog.Enabled := True;
sgcWebSocketClient1.WatchDog.Attempts := 0; // 0 = keep trying forever
sgcWebSocketClient1.WatchDog.Interval := 1; // seconds, the first delay
sgcWebSocketClient1.WatchDog.Backoff := wdbExponential;
sgcWebSocketClient1.WatchDog.BackoffMultiplier := 2.0; // double it every attempt
sgcWebSocketClient1.WatchDog.MaxInterval := 60; // seconds, the ceiling
sgcWebSocketClient1.WatchDog.Jitter := 0.2; // up to 20% random spread
end;
这里的算法故意做得很平淡。第一次重连尝试等待 Interval。之后每一次尝试都把上一次的延迟乘以 BackoffMultiplier,结果再被限制在 MaxInterval 之内。用上面的取值,延迟依次是 1 秒、2 秒、4 秒、8 秒、16 秒、32 秒、60 秒、60 秒、60 秒,以此类推。一旦客户端重新连上,计数器就会复位,所以一次短暂的网络抖动只让你损失一秒,而不是一分钟。
Interval 和 MaxInterval 都以秒为单位,这与 WatchDog 一贯的做法一致。MaxInterval 为 0 表示没有上限,这也是默认值。如果你启用了指数退避,请设置一个上限,否则延迟会一直翻倍,直到要用小时来计量。
对现有用户来说,重要的细节是:Backoff 默认为 wdbFixed。如果你升级但什么都不改,你的客户端仍然会按一贯的固定间隔重连。只有当你切换到 wdbExponential 或设置了 Jitter 时,延迟计算才会被使用,而且它和 HTTP 重试用的是同一个辅助函数,即 sgcBase_Helpers 中的 sgcNextBackoffMs。
抖动,以及它为何在规模化时至关重要
单靠指数退避并不能打散羊群。它只是让羊群到达得没那么频繁。一万个客户端全都先等 1 秒、再等 2 秒、再等 4 秒,仍然是一万个客户端同时到达,只不过浪头一波比一波大。
Jitter 才是真正把它们分散开的东西。它是计算出的延迟的一个比例,按客户端、按尝试次数随机施加。当 Jitter = 0.2 时,标称 8 秒的延迟会变成 8 秒上下 20% 区间内的一个随机值,于是客户端 A 在 7.1 秒醒来,客户端 B 在 8.6 秒,客户端 C 在 8.0 秒。整个集群像一片涂抹开的痕迹而不是一根尖峰那样到达,服务器的 accept 队列就跟得上了。
// A conservative fleet profile: fast first retry, hard ceiling, generous spread.
sgcWebSocketClient1.WatchDog.Enabled := True;
sgcWebSocketClient1.WatchDog.Interval := 2;
sgcWebSocketClient1.WatchDog.Backoff := wdbExponential;
sgcWebSocketClient1.WatchDog.BackoffMultiplier := 1.5; // gentler than doubling
sgcWebSocketClient1.WatchDog.MaxInterval := 120;
sgcWebSocketClient1.WatchDog.Jitter := 0.5; // spread over half the delay
抖动与退避模式是正交的。你可以让 Backoff 保持 wdbFixed 而只设置 Jitter,这样你得到的就是经典的固定间隔,但客户端之间彼此错开。这本身往往就已经足够了,而且这是对现有应用可能做出的最小改动。
自动重试 HTTP 请求
这项功能的另一半在 HTTP 这一侧。一个因为服务器正在重新部署而返回 503 的 HTTP 请求,或者因为你超过速率限制而返回 429 的请求,其实算不上错误。它是一个应该过一会儿再发一次的请求。手写那个循环既乏味,而且人人都会写错一点。
TsgcIdHTTP 现在有了一个 RetryOptions 对象,在 sgcHTTP_Client 中声明为 TsgcIdHTTPRetry_Options。启用它,请求就会自行重试:
uses
sgcHTTP_Client;
var
oHTTP: TsgcIdHTTP;
begin
oHTTP := TsgcIdHTTP.Create(nil);
try
oHTTP.RetryOptions.Enabled := True; // default False
oHTTP.RetryOptions.MaxRetries := 3;
oHTTP.RetryOptions.InitialInterval := 500; // milliseconds
oHTTP.RetryOptions.MaxInterval := 30000; // milliseconds
oHTTP.RetryOptions.Multiplier := 2.0;
oHTTP.RetryOptions.Jitter := 0.2;
oHTTP.RetryOptions.StatusCodes := '429,502,503,504';
ShowMessage(oHTTP.Get('https://api.example.com/v1/orders'));
finally
oHTTP.Free;
end;
end;
这里的时间间隔以毫秒为单位,而不是秒,因为 HTTP 重试所处的时间尺度比重连要短得多。延迟遵循与 WatchDog 相同的曲线:500 毫秒、1 秒、2 秒、4 秒,受 MaxInterval 限制,并由 Jitter 打散。
StatusCodes 是被视为可重试的 HTTP 状态码列表,用逗号分隔的字符串表示。默认值是 429,502,503,504,这些状态码的含义是"现在不行,请再试一次",而不是"你的请求有问题"。400 或 401 永远不会被重试,因为把同一个错误的请求再发四次帮不上任何人的忙。连接层面的失败,比如套接字被拒绝或读取超时,同样会被重试。
同样的对象也存在于 sgcHTTP_API 中的 TsgcHTTPAPI_client 上,它是库中每一个开箱即用 REST 客户端的基类。因此这套重试策略在所有客户端上都可用,无需为每个客户端单独接线:
// Any API client that descends from TsgcHTTPAPI_client
sgcHTTPAPI_Client1.RetryOptions.Enabled := True;
sgcHTTPAPI_Client1.RetryOptions.MaxRetries := 5;
sgcHTTPAPI_Client1.RetryOptions.StatusCodes := '429,500,502,503,504';
Retry-After:让服务器来定节奏
自己计算退避时间只是猜测。当服务器返回 429 或 503 时,它常常会在 Retry-After 响应头里明确告诉你该等多久,形式要么是秒数,要么是一个 HTTP 日期。那个数字不是建议。它是服务器在告诉你它什么时候会重新接受你,而一个无视它、按自己节奏重试的客户端,只会再次被拒绝。
HonorRetryAfter 默认为 True。当响应携带 Retry-After 头时,该值优先于计算出的退避时间,客户端会按服务器要求的时长等待:
oHTTP.RetryOptions.Enabled := True;
oHTTP.RetryOptions.HonorRetryAfter := True; // default True
oHTTP.RetryOptions.InitialInterval := 500;
oHTTP.RetryOptions.MaxRetries := 3;
// Server replies 429 with "Retry-After: 12".
// The computed backoff would have been 500ms, the client waits 12 seconds instead.
该头的两种形式都会被解析,即秒数差值形式和 HTTP 日期形式。这正是 OpenAI 和 Anthropic 在返回 429 时要求它们的客户端去做的事,而它决定了一个客户端是能从速率限制中恢复,还是一直被限流。
AI 客户端上的重试
AI 客户端是重试最能发挥价值的地方,因为在那里速率限制是家常便饭,而不是例外。它们每一个都在自己的 *Options 属性上暴露了各自的重试选项对象,所以这些设置会和模型、API 密钥一起出现在对象查看器中。
在 TsgcHTTP_API_OpenAI 上,路径是 OpenAIOptions.RetryOptions,类为 TsgcHTTPOpenAIRetry_Options:
uses
sgcHTTP_API_OpenAI;
begin
sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Enabled := True;
sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Retries := 3;
sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Wait := 10000; // ms, first delay
sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.MaxInterval := 30000; // ms, ceiling
sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Multiplier := 2.0;
sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.Jitter := 0.2;
sgcHTTPOpenAI1.OpenAIOptions.RetryOptions.HonorRetryAfter := True;
end;
Anthropic 的结构完全相同,位于 AnthropicOptions.RetryOptions 之下,Gemini 位于 GeminiOptions.RetryOptions 之下。同样的模式也存在于 Grok、Mistral、DeepSeek 和 Ollama 客户端上,以及 Stripe 和 Paddle 客户端上,在那里一次被重试的支付调用是另一种意义上的重要。
// Anthropic returns 429 and 529 under load, both are treated as retryable.
sgcHTTPAnthropic1.AnthropicOptions.RetryOptions.Enabled := True;
sgcHTTPAnthropic1.AnthropicOptions.RetryOptions.Retries := 3;
sgcHTTPAnthropic1.AnthropicOptions.RetryOptions.HonorRetryAfter := True;
请注意,AI 对象上的属性名是 Retries 和 Wait,而不是 MaxRetries 和 InitialInterval,这是为了与这些组件上其他地方已有的命名保持一致。行为是完全相同的,内部它们配置的是底层 HTTP 客户端上同一个 TsgcIdHTTPRetry_Options。
知道它为什么失败:Connect 与 LastError
一个重试循环的好坏取决于它的错误处理。在你能判断某次失败是否值得重试之前,你得先知道失败是什么,而直到现在,在 WebSocket 客户端上弄清这一点的唯一办法,就是接一个 OnError 处理器,然后把消息塞进某个字段里。
2026.7 新增了一个直接把错误交给你的 Connect 重载,位于 sgcWebSocket_Client_Base:
function Connect(const aTimeout: Integer = 10000): Boolean; overload;
function Connect(out AError: string; const aTimeout: Integer = 10000): Boolean; overload;
这样一来,手写的重连循环就可以检查失败原因并自行决定:
var
vError: string;
begin
if not sgcWebSocketClient1.Connect(vError, 5000) then
begin
// A DNS failure or a refused socket is worth retrying.
// A rejected certificate or a 401 on the handshake is not.
Memo1.Lines.Add('connect failed: ' + vError);
Exit;
end;
end;
最后一次错误也保存在组件本身上。sgcWebSocket_Classes 中的 TsgcWSComponent_Base 发布了一个只读的 LastError: string 属性和一个 ClearLastError 方法,所以你可以事后读取原因,也可以在一次想要测量的尝试之前把它重置:
sgcWebSocketClient1.ClearLastError;
if not sgcWebSocketClient1.Connect(5000) then
ShowMessage('connect failed: ' + sgcWebSocketClient1.LastError);
这些都不会取代 OnError,它仍然像以前一样触发。这只是意味着,一段简单的同步代码不再需要为了回答一个简单的问题而去搭建事件处理器。
可用性
WatchDog 退避、抖动、HTTP 重试以及新的 Connect 重载从 sgcWebSockets 2026.7 起提供,覆盖 Delphi 7 到 13 和 C++Builder,支持 Win32/Win64、Linux64、macOS、Android 和 iOS。这里描述的一切默认都是关闭的。Backoff 为 wdbFixed,Jitter 为 0,RetryOptions.Enabled 为 False,所以现有应用升级后行为完全不变,你可以在合适的地方按需启用。
拥有有效订阅的客户可以从客户专区下载新版本。试用用户可以在 esegece.com/products/websockets/download 获取更新后的安装程序。
有疑问、反馈,或者需要帮助为你的集群挑选一套退避配置?联系我们,回复你的将是编写这些代码的人。
