☰
负载均衡原理与算法实战:从健康检查到最小实现
2026/10/26 11:48:45 网站建设 项目流程

负载均衡这个词,做后端的几乎没有不认识的,但真正能把它的原理讲透、还能在代码里手写一遍调度逻辑的人并不多。我见过不少项目,架构图上画着一个流量入口,底下挂着三台机器,配置用的是默认轮询,然后一两年没人动过——直到某天其中一台机器的内存慢慢爬满,健康检查没配,请求被均匀地继续打到那台已经半死不活的机器上,用户侧的报错率直接飙到三成。这篇内容就从这个场景出发:先把负载均衡的原理拆开讲清楚,四层和七层到底差在哪、为什么健康检查比选什么算法重要十倍,然后把轮询、加权轮询、平滑加权轮询、最少连接、一致性哈希这几种算法的代码一行行写出来,最后拼出一个能真正跑起来的最小负载均衡器,配上压测脚本和一份我自己整理的排查手册。不管你是刚接触分布式的新手,还是用了几年现成组件、想搞清楚内部逻辑的老手,都能直接拿去用或者改。

1. 先想明白:负载均衡到底在解决什么问题

1.1 从一台机器扛不住的那一刻说起

任何关于负载均衡的讨论,起点都是一个非常朴素的事实:单台机器的处理能力有上限。这个上限可能来自 CPU、内存、网卡带宽、磁盘 IO,也可能来自单进程能维持的连接数。早期遇到瓶颈,最常见的做法是垂直扩容——加内存、换更好的 CPU、上更快的盘。这条路能走,但有两个绕不过去的坎:一是成本曲线不是线性的,性能翻倍往往要付出三四倍的价钱;二是天花板摆在那里,单机再强也有顶。

水平扩容的逻辑就完全不同了,加机器就行。但这里立刻冒出一个新问题:请求只有一份,机器有三台,谁来决定这一次请求该落到哪台上?这个"决定"的动作,就是负载均衡的全部内核。它听起来简单,实际要考虑的东西非常多——这台机器现在忙不忙、它的配置是不是别人的一半、它刚刚是不是挂过、同一个用户的连续请求要不要落在同一台机器上、后端返回慢的时候要不要提前把它摘掉。算法只是这个决策过程中最表层的一环,真正让一套负载均衡方案在生产环境里站得住脚的,是健康检查、超时控制、故障摘除和恢复策略这些"外围"机制。

我个人的判断标准很直接:如果一个系统的流量已经无法由单机承接,或者单机故障会导致业务中断,那负载均衡就不是"要不要做"的问题,而是"做到什么程度"的问题。反过来,日请求量几千、跑在一台小机器上的内部工具,硬套一套三层负载均衡架构,纯属给自己找麻烦。

1.2 四层转发和七层转发,差的不是一点半点

这是新手最容易含糊的地方。所谓四层,指的是传输层,调度器只看到源 IP、源端口、目标 IP、目标端口和 TCP 状态,它不关心你传输的是 HTTP 还是 MySQL 协议,也不解析任何内容。所谓七层,指的是应用层,调度器会把报文完整读出来,解析出请求行、请求头、URL、Cookie,再根据这些信息做决策。

这两者的差别体现在三个地方。第一是性能,四层只做包的转发,几乎不碰数据内容,单机扛几十万并发连接很常见;七层要解析完整的请求,还要维护和后端之间的连接池,同等硬件下吞吐会低一截。第二是灵活性,七层可以按 URL 路径分流,比如/api/走一组机器、/static/走另一组,也可以按 Cookie 做会话保持,四层做不到这些。第三是排障难度,四层出问题你只能看到连接建立失败或者超时,七层可以看到具体的状态码和响应头,定位问题快得多。

对比维度四层转发七层转发
决策依据IP、端口、协议URL、Header、Cookie、状态码
单机并发能力很高,几十万连接中等,取决于解析开销
会话保持方式源地址哈希Cookie 注入或源地址哈希
典型问题定位只能看连接是否建立能看到具体请求与响应
适用场景数据库、消息队列、长连接网关Web 服务、API 网关、微服务入口

选型上我的建议是分场景:TCP 长连接业务、数据库中间层、游戏网关这类,用四层;面向浏览器的 Web 服务和 API,用七层。很多团队是四层加七层组合,最外层四层做流量入口和粗略分流,里面再用七层做精细化路由,各司其职。

1.3 别急着上独立组件,先看看有没有更轻的路

一说到负载均衡,很多人第一反应是装一套独立的转发组件,前后端都围着它改。实际上,能在客户端解决的问题,不一定非要拉到服务端。现在不少 RPC 框架自带服务发现和客户端侧的负载均衡,SDK 从注册中心拉到实例列表,在本地按算法挑一个直接连,中间省掉一跳转发,延迟更低,故障扩散面也更小。这种方式在内部微服务调用里非常常见,代价是需要每种语言都有对应的 SDK。

还有一种情况是压根不需要复杂调度。比如静态资源站点,直接上 DNS 轮询就够了,配多个 A 记录,客户端自己挑。缺点是无法感知后端健康状态,摘除节点依赖 DNS 缓存过期,时效性差。所以它适合的是对故障不敏感、或者本身已经有 CDN 兜底的场景。

我踩过的一个坑是:早期为了"看起来正规",给一个日活不到两千的内部系统套了独立转发层,结果转发层本身成了单点,它一重启全站不可用,运维成本反而比原来高。后来改回客户端直连加注册中心,问题立刻消失。判断标准其实就一条——你的转发层能不能自己也做成高可用。如果不能,引入它只是把单点从应用挪到了入口,问题并没有解决,反而多了一个需要维护的组件。

2. 负载均衡的核心原理拆解

2.1 一次请求进来,调度器到底做了哪几件事

把整个过程拆开,调度器其实只做三件事:选一个后端、把流量送过去、把响应带回来。听起来简单,但第二件事的实现方式直接决定了整套方案的形态。

第一种是 NAT 模式,也叫地址转换。客户端请求到调度器,调度器改掉目标地址,转发给后端,后端回复也走调度器回来。这种模式最大的好处是后端机器不用做任何配置,网关指向调度器就行;坏处是回程流量也压到调度器身上,带宽容易先成为瓶颈,业界常说"回程压力大于去程"就是这个原因。

第二种是直接路由模式,调度器只改目标 MAC 地址,把包直接扔给后端,后端处理完直接把响应发给客户端,绕过调度器。这样回程带宽就不再是瓶颈了,吞吐能提升一大截。代价是所有后端机器都得和调度器在同一网段,还得配置虚拟 IP 和抑制 ARP 响应,运维复杂度明显上升。

第三种是 IP 隧道模式,把原始请求包整个封一层再送给后端,后端解包处理,回程同样直连客户端。它突破了同网段限制,可以跨机房调度,但要求后端支持隧道协议解码,报文也会变大,MTU 处理不当容易出问题。

这三种模式的取舍本质上是"运维复杂度"和"吞吐上限"的交换。中小规模业务,我一般直接选最简单的转发方式,别为了省那点带宽把自己绕进去;等到单机流量真的打到千兆以上,再考虑换模式。

2.2 健康检查:比选什么算法重要十倍

这是我最想强调的一点。很多团队花大量时间纠结轮询还是最少连接,却把健康检查配成默认值甚至干脆不开,这是典型的抓小放大。算法决定的是"流量怎么分配得更好看",健康检查决定的是"这台挂了的机器会不会继续吃请求"。前者影响性能,后者影响可用性。

主动探测是最常见的方式,调度器定期向后端发起探测请求。TCP 探测只建连接,成本最低,但后端进程活着而业务逻辑已经卡死的情况它发现不了;HTTP 探测会请求一个专门的健康接口,看状态码,能发现更深层的问题,但健康接口本身要写得轻量,千万别在里面查数据库或者调下游服务,否则探测本身就变成了压力源。实际配置里有四个参数要盯:探测间隔、超时时间、连续成功几次才恢复、连续失败几次才摘除。间隔太短会带来额外的探测流量,太长则故障发现不及时;恢复阈值设成 1 很容易在抖动时反复上下线,一般设 2 到 3 次比较稳妥。

被动摘除是另一层保险,也就是根据真实的请求失败情况做熔断。常用的配置项是最大失败次数和失败时间窗,比如十秒内失败三次就摘除这台,过一段时间再放一小部分流量试探。这层的价值在于,它能覆盖主动探测发现不了的问题——探测接口好好的,但某个业务接口就是报错。

提示:主动探测和被动摘除一定要同时开。只开主动探测,会出现"探测通过但业务失败"的盲区;只开被动摘除,故障发现要等到用户请求已经报错,损失已经产生了。

还有一个容易被忽略的机制叫慢启动。后端刚上线或者刚重启,缓存是空的,连接池是空的,JIT 还没热起来,这时候如果按正常权重一口气灌流量,很容易把它再次打挂,形成"上线就挂、挂了重启、重启又挂"的循环。慢启动的做法是在一段时间内把它的权重从很低逐步加到正常值,给系统一个预热窗口。这个参数在流量大的系统里几乎是必配项。

2.3 有状态请求怎么处理:会话保持的三种做法

选节点的时候还要回答一个问题:同一个用户的连续请求,需要落在同一台后端上吗?如果后端是无状态的,答案是不需要,随便挑都行,这也是最理想的状态。但现实里总有状态存在,比如登录态放在本地内存、上传的分片临时存在本地磁盘、购物车没落库。

第一种做法是会话复制,每台机器的会话数据实时同步到其他机器。好处是任意一台都能处理任意请求,负载可以完全均摊;坏处是机器数量一多,同步开销会呈平方级增长,超过五六台基本就不划算了。

第二种做法是粘性会话,通过 Cookie 或者源地址哈希,把同一个用户固定绑到一台机器上。实现简单,成本几乎为零;问题是负载会不均,某些热门来源的请求全压在少数几台上,而且机器一挂,绑在它上面的用户会话就全丢了。这种方式适合短期过渡,不适合当长期方案。

第三种是集中式存储,把会话放到一个独立的缓存或数据库里,所有机器都从那里读。这是我最推荐的方向,扩展性好,机器可以随意增删。代价是多了一次网络往返,以及对那个存储组件的依赖,所以它本身必须做成高可用。选型的时候算一笔账:一次会话读取大概多花一两毫秒,换来的是任意扩容和故障无感,这笔买卖在绝大多数场景下都是划算的。

3. 主流算法逐个拆解,附可运行代码

3.1 轮询与加权轮询:最简单也最容易配错

轮询的逻辑一句话就能说完:有一个计数器,每次请求加一,对后端数量取模,选出来的下标就是目标机器。它的优点是实现极简、分布绝对均匀、不需要维护任何额外状态。缺点是它假设所有后端能力完全一致,只要有一台机器配置是别人的一半,或者上面还跑着别的进程,它就会成为最先被打满的那个。

加权轮询就是为了解决这个问题。给每台机器一个权重,权重大的被选中的概率高。最朴素的实现是把权重展开成一个列表,权重是 5 就放 5 个进去,然后对这个列表轮询。这种做法在权重数值小的时候没问题,但权重一旦是几百,列表会变得很长,内存和遍历成本都不划算。

class SimpleRoundRobin: """最朴素的轮询,适合后端规格完全一致的场景""" def __init__(self, addrs): self.addrs = addrs self.index = 0 def next(self): addr = self.addrs[self.index % len(self.addrs)] self.index += 1 return addr class NaiveWeightedRoundRobin: """把权重展开成列表,权重小时可用,权重上百就别用了""" def __init__(self, nodes): # nodes: [{"addr": "10.0.0.11:8000", "weight": 5}, ...] self.pool = [] for n in nodes: self.pool.extend([n["addr"]] * n["weight"]) self.index = 0 def next(self): addr = self.pool[self.index % len(self.pool)] self.index += 1 return addr

朴素加权的另一个问题更隐蔽:它会把连续请求扎堆打到同一台机器上。假设权重是 5、1、1,展开后的序列是 A A A A A B C,五个连续的请求全落在 A 上,B 和 C 在旁边闲着。如果 A 上的请求比较重,这五个连续请求会造成短时间内的明显抖动。这就是为什么生产环境几乎都用平滑加权轮询。

3.2 平滑加权轮询:那套经典实现为什么更靠谱

平滑加权轮询的思路很巧妙:每台机器维护两个值,一个是固定权重,一个是当前权重。每次选节点,先把固定权重加到当前权重上,选出当前权重最大的那台,然后把它的当前权重减去所有机器固定权重之和。这样做的效果是,权重高的机器被选中的频率依然符合比例,但请求不会连续扎堆,而是均匀地穿插开。

import threading class SmoothWeightedRoundRobin: def __init__(self, nodes): # nodes: [{"addr": "10.0.0.11:8000", "weight": 5}, ...] self.nodes = [dict(n, current=0) for n in nodes] self.lock = threading.Lock() def next(self): with self.lock: total = sum(n["weight"] for n in self.nodes) if total <= 0: return None best = None for n in self.nodes: n["current"] += n["weight"] if best is None or n["current"] > best["current"]: best = n best["current"] -= total return best["addr"]

拿权重 5、1、1 跑一遍,选出来的序列是 A、A、B、A、C、A、A。对比朴素加权的 A A A A A B C,肉眼就能看出差别:平滑版本的分布明显更均匀,任意一个短窗口内都不会出现五连击。这个算法之所以成了事实标准,原因就在这里——它不是让比例更准,而是让比例在任意时间切片上都更准,而线上的流量从来不是均匀到达的,短时间抖动才是压垮机器的元凶。

需要提醒的是,这段代码加了锁,因为计数器是共享状态。如果你在多线程环境里用,锁不能省;如果是单线程或者每个工作进程各持一份状态,锁可以去掉,性能会更好。但要注意后者会导致每个进程的调度序列独立,全局看仍然是均匀的,误差可以接受。

3.3 最少连接与一致性哈希:两种完全不同的思路

最少连接的核心是实时性。轮询和加权轮询看的都是"静态配置",它们不知道后端此刻有多忙。最少连接的思路是维护一个每台机器当前活跃连接数的计数,每次选连接数最少的那个。这样做的效果是自适应的——哪台机器处理得快、积压少,就多给它一点活。它特别适合请求耗时差异大的场景,比如有的接口十毫秒返回,有的要几百毫秒,用轮询的话慢接口多的机器会越堆越多。

import threading class LeastConnections: def __init__(self, addrs): self.conns = {a: 0 for a in addrs} self.lock = threading.Lock() def acquire(self): with self.lock: addr = min(self.conns, key=lambda a: self.conns[a]) self.conns[addr] += 1 return addr def release(self, addr): with self.lock: self.conns[addr] -= 1

用这个实现必须记住一件事:计数一定要配对。拿到连接后必须保证在finally里调用 release,否则计数器只增不减,那台机器会被永久性地判定为最忙,从此再也接不到请求。这类 bug 特别隐蔽,因为系统不会立刻报错,只是流量慢慢倾斜,几天之后你才发现某台机器闲得发慌。

一致性哈希解决的是完全不同的问题——缓存命中率。在缓存集群里,如果按轮询把同一个 key 打到不同机器上,缓存基本等于没用,因为每次都要穿透到后端。一致性哈希的思路是把机器和 key 都映射到同一个哈希环上,key 顺时针找到的第一个机器就是它的归属。这样同一个 key 永远落在同一台机器上,而且增删机器时只影响相邻的一小段环,大部分 key 的归属不变。

import bisect import hashlib class ConsistentHash: def __init__(self, nodes, vnodes=160): self.ring = [] self.mapping = {} for node in nodes: for i in range(vnodes): key = self._hash(f"{node}#{i}") self.ring.append(key) self.mapping[key] = node self.ring.sort() @staticmethod def _hash(text): return int(hashlib.md5(text.encode()).hexdigest()[:8], 16) def get(self, key): if not self.ring: return None h = self._hash(key) idx = bisect.bisect_left(self.ring, h) if idx == len(self.ring): idx = 0 return self.mapping[self.ring[idx]]

这里的虚拟节点数量是个关键参数。机器少的时候,如果不设虚拟节点,三台机器在环上就是三个点,分布会非常不均匀,可能一台扛七成流量。每个物理节点虚拟出一百到两百个点位之后,分布就相当均匀了。数量越多越均匀,但环的查找和内存开销也越大,一百六十是个比较常用的经验值。

3.4 算法选型对照表:别盲目抄大厂配置

算法核心依据优点短板适用场景
轮询请求顺序实现极简,绝对均匀忽略后端能力差异后端规格一致、请求耗时接近
加权轮询请求顺序加权重能体现机器规格差异请求会短时扎堆新老机器混布、异构集群
平滑加权轮询权重加平滑因子分布均匀且不扎堆实现稍复杂生产环境默认首选
最少连接实时连接数自适应,能者多劳依赖计数准确请求耗时差异大
源地址哈希客户端 IP天然会话保持分布不均,NAT 后易倾斜需要粘性且无法改 Cookie
一致性哈希请求 key增删节点影响面小需要虚拟节点调优缓存集群、分片存储

选型的时候别只看算法本身,还要看你的入口组件支持到什么程度。如果用的是现成的转发组件,它提供的算法就那几种,硬要自己实现反而增加维护成本。真正需要自己写算法的场景,一般是内部的服务发现 SDK,或者业务逻辑里需要按特定 key 做亲和性路由。

4. 动手搭一个能跑的最小负载均衡器

4.1 目录结构和依赖

整个过程不依赖任何第三方库,Python 3.10 以上标准库足够。目录结构建议这样拆,后面加功能的时候不容易乱:

mini-lb/ ├── balancer.py # 调度算法 ├── health.py # 健康检查 ├── server.py # 转发主循环 ├── backend.py # 模拟后端服务,压测用 └── bench.py # 压测脚本

拆成四个文件而不是写在一个脚本里,理由很实际:调度算法是要单独做单元测试的,健康检查是后台线程要单独开关的,混在一起之后调试会非常痛苦。我见过太多人把这类 Demo 写成三百行的单文件,改一个参数要重新理一遍全局变量。

先在本地起三个后端,用标准库的 HTTP 服务模拟,端口分别开在 8000、8001、8002,每个服务在响应体里带上自己的端口号,压测的时候一眼就能看出请求落到了哪台。

# backend.py import sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/healthz": self.send_response(200) self.end_headers() self.wfile.write(b"ok") return body = f"served by {self.server.server_address[1]}".encode() self.send_response(200) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *args): pass if __name__ == "__main__": port = int(sys.argv[1]) ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever()

4.2 健康检查模块:状态机要写对

健康检查这块,很多人直接在选节点的循环里做探测,请求来了才去连一下后端,这样会把探测延迟叠加到用户请求上。正确做法是后台线程定时探测,把结果写进节点状态,调度时只读状态。

状态机要处理两种情况:从死到活,需要连续成功若干次;从活到死,需要连续失败若干次。这两个"若干次"就是前面提到的阈值,加这两个计数器的目的就是过滤抖动,避免后端偶尔一次超时就被摘掉。

# health.py import socket import threading class HealthChecker: def __init__(self, nodes, interval=2.0, timeout=1.0, rise=2, fall=3): self.nodes = nodes self.interval = interval self.timeout = timeout self.rise = rise self.fall = fall self._stop = threading.Event() def _probe(self, node): host, port = node["addr"].rsplit(":", 1) try: with socket.create_connection((host, int(port)), timeout=self.timeout): return True except OSError: return False def _loop(self): while not self._stop.is_set(): for node in self.nodes: if self._probe(node): node["ok_count"] += 1 node["fail_count"] = 0 if node["ok_count"] >= self.rise: node["alive"] = True else: node["fail_count"] += 1 node["ok_count"] = 0 if node["fail_count"] >= self.fall: node["alive"] = False self._stop.wait(self.interval) def start(self): threading.Thread(target=self._loop, daemon=True).start() def stop(self): self._stop.set()

节点初始状态在构造时就要初始化好alive=True、两个计数为 0,否则第一次调度的时候会读到缺失字段。这个细节我在一个项目里被坑过整整一个下午——字段名写错了一个字母,Python 字典访问抛异常被外层吞掉了,表现就是服务起来了但一个请求都不转发,日志里啥也看不到。

4.3 调度核心与转发实现

这里做四层转发,代码最短也最好理解。核心就是一个 accept 循环,拿到客户端连接之后,从调度器挑一个后端,建连,然后开两个线程双向搬数据。

# server.py import socket import threading from health import HealthChecker from balancer import SmoothWeightedRoundRobin def pipe(src, dst): try: while True: data = src.recv(65536) if not data: break dst.sendall(data) except OSError: pass finally: try: dst.shutdown(socket.SHUT_WR) except OSError: pass def pick_alive(nodes): alive = [n for n in nodes if n["alive"]] return alive if alive else None def main(listen_port=9000): nodes = [ {"addr": "127.0.0.1:8000", "weight": 5, "alive": True, "ok_count": 0, "fail_count": 0}, {"addr": "127.0.0.1:8001", "weight": 3, "alive": True, "ok_count": 0, "fail_count": 0}, {"addr": "127.0.0.1:8002", "weight": 1, "alive": True, "ok_count": 0, "fail_count": 0}, ] HealthChecker(nodes, interval=2.0, timeout=0.5, rise=2, fall=2).start() lb = SmoothWeightedRoundRobin(nodes) listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind(("0.0.0.0", listen_port)) listener.listen(1024) while True: client, _ = listener.accept() alive = pick_alive(nodes) if not alive: client.close() continue # 只在存活节点里做加权调度 addr = lb.next() while addr not in {n["addr"] for n in alive}: addr = lb.next() host, port = addr.rsplit(":", 1) try: backend = socket.create_connection((host, int(port)), timeout=2.0) except OSError: client.close() continue threading.Thread(target=pipe, args=(client, backend), daemon=True).start() threading.Thread(target=pipe, args=(backend, client), daemon=True).start() if __name__ == "__main__": main()

这段代码里有几个地方是刻意这么写的,值得说一下。

第一,SO_REUSEADDR必须设置。否则服务重启的时候,端口处于 TIME_WAIT 状态,会报地址已被占用,开发阶段频繁重启的时候特别烦。

第二,listen的 backlog 设成 1024 而不是默认值。默认值在流量稍微大一点的时候会导致新连接被拒,表现是客户端偶发连接超时,但服务端日志干干净净,非常难查。

第三,调度器在挑选的时候做了存活过滤。这段 while 循环写得有点糙,实际上更好的做法是把存活判断下沉到调度算法内部,让它只在整个存活集合里做加权计算,否则权重分配会偏向那些恰好被跳过的节点。这是个演示代码和工程代码的典型差距,你自己写的时候记得修正。

第四,两端的 pipe 都开了独立线程,任一端关闭时另一端的 write 会抛异常,被捕获后正常退出。这里如果不加shutdown(SHUT_WR),对端会一直等数据,连接迟迟不释放,长时间跑下来文件描述符会被耗光。

4.4 压测验证与参数调优

写完之后必须验证两件事:一是流量分布是否符合权重比例,二是杀掉一个后端之后流量是否能自动切走。

# bench.py import collections import concurrent.futures import http.client import time TARGET = ("127.0.0.1", 9000) def one(_): try: conn = http.client.HTTPConnection(*TARGET, timeout=5) conn.request("GET", "/") resp = conn.getresponse() body = resp.read().decode() conn.close() return body except Exception: return "error" def run(total=2000, workers=64): start = time.time() with concurrent.futures.ThreadPoolExecutor(workers) as pool: results = list(pool.map(one, range(total))) cost = time.time() - start counter = collections.Counter(results) ok = total - counter.get("error", 0) print(f"总数={total} 成功={ok} 耗时={cost:.2f}s QPS={total / cost:.0f}") for k, v in counter.most_common(): print(f"{k}: {v}") if __name__ == "__main__": run()

按 5:3:1 的权重跑 2000 个请求,理想情况下三个后端的占比应该接近 55.6%、33.3%、11.1%。实测下来如果偏差在 3% 以内,说明算法实现没问题;如果某个后端占比明显偏高,先检查是不是健康检查把它摘掉了,再看调度器有没有把死节点也纳入加权计算。

参数调优上有几个经验值可以参考。健康检查间隔设 2 秒是个平衡点,再短会让后端承受不必要的探测压力,再长故障发现就超过 5 秒了。探测超时一般设成间隔的一半,避免探测线程堆积。连续失败阈值设 2 到 3 次,设 1 次太敏感,一次网络抖动就摘节点,设 5 次又太迟钝。

连接超时这块要分层设:客户端到转发层的超时,一般设 3 到 5 秒,转发层到后端的连接超时要更短,1 到 2 秒比较合适。道理是,如果后端真的挂了,快速失败比慢慢等着强,让用户早点看到错误页,也好过挂在那里转圈。读超时则要看业务,普通接口设 5 秒,涉及文件导出、报表计算的接口单独设长一点,别图省事全局设一个值。

5. 上线之后最容易踩的坑与排查手册

5.1 五类高频故障速查

真跑起来之后,问题往往不出在算法上,而是出在配置和网络细节上。下面这张表是我这几年修过的问题里出现频率最高的几类,按现象倒推原因会快很多。

现象大概率原因排查动作处理方向
全站 502,所有请求失败后端端口配错、健康检查把全部节点摘了、后端进程未启动直接连后端端口验证,看健康检查日志修正配置,把探测阈值调大
部分请求 504后端处理慢,读超时设置过短抓取慢请求,看后端耗时分布调整超时,或对慢接口单独分组
流量严重倾斜到一台权重配置被改、长连接未复用、源地址哈希遇到大量同 IP 请求统计各后端实际连接数核对权重,改用七层调度
偶发连接超时但日志正常端口耗尽、backlog 太小、系统文件描述符限制查ulimit -n和连接状态统计调大限制,开启连接复用
后端重启后再次被打挂没有慢启动,冷启动瞬间流量全灌进去观察重启后头半分钟的错误率配置慢启动窗口,逐步放量
一致性哈希分布不均虚拟节点数量太少,物理机太少统计各节点 key 数量把虚拟节点数提到 150 以上

这张表里最值得展开说的是"流量倾斜"和"连接超时"这两类。流量倾斜的原因经常藏在连接复用上——如果转发层和后端之间用了长连接池,而某个后端建立连接的速度稍快,它就会抢占更多连接,接下来所有请求都走这些已经建好的连接,权重配置形同虚设。这种情况下你必须显式限制每个后端的最大连接数,让连接池按权重分配,否则加权只是写在配置里好看。

连接超时那类问题,八成是文件描述符限制没调。Linux 默认单进程 1024 个文件描述符,每一条 TCP 连接占一个,转发层每个请求要占两个(客户端一个、后端一个),五百个并发就把配额吃满了。表现就是系统能跑,但流量一上来就开始随机丢连接,而且日志里什么都看不到,因为 accept 直接返回错误了。上线前一定要确认这个值和系统的somaxconn都调到了合理范围。

5.2 超时、重试、长连接的三处细节

第一处是超时的方向性。很多配置项只写一个"超时时间",实际上连接超时和读超时必须分开设。连接超时反映的是网络可达性和端口存活,读超时反映的是后端处理速度,两者混在一起设,会出现"后端很慢但能连上"被误判成网络故障的情况,排障方向直接跑偏。

第二处是重试的放大效应。转发层配置了失败重试,这本身是好事,但如果重试次数是 3,而调用链有三层,那最底层一个慢请求会被放大成 27 次。这种放大在故障期间极其致命,本来只是某个接口变慢,结果被重试打成了雪崩。我的做法是只在最外层重试一次,内层一律不重试,并且只对幂等请求重试,非幂等的写操作失败就直接返回错误,让前端引导用户重新提交。看着保守,但这是踩过大坑之后的选择。

第三处是长连接的保活。转发层到后端用长连接能省掉握手开销,提升明显,但中间如果有防火墙或者 NAT 设备,空闲连接会被静默回收,等下次复用的时候才发现连接已经断了,表现就是每隔几分钟出现一批莫名其妙的 502。解决办法是在连接池里配置最大空闲时间,让它小于设备的回收时间,同时开启 TCP 层的保活探测。这个参数没有通用值,得看你的网络环境,一般是几十秒到几分钟,靠观察错误发生的周期性来推断。

5.3 几条我自己的经验

第一,上线前一定要做一次"杀节点演练"。手动停掉一个后端,观察流量多久切走、切走期间有多少错误、切回来之后多久恢复。这个演练能暴露九成以上的配置问题,比看文档管用得多。很多团队是等真出了故障才第一次验证切换逻辑,那时候手忙脚乱,连日志在哪都找不到。

第二,日志里一定要带上后端地址。转发层的访问日志如果不记录最终打到哪台机器,出了问题你连"是不是某台机器的问题"都判断不了。这条我强调过很多次,还是经常看到只记客户端 IP 和状态码的日志,排障时只能靠猜。

第三,权重不要设得太细。有人喜欢按 CPU 主频、内存大小精确计算权重,搞出 37 比 23 比 11 这种数字。实际上机器性能差异没那么精确,权重设成整数倍数就够,5 比 3 比 1 和 37 比 23 比 11 在实际流量分布上几乎没有区别,但前者好理解、好维护,出问题时一眼就能看出是不是配错了。

第四,别把健康检查接口写成业务接口。我见过有团队直接拿首页当健康检查地址,首页要查数据库、要渲染模板、要调三个下游服务,结果健康检查本身就贡献了相当一部分负载,探测一密集,后端就被自己的探测打挂了。健康接口就老老实实返回一个 200 和固定字符串,别做任何重操作。

第五,容量规划的时候留出故障余量。三台机器按各自能扛三分之一的量来规划,是最容易翻车的算法。只要挂掉一台,剩下两台立刻要扛一半的量,如果它们本来就跑在七成负载上,瞬间就过载了,然后连锁反应,一台接一台倒下。合理的规划是任意一台挂掉之后,剩余机器的负载仍然在安全水位以下,也就是说整体平时只跑到六成左右。这个余量看着浪费,但它是故障时唯一的缓冲。

第六,别忽略调度器自身的高可用。这套东西一旦成了单点,前面讲的所有机制都白搭。常见的做法是部署两套,用虚拟 IP 加主备切换,或者干脆用 DNS 做两层。切换方案本身也要定期演练,我见过配置了主备但从来没切过,真到主节点挂掉的时候,备节点因为配置没同步起不来,等于没有。

这套最小实现跑通之后,你再去读任何一个成熟转发组件的配置文件,会发现参数几乎都能对上号:weight对应权重,max_fails和fail_timeout对应被动摘除,backup对应备份节点,keepalive对应连接池。理解了每个参数在做什么决策,配置就不再是照抄文档,而是能根据自己系统的实际情况去调。这中间的差别,平时看不出来,真出事的时候就是能不能在十分钟内恢复的区别。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询