面向中型业务的集群架构设计实战:从高可用部署、流量调度到故障切换的落地方案
中型业务做集群,最怕的不是“没上集群”,而是“上了看起来像集群,出事时却像单机”。
我见过不少团队:机器数量有了,Nginx 也配了,数据库甚至做了主从,但线上一遇到流量抖动、单节点卡死、服务半故障,还是会出现请求堆积、切换不及时、误摘流量、雪崩扩散。
这篇文章不打算空讲概念,而是从排障和落地的角度,把一个适合中型业务的集群架构讲透:怎么部署高可用、怎么调度流量、怎么做故障切换,以及出了问题后该按什么路径定位。
背景与问题
对于中型业务,常见约束通常是这样的:
- 业务已经过了单机阶段,但还没大到可以无限堆基础设施
- 需要支持日常高峰、营销波峰、偶发机器故障
- 团队通常有运维和开发协作,但未必有专职 SRE
- 架构目标不是“无限扩展”,而是稳定、可观测、可切换、成本可控
典型线上故障也很像:
-
单节点存活但不可用
进程没挂,TCP 还能连,业务线程池却满了,结果负载均衡器还在继续转发流量。 -
流量切换慢
节点故障后,要几十秒甚至几分钟才能摘除,用户先感知到报错。 -
数据库主从切换后应用报错
配置没刷新、连接池没重建、读写路由没同步,导致切换不彻底。 -
健康检查设计错误
检查的是/healthz,这个接口永远返回 200,但数据库和 Redis 早就不可用了。 -
局部故障演变成全站雪崩
一个下游慢,调用线程被占满,重试叠加,整个集群一起超时。
所以这类架构设计的重点不是“把组件都装上”,而是围绕下面三个问题展开:
- 故障能不能被及时发现
- 流量能不能快速、准确地绕开故障节点
- 切换以后系统能不能稳定收敛,而不是持续震荡
核心原理
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. 故障切换不能只靠“检测到故障”
故障切换有三个阶段:
- 检测:识别节点不可用
- 隔离:停止新流量进入
- 恢复:重新加入流量,并避免抖动
我踩过一个坑:节点 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: 正常响应
这个时序里最容易被忽视的地方有两个:
-
检测窗口
你不可能在第一毫秒就知道节点坏了,所以总会有一部分请求打到故障节点。 -
摘流量与连接复用
即使已经摘掉新流量,已有 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=200 但 readyz=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
排查方法
检查健康检查代码是否覆盖以下内容:
- 线程池是否耗尽
- 数据库是否可连接
- 核心缓存是否可访问
- 是否处于人工摘流量状态
建议
livez与readyz分离- 深度依赖检查要有限时,不能把健康检查本身拖死
坑 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 - 节点异常可在秒级摘除
这是最划算的一步,因为它直接降低单点故障影响。
第二阶段:补齐“状态层高可用”
目标:
- 数据库主从/主备
- 缓存高可用
- 配置与注册中心有备份机制
注意:没有状态层的高可用,应用层再多副本也只是“看上去很美”。
第三阶段:把“切换”变成可验证能力
目标:
- 演练单节点故障
- 演练数据库切主
- 演练人工摘流量
- 演练机房网络抖动
如果一个方案只能写在架构图里,不能演练,它就不算真正落地。
总结
对中型业务来说,集群架构设计的重点不是追求最复杂,而是把这三件事做扎实:
- 高可用部署:副本分散、失败域隔离、状态层有冗余
- 流量调度:基于真实可服务状态,而不是只看进程活着
- 故障切换:检测、隔离、恢复三段式,避免切换震荡
如果只给你几个最可执行的建议,我会建议从这里开始:
- 立刻把健康检查拆成
livez和readyz - 让负载均衡基于
readyz做摘流量 - 为摘流量和恢复都设置阈值,不要秒摘秒加
- 应用尽量无状态,避免会话绑死节点
- 所有重试都要配合超时、熔断、限流
- 每月至少做一次故障演练,验证切换链路是否真的生效
最后给一个边界条件:
这套方案非常适合中型业务、有限运维资源、追求稳定性优先的团队;但如果你的业务已经进入超大规模、多地域多活、强一致高要求阶段,就要进一步引入更复杂的流量治理、自动化编排和分布式容灾体系。
真正靠谱的集群,不是“有很多台机器”,而是“坏一台时大家都知道该怎么办,而且系统能自己撑住”。