跳转到内容
123xiao | 无名键客

《集群架构实战:从单点故障到高可用的负载均衡与故障转移设计》

字数: 0 阅读时长: 1 分钟

背景与问题

很多系统一开始都很“朴素”:

  • 一个 Web 服务实例
  • 一个固定入口 IP
  • 一个数据库主库
  • 一个大家都觉得“先这么跑着”的 Nginx

系统在业务早期往往没问题,但一旦访问量上来,或者机器重启、网络抖动、磁盘打满,单点故障就会直接把服务带走。

我自己踩过一个很典型的坑:业务已经做了多实例部署,应用看起来“有集群”,但前面只有一个负载均衡节点。结果某次内核升级后负载均衡机器重启,后端十几台应用全都健康,却还是整体不可用。这个场景说明一件事:

高可用不是“多部署几台机器”,而是要把入口、流量分发、健康检查、状态同步、故障转移一起设计。

本文从 troubleshooting(排障) 的角度来讲,重点回答几个实际问题:

  1. 为什么“看起来有集群”还是会单点故障?
  2. 负载均衡和故障转移分别解决什么问题?
  3. 主备切换为什么会误判、脑裂、抖动?
  4. 出故障后,应该怎么定位:是应用挂了、LB 挂了、VIP 飘了,还是探活策略错了?
  5. 如何用一套可运行示例快速搭出一个可验证的高可用入口?

先看一个典型架构演进

从单点到高可用的最小闭环

flowchart LR
    U[用户请求] --> VIP[VIP/统一入口]
    VIP --> LB1[Nginx + Keepalived 主]
    VIP -. 故障切换 .-> LB2[Nginx + Keepalived 备]
    LB1 --> A1[App-1]
    LB1 --> A2[App-2]
    LB2 --> A1
    LB2 --> A2

上图是最常见、也最容易落地的一种方案:

  • Nginx 负责七层负载均衡
  • Keepalived 负责 VIP 漂移与主备切换
  • 后端应用多实例 负责承接流量

它的目标不是“永不故障”,而是:

  • 单个应用实例挂掉,业务不受明显影响
  • 单个负载均衡节点挂掉,入口自动切换
  • 发生故障后,恢复路径明确、观测手段足够

核心原理

这一部分不讲空话,直接围绕排障最常见的几个误区。

1. 负载均衡解决的是“分发”,不是“高可用全部问题”

负载均衡的主要工作:

  • 将请求分发到多个后端
  • 通过健康检查避免把流量打到坏节点
  • 在不同策略下做流量控制,如轮询、最少连接、权重

但如果只有一个负载均衡器,它本身还是单点。

所以很多系统的问题不是“没有负载均衡”,而是:

  • 应用层有冗余
  • 入口层没冗余

2. 故障转移解决的是“入口存活”

Keepalived 常见做法是使用 VRRP:

  • 主节点持有 VIP
  • 备节点监听主节点状态
  • 主节点故障时,备节点接管 VIP

这个过程的核心不是“机器宕机才切换”,而是“满足策略条件才切换”。这些条件可能包括:

  • Keepalived 进程是否还活着
  • Nginx 是否可用
  • 自定义健康检查脚本结果
  • 网络接口状态是否正常

故障转移状态图

stateDiagram-v2
    [*] --> BACKUP
    BACKUP --> MASTER: 未检测到优先级更高主节点
    MASTER --> BACKUP: 探活失败/优先级降低
    MASTER --> FAULT: 网卡异常/脚本异常
    FAULT --> BACKUP: 服务恢复
    BACKUP --> MASTER: 抢占成功

这里最容易被忽略的是:主备切换不是二元判断,而是状态机
如果探活脚本写得粗糙,就会出现频繁切换,也就是大家常说的“抖动”。

3. 健康检查的本质是“你定义什么叫活着”

这是实际生产里最关键也最容易出事的地方。

一个错误示例:

  • 只检查 Nginx 进程存在
  • 不检查 Nginx 是否还能处理请求
  • 不检查后端是否全部失败

结果是:

  • 进程还在,但 worker 卡死
  • 80 端口还在监听,但 upstream 全挂
  • Keepalived 认为“正常”,VIP 不切换
  • 用户看到的是持续 502/504

更合理的健康检查应该至少回答:

  • 负载均衡进程是否存活?
  • 对外端口是否可用?
  • 是否还能返回预期 HTTP 状态?
  • 是否还有可用后端?

4. 会话保持与无状态设计的冲突

负载均衡一旦把请求分散到多个节点,就会遇到登录态、购物车、临时会话等问题。

常见解决方式:

  • Session 粘滞:简单,但故障转移时体验差
  • Session 外置到 Redis:更适合集群
  • 应用无状态化:最推荐,但需要业务配合

中级读者做架构时,最值得记住的一条是:

高可用的前提,通常是尽量无状态;越依赖本地状态,越难稳定切换。


现象复现

为了让排障更有抓手,我们先复现几个典型现象。

现象 1:后端挂一台,用户偶发 502

原因通常有:

  • Nginx upstream 没有正确剔除失败节点
  • 健康检查间隔太长
  • 应用只是“半死不活”,TCP 通但 HTTP 失败

现象 2:LB 主节点挂了,但 VIP 没漂移

常见原因:

  • Keepalived 配置优先级逻辑有问题
  • 防火墙拦截了 VRRP
  • 健康检查脚本没触发降权
  • 备节点网卡或绑定配置错误

现象 3:主备反复切换,业务时好时坏

常见原因:

  • 探活脚本执行慢、超时短
  • 网络短抖动导致误判
  • 开启抢占但优先级设计不合理
  • 两边都认为自己该接管 VIP,接近脑裂

实战代码(可运行)

下面给一套能本地快速验证思路的示例:

  • 两个 Python 后端服务
  • 一个 Nginx 做负载均衡
  • 一个健康检查脚本
  • 两份 Keepalived 配置示例

说明:Keepalived 需要 Linux 环境和管理员权限运行。
Python 与 Nginx 部分可直接本机测试,Keepalived 部分更适合两台 Linux 虚机演练。


1. 两个后端服务

app1.py

from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import socket

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/health":
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            self.wfile.write(json.dumps({"status": "ok", "instance": "app1"}).encode())
            return

        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        data = {
            "message": "hello from app1",
            "host": socket.gethostname(),
            "path": self.path
        }
        self.wfile.write(json.dumps(data).encode())

if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", 8001), Handler)
    print("app1 listening on :8001")
    server.serve_forever()

app2.py

from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import socket

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/health":
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            self.wfile.write(json.dumps({"status": "ok", "instance": "app2"}).encode())
            return

        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        data = {
            "message": "hello from app2",
            "host": socket.gethostname(),
            "path": self.path
        }
        self.wfile.write(json.dumps(data).encode())

if __name__ == "__main__":
    server = HTTPServer(("0.0.0.0", 8002), Handler)
    print("app2 listening on :8002")
    server.serve_forever()

启动:

python3 app1.py
python3 app2.py

验证:

curl http://127.0.0.1:8001/health
curl http://127.0.0.1:8002/health

2. Nginx 负载均衡配置

nginx.conf

worker_processes auto;

events {
    worker_connections 1024;
}

http {
    upstream backend_cluster {
        least_conn;
        server 127.0.0.1:8001 max_fails=3 fail_timeout=10s;
        server 127.0.0.1:8002 max_fails=3 fail_timeout=10s;
        keepalive 32;
    }

    server {
        listen 8080;
        server_name localhost;

        location / {
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_connect_timeout 2s;
            proxy_read_timeout 5s;
            proxy_next_upstream error timeout http_502 http_503 http_504;
            proxy_pass http://backend_cluster;
        }

        location /lb-health {
            access_log off;
            return 200 "lb ok\n";
        }
    }
}

启动并测试:

nginx -c /path/to/nginx.conf
curl http://127.0.0.1:8080/

多请求几次,你会看到请求在 app1 / app2 间分发。


3. Keepalived 健康检查脚本

这个脚本会检查:

  • Nginx 进程是否存在
  • 8080 端口的 /lb-health 是否返回 200

check_nginx.sh

#!/usr/bin/env bash

set -e

if ! pgrep -x nginx >/dev/null 2>&1; then
  echo "nginx process not found"
  exit 1
fi

code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/lb-health || true)

if [ "$code" != "200" ]; then
  echo "nginx health endpoint failed, code=$code"
  exit 1
fi

exit 0

赋权:

chmod +x /etc/keepalived/check_nginx.sh

4. Keepalived 主节点配置示例

keepalived-master.conf

global_defs {
    router_id LB_MASTER
}

vrrp_script chk_nginx {
    script "/etc/keepalived/check_nginx.sh"
    interval 2
    weight -20
    fall 2
    rise 2
    timeout 2
}

vrrp_instance VI_1 {
    state MASTER
    interface eth0
    virtual_router_id 51
    priority 120
    advert_int 1

    authentication {
        auth_type PASS
        auth_pass 123456
    }

    virtual_ipaddress {
        192.168.1.100/24
    }

    track_script {
        chk_nginx
    }
}

5. Keepalived 备节点配置示例

keepalived-backup.conf

global_defs {
    router_id LB_BACKUP
}

vrrp_script chk_nginx {
    script "/etc/keepalived/check_nginx.sh"
    interval 2
    weight -20
    fall 2
    rise 2
    timeout 2
}

vrrp_instance VI_1 {
    state BACKUP
    interface eth0
    virtual_router_id 51
    priority 100
    advert_int 1

    authentication {
        auth_type PASS
        auth_pass 123456
    }

    virtual_ipaddress {
        192.168.1.100/24
    }

    track_script {
        chk_nginx
    }
}

6. 故障转移验证步骤

验证后端实例故障

先访问:

curl http://127.0.0.1:8080/

然后停掉一个后端:

pkill -f app1.py

再连续访问:

for i in {1..10}; do curl -s http://127.0.0.1:8080/; echo; done

你应该看到请求逐步只落到 app2。

验证 LB 健康检查

停掉 Nginx:

nginx -s stop

检查脚本会失败,Keepalived 会降低本机优先级,触发备机接管 VIP。

验证 VIP 漂移

在主、备机分别执行:

ip addr show

确认 192.168.1.100 是否漂移到备机。


故障转移时序图

下面这张图很适合帮助你定位“到底是哪一步没发生”。

sequenceDiagram
    participant Client as 客户端
    participant VIP as VIP
    participant M as LB主节点
    participant B as LB备节点
    participant App as 应用集群

    Client->>VIP: 发起请求
    VIP->>M: 当前VIP在主节点
    M->>App: 转发请求
    App-->>M: 返回响应
    M-->>Client: 返回结果

    Note over M: 主节点Nginx异常
    B->>M: VRRP心跳检测
    B->>B: 提升为MASTER并绑定VIP
    Client->>VIP: 新请求到达
    VIP->>B: VIP已漂移到备节点
    B->>App: 转发请求
    App-->>B: 返回响应
    B-->>Client: 返回结果

定位路径

当你遇到“服务不稳”“切换失败”“偶发 502”时,我建议按下面这条链路查,不要一上来就改配置。

第一步:先看入口到底在哪台机器

ip addr show

看 VIP 当前是否存在于预期机器上。

如果 VIP 不在任何节点:

  • Keepalived 可能都没起来
  • 配置文件有误
  • 网卡接口名写错
  • 权限或内核参数有问题

如果 VIP 在某节点,但访问仍失败:

  • Nginx 可能没起来
  • 端口未监听
  • 防火墙未放通
  • 上游应用全挂

第二步:确认 Keepalived 是否正常

systemctl status keepalived
journalctl -u keepalived -n 100 --no-pager

重点看:

  • 是否频繁切换 MASTER/BACKUP
  • 是否提示脚本超时
  • 是否有接口异常
  • 是否有认证不匹配

第三步:确认 Nginx 自己是否能处理请求

curl -v http://127.0.0.1:8080/lb-health
ss -lntp | grep 8080
ps -ef | grep nginx

如果本地都不通,不要再去怀疑后端。

第四步:确认 upstream 是否可达

curl http://127.0.0.1:8001/health
curl http://127.0.0.1:8002/health

如果后端偶尔超时,再看:

ss -s
top
dmesg | tail

排查是否存在:

  • 连接数耗尽
  • CPU 飙高
  • 内存不足
  • 被 OOM Killer 杀进程

第五步:确认网络层而不是应用层问题

如果主备看起来都正常,但 VIP 不漂移,重点排查:

  • VRRP 是否被交换机或防火墙限制
  • 安全组是否放行协议
  • 同网段配置是否一致
  • arp 缓存是否更新异常

常见坑与排查

这一节是最贴近实战的部分。

坑 1:只检查进程,不检查服务能力

表现

  • Nginx 进程在
  • Keepalived 不切换
  • 用户持续报错

原因

进程活着不代表业务可用。worker 卡死、upstream 全失败时都可能出现。

建议

健康检查至少访问一个本地 HTTP 端点,必要时增加后端可用性判定。


坑 2:interface 写错

表现

  • Keepalived 启动正常
  • 但 VIP 不上浮或不漂移

原因

现在很多系统网卡名不是 eth0,可能是 ens33enp0s3 等。

排查

ip link show

把配置中的 interface 改成真实网卡名。


坑 3:防火墙拦截 VRRP

表现

  • 主备都认为自己健康
  • 但互相收不到通告
  • 可能导致双主

风险

这是非常危险的,双主会导致 VIP 争抢,网络表现会非常诡异。

建议

明确放通 VRRP 所需协议,测试时先临时关闭主机防火墙验证,再做精细规则。


坑 4:探活过于敏感,导致频繁切换

表现

  • 日志里频繁 MASTER/BACKUP 变更
  • 用户感觉“时好时坏”

原因

interval 太短、fall/rise 太小、脚本超时太紧,都会放大短暂抖动。

建议

常见思路:

  • interval 2
  • fall 2~3
  • rise 2~3
  • timeout 不要比脚本实际耗时更短

不要追求“1 秒内必切换”,先保证稳定。


坑 5:后端无状态没做好

表现

  • 切换后用户重新登录
  • 部分请求成功、部分失败
  • 应用节点间行为不一致

原因

会话状态存在本地,负载均衡把请求打到另一台就失效。

建议

  • Session 外置
  • 文件上传、缓存、任务状态不要落本地
  • 节点配置统一管理

坑 6:以为有主备就一定安全

表现

  • 单机故障可以恢复
  • 但机房网络故障时整体不可用

原因

主备在同一台交换机、同一可用区、同一故障域里,抗风险能力有限。

建议

真正的高可用要考虑故障域隔离:

  • 不同物理机
  • 不同可用区
  • 不同电源/网络路径

止血方案

有时候线上出问题,第一目标不是“完美修复”,而是先恢复业务。

场景 1:Keepalived 抖动严重

先做止血:

  • 临时关闭抢占
  • 提高 fall/rise
  • 固定一个主节点承载流量

等流量稳定后再分析脚本与网络抖动原因。

场景 2:VIP 漂移异常

先确认哪台 LB 可用,然后临时将入口 DNS 或上游路由切到该节点固定 IP。
这不是长期方案,但能快速恢复服务。

场景 3:后端大量 502

先把异常节点从 upstream 中摘除,哪怕只保留一台健康实例,也比全量错误扩散更好。


安全/性能最佳实践

这一部分建议你当成上线前检查清单。

安全实践

1. 不要让健康检查脚本过度暴露内部信息

/lb-health 最好只返回简单状态,不暴露版本、主机名、内部路径。

2. Keepalived 认证不要使用弱口令

示例里为了演示用了简单密码,实际环境应使用更强的认证配置,并限制管理面访问。

3. 限制管理接口来源

Nginx 管理页、状态页、监控端点都应限制来源 IP,避免被随意探测。

4. 配置变更要可审计

LB 和 Keepalived 配置建议纳入版本控制,避免“某位同事在线手改后谁也说不清”。


性能实践

1. 合理设置超时

超时太长会拖死连接池,太短会误伤慢请求。

常见建议:

  • proxy_connect_timeout 较短
  • proxy_read_timeout 根据业务特性调整
  • 健康检查超时略小于业务超时

2. 长连接要配套后端能力

Nginx keepalive 可以提升性能,但要确认后端服务连接管理能力,否则会把问题隐藏得更深。

3. 负载策略要结合业务

  • round robin:通用默认
  • least_conn:适合请求耗时差异大时
  • ip_hash:有会话需求时可过渡使用,但不推荐长期依赖

4. 监控指标不能只盯 CPU

高可用架构里更应该关注:

  • VIP 所属节点
  • 主备切换次数
  • upstream 健康节点数
  • 5xx 比例
  • 请求时延分位数
  • 连接数、重传、丢包

方案边界与取舍

这套 Nginx + Keepalived 方案很常见,但不是万能的。

适用场景

  • 中小型业务入口高可用
  • 局域网内主备切换
  • 成本敏感、希望快速落地

不适用或需要增强的场景

  • 跨地域全局流量调度
  • 超大规模七层流量治理
  • 复杂灰度发布、服务网格控制
  • 多活架构

如果业务已经进入多机房、多地域阶段,就要考虑:

  • DNS/GSLB
  • 云 LB 托管服务
  • BGP/Anycast
  • 多活与数据一致性设计

也就是说,故障转移解决的是入口可用,不等于全链路高可用已经完成


总结

从单点故障走向高可用,最容易犯的错就是“以为部署了多台应用就是集群”。真正能扛故障的系统,至少要把这几件事串起来:

  1. 后端多实例:避免应用单点
  2. 负载均衡:把流量正确分发出去
  3. 健康检查:识别“真活着”还是“假活着”
  4. 故障转移:入口层主备切换
  5. 无状态设计:让切换成本足够低
  6. 可观测与排障路径:出问题能快速定位

如果你准备实战落地,我建议按这个顺序推进:

  • 先把单机 Nginx + 多后端跑通
  • 再补健康检查
  • 再加 Keepalived 做 VIP 漂移
  • 最后做故障注入测试:停应用、停 Nginx、断网卡、杀进程

最后给一个很务实的结论:

高可用设计的质量,不看架构图画得多漂亮,而看你是否真的演练过故障、是否知道故障发生时第一步该查哪里。

如果只能先做一件事,我建议你先把 健康检查与故障演练 做起来。因为多数“高可用失效”,本质上不是方案不存在,而是方案从来没有被真正验证过。


分享到:

上一篇
《安卓逆向实战:从 Frida Hook 到协议还原分析 App 登录鉴权流程》
下一篇
《从零实现基于以太坊智能合约的链上支付结算系统:架构设计、合约安全与部署实战》