DNS、NLB 与 Nginx:从一家小店理解多级负载均衡

从两台商品服务的选择开始,逐步引出七层代理、四层 NLB 和 DNS 调度,解释连接与请求怎样分配,以及健康检查、重试、排空和会话的边界。

一家小网店最初只有一台商品服务。顾客访问 /products/42,程序查询马克杯的价格,再返回页面。访问量增加后,店主增加第二台同样的程序,却遇到一个直接的问题:域名只有一个,顾客的这次请求该交给哪台?

负载均衡从这个问题开始:在多个候选目标之间,按照规则把流量分配出去。先不用记“全局调度”“四层”“七层”,我们从两台程序讲起,每增加一种规模,再引出一项职责。

文中的小店、容量比例和分配过程是教学场景。具体配置以 Nginx 官方文档为依据,云上四层行为以 AWS Network Load Balancer 为一个明确例子;不同产品的协议、转发和故障策略需要分别核实。DNS、NLB、Nginx 可以组合部署,但不是一切网站的固定三件套。

一、多开一台程序以后,谁替顾客做选择

假设商品程序 A 监听 127.0.0.1:18081,程序 B 监听 127.0.0.1:18082。两者提供相同 API,读取相同的业务数据;本文实验用相同内存数据代替数据库,不讨论数据复制。

如果让顾客自己选择 A 或 B,访问入口就暴露了部署细节。A 故障后,顾客还得知道 B 的新地址。我们在前面放一个统一入口,由它接收请求,再选 A 或 B。浏览器只认识入口,后端实例可以调整。

这里先使用 Nginx 做反向代理,即代替后端接收客户端请求的入口。一个请求到了 Nginx,它选 A,把请求发过去,收到响应后再返回浏览器;下一个请求可以选 B。顾客连接的是 Nginx,处理业务的是选中的应用实例。Nginx HTTP 负载均衡入门

于是最小架构里已经有两个不同问题。第一,哪些实例可以选择;第二,从这些实例中选谁。候选集合可以先写在配置里,规模扩大后再由平台或服务发现提供。负载均衡算法本身不会凭空发现新机器,也不会把两份相互矛盾的商品数据合并成正确答案。

副本的能力也需要满足业务条件。若 A 和 B 各自在内存中维护库存,顾客刷新时可能看到不同结果;若购物车只存在 A 的进程里,请求分给 B 后可能变空。这些是共享状态与一致性问题,不能靠“分得更平均”修好。先确保候选实例能按契约处理同一类请求,再讨论分配。

二、先用 Nginx 把请求分给两台商品服务

看懂配置里的每一个参与者

upstream 表示后端服务器组,里面先登记 A 和 B;server 监听对外入口;location 匹配请求路径;proxy_pass 把请求交给这个组。下面是完整的本机演示配置,随实验保存在 scripts/experiments/shop-load-balancer.conf:

worker_processes 1;
pid nginx.pid;
error_log error.log notice;

events { worker_connections 256; }

http {
    log_format shop '$request status=$status upstream=$upstream_addr '
                    'upstream_status=$upstream_status '
                    'connect=$upstream_connect_time '
                    'header=$upstream_header_time '
                    'total=$request_time';
    access_log access.log shop;

    upstream products {
        server 127.0.0.1:18081 max_fails=1 fail_timeout=5s;
        server 127.0.0.1:18082 max_fails=1 fail_timeout=5s;
    }

    server {
        listen 127.0.0.1:18080;

        location / {
            proxy_pass http://products;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_set_header Host $host;
            proxy_connect_timeout 1s;
            proxy_read_timeout 3s;
            proxy_next_upstream error timeout;
            proxy_next_upstream_tries 2;
        }
    }
}

这份配置没有配置 HTTPS,也没有声称包含生产的全部防护。它只监听回环地址,目的是观察分配、失败和日志。proxy_pass 后面没有附加 URI,本例保留原始请求路径,商品程序仍然收到 /api/products/42。

A、B 没有设置不同权重时,默认按加权轮询规则选择,在这个条件相同的最小场景中,可以看到请求在两台之间交替。它分配的是到达该后端组的请求,不是给某个用户永久分配一台服务器。一个页面可能请求 HTML、样式和 JSON,这些请求就可能由不同实例完成。

为什么叫七层,它能看见什么

Nginx 解析 HTTP,就可以根据主机名、路径等信息决定怎么处理。比如 /api/products/ 进入商品服务组,/api/orders/ 进入订单服务组,然后各自在组内选实例。前一步是业务路由,后一步才是在同类副本之间分配负载;两个动作常被写在同一个配置文件里。

这种理解应用协议的负载均衡,通常叫七层负载均衡,七层指参考模型中的应用层。HTTPS 下要读取路径,需要在 Nginx 或更早的可信入口解密;对不终止 TLS 的普通转发节点,不能假设它能读取加密的商品路径。

同一个 Nginx 也能通过 stream 模块代理 TCP/UDP。那时它执行的是另一种职责。因此不能把“Nginx”这个产品名永远等同于七层,也不能把“反向代理”与某一种选择算法画等号。Nginx stream 上游模块

三、所谓选得均匀,到底均匀了什么

请求次数相同,不代表工作量相同

假设十个请求里,九个查普通马克杯,每个很快;另一个需要组合许多优惠信息,耗时明显更长。轮询可以让 A、B 各收到五次请求,但拿到复杂查询的实例仍可能更忙。

权重能表达不同容量。例如 A 是较大规格,给它权重 2,B 权重 1,期望在足够多、条件相近的请求中大致形成 2:1 的分配。它并没有测量每次请求的 CPU 开销;A 的下游数据库变慢以后,固定权重也不会自动理解这个变化。

另一个思路是观察当前负担:新请求优先交给活动连接更少的实例,或交给在途请求更少的实例。Nginx 的 least_conn 比较活动连接,并考虑权重;“最少连接”和“最少请求”不能混写,特别是连接内可以并发多个请求时,两者的含义会分开。Nginx upstream 算法说明

选择依据 在小店里怎样理解 仍然可能出什么问题
轮询 A、B 依次接单 订单处理成本不一样
加权轮询 大规格实例多接一些 权重过期或实际瓶颈在共享数据库
最少活动连接 优先分给连接较少的实例 一条长连接里的工作量可能很大
最少在途请求 优先分给未完成请求较少的实例 请求成本不同,统计也可能延迟
哈希 同一个键倾向同一个实例 热点键集中,实例变化影响映射

实际系统还可能先筛选同区域、同可用区或同版本的候选,再在这些候选中选实例。候选范围缩小以后,即使算法非常公平,也只是在这个范围里公平;如果范围内已经过载,需要另行决定是否跨区域、限流或降级。

为什么哈希和会话粘性不能替代状态设计

假设希望同一个顾客总到 A,以复用 A 的本地缓存。可以对用户标识、Cookie 或 IP 做哈希,把同一个键映射到固定目标。它解决的是亲和性,不是保证业务状态永不丢失。

按 IP 分配时,一家公司的许多顾客可能共享出口 IP,流量被集中到一台;按用户分配时,一个超级活跃用户仍可能形成热点。采用一致性哈希可以在节点增减时减少部分键的迁移,但不会让最热的那个键自动拆散到多台。

如果购物车是必须保留的数据,就应由业务选择可靠的共享存储或可恢复设计。粘住 A 只能让正常期间更容易读到 A 的状态,A 宕机后不会凭空把购物车复制给 B。关键状态的生存方式,需要在选择算法之外回答。

四、Nginx 也要多台时,为什么前面再放 NLB

两台应用解决了一部分容量问题,但现在 Nginx 自己成了单一入口。它所在机器重启,A、B 即使健康,顾客也进不来。于是部署 Nginx G1 和 G2,再给它们安排一个稳定入口。

网络负载均衡器 NLB,英文 Network Load Balancer,可以承担这一层。典型四层负载均衡主要依据传输协议、地址和端口处理连接或数据流,将它们交给后端目标。名字里有 Network,不意味着它就是第三层的 IP 路由器;这里“四层”指传输层职责。

以 AWS NLB 的 TCP 流量为具体例子,它使用流哈希等信息选目标,同一条 TCP 连接在存续期间对应一个后端目标。浏览器建立连接 C1 时选到 G1,另一条连接 C2 可以选到 G2。不能把 C1 中后续某个 TCP 段随意交给 G2,否则 G2 不拥有对应的连接状态。AWS NLB 的目标选择与连接行为

这里要同时看见选择粒度。NLB 为一条连接选 Nginx;Nginx 解读连接内的 HTTP 请求,再为请求选择应用实例。HTTP/2 的一条客户端连接可以承载许多请求,所以连接数各占一半,不保证 HTTP 请求数也各占一半,更不保证 CPU 消耗各占一半。

四层实现也有不同路径。有的通过地址转换转发,有的采用全代理建立两端连接,还有的支持直接返回等设计。不能看到“四层”就假定所有产品都使用相同回程路径或源地址保留方式。网络访问规则、日志中的源 IP,以及后端如何返回,都要结合实际产品确认。

TLS 放在哪里,决定谁能读取请求

一种清楚的部署方式是 NLB 的 TCP 443 监听把加密字节交给 G1 或 G2,TLS 在 Nginx 终止。Nginx 解密后读路径,再通过 HTTP 或 HTTPS 访问应用。另一种方式是在支持 TLS 终止的入口完成握手,再按配置向下游发送。

是否保留客户端 IP 也需要配置与实现支持。如果使用代理提供的额外地址信息,接收方只能信任受控入口,不能无条件相信用户自己填的转发头。否则限流、审计和访问控制都可能使用伪造身份。TLS、源地址和业务路由是三项相关但不同的配置,不能互相替代。

NLB 也必须有自己的可用性设计。云产品的多节点入口、多个可用区,或自建入口的冗余与故障接管,解决的是 NLB 这一层的故障。单独在一台机器上安装一个四层转发程序,并不会因为它叫“负载均衡”就自动高可用。

五、区域也不止一个时,DNS 怎样参与分配

现在让小店有华东和华南两套入口,各自可以独立处理适合它的请求。顾客仍然输入 shop.example.com。域名系统可以按策略回答不同地址,让客户端先选到某个区域入口。

这叫 DNS 调度:通过改变名称查询的回答,影响后续连接的目的地。可以按地域、延迟、权重或主备策略配置;具体能力与健康条件由 DNS 服务提供。它不会解析 /products/42 来选商品服务,也不会在浏览器发出每条 HTTP 请求时都重新参与。Route 53 路由策略

注意这里有两条路径。第一条是客户端查询域名、解析器取回名称记录;第二条才是浏览器连接返回的入口,传输页面请求。网页数据通常不穿过 DNS 服务器。DNS 是影响连接目的地的控制环节,不是夹在 NLB 前面搬运每个 HTTP 数据包的代理。

DNS 先提供区域入口地址,连接经四层入口选择 Nginx,再由七层代理选择商品实例

图中 DNS 查询用单独分支表示;下面是商品请求的实际传输路径。NLB 的两台候选是 Nginx,Nginx 的两台候选是商品程序,两处负载均衡面对的对象不同。

DNS 权重不等于每百次页面访问精确分配

假设把区域权重设为 80:20。一个递归解析器得到华东地址后,可以把缓存答案提供给大量顾客;某些顾客又会复用已有连接。最终的页面请求比例可能偏离 DNS 查询回答比例。

TTL 指示解析器可缓存记录多久。改权威记录不会主动清掉全部缓存,更不会主动迁移已经建立的连接。因此 DNS 切换通常存在传播窗口,不能承诺改完记录后下一秒所有顾客都改走新入口。Route 53 的 TTL 与变更传播说明

区域故障时,还需要知道健康检查从哪里执行、检查哪个依赖、什么条件触发返回备用地址。若备用区的商品数据没有准备好,DNS 成功切过去仍然可能给出错误价格。区域选择只改变请求方向,业务数据和写入权限能否接管,是更深一层的容灾问题。

六、实例坏了以后,谁知道,谁停止选它

把应用 A 关掉,入口还可能在短时间内继续选它。因为配置里存在 A,只说明它是候选,并不证明此刻健康。需要获取新证据,再改变可选集合。

主动健康检查由检查者定期请求目标,例如连端口或访问 /healthz。被动检查则从真实请求中观察连接失败、超时等情况。前者可以在没有用户请求时发现故障,后者更接近实际调用路径;两者都有局限。端口能连接,不保证商品数据库可用;一次真实请求失败,也可能是网络抖动或该请求自己的参数问题。

检查还有时间窗口。假设每两秒探测一次,连续三次失败才摘除,在某次探测刚结束后宕机的实例,并不会立即从所有地方消失。门槛低,切换更快但容易误摘;门槛高,判断更稳却可能多让一批请求碰到坏实例。

本机 Nginx 示例中的 max_fails=1 fail_timeout=5s 使用被动失败判断。对计入失败的尝试,在相应时间窗口达到阈值后,暂时避开该实例;窗口结束后可重新尝试。哪些状态计入失败与相关代理规则有关,不能简单把所有 4xx 当成机器故障。只有一个上游实例等特殊条件下,参数行为还有区别。Nginx server 失败参数

商业版本或其他代理可能提供主动探测等额外能力,不能把一份开源被动检查配置写成“已经定期主动探测”。云 NLB 的检查和应用层代理的检查也分别针对自己的直接目标:NLB 能连接 Nginx,不说明每一台商品实例都健康。

极端情况下,产品可能采用“全部目标不健康时继续尝试目标”的失败开放策略,而不是统一停止转发。AWS NLB 文档明确描述了相应情形,所以“负载均衡绝不把请求交给不健康目标”不是无条件承诺。NLB 健康检查与失败开放

恢复与下线都需要过渡

实例刚启动,可能尚未加载商品规则、建立连接池或预热缓存。应该在可以接业务时再进入候选集合,必要时逐步增加流量。否则新实例虽然端口已经开着,却会被第一批真实请求压垮。

计划下线时也不要先杀进程。先停止给它分配新工作,等待已接收请求完成,在宽限期结束后再关闭。这个过程叫连接或请求排空。不同层的排空对象不同:四层入口面对连接,七层入口还需要处理同一长连接上是否继续接收新请求。

尤其是 HTTP/2、gRPC 和 WebSocket,连接可以存活很久。只把实例从新连接候选中删除,旧连接仍可能产生新工作。需要相应协议的退出信号与期限管理;超过期限的请求如何重试或恢复,也要另行约定。摘除不是把正在执行的业务瞬间搬到另一台。

七、换台机器再试一次,为什么也可能出错

顾客读取商品失败,入口改向 B 查询,通常容易理解。但如果请求变成 POST /orders:A 已经创建订单,只是响应回来的时候断了,入口再把同样内容发给 B,就可能创建第二笔订单。

这时“连接失败”和“结果未知”需要分开。能确认请求还没交给任何业务实例,与发出后没拿到响应,提供的证据不同。即便入口自身知道某次尝试失败,也未必知道上游数据库有没有提交。

Nginx 的 proxy_next_upstream 控制哪些情况可以尝试下一个上游;非幂等方法在请求已发送之后,默认有额外限制。启用 non_idempotent 等选项会改变重试行为,不能为减少错误页面就直接打开。响应内容已开始发送给客户端后,也不能随意从另一台重新返回一份完整响应。Nginx 上游重试规则

需要安全重试的写操作,应使用同一业务编号,由服务端确认原结果、阻止重复生效。重试还受总等待预算约束。浏览器、网关、RPC 客户端若各自重试三次,底层尝试数可能放大;目标已经过载时,这些额外流量会拖慢恢复。超时后怎样安全重试解释了业务幂等键与结果查询。

超时参数本身也应读清含义。示例中的 proxy_connect_timeout 限制建立上游连接的等待,proxy_read_timeout 是相邻读取之间的超时,并非整项业务的绝对总截止时间。最大尝试次数限制这一层的尝试,也不会自动约束所有更深的调用。

失败后先判断是否已经产生业务效果;只在语义允许且预算足够时重试,实例下线先排空再退出

图中的判断依赖能够获得的证据。结果未知时,不能把“换一台”当作已经证明前一次没执行;换机器改变目标,不会擦掉数据库里可能已存在的订单。

八、动手看两台服务怎样轮流响应

沿用网络篇的小店程序,在两个终端分别启动 A 和 B:

node scripts/experiments/shop-network-demo.mjs --port 18081 --name product-a
node scripts/experiments/shop-network-demo.mjs --port 18082 --name product-b

使用已安装的 Nginx,在独立目录准备日志和 PID 文件。下面以 Windows PowerShell 为例,在项目根目录执行;把第一行路径改成自己的 Nginx 安装位置。演示前应确认 18080、18081、18082 未被其他程序占用。

$shopNginx = 'C:/softWhere/nginx-1.20.2/nginx.exe'
$shopPrefix = Join-Path $env:TEMP 'king-of-water-shop-lb'
New-Item -ItemType Directory -Force -Path "$shopPrefix/logs", "$shopPrefix/temp" | Out-Null
Copy-Item scripts/experiments/shop-load-balancer.conf "$shopPrefix/nginx.conf"
$shopPrefixArg = ($shopPrefix -replace '\\', '/') + '/'
& $shopNginx -p $shopPrefixArg -c nginx.conf -t
if ($LASTEXITCODE -ne 0) { throw 'Nginx 配置检查未通过' }
Start-Process -FilePath $shopNginx -WindowStyle Hidden -ArgumentList @(
    '-p', $shopPrefixArg, '-c', 'nginx.conf'
)
1..6 | ForEach-Object {
    curl.exe -s http://127.0.0.1:18080/api/products/42
}

返回 JSON 的 backend 字段表示真正处理请求的实例。两台都健康、单 worker 且串行请求时,应看到 A、B 交替;不要将“第一条一定是 A”当成验收条件。通过 access.log 中的 upstream 可以核对目标地址。

关闭 A 的终端,再发几次读取请求。可能先发生一次到 A 的失败尝试,再由 B 返回成功;日志中的上游地址、状态和耗时可出现多个值,表示这一条外部请求内部经历了多次上游尝试。客户端最后只看见 200,也可能已经发生过故障转移。

本文在本机 Nginx 1.20.2 上验证了这个过程:两台运行时,六次串行读取的 backend 为 A、B 交替;停止 A 后,四次读取都由 B 返回 200。其中一次访问日志包含下面的信息。504, 200 是两次上游尝试的状态,前面的 504 由代理在尝试失败时记录,不表示已经停掉的 A 真的返回了一份 HTTP 响应:

status=200
upstream=127.0.0.1:18081, 127.0.0.1:18082
upstream_status=504, 200

恢复 A 后,等被动失败窗口过去,继续请求,观察 A 是否重新出现。最后在同一个 PowerShell 终端用相同前缀退出本次入口,再停止两台演示程序:

& $shopNginx -p $shopPrefixArg -c nginx.conf -s quit

这组实验只验证单机 Nginx 的请求分配和失败处理,不模拟云 NLB 或真实 DNS 权重调度。要验证四层,应观察多条连接分别落在哪个入口;要验证 DNS,应分别记录解析回答、缓存和后续连接。三者的观测单位不同,不能用六次本机 curl 得出跨区域请求比例的结论。

九、一家小店扩成多级入口后,该观察什么

将前面的机制接起来,顾客先解析名称,拿到某个区域入口;NLB 为连接选择 G1;G1 接收并解密 HTTP,按路径找到商品组,再选择 A;A 返回商品,响应经 G1 和入口回到顾客。另一请求可以选 B,而已有 TCP 连接仍可能一直经过 G1。

此时每一层都要留下与自己职责相符的证据。DNS 看解析回答和区域分布;NLB 看连接、目标健康和流量;Nginx 看请求、上游地址、各段耗时和重试;应用看请求编号、业务状态和数据库结果。四处都写一个“成功数”,却不知道分母是查询、连接还是请求,数字就无法对齐。

例如所有应用都慢,先查共享数据库、入口容量和重试放大,不能只把 A 的权重减半。只有少数用户慢,则要结合区域入口、长连接、哈希键和网络路径。总连接数很均匀但某台 Nginx CPU 高,可能是连接内请求数量与处理成本不同,需要继续看 HTTP 层。

当前规模或需求 可以先采用的结构 扩展前必须确认
单机学习或很小的网站 一个入口与一个应用 备份、进程恢复和监控
应用需要多副本 七层入口 + 应用组 状态共享、超时和入口单点
入口也需要冗余 四层入口 + 多台七层代理 入口高可用、连接排空与地址传递
多区域部署 DNS 调度 + 区域入口 数据接管、缓存窗口与跨区成本
已有托管七层产品 由托管入口承担相应职责 实际功能、配置、限制与故障边界

托管应用负载均衡器可以承担 Nginx 的一部分入口职责,四层入口也可以直接把流量交给应用。只有一台应用的小站,不必为了画齐架构而人为加三层。每增加一层,都增加连接、配置、日志与故障边界,收益需要对应真实问题。

负载均衡最终要回答三件具体的事:现在有哪些可用目标,这次按什么粒度和规则选择,失败或变更后怎样停止旧选择并恢复。算法负责其中一部分;连接生命周期、健康证据和业务重试语义接上以后,整个入口才具备可解释的行为。