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

《面向中型业务的集群架构设计实战:从高可用部署、流量调度到故障切换的落地方案》

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

面向中型业务的集群架构设计实战:从高可用部署、流量调度到故障切换的落地方案

中型业务做集群,最怕的不是“没上集群”,而是“上了看起来像集群,出事时却像单机”。
我见过不少团队:机器数量有了,Nginx 也配了,数据库甚至做了主从,但线上一遇到流量抖动、单节点卡死、服务半故障,还是会出现请求堆积、切换不及时、误摘流量、雪崩扩散。

这篇文章不打算空讲概念,而是从排障和落地的角度,把一个适合中型业务的集群架构讲透:怎么部署高可用、怎么调度流量、怎么做故障切换,以及出了问题后该按什么路径定位。


背景与问题

对于中型业务,常见约束通常是这样的:

  • 业务已经过了单机阶段,但还没大到可以无限堆基础设施
  • 需要支持日常高峰、营销波峰、偶发机器故障
  • 团队通常有运维和开发协作,但未必有专职 SRE
  • 架构目标不是“无限扩展”,而是稳定、可观测、可切换、成本可控

典型线上故障也很像:

  1. 单节点存活但不可用
    进程没挂,TCP 还能连,业务线程池却满了,结果负载均衡器还在继续转发流量。

  2. 流量切换慢
    节点故障后,要几十秒甚至几分钟才能摘除,用户先感知到报错。

  3. 数据库主从切换后应用报错
    配置没刷新、连接池没重建、读写路由没同步,导致切换不彻底。

  4. 健康检查设计错误
    检查的是 /healthz,这个接口永远返回 200,但数据库和 Redis 早就不可用了。

  5. 局部故障演变成全站雪崩
    一个下游慢,调用线程被占满,重试叠加,整个集群一起超时。

所以这类架构设计的重点不是“把组件都装上”,而是围绕下面三个问题展开:

  • 故障能不能被及时发现
  • 流量能不能快速、准确地绕开故障节点
  • 切换以后系统能不能稳定收敛,而不是持续震荡

核心原理

1. 一个适合中型业务的参考分层

通常我会建议按这几层来理解:

  • 接入层:L4/L7 负载均衡,如 Nginx、HAProxy、云 SLB
  • 应用层:无状态应用实例,多副本部署
  • 状态层:数据库主从/主备、Redis Sentinel 或集群
  • 控制层:服务注册、健康检查、监控告警、自动摘流量
  • 观测层:日志、指标、链路追踪

下面这个图可以作为一个比较实用的基线。

flowchart TD
    U[用户请求] --> LB1[负载均衡器 A]
    U --> LB2[负载均衡器 B]
    LB1 --> APP1[应用节点 1]
    LB1 --> APP2[应用节点 2]
    LB2 --> APP2
    LB2 --> APP3[应用节点 3]

    APP1 --> RDSW[数据库主库]
    APP2 --> RDSW
    APP3 --> RDSW

    APP1 --> RDSR[数据库从库]
    APP2 --> RDSR
    APP3 --> RDSR

    APP1 --> REDIS[缓存/会话]
    APP2 --> REDIS
    APP3 --> REDIS

    MON[监控告警] --> LB1
    MON --> LB2
    MON --> APP1
    MON --> APP2
    MON --> APP3
    MON --> RDSW

2. 高可用部署的关键,不是副本数,而是“失败域隔离”

很多人以为高可用 = 多部署几台。其实不够。
真正决定可用性的,是同一类风险是否会一次性打掉所有副本

常见失败域包括:

  • 同一台宿主机
  • 同一机架
  • 同一可用区
  • 同一网络出口
  • 同一数据库实例
  • 同一配置中心或注册中心

所以中型业务至少要做到:

  • 应用副本分散到不同机器
  • 负载均衡器双实例或托管高可用
  • 数据库有主从或主备
  • 配置变更可回滚
  • 会话尽量无状态化,避免单节点粘滞

3. 流量调度的本质:不是平均分,而是按节点真实承载能力分配

负载均衡常见策略:

  • 轮询:简单,适合能力接近的节点
  • 加权轮询:适合异构机器
  • 最少连接:适合请求时长差异较大的场景
  • 一致性哈希:适合缓存、会话粘性

但仅选算法还不够,关键在健康检查摘流量策略

  • TCP 检查只能说明端口活着
  • HTTP 200 检查只能说明接口返回了
  • 真正靠谱的做法是:
    • 轻量健康检查:只看进程和线程池是否正常
    • 深度就绪检查:确认依赖是否满足对外服务条件
    • 分级摘流量:短时抖动先降权,持续异常再摘除

4. 故障切换不能只靠“检测到故障”

故障切换有三个阶段:

  1. 检测:识别节点不可用
  2. 隔离:停止新流量进入
  3. 恢复:重新加入流量,并避免抖动

我踩过一个坑:节点 GC 抖动 8 秒,健康检查失败两次后被摘掉,恢复后又马上加回,结果流量来回震荡。
所以切换一定要考虑:

  • 失败阈值
  • 成功阈值
  • 冷却时间
  • 半开恢复探测

下面这个状态图比较贴近真实系统。

stateDiagram-v2
    [*] --> Healthy
    Healthy --> Suspect: 连续健康检查失败
    Suspect --> Unhealthy: 达到失败阈值
    Suspect --> Healthy: 短暂抖动恢复
    Unhealthy --> Recovering: 探测恢复成功
    Recovering --> Healthy: 连续成功达到阈值
    Recovering --> Unhealthy: 再次失败

现象复现:一个“看着没挂,其实已不可服务”的故障

先定义一个典型问题:应用进程还活着,但依赖数据库超时,业务接口大量 500;而健康检查接口却始终返回 200。

现象

  • 负载均衡器显示节点正常
  • 应用日志里大量数据库连接超时
  • 用户接口报错率高
  • CPU 不高,但线程池阻塞严重

根因

/healthz 只检查进程是否活着,没有检查“是否还能接收业务请求”。

正确思路

健康检查至少分两类:

  • /livez:进程存活,给容器/进程守护用
  • /readyz:是否可以接业务流量,给负载均衡器摘流量用

也就是说:

  • 存活不等于可服务
  • 可服务必须看依赖和资源状态

实战代码(可运行)

下面用一个简化但可运行的方案演示:

  • Python Flask 模拟业务服务
  • 提供 /livez/readyz/api/orders
  • 用 Nginx 做上游负载均衡和健康调度
  • 演示如何让“节点活着但不接流量”

说明:代码重点是演示架构与排障思路,不是生产框架最佳实现。

1. Python 应用示例

安装依赖:

pip install flask

应用代码 app.py

from flask import Flask, jsonify
import os
import time
import threading

app = Flask(__name__)

node_name = os.getenv("NODE_NAME", "node-unknown")
simulate_db_down = False
degraded = False

def background_toggle():
    global simulate_db_down, degraded
    while True:
        flag_file = f"/tmp/{node_name}.down"
        degraded_file = f"/tmp/{node_name}.degraded"
        simulate_db_down = os.path.exists(flag_file)
        degraded = os.path.exists(degraded_file)
        time.sleep(1)

threading.Thread(target=background_toggle, daemon=True).start()

@app.route("/livez")
def livez():
    return jsonify({
        "status": "alive",
        "node": node_name
    }), 200

@app.route("/readyz")
def readyz():
    if simulate_db_down:
        return jsonify({
            "status": "not_ready",
            "node": node_name,
            "reason": "db_unreachable"
        }), 503

    if degraded:
        return jsonify({
            "status": "not_ready",
            "node": node_name,
            "reason": "manual_draining"
        }), 503

    return jsonify({
        "status": "ready",
        "node": node_name
    }), 200

@app.route("/api/orders")
def orders():
    if simulate_db_down:
        return jsonify({
            "error": "database timeout",
            "node": node_name
        }), 500

    time.sleep(0.05)
    return jsonify({
        "orders": [1001, 1002, 1003],
        "node": node_name
    }), 200

if __name__ == "__main__":
    port = int(os.getenv("PORT", "5000"))
    app.run(host="0.0.0.0", port=port)

启动两个节点:

NODE_NAME=node1 PORT=5001 python app.py
NODE_NAME=node2 PORT=5002 python app.py

2. Nginx 负载均衡配置

安装 Nginx 后,配置示例 nginx.conf

worker_processes  1;

events {
    worker_connections  1024;
}

http {
    upstream app_cluster {
        server 127.0.0.1:5001 max_fails=2 fail_timeout=10s;
        server 127.0.0.1:5002 max_fails=2 fail_timeout=10s;
        keepalive 32;
    }

    server {
        listen 8080;

        location /api/ {
            proxy_pass http://app_cluster;
            proxy_connect_timeout 1s;
            proxy_read_timeout 2s;
            proxy_send_timeout 2s;

            proxy_next_upstream error timeout http_500 http_502 http_503 http_504;
            proxy_next_upstream_tries 2;

            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }

        location /node1-ready {
            proxy_pass http://127.0.0.1:5001/readyz;
        }

        location /node2-ready {
            proxy_pass http://127.0.0.1:5002/readyz;
        }
    }
}

启动 Nginx:

nginx -c /path/to/nginx.conf

请求验证:

curl http://127.0.0.1:8080/api/orders

3. 模拟节点故障与摘流量

node1 进入“依赖不可用”状态:

touch /tmp/node1.down

此时直接访问:

curl http://127.0.0.1:5001/readyz

返回应为:

{"node":"node1","reason":"db_unreachable","status":"not_ready"}

继续多次访问集群入口:

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

如果你的负载策略和重试生效,请求会逐步更多落到 node2

4. 一个更贴近生产的流量摘除脚本

有些场景会用外部脚本定期检查业务就绪状态,再动态更新上游配置。下面给一个可运行的 Python 脚本,演示“探测失败则摘流量”。

scheduler.py

import time
import requests

NODES = [
    {"name": "node1", "ready_url": "http://127.0.0.1:5001/readyz"},
    {"name": "node2", "ready_url": "http://127.0.0.1:5002/readyz"},
]

FAIL_THRESHOLD = 2
SUCCESS_THRESHOLD = 2

state = {
    "node1": {"fail": 0, "success": 0, "online": True},
    "node2": {"fail": 0, "success": 0, "online": True},
}

def check(node):
    try:
        r = requests.get(node["ready_url"], timeout=1)
        return r.status_code == 200
    except Exception:
        return False

def render_upstream():
    lines = ["upstream app_cluster {"]
    online_count = 0
    for node in NODES:
        st = state[node["name"]]
        if st["online"]:
            port = "5001" if node["name"] == "node1" else "5002"
            lines.append(f"    server 127.0.0.1:{port};")
            online_count += 1
    if online_count == 0:
        lines.append("    server 127.0.0.1:5999 backup;")
    lines.append("}")
    return "\n".join(lines)

while True:
    changed = False
    for node in NODES:
        ok = check(node)
        st = state[node["name"]]

        if ok:
            st["success"] += 1
            st["fail"] = 0
            if not st["online"] and st["success"] >= SUCCESS_THRESHOLD:
                st["online"] = True
                changed = True
        else:
            st["fail"] += 1
            st["success"] = 0
            if st["online"] and st["fail"] >= FAIL_THRESHOLD:
                st["online"] = False
                changed = True

    if changed:
        conf = render_upstream()
        with open("/tmp/upstream.conf", "w") as f:
            f.write(conf)
        print("upstream changed:")
        print(conf)
        print("-" * 40)

    time.sleep(2)

安装依赖并运行:

pip install requests
python scheduler.py

这个脚本没有直接热更新 Nginx,但已经把“失败阈值 + 成功阈值 + 状态收敛”演示出来了。生产中可以把这一步接到服务发现、Ingress Controller 或配置热加载机制里。


故障切换时序:请求是怎么被绕开的

很多人以为故障切换是“节点挂了,LB 自动不转发了”。
现实里它是一个时序过程,里面每一步都可能出问题。

sequenceDiagram
    participant User as 用户
    participant LB as 负载均衡器
    participant App1 as 应用节点1
    participant HC as 健康检查器
    participant App2 as 应用节点2

    User->>LB: 发起请求
    LB->>App1: 转发请求
    App1-->>LB: 500 / 超时

    HC->>App1: /readyz 检查
    App1-->>HC: 503 not ready
    HC->>LB: 标记节点异常/摘流量

    User->>LB: 后续请求
    LB->>App2: 转发请求
    App2-->>LB: 200 OK
    LB-->>User: 正常响应

这个时序里最容易被忽视的地方有两个:

  1. 检测窗口
    你不可能在第一毫秒就知道节点坏了,所以总会有一部分请求打到故障节点。

  2. 摘流量与连接复用
    即使已经摘掉新流量,已有 keepalive 连接上的请求可能还会受影响。

所以故障切换设计的目标通常不是“零影响”,而是:

  • 把影响窗口压缩到秒级
  • 把错误比例控制在可接受范围
  • 不让故障扩散到全局

定位路径:线上出问题时先查什么

我建议按“从外到内”的路径查,不要一上来就 SSH 上机器翻日志。

第一步:先分清是“全局故障”还是“局部故障”

观察:

  • 所有请求都失败,还是部分失败
  • 所有节点都报错,还是个别节点异常
  • 某个机房/可用区异常,还是全网异常

如果只有部分失败,优先怀疑:

  • 单节点故障
  • 负载均衡配置问题
  • 会话粘滞导致热点
  • 某个下游依赖只影响部分节点

第二步:看负载均衡层是否还在转发到坏节点

检查点:

  • 上游节点存活数
  • 摘流量是否生效
  • 失败重试是否开启
  • 健康检查是否过于宽松

Nginx 常见检查命令:

nginx -t
ps -ef | grep nginx
curl -I http://127.0.0.1:8080/api/orders

第三步:区分“进程活着”和“服务可用”

直接打节点:

curl http://127.0.0.1:5001/livez
curl http://127.0.0.1:5001/readyz

如果 livez=200readyz=503,说明节点应该被摘流量,而不是重启。

第四步:再看依赖层

重点看:

  • 数据库连接数是否打满
  • Redis 是否慢查询/阻塞
  • 下游接口超时是否变多
  • DNS 解析是否异常
  • 网络丢包是否升高

第五步:确认是否发生了“误恢复”或“流量抖动”

表现为:

  • 节点刚摘掉又加回
  • 指标在正常和异常之间反复横跳
  • 用户错误率持续波动

一般是这些配置不合理:

  • 健康检查周期过短
  • 失败阈值过低
  • 恢复阈值过低
  • 没有冷却时间

常见坑与排查

这一节我专门挑几个在中型业务里特别常见、而且容易被低估的问题。

坑 1:只做了 TCP 健康检查

现象

  • 端口通,LB 认为节点正常
  • 业务接口 500/超时很多

原因

  • TCP 成功只能说明进程监听着端口

排查方法

curl http://127.0.0.1:5001/livez
curl http://127.0.0.1:5001/readyz
curl http://127.0.0.1:5001/api/orders

建议

  • LB 尽量基于 readyz
  • readyz 只检查关键依赖,不要做过重逻辑

坑 2:健康检查接口写得太“乐观”

现象

  • 监控全绿,用户却在报错

原因

  • /health 只是 return 200

排查方法

检查健康检查代码是否覆盖以下内容:

  • 线程池是否耗尽
  • 数据库是否可连接
  • 核心缓存是否可访问
  • 是否处于人工摘流量状态

建议

  • livezreadyz 分离
  • 深度依赖检查要有限时,不能把健康检查本身拖死

坑 3:故障切换后数据库连接池不刷新

现象

  • 数据库主备切换完成,但应用继续报连接错误
  • 少量实例恢复,少量实例一直失败

原因

  • 长连接池还持有旧主库连接
  • DNS 缓存未刷新
  • ORM 连接池没有重建

排查方法

看应用日志中的连接目标、错误类型和重连行为。

止血方案

  • 主动重建连接池
  • 缩短连接存活时间
  • 切换期间临时扩大超时与重试保护,但要有限度

坑 4:重试策略把故障放大了

现象

  • 下游慢一点,上游 QPS 暴涨
  • 每层都在重试,请求数指数式增加

原因

  • 网关重试一次
  • 应用再重试两次
  • SDK 再重试三次

排查方法

画出调用链,确认每层是否都开启重试。

建议

  • 重试只能在一层做主控
  • 非幂等请求默认不要自动重试
  • 要配合超时、熔断、限流一起使用

坑 5:会话粘滞导致单节点热点

现象

  • 某个节点负载特别高
  • 其他节点很闲

原因

  • 用户会话绑定固定节点
  • 热门用户或大客户流量集中

建议

  • 应用尽量无状态
  • 会话放 Redis 或签名 Token
  • 必须粘滞时,给热点用户单独限流

安全/性能最佳实践

高可用和性能优化经常被分开谈,但在线上其实是一体两面:系统越稳定、越可控,越容易保住性能。

1. 健康检查要轻量、分级、超时明确

建议:

  • livez:只看进程主循环是否正常
  • readyz:只看关键依赖
  • 每个依赖检查要设超时,通常 100ms~500ms 级别
  • 不要在健康检查里做 SQL 全表查询

2. 超时要比重试更先设计

经验上:

  • 连接超时要短
  • 读超时按接口 SLA 设定
  • 总超时必须有上限
  • 避免无限等待把线程池拖满

例如 Python 请求下游时:

import requests

def fetch_data():
    resp = requests.get(
        "http://127.0.0.1:9000/query",
        timeout=(0.5, 1.5)
    )
    return resp.json()

3. 故障切换要有“冷却时间”

建议做法:

  • 连续失败 N 次才摘流量
  • 连续成功 M 次才恢复
  • 恢复后先放少量流量观察
  • 设 30~120 秒冷却窗口,避免抖动

4. 应用实例尽量无状态

无状态的好处非常直接:

  • 扩容容易
  • 缩容容易
  • 切流容易
  • 故障节点替换快

不建议把这些状态绑在单机内存里:

  • 用户会话
  • 任务游标
  • 限流计数
  • 上传中间状态

5. 给关键依赖加隔离与降级

中型业务常见依赖:

  • MySQL
  • Redis
  • 搜索服务
  • 第三方支付/短信/风控

建议:

  • 非核心链路失败时允许降级
  • 读多写少接口可短暂返回缓存数据
  • 核心写操作失败要快速失败,不要长时间阻塞

6. 安全上不要忽略内部接口

很多团队觉得 /readyz、管理接口、摘流量接口在内网就安全。实际上风险并不低。

建议:

  • 健康检查接口限制来源
  • 管理接口加鉴权
  • 配置热更新要审计
  • 不暴露数据库和缓存到公网
  • 最小权限原则配置账号

一套更稳妥的落地建议

如果你现在正准备给中型业务上集群,我建议按下面顺序做,而不是一口气把所有组件都堆上去。

第一阶段:先做“可摘流量”

目标:

  • 应用多副本
  • LB 能识别 readyz
  • 节点异常可在秒级摘除

这是最划算的一步,因为它直接降低单点故障影响。

第二阶段:补齐“状态层高可用”

目标:

  • 数据库主从/主备
  • 缓存高可用
  • 配置与注册中心有备份机制

注意:没有状态层的高可用,应用层再多副本也只是“看上去很美”。

第三阶段:把“切换”变成可验证能力

目标:

  • 演练单节点故障
  • 演练数据库切主
  • 演练人工摘流量
  • 演练机房网络抖动

如果一个方案只能写在架构图里,不能演练,它就不算真正落地。


总结

对中型业务来说,集群架构设计的重点不是追求最复杂,而是把这三件事做扎实:

  1. 高可用部署:副本分散、失败域隔离、状态层有冗余
  2. 流量调度:基于真实可服务状态,而不是只看进程活着
  3. 故障切换:检测、隔离、恢复三段式,避免切换震荡

如果只给你几个最可执行的建议,我会建议从这里开始:

  • 立刻把健康检查拆成 livezreadyz
  • 让负载均衡基于 readyz 做摘流量
  • 为摘流量和恢复都设置阈值,不要秒摘秒加
  • 应用尽量无状态,避免会话绑死节点
  • 所有重试都要配合超时、熔断、限流
  • 每月至少做一次故障演练,验证切换链路是否真的生效

最后给一个边界条件:
这套方案非常适合中型业务、有限运维资源、追求稳定性优先的团队;但如果你的业务已经进入超大规模、多地域多活、强一致高要求阶段,就要进一步引入更复杂的流量治理、自动化编排和分布式容灾体系。

真正靠谱的集群,不是“有很多台机器”,而是“坏一台时大家都知道该怎么办,而且系统能自己撑住”。


分享到:

上一篇
《从浏览器到接口:一次典型 Web 逆向中请求签名算法的定位、还原与自动化复现》
下一篇
《Docker 镜像构建提速实战:利用多阶段构建、BuildKit 与缓存策略优化中型项目 CI/CD 流程》