ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

负载均衡四层七层、调度算法与Nginx高可用实战

负载均衡四层七层、调度算法与Nginx高可用实战 1. 负载均衡解决的到底是什么问题1.1 从一台机器被压垮说起我最早接触负载均衡不是什么高大上的架构设计而是被现实逼的。当时一个内部系统单台服务器跑得好好的日常两三百个并发访问毫无压力直到某个活动日流量翻了六倍接口响应从 80ms 一路飘到 4 秒最后连接池打满、进程假死。运维同学重启了三次每次重启后十分钟又挂。那时候我们才意识到问题不在于代码写得多烂而在于整条链路只有一个出口——所有请求都压在同一个进程、同一块网卡、同一颗 CPU 上。负载均衡要解决的就是这件事把涌入的请求分散到多台机器上让整体吞吐能力接近线性叠加同时通过健康探测把已经出问题的节点摘掉避免用户请求打到死马身上。它不是一个单独的软件而是一种思路可以落在硬件设备上可以落在内核网络转发上也可以落在应用层的反向代理里。对读者来说无论你是刚学后端的学生还是已经写了几年业务代码的工程师理解负载均衡的收益都很直接你能看懂公司架构图里那一层是干嘛的能自己给项目配一个可用的分发入口更重要的是当线上出现部分用户慢、部分用户快这类诡异现象时你知道该往哪个方向查。1.2 它在整条链路里站在哪个位置先建立一个位置感。典型的请求链路长这样[ 用户 ] | v [ DNS 解析 ] --- 图1请求进入系统的第一道分发 | v [ 负载均衡入口 ] --- 图2本篇的主角横向扩展的闸门 | -- [ 应用节点 A ] -- [ 应用节点 B ] -- [ 应用节点 C ] | v [ 缓存 / 数据库 ] --- 图3真正难扩展的部分这张图里有两个关键认知很多人第一次做架构时都栽在上面。第一负载均衡解决的是横向扩展问题不是性能优化问题。如果单个请求本身就慢比如一个查询要扫三百万行数据你加十台机器也只是让十倍的慢请求同时发生用户该等还是等。负载均衡的前提是每个节点都能独立、正确地处理请求并且单请求耗时在可接受范围内。第二负载均衡本身也可能成为新的单点。图2 那个方框如果只有一台它挂了整个系统就没了这就是为什么后面一定要讲高可用第 5 节。很多人第一次搭建时只做了分发没做入口冗余结果可用性反而比单机还低——因为多了一个必须存活的中间层。理解这两点你就明白为什么业界会同时存在这么多种实现从内核层的 ECMP、传输层的 LVS、应用层的 Nginx 和 HAProxy到服务网格里的边车代理。它们不是互相替代的关系而是站在链路的不同高度上各自解决一段问题。2. 四层与七层选错层级等于白折腾2.1 四层负载均衡看的是什么四层指的是传输层典型协议是 TCP 和 UDP。四层负载均衡在做转发决策时能看到的只有源 IP、源端口、目的 IP、目的端口以及协议号。它不知道 HTTP 请求里写的是/api/order还是/api/user也看不到 Cookie、Header 和请求体。工作方式很朴素收到一个 TCP 连接按算法挑一台后端然后做地址转换或者直接转发 IP 包把这条连接的所有数据包都送到同一台后端。整个过程中它基本不碰应用数据所以性能极高单机扛几十万并发连接不是罕见的事。客户端 ---- SYN ---- [ 四层 LB ] | | 记录连接表Client_X:Port - Backend_B v [ Backend B ] --- 图4四层只认连接不认内容代价是灵活性差。你没法根据 URL 做路由没法做基于 Cookie 的会话保持没法在转发时改写请求头也很难做精细的限流和灰度。它的信条是连接建立时决定一次之后绝不反悔。提示四层负载均衡的连接表是它的命脉。当后端节点下线时已经建立的连接不会自动迁移只会被重置或超时断开。这一点在设计长连接业务时必须提前考虑。2.2 七层负载均衡看的是什么七层工作在应用层它会把 TCP 流重新组装成完整的 HTTP 请求看清楚方法、路径、Header、Cookie然后才决定转发给谁。这也是为什么 Nginx 能写出location /api/这种配置——它真的读懂了请求内容。多出来的解析成本换来的是大量实用能力按域名分流、按路径分流、改写 Header、注入追踪 ID、做 A/B 测试灰度、按请求维度限流、统一处理跨域和压缩。现代业务里入口层几乎都跑在七层。客户端 -- GET /api/order -- [ 七层 LB ] | | 解析出 path/api/order | 命中 location /api/ - 后端组 api_pool v [ api_pool 某节点 ] --- 图5七层按内容决策2.3 怎么选四个判断维度实际选型时我一般看四点不问哪个更好只问哪个够用。判断维度偏向四层偏向七层协议类型非 HTTP如数据库、消息队列、游戏长连接HTTP/HTTPS、gRPC 等可解析协议路由需求不需要按路径、域名分流需要灰度、多租户、按路径分流性能压力单机数十万连接极致吞吐单机数万连接需要功能换性能运维复杂度配置简单排查靠连接表配置灵活排查需看访问日志真实生产里更常见的是组合最外层用四层做流量入口和抗量内层用七层做业务路由。这样既保住了吞吐又拿到了灵活性。我第一次做入口设计时图省事全用七层结果 HTTPS 握手和证书卸载的 CPU 开销在高峰期非常明显后来把纯转发的那部分下沉到四层机器数量直接省下来一小半。3. 调度算法逐个拆解别只会轮询3.1 轮询、加权轮询与平滑加权轮询是最容易理解的算法后端列表按顺序一个一个发发完回到第一个。它的隐含假设是所有后端能力相同这在同构集群里成立在混合部署里基本不成立。加权轮询给每台机器加一个权重权重高的分到更多请求。关键在于实现方式。最朴素的实现是按权重把节点重复填进列表再轮询比如 A 权重 5、B 权重 1列表就是 A A A A A B。这会导致流量在时间上严重不均前五个请求全打给 A第六个才给 B短时间窗口内的抖动非常大。平滑加权轮询解决了这个问题。它的做法是每个节点维护一个当前权重每轮把配置权重加到当前权重上选出当前权重最大的节点然后给它的当前权重减去总权重。以 A(5)、B(1) 为例总权重 6轮次 当前权重(A,B) 选中 选中后(A,B) 1 (5, 1) A (-1, 1) 2 (4, 2) A (-2, 2) 3 (3, 3) A (-3, 3) 4 (2, 4) B (2, -2) 5 (7, -1) A (1, -1) 6 (6, 0) A (0, 0) --- 图6平滑加权轮询的六轮过程对比一下就很清楚朴素实现是 AAAAA B平滑实现是 A A A B A A流量被打散了后端负载曲线明显更平。Nginx 的weight参数默认就是平滑版本这点不用自己操心但你要知道它为什么平滑——排查为什么权重没严格按比例的时候答案就在这里。3.2 最小连接数与最快响应最小连接数的思路是谁手里的活少就给谁。它不关心权重只关心当前活跃连接数或请求数。这个算法特别适合请求耗时差异极大的场景比如一个接口有时 10ms 有时 3 秒轮询会把慢请求均匀地摊到所有机器上而最小连接数会自动把新流量倾斜给那些已经处理完的节点。它有个明显缺陷只统计数量不统计成本。某台机器上挂了十个超慢请求它看起来连接数很少于是新请求继续往它身上压雪崩就是这么来的。所以实际使用中最小连接数通常要配合合理的超时配置一起用让慢请求及时被清掉。最快响应则是直接看响应时间谁回得快选谁。听起来最聪明实际上最容易抖——响应时间是个高频波动的指标如果采样窗口太短流量会在节点之间来回跳形成震荡。我一般只在后端节点硬件差异确实很大、且响应时间统计做了平滑的场合才用它。3.3 哈希类算法与一致性哈希有状态场景下你会希望同一个用户的请求固定落到同一台机器。源地址哈希和 URL 哈希都是这个思路对某个字段做哈希对后端数量取模。问题在于取模。后端从 4 台扩到 5 台hash % 4变成hash % 5绝大部分映射关系都会变缓存全量失效数据库瞬间被打穿。这就是扩容时最经典的缓存雪崩来源之一。一致性哈希把节点和数据都映射到一个环上每个请求顺时针找到第一个遇到的节点。增加一个节点时只影响环上相邻区间的一小部分数据其余映射关系保持不变。[Node A] | [data3] -- [data1] (环上顺时针查找) | [Node B] | [data2] -- [Node C] --- 图7一致性哈希环新增节点只影响一段区间它带来的好处是扩容时迁移量约为1/NN 为节点数代价是数据分布可能不均。解决办法是虚拟节点每个物理节点在环上映射出几百个虚拟点分布就均匀了。这个数字我一般设 128 到 256 之间太少不均匀太多内存和查找开销上升。3.4 等开销负载均衡网络层把流量摊平等开销负载均衡Equal-Cost Multi-Path通常简称 ECMP是另一套思路它不发生在应用层而在路由层。当一台设备到某个目的地存在多条开销相同的路径时它会把流量按哈希规则分摊到这些路径上。-- [ 链路1 / 下一跳A ] [ 源 ] -- [ 转发设备 ] -- [ 链路2 / 下一跳B ] --- 图8等价多路径两条路径同时承载流量它的哈希键通常是源 IP 目的 IP 协议号 源端口 目的端口这五元组所以同一条 TCP 流会被稳定地送到同一条路径上不会出现乱序。这个特性非常关键如果五元组被拆到不同路径到达顺序错乱接收端会大量重传性能反而暴跌。ECMP 的常见用武之地有两处一是数据中心内部把流量分摊到多条物理链路和多个上游设备二是在大规模服务入口处作为最外层的流量分摊手段。它最大的优点是几乎不引入额外的处理延迟缺点是不感知后端健康状态和服务质量链路断了自己恢复依赖路由收敛速度而且它对大象流少数超大流量连接无能为力——一条 10Gbps 的连接只能走一条路径再怎么等开销也分不开。所以我的经验是ECMP 用来做粗粒度摊平应用层负载均衡用来做细粒度调度两者叠加而不是二选一。4. Nginx 负载均衡配置从能跑到好用4.1 最小可用配置Nginx 的入口配置就是upstream加proxy_pass。先看一个能跑起来的最小版本upstream backend { server 10.0.0.11:8080; server 10.0.0.12:8080; server 10.0.0.13:8080; } server { listen 80; server_name api.example.internal; location / { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里默认使用平滑加权轮询三台机器权重都为 1。几个容易忽略的点proxy_set_header Host $host一定要写否则后端拿到的是backend这个上游组名很多框架的域名校验和签名逻辑会直接失败。X-Forwarded-For用了追加形式保留代理链后端取真实 IP 时要取第一个非可信地址不能直接取最后一个。注意proxy_pass http://backend;后面没有斜杠和proxy_pass http://backend/;的语义完全不同。前者会把原始 URI 原样透传后者会把location匹配到的前缀替换掉。这一个斜杠的差别我见过不止一次把接口全部打到 404。4.2 权重、备份与优雅下线生产配置比最小配置多出来的部分几乎都是围绕可控两个字。upstream backend { least_conn; server 10.0.0.11:8080 weight5 max_fails3 fail_timeout10s; server 10.0.0.12:8080 weight3 max_fails3 fail_timeout10s; server 10.0.0.13:8080 weight1 backup; keepalive 64; }weight用来表达机器规格差异新老机型混部时非常必要。max_fails和fail_timeout组成被动健康检查在fail_timeout窗口内失败次数达到max_fails节点被摘除fail_timeout时长。这两个参数默认是 1 和 10s对于偶发抖动的服务太敏感了我一般把max_fails提到 3避免一次网络抖动就把节点踢出。backup标记的是备用节点只有主节点全部不可用时才接管流量。这个标记在灰度发版时特别好用把新版本节点设为 backup可以先用少量真实流量验证出问题影响面极小。下线节点时正确做法不是直接删配置然后reload。直接删会让正在处理的请求被中断。更稳妥的是先加down标记保留配置让存量连接自然结束观察一段时间后再删除。server 10.0.0.13:8080 down; # 保留配置不再分发新请求keepalive 64是上游连接池用来复用和后端之间的长连接。不加这个参数Nginx 每个请求都要和后端重新三次握手在高 QPS 下会吃掉大量端口资源和握手开销。经验值是按后端节点数乘以 16 到 32 来设同时要配合proxy_http_version 1.1和清空Connection头否则连接池根本不生效location / { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ; }4.3 会话保持的三种做法会话保持是个绕不开的话题。有三种常见做法我按推荐度从高到低排。第一是把状态外移会话数据放 Redis 或数据库应用节点本身无状态。这是最优解因为一旦无状态任何算法都能用扩缩容也不影响用户。代价是要多一次外部访问通常用本地缓存加过期时间来缓解。第二是基于 Cookie 的会话保持。Nginx 的ip_hash是按客户端 IP 哈希问题在于大量用户共享出口 IP 时流量会严重倾斜到同一台机器上。如果一定要在代理层做我更倾向用hash $cookie_sessionid consistent;按业务自己的会话 ID 做一致性哈希分布比 IP 均匀得多而且客户端换网络也不影响。第三是粘性 Cookie 注入由代理层在响应里种一个标记后端编号的 Cookie后续请求按这个标记路由。功能最精确但需要额外模块且用户禁用 Cookie 时失效。方案分布均匀度扩容影响实现成本状态外移高无中需要外部存储会话 ID 一致性哈希高小仅迁移部分低IP 哈希低受出口 IP 影响大低代理注入粘性 Cookie高中中需要模块支持4.4 超时与缓冲把慢后端隔离开负载均衡最怕的不是后端挂掉而是后端变慢。挂掉会被健康检查摘除变慢则会持续占用连接和线程最终把代理层自己拖死。proxy_connect_timeout 2s; proxy_send_timeout 10s; proxy_read_timeout 10s; proxy_next_upstream error timeout http_502 http_504; proxy_next_upstream_tries 2; proxy_buffering on;proxy_connect_timeout设短一点是必须的2 秒足够完成同机房握手。proxy_read_timeout要根据业务最长耗时来定设太小会误杀正常的长请求设太大则慢后端会长期霸占连接。proxy_next_upstream是重试机制遇到连接错误或超时自动换一台后端重试。这里有个大坑如果请求不是幂等的比如创建订单重试可能导致重复下单。所以我的做法是默认只对error timeout重试并结合proxy_next_upstream_tries 2限制次数对于写接口要么在业务层做幂等键要么用location单独关掉重试。5. 高可用别让入口变成新的单点5.1 主备与双活的差别入口层做高可用最经典的是主备模式两台代理机器一个持有虚拟 IP 对外服务另一个待命通过心跳互相探测主节点失联后备节点接管虚拟 IP。这样对上游完全透明切换通常在秒级。它的缺点是备机平时完全闲置资源利用率只有 50%。而且切换瞬间已建立的连接会断对长连接业务不友好。双活模式是两台都对外服务通过 DNS 轮询或上层再放一层 ECMP 把流量分给两台。资源利用率上去了但带来两个新问题一是两台机器之间的会话状态不同步用户可能一会儿打到 A 一会儿打到 B二是 DNS 轮询的客户端缓存不可控摘除一台后仍有大量请求按缓存打到已下线节点。我的取舍是中小规模用主备简单可靠出问题概率低规模大到备机成本不可忽略时用 ECMP 加多台代理做双活因为路由收敛比 DNS 缓存可控得多。5.2 健康检查的粒度和抖动健康检查的粒度决定了故障发现的快慢也决定了误判的概率。检查方式发现速度误判风险适用场景TCP 端口探测快秒级低但可能误判进程在但业务挂快速剔除宕机节点HTTP 状态码探测中中依赖探测路径本身稳定大多数 HTTP 服务业务语义探测慢低核心链路需验证依赖可用被动统计失败率快受流量影响低流量时不准兜底配合主动探测只做 TCP 探测是最常见的偷懒做法后果是后端进程还在 listen但数据库连接池已经打满所有请求都返回 500而负载均衡依然认为它健康继续往上面灌流量。至少要做到 HTTP 层探测并且探测路径要选一个真正会访问数据库或缓存的接口而不是一个直接 return 200 的假接口。这一点很多人栽过探测接口写成了return ok结果它永远健康形同虚设。抖动处理也有讲究。探测连续失败两次才判定不健康连续成功三次才判定恢复这叫去抖。如果一失败就摘、一成功就加节点会在健康与不健康之间反复横跳流量跟着忽上忽下监控曲线上会出现密集的锯齿。6. 常见问题与排查技巧实录6.1 问题速查表下面这张表是我自己攒的基本覆盖了负载均衡相关问题的九成场景。现象常见原因排查动作流量全打到一台机器权重配错、会话保持生效、连接池粘住看分发统计检查upstream与哈希配置部分用户间歇性 502后端主动断连、连接池复用了已关闭连接检查上游keepalive与后端 keepalive 超时配合上线后缓存大面积失效节点数变化导致取模哈希重排换一致性哈希或预热缓存后端所有机器负载都很低但用户慢代理层本身成为瓶颈worker 数或连接数不够看代理机器 CPU、连接数与工作进程数扩容后新节点几乎没有流量客户端 DNS 缓存、连接未重建检查客户端连接池与 DNS 缓存时间切流后仍有请求到旧节点客户端长连接未断、注册中心推送延迟等待连接自然老化配合优雅下线6.2 几个我踩过的坑第一个坑是长连接导致的流量固化。客户端用了连接池并保持长连接负载均衡在连接建立时就把这条连接钉在了某台后端上于是十条长连接可能全落到同一台机器。看起来负载均衡生效了实际分布极不均匀。解决办法是在客户端侧设置连接的存活时间上限比如 60 秒就重建或者在服务端设置 idle 超时强制连接轮换。第二个坑是健康检查导致的惊群。后端恢复的瞬间所有探测同时成功流量瞬间全量涌入刚恢复的节点立刻又被打挂。应对方式是给恢复加权重爬坡让新上线的节点权重从 1 逐步升到满值给它喘息时间。第三个坑是代理层和后端的超时时间不匹配。代理设置 10 秒超时后端业务最长要跑 15 秒结果后端把活干完了返回的响应已经没有接收方既浪费资源又产生大量超时日志。原则永远是外层超时大于内层超时逐层递增形成清晰的超时预算。提示排查负载均衡问题时第一步永远是确认请求到底去了哪台机器。在响应头里带上后端节点标识比翻十份日志都快。7. 压测验证与容量估算7.1 到底需要几台机器估算逻辑其实很简单先算出总承载需求再除以单机能力最后留出余量。假设峰值 QPS 是 3000单台应用节点压测下来稳定能扛 500 QPSP99 控制在 200ms 内那么理论上需要3000 / 500 6台。但这只是起点还要考虑三个修正。一是余量。至少留 30% 冗余应对流量突增和节点故障所以6 / 0.7 ≈ 8.6取 9 台。二是故障冗余。要保证挂掉任意一台仍有足够容量所以多备一台共 10 台。三是水位上限。单机压测的 500 QPS 是极限值长期跑在这个水位上抖动会很大实际按 70% 作为工作点即 350 QPS那么3000 / 350 ≈ 8.6同样是 9 到 10 台。需求 QPS 3000 单机安全水位 350 (500 极限 x 70%) 理论台数 3000 / 350 ≈ 8.6 加故障冗余 10 --- 图9容量估算的推导路径需要强调的是这个计算里的单机能力必须来自真实压测不能拍脑袋。很多团队这块全靠猜结果要么资源浪费要么一上线就崩。7.2 压测怎么做才有意义压测最容易犯的错是只压后端、不压入口。实际上如果你是初次搭建入口层代理层本身可能就是瓶颈。压测要从最外层打进去观察整条链路的 P99 和错误率。压测流量要尽可能贴近真实请求体大小要还原、缓存命中率要接近线上、连接复用策略要和真实客户端一致。用短连接猛打和用长连接稳定打得到的结果可能差两倍以上。观察指标上我会同时看四个入口层的活跃连接数、后端各节点的 QPS 分布、P99 响应时间、以及错误率。其中 QPS 分布最能说明问题——如果各节点之间的差异超过 20%先去查算法和权重配置别急着加机器。还有一点压测要测故障场景。手动下线一台后端观察流量是否在预期时间内完成重分布以及期间错误率有没有尖峰把入口的一台代理停掉看备用是否正常接管。这些演练过的场景才是真正故障时能救命的东西。最后分享一个我在实际项目里的体会负载均衡的配置从来不是一次写对的而是被打出来的。每次故障复盘我都会回头看一眼当时的upstream和超时参数往往能发现一两个当时觉得无所谓、事后看致命的细节。把这些细节沉淀成配置模板和检查清单比记住任何算法公式都管用。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进