对象存储负载均衡避坑:当客户端重试遇上后端雪崩,我是如何用指数退赘救场的?

前言:一个让你半夜惊醒的案例

去年我接手一个S3兼容对象存储集群,负载均衡层用Nginx+Upstream,客户端用AWS SDK(默认重试3次)。某天流量突增,一个网关节点因磁盘I/O打满挂了。结果呢?2秒内集群整体吞吐跌到0——活生生被客户端的自动重试和负载均衡的“傻瓜式转发”干崩了。这不是个例,80%的对象存储负载均衡事故都跟“重试风暴”有关。今天只讲怎么避坑,不讲废话。

一、根源:客户端重试与负载均衡的“死亡螺旋”

对象存储的SDK(比如boto3、aws-sdk-go)默认开启重试:请求超时或返回5xx时,会立即重试(通常等1秒、2秒、4秒)。但负载均衡器如果配置了proxy_next_upstream error timeout,会把重试请求再次转发到同一批后端。如果后端已经雪崩,所有新请求和重试请求一起涌入,形成指数级增长。

具体链条:
1. 节点A故障 → 请求超时 → SDK重试请求1
2. 负载均衡再把重试请求转发给节点B(节点B此时也因A的故障导致压力增大)→ 节点B超时 → SDK重试请求2
3. 所有节点都在处理重试和正常请求 → 资源耗尽 → 全体超时 → 客户端重试次数达到上限(如3次)→ 请求失败,但集群已崩。

二、负载均衡侧:必须做的3项配置

以Nginx为例,大多数指南只给基础配置,下面才是硬核改法:

1. 关闭无脑重试,改用proxy_next_upstream_tries限制

upstream s3gateway {
    server 10.0.0.1:9000 max_fails=2 fail_timeout=30s;
    server 10.0.0.2:9000 max_fails=2 fail_timeout=30s;
}
server {
    location / {
        proxy_pass http://s3gateway;
        proxy_next_upstream error timeout http_500 http_502;
        proxy_next_upstream_tries 1;  # 只试一次,避免无限转发重试
        proxy_next_upstream_timeout 2s; # 每次尝试总耗时不超过2s
        proxy_connect_timeout 1s;
        proxy_read_timeout 10s;
    }
}

关键:proxy_next_upstream_tries 1让Nginx最多转发到另一个后端,而不是无限循环。同时把http_404之类从proxy_next_upstream去掉,避免误重试。

2. 主动健康检查:用health_check模块(Nginx Plus或开源版用nginx_upstream_check_module

不依赖被动max_fails,直接对每个节点发HEAD请求到/minio/health(如果是MinIO)或/_health(Ceph RGW):

upstream s3gateway {
    server 10.0.0.1:9000;
    server 10.0.0.2:9000;
    check interval=1000 rise=2 fall=3 timeout=500 type=http;
    check_http_send "HEAD /minio/health HTTP/1.0\r\nHost: localhost\r\n\r\n";
    check_http_expect_alive http_2xx http_3xx;
}

注意:健康检查间隔不能太短(小于500ms),否则健康检查自身可能成为DDoS。配合fail_timeout=30s让故障节点冷却。

3. 连接与请求的背压:增加proxy_limit_ratelimit_conn

防止单个客户端大量重试淹没后端:

limit_conn_zone $binary_remote_addr zone=addr:10m;
limit_conn addr 20;

limit_req_zone $binary_remote_addr zone=one:10m rate=30r/s;
limit_req zone=one burst=10 nodelay;

location / {
    proxy_limit_rate 1024k;  # 单个客户端写入速度限制
    ...
}

三、客户端侧:必须配置的“指数退赘+随机抖动”

SDK默认重试策略常常是exponential_backoff,但默认底数2,没有抖动(jitter)。在对象存储场景下,多客户端同时重试时,退赘时间相同会造成同步波峰。手动改写重试策略(以Python boto3为例):

import random
import time
from botocore.config import Config

def custom_retry_handler(attempt, response=None, exception=None):
    if attempt > 3:
        return None
    base_delay = min(2 ** attempt, 30)   # 指数退赘,最大30秒
    jitter = random.uniform(0, base_delay)
    delay = base_delay + jitter
    time.sleep(delay)
    return True

config = Config(
    retries={'max_attempts': 3, 'mode': 'adaptive'}
    # adaptive模式在boto3 1.26+中自动应用指数退赘+随机抖动
)
client = boto3.client('s3', config=config)

如果是Go SDK,设置Retryerclient.DefaultRetryerMaxNumRetries=3并开启client.MaxRetryDelay。务必增加jitter因子。

四、真实排错案例:一次因健康检查滞后导致的雪崩

我维护的一个MinIO集群,负载均衡用Nginx,健康检查只检查端口存活。某天内核OOM杀掉一个MinIO进程,但操作系统端口仍在(因为进程刚被杀,内核未及时释放端口),健康检查认为节点正常。客户端请求全集中到该节点,等待10秒超时后重试,重试请求又被Nginx发给同一节点(因为其他节点健康但压力未均衡?实际因为least_conn算法会倾向于连接数少的节点,而该节点连接数暴涨后least_conn反而会分流到其他节点?)——错误!当时配置的是ip_hash,同一个客户端IP总是发给同一个节点。幸好没大范围影响。

解决方案:改用/minio/health端点做HTTP健康检查,并设置fail_timeout=10s,20秒内连续3次失败则踢出。同时把ip_hash换成hash $request_uri consistent(一致性哈希),避免单点故障影响大量客户端。

当时用的云服务器恰是某家不稳定的机器,后来迁移到轻云互联的BGP多线主机,IOPS稳定,那次问题再没出现。但健康检查机制不能依赖硬件。

五、终极防御:在负载均衡层使用令牌桶限流(针对重试请求)

业务场景:客户端已经配置了指数退赘,但遇到突发故障时,成千上万的客户端同时开始第一次重试,仍然有峰值。可以在Nginx用lua编写一个按来源IP+请求路径的令牌桶,限制单位时间内重试请求(区别于正常请求):

local limit = require "resty.limit.req"
local lim = limit.new("my_limit_store", 100, 200)  -- 每秒100个,突发200
local key = ngx.var.binary_remote_addr .. ":" .. ngx.var.request_uri
local delay, err = lim:incoming(key, true)
if not delay then
    return ngx.exit(503)  -- 返回503,触发客户端退赘机制
end

注意:要区分是否能识别“重试请求”。通常可以在HTTP头部加入X-Retry-Count,由客户端SDK自定义重试时带上。无此头部的按正常请求处理。

总结:三招防重试风暴

  • 负载均衡:关闭无脑proxy_next_upstream,用健康检查和主动限流
  • 客户端:配置指数退赘+随机抖动,不要用默认重试
  • 网关层:实现重试请求识别与限流

对象存储负载均衡不是简单配个upstream就完事的。在遇到故障时,一个错误的配置可以让整个集群变成“木马电梯”——所有请求都卡在重试循环里。希望这篇文章能帮你少走弯路。