单机房三服务器高可用部署方案
三台应用同时服务;MySQL 采用 Group Replication(MGR)单主模式的一主两从;Redis 采用原生 Cluster 三主三从,不使用 Sentinel。入口由 Nginx、HAProxy 与 Keepalived 组成。
单机房三服务器高可用部署方案
JDK 11 · Spring Boot · MyBatis · Vue · MySQL 5.7 MGR 单主 · Redis Cluster
先明确“一主两从”的含义
本文所说的“一主两从”指三个 MySQL 实例组成一个 MGR 复制组,并开启单主模式(group_replication_single_primary_mode=ON):
- 三个成员各自保存同一套完整业务数据;
- 同一时刻只有一个 PRIMARY 接受写入,两个 SECONDARY 由 MGR 自动置为
super_read_only,作为热备; - PRIMARY 故障后,剩余多数派自动选出新 PRIMARY,不需要人工提升,也不依赖 VIP 选主。 与修订版 2.0 相比:不再使用 MGR 多主模式,删除三个异步只读副本,MySQL 实例由 6 个减为 3 个(每台 1 个),同时消除了多主写冲突和副本换源的运维负担。 | 层级 | 设计 | 主要边界 | | -------- | ----------------------------------------------- | ---------------------------------------- | | 入口 | 三套 Nginx / HAProxy / Keepalived;一个业务 VIP | 同一时间一台持有 VIP,入口带宽受单机限制 | | 应用 | 每台一个 Spring Boot 实例,三实例同时服务 | 定时任务和上传文件不得依赖单台机器 | | MySQL | 3 成员 MGR 单主(1 主 2 从热备) | 切主期间写入短暂中断;两台故障停止写入 | | Redis | 3 主分片+3 跨主机副本,原生 Cluster | 异步复制,故障可能丢失最近的缓存写入 | | 故障范围 | 目标为任意单台服务器故障后继续服务 | 不能抵御整机房故障;两台故障通常停止写入 |
适用性结论
方案能够满足单机房三服务器的高可用目标。每台只运行一个 MySQL 实例,磁盘和内存压力比修订版 2.0 明显下降。上线前仍需完成资源评估、现有表结构的 MGR 兼容性检查和单机故障压测;本文中的地址、内存与超时均为设计示例,并非对现有硬件的容量承诺。
1. 三台服务器的部署分配
| 组件 | 服务器 A | 服务器 B | 服务器 C |
|---|---|---|---|
| 示例地址 | 10.10.10.11 | 10.10.10.12 | 10.10.10.13 |
| 接入层 | Nginx+HAProxy Keepalived,优先级 150 | Nginx+HAProxy Keepalived,优先级 140 | Nginx+HAProxy Keepalived,优先级 130 |
| 应用与前端 | App-A :8080 相同 Vue dist | App-B :8080 相同 Vue dist | App-C :8080 相同 Vue dist |
| MySQL MGR 成员 | M1 :3306 初始 PRIMARY,weight 70 | M2 :3306 SECONDARY,weight 60 | M3 :3306 SECONDARY,weight 50 |
| MySQL 健康检查代理 | :9201 / :9202 | :9201 / :9202 | :9201 / :9202 |
| Redis 主实例 | R1 :6379 | R2 :6379 | R3 :6379 |
| Redis 副本 | r3 :6380 ← R3 | r1 :6380 ← R1 | r2 :6380 ← R2 |
M1、M2、M3 属于同一个单主复制组。初始 PRIMARY 为 M1,发生故障或切换后主节点会变化,当前主节点一律以 group_replication_primary_member 状态变量为准,而不是按服务器名称判断。Redis 的 R1、R2、R3 各持有部分槽位,三个副本分别复制对应分片,并与其主节点分处不同服务器。
部署架构图
以下五张图均为正常状态(VIP 在服务器 A、M1 为 PRIMARY)。粗实线为当前生效的路径,虚线为待命、兜底或复制关系,①–⑦ 对应图后的故障切换备注。
图 1 总体部署拓扑:组件所在服务器与用户入口
%%{init: {"themeVariables": {"fontSize": "18px"}, "flowchart": {"nodeSpacing": 30, "rankSpacing": 40}}}%%
flowchart TB
U(["用户浏览器<br/>https://app.example.com"])
VIP{{"业务 VIP 10.10.10.10:443"}}
U ==> VIP
subgraph SA["服务器 A · 10.10.10.11"]
direction TB
KA["Keepalived 优先级 150<br/>持有 VIP"]
NA["Nginx :443"]
HA["HAProxy<br/>:18080 应用 / :13306 写 / :13307 读"]
AA["App-A :8080"]
CA["健康检查代理 :9201/:9202"]
MA[("MySQL M1<br/>PRIMARY")]
RA["Redis R1 主 :6379<br/>Redis r3 副本 :6380"]
KA ~~~ NA ~~~ HA ~~~ AA ~~~ CA ~~~ MA ~~~ RA
end
subgraph SB["服务器 B · 10.10.10.12"]
direction TB
KB["Keepalived 优先级 140<br/>BACKUP"]
NB["Nginx :443"]
HB["HAProxy<br/>:18080 应用 / :13306 写 / :13307 读"]
AB["App-B :8080"]
CB["健康检查代理 :9201/:9202"]
MB[("MySQL M2<br/>SECONDARY")]
RB["Redis R2 主 :6379<br/>Redis r1 副本 :6380"]
KB ~~~ NB ~~~ HB ~~~ AB ~~~ CB ~~~ MB ~~~ RB
end
subgraph SC["服务器 C · 10.10.10.13"]
direction TB
KC["Keepalived 优先级 130<br/>BACKUP"]
NC["Nginx :443"]
HC["HAProxy<br/>:18080 应用 / :13306 写 / :13307 读"]
AC["App-C :8080"]
CC["健康检查代理 :9201/:9202"]
MC[("MySQL M3<br/>SECONDARY")]
RC["Redis R3 主 :6379<br/>Redis r2 副本 :6380"]
KC ~~~ NC ~~~ HC ~~~ AC ~~~ CC ~~~ MC ~~~ RC
end
VIP ==>|"当前持有"| SA
VIP -.->|"① 漂移"| SB
VIP -.->|"① 漂移"| SC
图 2 请求入口与应用层切换
%%{init: {"themeVariables": {"fontSize": "18px"}}}%%
flowchart LR
U(["用户浏览器"]) ==> VIP{{"VIP<br/>10.10.10.10:443"}}
VIP ==>|"当前持有"| NA["A:Nginx :443"]
VIP -.->|"① 漂移"| NB["B:Nginx :443"]
VIP -.->|"① 漂移"| NC["C:Nginx :443"]
NA ==> HA["A:HAProxy :18080<br/>就绪检查"]
HA ==>|"③"| AA["App-A :8080"]
HA ==>|"③"| AB["App-B :8080"]
HA ==>|"③"| AC["App-C :8080"]
NA -.->|"② backup"| AA
NA -.->|"② backup"| AB
NA -.->|"② backup"| AC
B、C 的 Nginx 与 HAProxy 配置和 A 完全一致,接管 VIP 后按相同路径分发。
图 3 MySQL 一主两从与写入路由
%%{init: {"themeVariables": {"fontSize": "18px"}}}%%
flowchart TB
subgraph APP["每台 App → 本机 HAProxy :13306"]
direction LR
HDA["A:HAProxy"]
HDB["B:HAProxy"]
HDC["C:HAProxy"]
end
subgraph MGR["MySQL MGR 单主组"]
direction LR
MB[("M2 · B<br/>SECONDARY<br/>weight 60")]
MA[("M1 · A<br/>PRIMARY<br/>weight 70")]
MC[("M3 · C<br/>SECONDARY<br/>weight 50")]
end
HDA ==>|"④ 写"| MA
HDB ==>|"④ 写"| MA
HDC ==>|"④ 写"| MA
MA <-.->|"⑤ 组复制"| MB
MB <-.->|"⑤"| MC
MA <-.->|"⑤"| MC
HAProxy 通过本机健康检查代理 9201/9202 判定主从,只有当前 PRIMARY 通过写检查。
图 4 Redis Cluster 三主三从跨机复制
%%{init: {"themeVariables": {"fontSize": "18px"}}}%%
flowchart LR
R1["R1 主 :6379<br/>服务器 A"] -.->|"⑥ 复制"| r1["r1 副本 :6380<br/>服务器 B"]
R2["R2 主 :6379<br/>服务器 B"] -.->|"⑥ 复制"| r2["r2 副本 :6380<br/>服务器 C"]
R3["R3 主 :6379<br/>服务器 C"] -.->|"⑥ 复制"| r3["r3 副本 :6380<br/>服务器 A"]
每个分片的主与副本分处不同服务器;各 App 经 Lettuce 直连全部 Redis 节点,图中省略。
图 5 组件视角:组件职责与协作关系
%%{init: {"themeVariables": {"fontSize": "16px"}, "flowchart": {"nodeSpacing": 40, "rankSpacing": 60}}}%%
flowchart TB
U(["用户浏览器"])
OPS(["报表 / 运维工具"])
VIP{{"业务 VIP 10.10.10.10:443"}}
subgraph L1["接入层(三台各一套,仅 VIP 所在节点承接流量)"]
direction LR
KP["Keepalived<br/>VRRP 决定 VIP 持有者<br/>只漂移地址,不参与数据库选主"]
NG["Nginx :443<br/>TLS 终止 · 提供 Vue 静态文件<br/>/api 反向代理"]
end
subgraph L2["流量调度层:HAProxy(三台各一套,配置一致)"]
direction LR
HPA["app_pool :18080<br/>应用负载均衡+就绪检查"]
HPW["mysql_rw :13306<br/>只转发到当前 PRIMARY"]
HPR["mysql_ro :13307<br/>转发到 SECONDARY(默认不接入应用)"]
HPL["haproxy_alive :18404<br/>HAProxy 自身存活探针"]
end
subgraph L3["应用层:Spring Boot × 3(无状态,同时服务)"]
APP["App-A / App-B / App-C :8080<br/>业务逻辑 · MyBatis+HikariCP · Lettuce<br/>暴露就绪检查,支持优雅停机"]
end
subgraph L4["数据层"]
direction LR
AGT["MGR 健康检查代理(每台一个)<br/>:9201 本机是否可写 PRIMARY<br/>:9202 本机是否可读 SECONDARY"]
MGR[("MySQL MGR 单主组 M1/M2/M3<br/>持久业务数据<br/>组复制 :33061 · 多数派自动选主")]
RDS[("Redis Cluster R1/R2/R3+r1/r2/r3<br/>缓存 · 16384 槽位分片<br/>集群总线 · 副本自动提升")]
end
U ==>|"HTTPS"| VIP ==> NG
KP -.->|"绑定 / 漂移 VIP"| VIP
KP -.->|"check_entry.sh 探测"| NG
KP -.->|"探测 /alive"| HPL
NG ==>|"/api 请求"| HPA
HPA ==>|"轮询分发"| APP
HPA -.->|"就绪检查,失败摘流"| APP
NG -.->|"HAProxy 不可用时 backup 直连"| APP
APP ==>|"JDBC 127.0.0.1:13306"| HPW
HPW ==>|"读写事务"| MGR
OPS -.->|"只读查询"| HPR
HPR -.-> MGR
HPW -.->|"httpchk /rw :9201"| AGT
HPR -.->|"httpchk /ro :9202"| AGT
AGT -.->|"查询成员角色与积压"| MGR
APP ==>|"Lettuce 直连节点,按槽位路由<br/>自适应刷新拓扑"| RDS
classDef app fill:#fff3cd,stroke:#d39e00,stroke-width:3px
classDef data fill:#e8f4fd,stroke:#1f77b4,stroke-width:2px
classDef ctrl fill:#f2f2f2,stroke:#888,stroke-dasharray:4 3
class APP app
class MGR,RDS data
class KP,AGT,HPL ctrl
粗实线为业务请求与数据访问路径,虚线为健康检查、控制、兜底与非应用访问;黄色为应用,蓝色为数据存储,灰色虚框为只负责探测或控制、不承载业务数据的组件。
| 组件 | 作用 | 与其他组件的协作 | 与应用的关联 |
|---|---|---|---|
| Keepalived | 在三台间选出 VIP 持有者 | 通过本机 Nginx :443 与 HAProxy :18404 判断入口是否可用,失败则释放 VIP | 不直接接触应用,只决定由哪台的入口承接用户请求 |
| Nginx | TLS 终止、Vue 静态文件、同域 /api 代理 | 正常把 /api 交给本机 HAProxy :18080;HAProxy 不可用时以 backup 直连三台 App | 应用通过 X-Forwarded-* 头获得原始协议与主机名(forward-headers-strategy) |
| HAProxy | 应用分发;数据库写/只读端口转发 | 依赖健康检查代理 9201/9202 的结果决定数据库后端;本身不提升主节点 | 主动探测 App 就绪状态并摘流;App 的数据源只连本机 13306 |
| 健康检查代理 | 把 MGR 成员角色转换为 HTTP 状态码 | 查询本机 MySQL 的成员状态,供三台 HAProxy 调用 | 无直接关联,间接保证应用的写连接只落到 PRIMARY |
| MySQL MGR | 持久化业务数据,单主写入、两从热备 | 成员间组复制,PRIMARY 故障时多数派自动选主 | 应用经 HAProxy 访问,切主时连接被断开,由 HikariCP 重建 |
| Redis Cluster | 缓存,按槽位分片 | 主从跨机复制,主故障时副本自动提升 | 应用经 Lettuce 直连各节点,不经过 HAProxy 与 VIP;缓存丢失须可回源 |
| Spring Boot App | 三实例同时处理业务请求 | 同时依赖 HAProxy(入口与数据库)和 Redis Cluster | 须无状态、提供就绪检查、定时任务防重、文件不落本地盘(见第 6 节) |
故障切换备注:
| 编号 | 故障场景 | 切换动作 | 用户影响 |
|---|---|---|---|
| ① | VIP 节点(A)整机故障,或其 Nginx/HAProxy 经 systemd 重启仍失败 | Keepalived 检查连续失败,A 进入 FAULT 释放 VIP;B(140)接管,B 也故障时由 C(130)接管;nopreempt 使 A 恢复后不抢回 | 约 4–6 秒入口不可用,已有连接断开需重试 |
| ② | VIP 节点的 HAProxy 故障 | systemd 先重启;期间 Nginx 经 app_entry 的 backup 直连三台 App;重启无效时触发 ① | 通常无感知;兜底期间仅被动健康检查 |
| ③ | 某台 App 故障或发布摘流 | HAProxy 就绪检查失败后摘除,请求由其余 App 承接 | 在途请求可能失败 |
| ④ | MySQL PRIMARY(M1)故障 | M2、M3 多数派按权重选 M2 为新主(M2 也不可用时选 M3);三台 HAProxy 的 9201 检查随之切到新主,并断开旧主存量连接 | 写入中断约 10–30 秒,在途写事务失败需重试 |
| ⑤ | MySQL SECONDARY 故障 | 剩余两成员维持多数派继续服务;9202 只读端口摘除该成员 | 无写入影响,但已无冗余,需尽快修复入组 |
| ⑥ | Redis 分片主故障(如 A 故障导致 R1 不可用) | B 上的副本 r1 自动提升为该分片主;A 上的副本 r3 同时失效,R3 暂无副本 | 数秒缓存访问失败,最近缓存写入可能丢失 |
| ⑦ | 任意两台服务器同时故障 | MGR 失去多数派停止写入;剩余一台可持有 VIP,但禁止强制重建组 | 不承诺可用,按第 8 节人工恢复 |
请求与数据路径
- 浏览器 →
https://app.example.com→ VIP 10.10.10.10:443 → 当前 VIP 节点 Nginx。Vue 静态文件在本地提供;/api动态请求转到本机 HAProxy :18080,再分发给三台 App。 - 每个 App → 本机 HAProxy
127.0.0.1:13306(写端口)→ 当前 MySQL PRIMARY。HAProxy 依据健康检查代理只把流量送往真正的主节点。应用访问本地数据库代理,不依赖业务 VIP。 - 本机 HAProxy
127.0.0.1:13307(只读端口)指向两个 SECONDARY,默认不接入应用,供报表、导出或运维查询使用。 - 每个 App 的 Lettuce Cluster 客户端 → Redis 各节点真实地址。Redis 不经过 HAProxy TCP 轮询,也不依赖 VIP。
- 若将来启用用户登录,认证回调路径与会话共享见第 5 节。
网络与进程隔离
VIP 要求网络支持地址漂移与 gratuitous ARP;三台机器使用相同业务网段,事先检查虚拟化平台的反欺骗限制。内网按需放通 MySQL 3306、MGR 33061、健康检查 9201/9202(仅允许三台 HAProxy 访问)、Redis 6379/6380 及总线 16379/16380、应用 8080;VRRP 使用 IP 协议 112,不是 TCP/UDP 112。外部只开放业务 HTTPS。
每台只运行一个 MySQL 实例,数据目录、日志目录与 systemd 服务独立。同机两个 Redis 实例必须使用独立端口、目录、节点配置文件与服务,禁止共享数据目录。可使用容器或 systemd,资源限额及持久目录须明确。
2. MySQL:MGR 单主一主两从
主节点与选主
M1、M2、M3 均保存完整数据,但只有 PRIMARY 可写。SECONDARY 由 MGR 自动开启 super_read_only,即使业务账户误连也无法写入。三成员需要多数派(至少两台在线且互通)维持组的正常运行,可以容忍任意一台故障。
PRIMARY 故障或离组后,剩余成员按以下规则选出新主(MySQL 5.7.20 及以上):优先选择 group_replication_member_weight 最高的成员,权重相同时选 server_uuid 最小的成员。本方案设置 M1=70、M2=60、M3=50,使切换顺序可预期。新主在应用完积压的中继日志后才开放写入。
MGR 对业务表的要求:全部使用 InnoDB,每张表有主键(或等效非空唯一键),启用 GTID 和 ROW 格式 binlog。[R1] 单主模式没有跨成员写冲突,但切主瞬间正在执行的写事务会失败或连接被断开,应用应对关键写操作使用业务幂等键、唯一约束和有限次整笔事务重试;提交结果未知时先查业务结果,不可直接重复提交。DDL 由发布任务在 PRIMARY 上串行执行。
账户与权限
- 应用读写账户:仅授予业务库所需的 SELECT/INSERT/UPDATE/DELETE,不授予 SUPER 或管理权限,只通过 13306 连接。
- 运维只读账户:仅授予 SELECT(按需 SHOW VIEW),通过 13307 连接。
- 复制账户:
REPLICATION SLAVE,仅用于group_replication_recovery通道。 - 健康检查账户:仅需读取
performance_schema与系统变量,只允许本机登录。
数据库权限与 SECONDARY 的 super_read_only 共同承担写保护,不依赖代码约定。
读写分离:默认不做
应用保持现有单数据源,只连接本机 13306,所有读写都在 PRIMARY 上执行,保证写后立即读的一致性,应用代码无需改动。两个 SECONDARY 作为热备,同时可经 13307 承接报表、导出等允许轻微延迟的查询。
如需在不改应用代码的前提下把普通查询分摊到三台,参见第 7 节“可选:ProxySQL 透明读写分离”。HAProxy 按 TCP 连接转发,无法区分 SELECT 与 UPDATE,不能单独实现 SQL 级读写分离。
3. MySQL 配置与健康检查约定
以下为 M1 的关键配置片段,用于说明参数含义。基于 Docker 的完整配置文件、自动建组脚本与部署操作步骤见附录 B。M2/M3 修改 server_id、group_replication_local_address 与 group_replication_member_weight,组名保持一致。
[mysqld]
port=3306
server_id=101
log_bin=mysql-bin
log_slave_updates=ON
binlog_format=ROW
binlog_checksum=NONE
gtid_mode=ON
enforce_gtid_consistency=ON
master_info_repository=TABLE
relay_log_info_repository=TABLE
transaction_write_set_extraction=XXHASH64
sync_binlog=1
innodb_flush_log_at_trx_commit=1
plugin_load_add=group_replication.so
group_replication_group_name="3ef332c0-9318-4e52-bc03-7508bda927ab"
group_replication_local_address="10.10.10.11:33061"
group_replication_group_seeds="10.10.10.11:33061,10.10.10.12:33061,10.10.10.13:33061"
group_replication_ip_whitelist="10.10.10.0/24"
group_replication_single_primary_mode=ON
group_replication_enforce_update_everywhere_checks=OFF
group_replication_member_weight=70
group_replication_exit_state_action=READ_ONLY
group_replication_unreachable_majority_timeout=30
group_replication_start_on_boot=OFF
group_replication_bootstrap_group=OFF
说明:
auto_increment_increment/offset保持默认 1/1,单主模式只有一个写入点,不需要多主的自增偏移策略。group_replication_exit_state_action(5.7.24+)设为 READ_ONLY,成员被逐出组后保持只读,避免继续接受写入。group_replication_unreachable_majority_timeout使处于少数派的成员在超时后进入 ERROR 状态并回滚阻塞事务,便于健康检查及时摘除。
建组与日常启动
- 在 M1 上配置
group_replication_recovery通道凭据,临时执行SET GLOBAL group_replication_bootstrap_group=ON; START GROUP_REPLICATION; SET GLOBAL group_replication_bootstrap_group=OFF;。 - 在 M2、M3 上依次执行
START GROUP_REPLICATION,确认replication_group_members中三成员均为 ONLINE。 - 日常启动通过受控脚本入组,健康检查通过后放流。禁止多台同时 bootstrap。
- 全组停机后恢复:先比较各成员
gtid_executed,选择事务最完整的成员 bootstrap,其余成员再入组;不能随意强制重建。
健康检查代理
每台部署一个本机健康检查代理(小型 HTTP 服务或 xinetd 脚本),只允许 HAProxy 访问,健康返回 HTTP 200,异常返回 503:
| 端口 | 放流条件 | 失败动作 |
|---|---|---|
| 9201(写) | 本机 MEMBER_STATE=ONLINE;本机是 group_replication_primary_member;super_read_only=OFF;本机视图中 ONLINE 成员数 ≥ 2 | HAProxy 摘除并关闭存量连接;代理不执行任何升主操作 |
| 9202(只读) | 本机 MEMBER_STATE=ONLINE;本机不是 PRIMARY;group_replication_applier 积压在阈值内 | 摘除;追平后再加入 |
MySQL 5.7 的 replication_group_members 没有 MEMBER_ROLE 列,需要用状态变量判断主节点。判定 SQL 示例:
SELECT m.MEMBER_STATE,
(SELECT VARIABLE_VALUE FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'group_replication_primary_member') = @@server_uuid AS is_primary,
@@super_read_only AS sro,
(SELECT COUNT(*) FROM performance_schema.replication_group_members
WHERE MEMBER_STATE = 'ONLINE') AS online_cnt
FROM performance_schema.replication_group_members m
WHERE m.MEMBER_ID = @@server_uuid;
9201 返回 200 的条件:MEMBER_STATE='ONLINE' AND is_primary=1 AND sro=0 AND online_cnt>=2。9202 返回 200 的条件:MEMBER_STATE='ONLINE' AND is_primary=0 且应用队列未超阈值(可读取 performance_schema.replication_group_member_stats.COUNT_TRANSACTIONS_IN_QUEUE)。
不能只检查 TCP 端口或 SELECT 1:被逐出组或处于少数派的成员仍可能接受连接。
4. Redis:原生 Cluster 三主三从(缓存用途)
建立六节点 Redis Cluster:R1、R2、R3 分配全部 16384 个槽,r1、r2、r3 各复制对应主节点,并按第 1 节跨服务器摆放。不安装 Sentinel。Cluster 负责槽位路由和副本提升;Lettuce 负责拓扑发现及 MOVED/ASK 重定向。[R3]
创建步骤
- 先启动三台 :6379 与三台 :6380,使用固定内网地址;配置
cluster-enabled yes、独立cluster-config-file、明确的cluster-announce-ip/port/bus-port。 - 只使用三个 :6379 创建主分片集群,再通过
redis-cli --cluster add-node的--cluster-slave、--cluster-master-id参数逐个加入副本。按实际 node ID 指定配对,不依赖自动分配猜测物理位置。 - 通过
CLUSTER NODES/CLUSTER INFO核验槽位覆盖、主从配对、cluster_state:ok;逐台停机演练,确认对应跨机副本提升及客户端恢复。
每节点关键参数示例
port 6379
cluster-enabled yes
cluster-config-file nodes-6379.conf
cluster-node-timeout 5000
cluster-announce-ip 10.10.10.11
cluster-announce-port 6379
cluster-announce-bus-port 16379
cluster-require-full-coverage yes
appendonly yes
appendfsync everysec
maxmemory 4gb
maxmemory-policy volatile-lru
6380 实例相应修改端口、总线端口和目录。基于 Docker 的完整配置文件、自动建集群脚本与部署操作步骤见附录 C。认证凭据通过受控配置注入,配置主从认证,限制内网访问;按所选 Redis 版本启用兼容的 ACL/TLS。maxmemory 只是数据内存上限,须额外预留复制缓冲、碎片及 AOF 重写内存。
缓存使用约定
- 不同用途使用独立 key 前缀并设置 TTL;Cluster 只使用 DB 0。
- 淘汰策略采用
volatile-lru:只淘汰设置了 TTL 的 key。当前项目在 Redis 中还保存批次号计数器(BatchNoGenerator)和登录流程状态(Epaas*),这类数据被淘汰会导致批次号重复或登录失败,因此不使用allkeys-lru。缓存类 key 必须设置 TTL;不能淘汰的 key 不设 TTL 或由业务自行清理,并对内存使用率提前告警(无可淘汰 key 时写入会报 OOM)。 - Redis 异步复制不保证已确认写入绝不丢失,AOF everysec 也不提供零丢失承诺。缓存不能作为订单、状态或唯一幂等结果的权威存储。
- 多键事务或 Lua 只能操作同槽键,可对同一业务对象使用 hash tag;不要给大量 key 加相同 hash tag 造成单分片热点。
- 不要默认设置
min-replicas-to-write=1:每主只有一个副本时,一台机器故障会让某些存活主失去唯一副本并拒写,可能违背单机故障续服目标。 - 若启用第 5 节的共享会话,需重新评估淘汰策略(会话不能被 LRU 淘汰)以及是否为会话单独建立集群。
5. 可选:用户登录与跨实例会话(当前项目不启用)
当前项目不需要用户登录访问,本节默认不部署。仅当将来接入 OAuth2 登录、且后端通过 oauth2Login 建立 HttpSession 时才需要实施;若前端携带 Bearer Token、后端为无状态资源服务器,则无需共享会话。
启用时的方案:OAuth2 登录+Spring Session Redis
- 浏览器通过唯一 HTTPS 域名发起登录;App-A 生成的授权请求及 state 保存到共享 Session。若使用 OIDC,同时验证 nonce;按提供方能力使用 PKCE。
- 第三方认证完成后回调同一域名,可能被分配到 App-B;App-B 通过 Session Cookie 从 Redis 读取对应状态并校验,完成授权码交换,建立认证上下文。
- 后续请求可以落到任何 App;三台版本、序列化配置和命名空间一致,不以 ip_hash 或粘性会话作为正确性前提。
- 本地退出删除共享会话、清理 Cookie;全局退出需按提供方注销能力接入。
启用时必须共享的状态
| 内容 | 处理方式 |
|---|---|
| HttpSession / SecurityContext | Spring Session Data Redis,初始空闲超时 30 分钟,按业务政策调整 |
| OAuth2 授权请求、state、PKCE verifier | 存于共享 Session,短期有效;禁止只放本机 Map |
| OAuth2AuthorizedClient / refresh token | 内存实现必须替换为共享存储(如加密的 JDBC 持久化,走 13306 主库) |
| 令牌刷新并发 | 跨实例协调刷新,用数据库版本号/CAS 避免刷新令牌轮换被覆盖 |
启用时的配置与依赖
依赖加入 spring-session-data-redis(由 Boot BOM 管理版本)。推荐选择不依赖用户索引的 RedisSessionRepository;若使用 RedisIndexedSessionRepository,需验收 Cluster 下的事件订阅与索引清理问题。[R5] 共享 Session 的序列化必须能往返处理 SecurityContext 与 OAuth2 认证对象,并测试混合版本滚动发布。
spring:
session:
store-type: redis
timeout: 30m
redis:
namespace: prod:myapp:session
server:
servlet:
session:
cookie:
secure: true
http-only: true
same-site: lax
Nginx 需追加 /oauth2、/login、/logout 路由(见第 7 节注释);Cookie 设置 Secure、HttpOnly、Path=/;代理覆写可信转发头,确保生成的回调地址是外部 HTTPS 地址。Redis 不可用时受保护接口应返回明确错误,不能放行未认证请求。
6. Spring Boot 配置与应用改造
JDK 11 下示例采用 Spring Boot 2.7 风格属性,具体版本以现有 pom.xml 为准。[R4] 数据源通过现有环境变量指向本机 HAProxy 写端口:MYSQL_HOST=127.0.0.1、MYSQL_PORT=13306。
spring:
datasource:
url: jdbc:mysql://127.0.0.1:13306/${MYSQL_DB}?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&connectTimeout=5000&socketTimeout=60000&tcpKeepAlive=true
hikari:
max-lifetime: 1500000 # 小于 HAProxy 与 MySQL wait_timeout
keepalive-time: 300000
connection-timeout: 5000
validation-timeout: 3000
redis:
cluster:
nodes: 10.10.10.11:6379,10.10.10.12:6379,10.10.10.13:6379
password: ${REDIS_PASSWORD}
timeout: 2s
connect-timeout: 2s
lettuce:
cluster:
refresh:
adaptive: true
period: 30s
server:
forward-headers-strategy: framework
应用集群的必改点
- 三个 App 使用相同发布包与外部配置;设置就绪检查、优雅停机、连接池上限和请求超时。健康检查不得只看 Java 进程是否存在。
- 切主时 HAProxy 会关闭旧连接,连接池必须能丢弃失效连接并重建:
max-lifetime小于 HAProxy 超时,依赖 JDBC4isValid校验,关键写操作有限重试。 - 定时任务避免三台重复执行:采用数据库任务认领与幂等控制;JVM 内锁无法协调跨实例执行。
- 上传文件不能仅落在当前 App 磁盘;使用持久文件服务/对象存储(如 MinIO 集群)。
- 滚动发布一次一台:摘流、等待在途请求、更新、就绪、恢复流量。Vue
index.html短缓存或不缓存,带 hash 的资源保留旧版本。
7. Nginx、HAProxy 与 Keepalived
职责分工
Nginx 处理 TLS、Vue 静态文件及同域代理;HAProxy 承担应用分发与数据库写/只读端口转发;Keepalived 只漂移 VIP。MGR 自行选主,Redis Cluster 自行管理槽位与副本提升,systemd 管理进程。Keepalived 不是数据库选主器,HAProxy 也不会提升主节点,它只跟随健康检查代理的结果。
为什么 Nginx 还要经过 HAProxy 分发应用:Nginx 的 upstream 本身也能把请求分给三台 App,两者在这一层职责有重叠,但并不冲突。本方案仍由 HAProxy 分发,原因是:
- 开源版 Nginx 只有被动健康检查(请求失败达到
max_fails才摘除),HAProxy 可主动探测/actuator/health/readiness,App 启动中、发布摘流或假死时能在用户请求失败前摘除; - 数据库转发必须依赖 HAProxy 通过 9201/9202 主动判断主从角色,Nginx 开源版无法实现;应用与数据库的健康检查、摘流统一在 HAProxy 管理,运维方式一致;
- Nginx 专注 TLS、静态文件与路径路由,配置三台一致且较少变动。
若希望减少一跳,也可改为 Nginx upstream 直连三台 App(配置 max_fails/fail_timeout),HAProxy 只保留数据库部分;代价是故障时会有少量请求先失败再摘除,滚动发布需先在 Nginx 中手动摘除节点。
Nginx 路由片段:三台一致
# http 块:本机 HAProxy 为主,三台 App 直连为兜底
upstream app_entry {
server 127.0.0.1:18080 max_fails=1 fail_timeout=5s;
server 10.10.10.11:8080 backup;
server 10.10.10.12:8080 backup;
server 10.10.10.13:8080 backup;
}
# 放在 HTTPS server 中;另行配置证书、80 跳转、日志与限流
root /srv/vue/current;
location / { try_files $uri $uri/ /index.html; }
# 默认只代理 /api;启用第 5 节登录时,将正则扩展为
# ^/(api(?:/|$)|oauth2(?:/|$)|login(?:/|$)|logout(?:/|$))
location ~ ^/api(?:/|$) {
proxy_pass http://app_entry;
proxy_next_upstream error timeout;
proxy_connect_timeout 2s;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-For $remote_addr;
}
app_entry 用于兜底本机 HAProxy 故障:HAProxy 进程退出或端口不可连接时,Nginx 在 VIP 漂移完成前直接把请求转给三台 App,避免检测窗口内持续返回 502。说明:
backup节点只在本机 HAProxy 被判定不可用时启用,正常情况下所有请求仍经 HAProxy 分发;- 兜底期间 Nginx 只有被动健康检查,某台 App 异常时会有少量请求失败后才被跳过;
proxy_next_upstream error timeout只在连接失败或超时时换节点;Nginx 1.9.13 起默认不对 POST 等非幂等请求重试,不要额外开启non_idempotent;- 不把
http_502/http_503列入重试条件:HAProxy 在三台 App 都不可用时返回 503,此时兜底也无意义。
Nginx 直接面向用户时覆写转发头;若前面还有可信代理,需重新定义可信来源。WebSocket 使用单独的 Upgrade/Connection 配置。
HAProxy 结构片段:三台一致
defaults
mode tcp
timeout connect 3s
timeout client 30m
timeout server 30m
frontend haproxy_alive
bind 127.0.0.1:18404
mode http
monitor-uri /alive
listen app_pool
bind 127.0.0.1:18080
mode http
timeout client 60s
timeout server 60s
balance roundrobin
option httpchk GET /actuator/health/readiness
http-check expect status 200
server a 10.10.10.11:8080 check
server b 10.10.10.12:8080 check
server c 10.10.10.13:8080 check
listen mysql_rw
bind 127.0.0.1:13306
option httpchk GET /rw
http-check expect status 200
default-server inter 2s fall 2 rise 3 on-marked-down shutdown-sessions
server m1 10.10.10.11:3306 check port 9201
server m2 10.10.10.12:3306 check port 9201
server m3 10.10.10.13:3306 check port 9201
listen mysql_ro
bind 127.0.0.1:13307
balance leastconn
option httpchk GET /ro
http-check expect status 200
default-server inter 2s fall 2 rise 3 on-marked-down shutdown-sessions
server m1 10.10.10.11:3306 check port 9202
server m2 10.10.10.12:3306 check port 9202
server m3 10.10.10.13:3306 check port 9202
以上为结构示意。当前项目应用实际端口为 40001,且尚未引入 actuator,应用池暂用 TCP 端口检查;可直接部署的完整配置、9201/9202 健康检查代理实现与部署步骤见附录 D。
haproxy_alive 只反映 HAProxy 进程自身是否存活(monitor-uri 固定返回 200,不依赖任何后端),供第 8 节 Keepalived 检查脚本使用。mysql_rw 配置全部三个成员,但同一时刻只有当前 PRIMARY 能通过 9201 检查,因此写流量始终只到主节点;切主后旧主被标记 down 并断开存量连接,新主通过检查后接管。mysql_ro 默认不被应用使用。数据库连接的 HAProxy 超时(示例 30 分钟)需大于连接池 max-lifetime。
Keepalived 与可选数据库 VIP
应用通过本机 HAProxy 访问数据库,不依赖任何 VIP。业务 VIP 10.10.10.10 保持不变。若外部系统或运维工具需要统一的数据库地址,可选增加数据库 VIP(示例 10.10.10.20),在 HAProxy 中增加 bind 10.10.10.20:3306 指向与 mysql_rw 相同的后端,由同一组 Keepalived 漂移。该 VIP 只是访问入口,写入目标仍由 9201 健康检查决定。
可选:ProxySQL 透明读写分离
需要在不改应用代码的前提下,让三台 MySQL 都承担查询、只有 PRIMARY 执行写入时,可在每台用 ProxySQL 替代 HAProxy 的数据库部分(应用层分发仍由 HAProxy 负责)。应用只需把 MYSQL_PORT 改为本机 ProxySQL 端口(默认 6033)。
ProxySQL 原生支持 MGR,自动识别主节点并在切主时调整路由。MySQL 5.7 需先在 PRIMARY 上导入 sys.gr_member_routing_candidate_status 视图脚本(ProxySQL 官方提供的 addition_to_sys)。[R10]
-- 主机组:10 写、20 备用写、30 读、40 离线
INSERT INTO mysql_servers (hostgroup_id, hostname, port) VALUES
(10,'10.10.10.11',3306),(10,'10.10.10.12',3306),(10,'10.10.10.13',3306);
INSERT INTO mysql_group_replication_hostgroups
(writer_hostgroup, backup_writer_hostgroup, reader_hostgroup, offline_hostgroup,
active, max_writers, writer_is_also_reader, max_transactions_behind)
VALUES (10, 20, 30, 40, 1, 1, 1, 100);
-- 应用账户默认发往写组,事务内语句固定在同一后端
INSERT INTO mysql_users (username, password, default_hostgroup, transaction_persistent)
VALUES ('app_rw', '******', 10, 1);
-- 查询规则:加锁读走写组,普通 SELECT 走读组
INSERT INTO mysql_query_rules (rule_id, active, match_digest, destination_hostgroup, apply)
VALUES (1, 1, '^SELECT.*FOR UPDATE', 10, 1),
(2, 1, '^SELECT', 30, 1);
LOAD MYSQL SERVERS TO RUNTIME; SAVE MYSQL SERVERS TO DISK;
LOAD MYSQL USERS TO RUNTIME; SAVE MYSQL USERS TO DISK;
LOAD MYSQL QUERY RULES TO RUNTIME; SAVE MYSQL QUERY RULES TO DISK;
风险与约束:
- 写后立即读可能读到旧数据:SECONDARY 通常只落后毫秒级,但不为零。事务内的读取固定在主节点,不受影响;“保存后在事务外马上查询回显”的逻辑应放入同一事务,或为这类查询增加走写组的规则。
writer_is_also_reader=1使主节点也承担读,适合三台共同分摊;主节点压力大时可设为 0。- ProxySQL 需在三台都部署,配置通过 ProxySQL Cluster 或发布脚本保持一致;监控账户需有读取
sys视图的权限。 - 启用前验证:写后读场景、
FOR UPDATE、存储过程/多语句、切主时的连接行为,以及与现有 MyBatis 事务边界的配合。
8. VIP 切换与故障处理
Keepalived 实施约定
三台建立相同 virtual_router_id 的 VRRP 实例,启动 state 均为 BACKUP,priority 分别 150/140/130,建议 nopreempt 减少恢复后的来回切换。绑定实际网卡与 VIP 10.10.10.10/24;按网络选择组播或明确配置所有单播对端。
track_script 同时检查本机 Nginx 与 HAProxy 自身是否存活,连续失败使本机进入 FAULT。脚本以最小权限运行并设置超时。VIP 持有者失败后由其他健康节点接管;已有连接会断开,非幂等业务不能盲目重试。
可直接使用的配置模板、检查脚本、安装脚本与部署步骤见附录 E。
VIP 节点上的 Nginx 或 HAProxy 故障
VIP 所在机器的 Nginx 或 HAProxy 故障时,按以下三层依次处理:
- systemd 自动重启:Nginx 与 HAProxy 的 systemd 单元配置
Restart=on-failure、RestartSec=2s,进程偶发崩溃时一般数秒内恢复,VIP 不漂移。 - Nginx 兜底(仅 HAProxy 故障):本机 HAProxy 不可连接时,Nginx 按第 7 节
app_entry的backup配置直接转发到三台 App,覆盖 VIP 漂移前的检测窗口。 - VIP 漂移:重启无效、检查连续失败后,本机进入 FAULT 并释放 VIP,优先级次高的健康节点接管,由该节点的 Nginx 与 HAProxy 继续服务。
检查脚本只判断本机组件自身是否存活,不能检查后端 App 或数据库状态。否则三台 App 同时异常时,三台 Keepalived 会同时进入 FAULT,VIP 无人持有,入口整体不可用。
#!/bin/bash
# /etc/keepalived/check_entry.sh
curl -fsS -m 2 -o /dev/null http://127.0.0.1:18404/alive || exit 1 # HAProxy 自身
curl -ksS -m 2 -o /dev/null https://127.0.0.1/ || exit 1 # Nginx 自身(本地静态首页)
exit 0
vrrp_script chk_entry {
script "/etc/keepalived/check_entry.sh"
interval 2
timeout 3
fall 2
rise 2
}
vrrp_instance VI_APP {
state BACKUP
interface eth0
virtual_router_id 51
priority 150 # B=140,C=130
nopreempt
advert_int 1
virtual_ipaddress { 10.10.10.10/24 }
track_script { chk_entry }
}
按上述参数,从组件故障到 VIP 漂移约 4–6 秒;这段时间内 HAProxy 故障由 Nginx 兜底,Nginx 自身故障则请求失败,需客户端重试。
VRRP 不提供分布式共识,网络分区可能出现双 VIP,需要验证双向 VRRP、监控重复 IP,并准备禁用隔离节点入口的处置。数据库写入安全由 MGR 多数派保证:少数派一侧无法提交写事务,并会被健康检查摘除。
| 故障 | 预期动作 | 用户影响 / 注意事项 |
|---|---|---|
| App-A 故障 | HAProxy 摘除;B/C 接管请求 | 在途请求可能失败 |
| VIP 节点故障 | Keepalived 在健康节点漂移 VIP | 短暂断连;新连接重建 |
| VIP 节点 HAProxy 故障 | systemd 重启;期间 Nginx 经 backup 直连三台 App;重启无效时 VIP 漂移 | 通常无感知;兜底期间仅被动健康检查 |
| VIP 节点 Nginx 故障 | systemd 重启;重启无效时 VIP 漂移 | 约 4–6 秒请求失败,需重试 |
| MySQL PRIMARY 故障(如 M1) | M2/M3 多数派按权重选 M2 为新主;9201 检查切换,HAProxy 转发到 M2 | 写入中断数秒;在途写事务失败需应用重试 |
| MySQL SECONDARY 故障 | 组内剩余两成员继续服务;只读端口摘除该成员 | 无写入影响;此时已无冗余,需尽快修复 |
| 服务器 A 整机故障 | B/C 应用继续;M1 为主时切到 M2;r1 在 B 提升为 R1 主 | 短暂写入中断与缓存访问失败,随后恢复 |
| 一个 Redis 分片主故障 | 跨机副本在集群满足条件时自动提升 | 短暂失败,最近缓存写入可能丢失 |
| 两台物理机故障 | MGR 失去多数派,停止写入 | 不承诺可用;禁止自动强制重建组 |
| 全部 Redis 不可用 | 应用对缓存降级,直接查库并限流 | 避免缓存故障冲垮数据库 |
| 全机房故障 | 从机房外备份恢复 | 本架构不具备异地容灾能力 |
恢复次序
故障机恢复后先验证磁盘、时钟与网络;MySQL 成员追平后重新入组为 SECONDARY,不强行抢回主节点(如需让 M1 恢复为主,在业务低峰通过 group_replication_member_weight 调整并受控切换);Redis 按当前槽位角色重新加入;应用最后就绪放流。每次只维护一台,并等集群恢复冗余后再处理下一台。
目标而非保证
单 App 或入口切换可先以 5–15 秒、MySQL 切主以 10–30 秒、Redis 主故障以 10–30 秒作为演练目标,实际受检测参数、负载与客户端重试影响。MySQL 要通过故障与备份恢复验证数据结果。未完成演练前不承诺 RTO/RPO 数值。
9. 资源预算、优缺点与上线验收
资源预算示例:每台 16 核 / 64 GB 起评估
这只是中小负载的压测起点,不是最低配置或容量保证。示例内存预算:MySQL buffer pool 24 GB;两个 Redis maxmemory 各 4 GB;JVM heap 6 GB;剩余约 26 GB 留给 MySQL 连接/排序、Redis 重写及碎片、JVM 堆外、Nginx/HAProxy、系统和监控。磁盘采用企业级 SSD,尽量隔离数据库与日志 IO。
每台存一份完整 MySQL 数据,全体约三份数据占用,另加索引、binlog、relay log、备份和恢复临时空间。Redis 逻辑容量约为三个主节点可用容量之和。单机故障后存活应用平均负载约增至 1.5 倍;默认所有 SQL 都在 PRIMARY 执行,需实测单个 MySQL 主节点能否承载峰值。
| 方面 | 好处 | 代价 / 局限 |
|---|---|---|
| 三机复用 | 无需新增专用负载均衡服务器 | 数据、缓存、Java 争用同机资源,故障相关性高 |
| MySQL MGR 单主 | 自动选主、无写冲突;每台一实例,资源与运维负担低 | 写入只在一台;5.7 需自研健康检查、手工建组恢复 |
| Redis Cluster | 分片扩展,原生副本提升,不使用哨兵 | 跨槽限制、客户端要求;最近写入可能丢失 |
| 应用同时服务 | 分摊请求,支持滚动发布 | 定时任务、文件存储需要改造 |
| 共享会话(可选) | 启用登录后跨节点请求不依赖固定 App | 增加 Redis 依赖及序列化、刷新并发治理 |
| VIP 入口 | 客户端域名稳定,切换简单 | 单活入口容量限制;依赖同网段与 VRRP 网络 |
| 单机房 | 成本和网络复杂度较低 | 无法抵御断电、火灾、运营商或整机房事故 |
上线前必须完成的验收
- 核对硬件、磁盘容量、数据规模、峰值流量和软件小版本(本文参数要求 MySQL 5.7.24 及以上,现网 5.7.38 满足;三个成员须使用相同小版本);备份现网并准备回退窗口。
- 检查表引擎、主键、外键,验证 MGR 兼容性;通过 13306 读写成功,直连 SECONDARY 执行 INSERT/UPDATE/DELETE 被
super_read_only拒绝。 - 切主演练:停止当前 PRIMARY,验证新主选举结果、HAProxy 9201 切换耗时、存量连接关闭以及应用连接池自动恢复;再让旧主重新入组为 SECONDARY。
- 网络分区演练:隔离一台成员,确认少数派不再通过 9201 检查、多数派继续写入。
- 入口组件演练:在 VIP 节点分别停止 HAProxy、Nginx(含禁止 systemd 重启的场景),验证 Nginx 兜底、VIP 漂移耗时与请求失败数;再停止全部 App,确认 VIP 不会因检查脚本误判而无人持有。
- 逐台停机并叠加峰值负载,观察 VIP、MGR、Redis、应用的恢复;测试全组停机后按 GTID 的受控恢复。
- (启用登录时)登录发起在 A、回调落 B、后续落 C;测试刷新令牌并发、退出、会话过期、Redis 切换及滚动发布中的序列化兼容。
- 配置机房外备份:定期全量+持续保留 binlog,并验证指定时间点恢复。
10. 实施顺序与参考资料
建议实施顺序
- 基础设施:统一时间、主机名、网络策略、证书、目录、账号、磁盘与资源限额,验证 VIP 漂移。
- 数据库:按附录 B 在三台以 Docker 各启动一个 MySQL 5.7.38 实例,自动建立 MGR 单主组并验证选主顺序;部署 9201/9202 健康检查代理。保留原数据库回退能力,切换时避免双边继续写入。
- Redis:按附录 C 以 Docker 建立三个分片主与明确配对的三个副本,验证全部槽位和单主机故障。
- 应用:数据源指向本机 13306、接入 Redis Cluster 客户端;完成定时任务与文件存储改造。
- 接入:同步 Vue 产物和 Nginx 配置,接入三台 HAProxy,配置 Keepalived。
- 验收与切流:完成第 9 节演练后再迁移流量;切换后监控 MGR 成员状态、应用队列、切主事件、Redis 内存及 JVM 指标。
- (可选)按需启用 ProxySQL 读写分离或第 5 节登录会话。
最终决策建议
MGR 单主一主两从是单机房三服务器场景下兼顾一致性、自动故障转移和运维复杂度的常规方案,本文作为推荐实现。需要注意 MySQL 5.7 社区版已于 2023 年 10 月停止维护,自研健康检查代理与手工建组恢复也增加了运维负担。建议将升级到 MySQL 8.4 LTS 纳入规划,届时可直接采用附录 A 的标准 InnoDB Cluster,由 MySQL Shell 与 MySQL Router 取代手工流程和健康检查代理。
已确认信息:现网 MySQL 版本为 5.7.38。待补齐的信息:三台服务器 CPU/内存/磁盘与网络;MySQL 数据量及写入 QPS;Spring Boot、Redis 准确版本;是否已有机房外备份与上传文件存储。上述信息用于把设计参数变成最终配置,不影响本文的部署关系。
官方参考资料
- R1 MySQL 5.7:Group Replication Requirements
- R2 MySQL 5.7:Single-Primary Mode 与 Primary Election(
group_replication_member_weight) - R3 Redis:Cluster specification
- R4 Spring Boot 2.7.18:配置属性
- R5 Spring Session:Redis repository 与 Cluster 注意事项(仅第 5 节可选启用时)
- R6 Nginx:proxy 与 upstream 模块
- R7 HAProxy:健康检查(
option httpchk、on-marked-down) - R8 Keepalived:架构简介
- R9 MySQL 8.4:InnoDB Cluster 与 MySQL Router(含读写分离)
- R10 ProxySQL:Group Replication 支持
参考资料用于核验机制;示例容量、端口规划、路由规则和验收目标是本方案的工程设计。本文未连接或修改实际服务器,配置片段须经版本适配和联调后部署。
附录 A:MySQL 8.4 LTS InnoDB Cluster 方案
升级到 MySQL 8.4 LTS 后,一主两从可直接采用官方 InnoDB Cluster:底层仍是 MGR 单主模式,由 MySQL Shell(AdminAPI)负责建组、入组和恢复,由 MySQL Router 负责路由,替代正文中的 HAProxy 数据库池和自研 9201/9202 健康检查代理。[R9]
拓扑
| 组件 | 服务器 A | 服务器 B | 服务器 C |
|---|---|---|---|
| MySQL 8.4 | M1 :3306(初始 PRIMARY) | M2 :3306 | M3 :3306 |
| MySQL Router | 本机,供 App-A 使用 | 本机,供 App-B 使用 | 本机,供 App-C 使用 |
Router 默认端口:6446 读写(只到 PRIMARY)、6447 只读(轮询 SECONDARY)、6450 读写分离(按语句自动路由,事务内固定在主节点)。应用把数据源改为 127.0.0.1:6446 即与正文效果一致;改为 127.0.0.1:6450 即获得与 ProxySQL 类似的透明读写分离,同样存在写后读延迟问题。
建集群
// MySQL Shell,JS 模式
dba.configureInstance('clusteradmin@10.10.10.11:3306')
dba.configureInstance('clusteradmin@10.10.10.12:3306')
dba.configureInstance('clusteradmin@10.10.10.13:3306')
shell.connect('clusteradmin@10.10.10.11:3306')
var c = dba.createCluster('jkqCluster', {memberWeight: 70})
c.addInstance('clusteradmin@10.10.10.12:3306', {recoveryMethod: 'clone', memberWeight: 60})
c.addInstance('clusteradmin@10.10.10.13:3306', {recoveryMethod: 'clone', memberWeight: 50})
c.status()
# 每台 App 所在服务器执行一次
mysqlrouter --bootstrap clusteradmin@10.10.10.11:3306 \
--directory /srv/mysqlrouter --user=mysqlrouter --conf-bind-address=127.0.0.1
systemctl enable --now mysqlrouter
常用运维
- 查看状态:
dba.getCluster().status() - 成员故障恢复后重新入组:
c.rejoinInstance('clusteradmin@10.10.10.11:3306') - 受控切主:
c.setPrimaryInstance('clusteradmin@10.10.10.11:3306') - 全组停机后恢复:
dba.rebootClusterFromCompleteOutage()(Shell 会比较 GTID 选择起点) - 新成员或落后过多的成员使用 clone 自动全量同步,无需手工备份恢复。
5.7 → 8.4 升级注意事项
- 升级路径: 8.4 只支持从 8.0 原地升级,5.7 不能直接原地升到 8.4。可选先原地升级到 8.0 再到 8.4,或新建 8.4 集群后逻辑导出导入(
util.dumpInstance/util.loadDump)并切换。 - 预检: 使用 MySQL Shell
util.checkForServerUpgrade()检查保留字冲突、废弃语法、分区、字符集等问题。 - 认证插件: 8.4 默认
caching_sha2_password,且mysql_native_password默认不再加载。应用驱动需支持新插件,或显式启用旧插件作为过渡。 - 驱动:
mysql:mysql-connector-java替换为com.mysql:mysql-connector-j8.x,JDBC URL 中的serverTimezone、useSSL等参数按新版本复核。 - SQL 兼容: 核对
sql_mode默认值、utf8→utf8mb4默认字符集与排序规则变化、GROUP BY隐式排序移除、查询缓存移除等对现有 MyBatis SQL 的影响。 - 参数变化: 5.7 的
group_replication_ip_whitelist、master_info_repository等参数在 8.x 已更名或移除,由 AdminAPI 统一管理,不要照搬正文的 my.cnf。 - 回退: 升级前全量备份并保留 5.7 集群只读快照;逻辑迁移方式可在切流前保留原集群作为回退目标。
附录 B:MySQL 5.7.38 MGR 单主一主两从 Docker 部署手册
本附录给出三台服务器上可直接使用的全部文件。按 B.3 把文件放到对应服务器,执行 docker compose up -d 即可:M1 首次启动自动建组(bootstrap)成为 PRIMARY,M2、M3 启动后自动入组成为 SECONDARY。之后任意成员重启都会自动重新入组。
B.1 设计要点
| 项目 | 做法 | 原因 |
|---|---|---|
| 镜像 | mysql:5.7.38,三台同一镜像 | 与现网版本一致;MGR 要求成员小版本相同 |
| 网络 | network_mode: host | MGR 需以宿主机真实 IP 通告 3306/33061,bridge 映射易导致通告地址错误 |
| 首次初始化 | 官方入口的 docker-entrypoint-initdb.d 执行 01-mgr-init.sh:以 SQL_LOG_BIN=0 建复制与检查账户、配置恢复通道,最后 RESET MASTER | 保证三台初始 gtid_executed 为空,否则新成员因“本地多出事务”被拒绝入组 |
| 自动入组 | 自定义入口 entrypoint-mgr.sh 后台启动 mysqld,就绪后调用 mgr-start.sh | group_replication_start_on_boot=OFF,由脚本统一控制,避免初始化阶段的临时实例误启组复制 |
| 防止重复建组 | 只有同时满足:MGR_BOOTSTRAP_NODE=true、入组失败、数据目录无 .mgr_bootstrapped 标记、gtid_executed 为空,才执行 bootstrap | bootstrap 一生只发生一次;已有数据的成员永远不会自行新建组,杜绝脑裂 |
| 业务库与账户 | 不使用 MYSQL_DATABASE / MYSQL_USER 环境变量,建组后在 PRIMARY 上创建 | 在 PRIMARY 上创建会复制到全组,三台一致 |
三台服务器的文件差异只有两处:conf/my.cnf 中 4 个参数(server_id、report_host、group_replication_local_address、group_replication_member_weight),以及 .env 中的 MGR_BOOTSTRAP_NODE(仅 M1 为 true)。
B.2 主机准备(三台都执行)
- 操作系统时间同步(chrony),三台时差控制在 1 秒内;时区 Asia/Shanghai。
- 安装 Docker 20.10+ 与 Compose v2(
docker compose version可用)。内网无法拉取镜像时,在可联网机器执行docker pull mysql:5.7.38 && docker save -o mysql-5.7.38.tar mysql:5.7.38,拷贝后docker load -i mysql-5.7.38.tar。 - 防火墙只对三台所在网段放通 3306(业务、恢复)与 33061(MGR 组通信),示例(firewalld):
firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=10.10.10.0/24 port port=3306 protocol=tcp accept'
firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=10.10.10.0/24 port port=33061 protocol=tcp accept'
firewall-cmd --reload
- 建目录并设置属主(镜像内 mysql 用户 UID/GID 为 999,可用
docker run --rm mysql:5.7.38 id mysql核对):
mkdir -p /srv/mysql/{conf,init,scripts,data,logs}
chown -R 999:999 /srv/mysql/data /srv/mysql/logs
- 若 SELinux 为 enforcing,在 B.3 compose 各挂载项末尾加
:z(如./data:/var/lib/mysql:z)。 - 生成一次组名 UUID(
uuidgen),三台my.cnf使用同一个值;下文示例值仅供格式参考。
B.3 目录结构与文件清单
/srv/mysql/
├── docker-compose.yml # 三台相同
├── .env # 密码;仅 MGR_BOOTSTRAP_NODE 不同;权限 600
├── conf/my.cnf # 三台不同(4 个参数);权限 644
├── init/01-mgr-init.sh # 三台相同;权限 644(需被 source,不能加执行权限)
├── scripts/entrypoint-mgr.sh # 三台相同;权限 755
├── scripts/mgr-start.sh # 三台相同;权限 755
├── scripts/healthcheck.sh # 三台相同;权限 755
├── data/ # 数据目录,属主 999
└── logs/ # 错误日志与慢日志,属主 999
注意:所有 .sh 与 .cnf 文件必须为 LF 换行(在 Windows 编辑后可用 dos2unix 转换);my.cnf 不可为全局可写,否则 mysqld 会忽略它。
docker-compose.yml(三台相同)
services:
mysql:
image: mysql:5.7.38
container_name: mysql-mgr
network_mode: host
restart: unless-stopped
env_file: .env
entrypoint: ["/opt/mgr/entrypoint-mgr.sh"]
command: ["mysqld"]
volumes:
- ./conf/my.cnf:/etc/mysql/conf.d/zz-mgr.cnf:ro
- ./init:/docker-entrypoint-initdb.d:ro
- ./scripts:/opt/mgr:ro
- ./data:/var/lib/mysql
- ./logs:/var/log/mysql
ulimits:
nofile:
soft: 65535
hard: 65535
stop_grace_period: 180s
healthcheck:
test: ["CMD", "/opt/mgr/healthcheck.sh"]
interval: 10s
timeout: 5s
retries: 3
start_period: 300s
logging:
driver: json-file
options:
max-size: "100m"
max-file: "5"
stop_grace_period 给 mysqld 留足刷脏页时间,避免 docker stop 超时后被强杀。
.env(M1)
TZ=Asia/Shanghai
MYSQL_ROOT_PASSWORD=请替换为强密码
# root 仅允许容器内本机登录,不创建 root@'%'
MYSQL_ROOT_HOST=127.0.0.1
MYSQL_INITDB_SKIP_TZINFO=1
MGR_REPL_USER=mgr_repl
MGR_REPL_PASSWORD=请替换为强密码
MGR_CHECK_PASSWORD=请替换为强密码
# 仅 M1 为 true;建组完成后改为 false(见 B.4 第 4 步)
MGR_BOOTSTRAP_NODE=true
# 入组失败的重试次数,每次间隔 30 秒
MGR_JOIN_RETRY=20
M2、M3 的 .env 与 M1 完全相同,只将 MGR_BOOTSTRAP_NODE=false。三台的 MGR_REPL_PASSWORD 必须一致(新成员用它连接任意成员做恢复)。密码中不要包含 $、引号、空格与反斜杠。
conf/my.cnf(M1,10.10.10.11)
[client]
default-character-set=utf8mb4
socket=/var/run/mysqld/mysqld.sock
[mysql]
default-character-set=utf8mb4
[mysqld]
# ---------- 本机差异项(M1) ----------
server_id=101
report_host=10.10.10.11
loose-group_replication_local_address="10.10.10.11:33061"
loose-group_replication_member_weight=70
# ---------- 基础 ----------
user=mysql
port=3306
report_port=3306
bind-address=0.0.0.0
datadir=/var/lib/mysql
socket=/var/run/mysqld/mysqld.sock
pid-file=/var/run/mysqld/mysqld.pid
skip-name-resolve
default-time-zone='+08:00'
log_timestamps=SYSTEM
character-set-server=utf8mb4
collation-server=utf8mb4_general_ci
# 以下两项必须与现网 SHOW VARIABLES 结果一致,三台相同
lower_case_table_names=1
sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION
max_connections=1000
max_allowed_packet=64M
default_storage_engine=InnoDB
disabled_storage_engines="MyISAM,BLACKHOLE,FEDERATED,ARCHIVE,MEMORY"
# ---------- InnoDB ----------
innodb_buffer_pool_size=24G
innodb_buffer_pool_instances=8
innodb_log_file_size=1G
innodb_flush_method=O_DIRECT
innodb_file_per_table=1
innodb_flush_log_at_trx_commit=1
# ---------- 日志 ----------
log_error=/var/log/mysql/error.log
slow_query_log=ON
slow_query_log_file=/var/log/mysql/slow.log
long_query_time=1
# ---------- binlog 与 GTID(MGR 必需) ----------
log_bin=mysql-bin
relay_log=relay-bin
log_slave_updates=ON
binlog_format=ROW
binlog_checksum=NONE
binlog_row_image=FULL
sync_binlog=1
expire_logs_days=7
gtid_mode=ON
enforce_gtid_consistency=ON
master_info_repository=TABLE
relay_log_info_repository=TABLE
transaction_write_set_extraction=XXHASH64
binlog_transaction_dependency_tracking=WRITESET
slave_parallel_type=LOGICAL_CLOCK
slave_parallel_workers=4
slave_preserve_commit_order=ON
# ---------- Group Replication 单主 ----------
plugin_load_add=group_replication.so
loose-group_replication_group_name="3ef332c0-9318-4e52-bc03-7508bda927ab"
loose-group_replication_group_seeds="10.10.10.11:33061,10.10.10.12:33061,10.10.10.13:33061"
loose-group_replication_ip_whitelist="10.10.10.0/24,127.0.0.1/8"
loose-group_replication_single_primary_mode=ON
loose-group_replication_enforce_update_everywhere_checks=OFF
loose-group_replication_start_on_boot=OFF
loose-group_replication_bootstrap_group=OFF
loose-group_replication_exit_state_action=READ_ONLY
loose-group_replication_unreachable_majority_timeout=30
参数说明:
loose-前缀使插件尚未加载时 mysqld 不因未知参数退出,是 5.7 官方推荐写法。lower_case_table_names、sql_mode、字符集务必先在现网执行SHOW VARIABLES LIKE 'lower_case_table_names'; SELECT @@sql_mode, @@character_set_server, @@collation_server;核对后填写,示例值不能照抄。三台不一致时成员无法入组或数据行为不同。innodb_buffer_pool_size=24G按第 9 节 64 GB 内存预算设置,机器内存不同时按比例调整(一般为物理内存的 40%–60%,扣除同机 Redis 与 JVM)。expire_logs_days=7:离线超过 7 天的成员所需 binlog 可能已被清理,只能按 B.6 重建。disabled_storage_engines禁止新建非 InnoDB 表;MGR 只复制 InnoDB 事务。
conf/my.cnf(M2,10.10.10.12)
与 M1 完全相同,只替换“本机差异项”4 行:
server_id=102
report_host=10.10.10.12
loose-group_replication_local_address="10.10.10.12:33061"
loose-group_replication_member_weight=60
conf/my.cnf(M3,10.10.10.13)
与 M1 完全相同,只替换“本机差异项”4 行:
server_id=103
report_host=10.10.10.13
loose-group_replication_local_address="10.10.10.13:33061"
loose-group_replication_member_weight=50
init/01-mgr-init.sh(三台相同,仅首次初始化执行一次)
# 由官方入口在数据目录首次初始化时 source 执行(文件不能有执行权限)
# 此时 mysqld 为 --skip-networking 的临时实例,本脚本只做本地准备,不启动组复制
docker_process_sql <<-EOSQL
SET SQL_LOG_BIN=0;
CREATE USER '${MGR_REPL_USER}'@'10.10.10.%' IDENTIFIED BY '${MGR_REPL_PASSWORD}';
GRANT REPLICATION SLAVE ON *.* TO '${MGR_REPL_USER}'@'10.10.10.%';
CREATE USER 'mgr_check'@'127.0.0.1' IDENTIFIED BY '${MGR_CHECK_PASSWORD}';
GRANT SELECT ON performance_schema.* TO 'mgr_check'@'127.0.0.1';
FLUSH PRIVILEGES;
SET SQL_LOG_BIN=1;
CHANGE MASTER TO MASTER_USER='${MGR_REPL_USER}', MASTER_PASSWORD='${MGR_REPL_PASSWORD}'
FOR CHANNEL 'group_replication_recovery';
RESET MASTER;
EOSQL
heredoc 内缩进必须是 Tab(<<- 只去除 Tab)。mgr_check 账户同时供第 3 节 9201/9202 健康检查代理使用。
scripts/entrypoint-mgr.sh(三台相同)
#!/bin/bash
# 包装官方入口:前台保持 mysqld,后台执行自动入组,并把停止信号转发给 mysqld
docker-entrypoint.sh "$@" &
MYSQLD_PID=$!
term() {
kill -TERM "$MYSQLD_PID" 2>/dev/null
wait "$MYSQLD_PID"
exit $?
}
trap term TERM INT
/opt/mgr/mgr-start.sh &
wait "$MYSQLD_PID"
exit $?
scripts/mgr-start.sh(三台相同)
#!/bin/bash
# 每次容器启动时执行:已在组内则退出;否则入组;仅 M1 首次建组时 bootstrap
log() { echo "[mgr-start] $(date '+%F %T') $*"; }
export MYSQL_PWD="${MYSQL_ROOT_PASSWORD}"
sql() { mysql -uroot -h127.0.0.1 -P3306 --protocol=tcp -N -s -e "$1"; }
FLAG=/var/lib/mysql/.mgr_bootstrapped
RETRY=${MGR_JOIN_RETRY:-20}
# 初始化阶段的临时实例不监听 TCP,这里等到正式实例就绪
until sql "SELECT 1" >/dev/null 2>&1; do sleep 3; done
log "mysqld 已就绪"
member_state() {
sql "SELECT MEMBER_STATE FROM performance_schema.replication_group_members WHERE MEMBER_ID=@@server_uuid"
}
for ((i = 1; i <= RETRY; i++)); do
state=$(member_state)
if [[ "$state" == "ONLINE" || "$state" == "RECOVERING" ]]; then
log "已在组内:$state"
exit 0
fi
if sql "START GROUP_REPLICATION" 2>/tmp/mgr-start.err; then
log "入组成功,当前状态:$(member_state)"
exit 0
fi
log "第 ${i}/${RETRY} 次入组失败:$(tr '\n' ' ' </tmp/mgr-start.err)"
sql "STOP GROUP_REPLICATION" >/dev/null 2>&1
if [[ "$MGR_BOOTSTRAP_NODE" == "true" && ! -f "$FLAG" && -z "$(sql 'SELECT @@GLOBAL.gtid_executed')" ]]; then
log "首次建组:本机执行 bootstrap"
sql "SET GLOBAL group_replication_bootstrap_group=ON; START GROUP_REPLICATION;"
rc=$?
sql "SET GLOBAL group_replication_bootstrap_group=OFF"
if [[ $rc -eq 0 ]]; then
touch "$FLAG"
log "bootstrap 完成,本机为 PRIMARY"
exit 0
fi
log "bootstrap 失败,请检查 /var/log/mysql/error.log"
exit 1
fi
sleep 30
done
log "入组重试已用尽,需人工处理(见 B.6)"
exit 1
脚本行为:
- M2、M3 先于 M1 启动也没关系,它们会每 30 秒重试入组,直到 M1 完成建组(默认最多约 10 分钟)。
- M1 首次启动时先尝试入组,失败后才 bootstrap,之后写入
.mgr_bootstrapped标记。 - 已有数据(
gtid_executed非空)或已有标记的成员永远不会自行 bootstrap;全组停机后需人工恢复(见 B.6)。 - 脚本只负责入组,不做切主或强制重建组(
group_replication_force_members)。
scripts/healthcheck.sh(三台相同)
#!/bin/bash
# 仅本机成员 ONLINE 时视为健康;用于 docker ps 观察,不触发自动重启
state=$(MYSQL_PWD="$MGR_CHECK_PASSWORD" mysql -umgr_check -h127.0.0.1 -P3306 --protocol=tcp -N -s -e \
"SELECT MEMBER_STATE FROM performance_schema.replication_group_members WHERE MEMBER_ID=@@server_uuid" 2>/dev/null)
[[ "$state" == "ONLINE" ]]
B.4 部署操作步骤
以下 mysql-exec 表示在容器内以 root 执行 SQL,支持 -e "语句" 与标准输入两种用法。可在三台的 ~/.bashrc 中定义:
mysql-exec() {
docker exec -i mysql-mgr sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -t "$@"' mysql-exec "$@"
}
第 1 步:放置文件并设置权限(三台)
按 B.3 放置文件,M1/M2/M3 分别使用自己的 my.cnf 与 .env,然后:
cd /srv/mysql
chmod 600 .env
chmod 644 conf/my.cnf init/01-mgr-init.sh
chmod 755 scripts/*.sh
docker compose config -q && echo "compose 配置正确"
第 2 步:启动 M1(服务器 A)
cd /srv/mysql && docker compose up -d
docker logs -f mysql-mgr 2>&1 | grep mgr-start
首次初始化约需 1–3 分钟。看到 bootstrap 完成,本机为 PRIMARY 即建组成功(在此之前出现一次“入组失败”属于正常现象)。
第 3 步:依次启动 M2(服务器 B)、M3(服务器 C)
cd /srv/mysql && docker compose up -d
docker logs -f mysql-mgr 2>&1 | grep mgr-start
看到 入组成功 后,在任意一台检查成员状态:
mysql-exec <<'SQL'
SELECT MEMBER_HOST, MEMBER_PORT, MEMBER_STATE,
IF(MEMBER_ID = (SELECT VARIABLE_VALUE FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'group_replication_primary_member'),
'PRIMARY', 'SECONDARY') AS ROLE
FROM performance_schema.replication_group_members;
SQL
预期三行都为 ONLINE,10.10.10.11 为 PRIMARY,另外两台为 SECONDARY。在 M2、M3 上执行 SELECT @@super_read_only; 应返回 1。
第 4 步:关闭 M1 的 bootstrap 开关
把 M1 .env 中的 MGR_BOOTSTRAP_NODE 改为 false。不需要立即重启,下次重建容器时生效;在此之前,.mgr_bootstrapped 标记已能防止重复建组。
第 5 步:在 PRIMARY 上创建业务库与账户
以下只在当前 PRIMARY(M1)执行,会自动复制到 M2、M3。库名、账户与密码按现网替换;应用经本机 HAProxy 连接,MySQL 看到的来源地址是三台服务器的 IP,因此主机限定为 10.10.10.%。
mysql-exec <<'SQL'
CREATE USER 'app_rw'@'10.10.10.%' IDENTIFIED BY '请替换';
CREATE USER 'ops_ro'@'10.10.10.%' IDENTIFIED BY '请替换';
SQL
业务库由第 6 步导入的 dump 创建。导入后再授权:
GRANT SELECT, INSERT, UPDATE, DELETE ON `业务库名`.* TO 'app_rw'@'10.10.10.%';
GRANT SELECT, SHOW VIEW ON `业务库名`.* TO 'ops_ro'@'10.10.10.%';
若 dump 中视图、存储过程、触发器的 DEFINER 引用了其他账户,需在导入前先在 PRIMARY 上创建同名账户。
第 6 步:导入现网数据
先在现网库检查 MGR 兼容性,两条查询都应返回空:
-- 非 InnoDB 表
SELECT TABLE_SCHEMA, TABLE_NAME, ENGINE FROM information_schema.TABLES
WHERE TABLE_SCHEMA = '业务库名' AND TABLE_TYPE = 'BASE TABLE' AND ENGINE <> 'InnoDB';
-- 无主键表
SELECT t.TABLE_SCHEMA, t.TABLE_NAME FROM information_schema.TABLES t
LEFT JOIN information_schema.TABLE_CONSTRAINTS c
ON c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME AND c.CONSTRAINT_TYPE = 'PRIMARY KEY'
WHERE t.TABLE_SCHEMA = '业务库名' AND t.TABLE_TYPE = 'BASE TABLE' AND c.CONSTRAINT_NAME IS NULL;
有结果时先在现网改为 InnoDB 或补主键,否则导入这些表时会报错 3098(表不满足插件要求)。
从现网导出(--set-gtid-purged=OFF 必须加上,否则导入时会与集群已有 GTID 冲突):
mysqldump -h<现网地址> -uroot -p \
--single-transaction --routines --triggers --events \
--set-gtid-purged=OFF --default-character-set=utf8mb4 \
--databases 业务库名 > /srv/mysql/biz.sql
在 PRIMARY 导入并授权:
mysql-exec < /srv/mysql/biz.sql
然后执行第 5 步的 GRANT 语句。导入期间两个 SECONDARY 同步应用;可观察应用队列回落到 0:
SELECT MEMBER_ID, COUNT_TRANSACTIONS_IN_QUEUE FROM performance_schema.replication_group_member_stats;
第 7 步:验证数据一致与只读保护
# 三台分别执行,同一张表的校验值应一致
mysql-exec -e "CHECKSUM TABLE 业务库名.表名;"
# 在 M2 或 M3 执行,预期报错 ERROR 1290 (super-read-only)
mysql-exec -e "CREATE DATABASE should_fail;"
第 8 步:切主演练
- 在服务器 A 执行
docker stop mysql-mgr,在 B 上查询成员状态:预期 M2 成为 PRIMARY(weight 60 最高),M3 为 SECONDARY。 - 在服务器 A 执行
docker start mysql-mgr,日志出现入组成功;M1 以 SECONDARY 身份回到组内,不会抢回主节点。 - 如需让 M1 恢复为主:在低峰期停止当前 PRIMARY(M2)的容器,待 M1 当选后再启动 M2(5.7 没有
group_replication_set_as_primary(),只能通过停主触发按权重重选)。
完成以上步骤后,按第 3 节部署 9201/9202 健康检查代理,按第 7 节配置 HAProxy mysql_rw / mysql_ro,应用数据源指向本机 13306。
B.5 日常运维
| 场景 | 操作 | 说明 |
|---|---|---|
| 查看成员状态 | 执行 B.4 第 3 步的查询 | 以 group_replication_primary_member 判断主节点 |
| 单台重启或宿主机重启 | docker restart mysql-mgr,或等待 Docker 自动拉起 | 脚本自动入组;重启 PRIMARY 会触发切主,安排在低峰期 |
| 修改 my.cnf | 一次只改一台:修改后 docker compose up -d --force-recreate,确认 ONLINE 后再改下一台 | 组内参数(组名、单主模式等)三台必须一致 |
| 停止整个集群 | 依次停止两个 SECONDARY,最后停止 PRIMARY | 最后停的成员数据最完整,便于恢复 |
| 查看入组日志 | docker logs mysql-mgr 2>&1 | grep mgr-start,以及 logs/error.log | 入组失败原因以 error.log 为准 |
| binlog 空间 | expire_logs_days=7 自动清理 | 磁盘告警时不要手工删除 binlog 文件 |
B.6 故障恢复
全组停机后恢复(例如机房断电)
三台启动后都不会自动 bootstrap,会停在“入组失败重试”。按以下步骤恢复:
- 在三台分别执行
mysql-exec <<< "SELECT @@GLOBAL.gtid_executed;",记录结果。 - 选择 GTID 集合最完整(包含其他成员全部事务)的成员 X。无法判断哪台更完整时,停下来人工核对,不要猜测。
- 在 X 上执行:
mysql-exec <<'SQL'
STOP GROUP_REPLICATION;
SET GLOBAL group_replication_bootstrap_group=ON;
START GROUP_REPLICATION;
SET GLOBAL group_replication_bootstrap_group=OFF;
SQL
- 其余两台仍在重试时会自动入组;如果重试已用尽,执行
docker restart mysql-mgr。 - 按 B.4 第 3 步确认三台 ONLINE。
单个成员入组失败
| error.log 关键信息 | 原因 | 处理 |
|---|---|---|
has more executed transactions than those present in the group | 该成员有组内不存在的本地事务(曾被误写或来自旧组) | 不要设置 allow_local_disjoint_gtids_join;按下方步骤重建该成员 |
Access denied for user 'mgr_repl' | 复制账户、密码或主机限定不一致 | 核对三台 .env 与 01-mgr-init.sh;在该成员重新执行 CHANGE MASTER ... FOR CHANNEL 'group_replication_recovery' |
| recovery 无法连接到某个主机名 | report_host 未配置或配置错误 | 修正 my.cnf 中的 report_host 后重建容器 |
not in the IP whitelist | 来源地址不在白名单中 | 修正 group_replication_ip_whitelist(三台一致) |
member configuration is not compatible / 参数不一致 | lower_case_table_names、组名、单主模式等不一致 | 对齐三台 my.cnf |
binary logs ... have been purged | 离线时间超过 binlog 保留期 | 按下方步骤重建该成员 |
World-writable config file ... is ignored | my.cnf 权限过宽 | chmod 644 conf/my.cnf |
数据目录 Permission denied | 宿主目录属主不对 | chown -R 999:999 data logs |
重建单个成员(以 M3 为例,另外两台正常)
组内 binlog 仍完整(集群建立不足 7 天)时,清空数据目录后直接启动即可自动追平,只需执行下面第 1 步和第 6 步。否则按全部步骤操作:
- 在 M3:
docker compose down,将数据目录移走备份(mv data data.bak.$(date +%F)),执行mkdir data && chown 999:999 data。 - 把 M3
.env中的MGR_JOIN_RETRY临时改为0,防止导入期间脚本自动入组;然后执行docker compose up -d,等待日志出现mysqld 已就绪(init 脚本会重新创建mgr_repl、mgr_check和恢复通道)。 - 在一个 SECONDARY(如 M2)上导出业务库,保留 GTID,并拷贝到 M3:
docker exec mysql-mgr sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot \
--single-transaction --routines --triggers --events --set-gtid-purged=ON \
--databases 业务库名' > /srv/mysql/rebuild.sql
- 在 M3 清空本地 GTID 后导入。导入文件中的
SET @@GLOBAL.GTID_PURGED会把 M3 的 GTID 集合设为导出时刻的集群进度:
mysql-exec -e "RESET MASTER;"
mysql-exec < /srv/mysql/rebuild.sql
- 业务账户存放在 mysql 库,不在上述导出中。在 M3 以不写 binlog 的方式补建,语句与 B.4 第 5 步相同:
mysql-exec <<'SQL'
SET SQL_LOG_BIN=0;
CREATE USER 'app_rw'@'10.10.10.%' IDENTIFIED BY '与集群一致';
CREATE USER 'ops_ro'@'10.10.10.%' IDENTIFIED BY '与集群一致';
GRANT SELECT, INSERT, UPDATE, DELETE ON `业务库名`.* TO 'app_rw'@'10.10.10.%';
GRANT SELECT, SHOW VIEW ON `业务库名`.* TO 'ops_ro'@'10.10.10.%';
SET SQL_LOG_BIN=1;
SQL
- 把
MGR_JOIN_RETRY改回20,执行docker compose up -d --force-recreate。脚本自动入组,recovery 从 donor 的 binlog 追平导出之后的事务;按 B.4 第 3 步确认 ONLINE,并按第 7 步核对校验值。
数据量较大(数百 GB)时,mysqldump 重建耗时过长,改用 Percona XtraBackup 从 SECONDARY 做物理备份恢复后再入组。
B.7 与正文的衔接
- 本附录只覆盖 MySQL 实例与 MGR 组本身。9201/9202 健康检查代理(使用
mgr_check账户)、HAProxy13306/13307端口与应用数据源配置见第 3、6、7 节。 - 上线前仍需完成第 9 节的切主、网络分区、逐台停机与备份恢复验收。
附录 C:Redis 7.2 Cluster 三主三从 Docker 部署手册
本附录给出三台服务器上可直接使用的全部文件。按 C.3 把文件放到对应服务器,执行 bash prepare.sh && docker compose up -d 即可:每台启动一个主实例(6379)和一个副本实例(6380),m1 上的一次性容器 redis-cluster-init 等待六个节点就绪后自动创建集群,并按跨机规则配对副本。
本附录使用实际地址,对应正文的服务器 A、B、C:
| 服务器 | 地址 | 主实例 | 副本实例 |
|---|---|---|---|
| m1(A) | 172.16.1.235 | R1 :6379 | r3 :6380,复制 m3:6379 |
| m2(B) | 172.16.1.244 | R2 :6379 | r1 :6380,复制 m1:6379 |
| m3(C) | 172.16.1.239 | R3 :6379 | r2 :6380,复制 m2:6379 |
每个分片的主和副本都在不同服务器上,任意一台宕机时,它上面的主分片由另一台的副本接管。
C.1 设计要点
| 项目 | 做法 | 原因 |
|---|---|---|
| 镜像 | redis:7.2.11,三台同一镜像 | 7.2 为最后的 BSD 许可版本线;7.2.11 包含 2025 年 10 月 Lua 远程代码执行漏洞(CVE-2025-49844)修复 |
| 网络 | network_mode: host | Cluster 节点需以真实 IP 通告数据端口与总线端口,bridge 映射会导致通告地址错误 |
| 自动建集群 | m1 的 .env 设置 COMPOSE_PROFILES=init,启动一次性容器执行 cluster-init.sh | 集群只需创建一次;之后节点重启通过 nodes-*.conf 自动恢复集群关系,无需每次启动执行动作 |
| 幂等与安全 | 槽位已全部分配则跳过创建;副本已入集群则跳过;发现有节点带旧数据或已加入其他集群时中止 | 重复执行不会破坏集群,也不会覆盖已有数据 |
| 副本配对 | 先用三个 6379 建主分片,再按节点 ID 用 --cluster-slave --cluster-master-id 加入副本 | 不依赖 --cluster-replicas 1 的自动分配,保证副本与主节点分处不同服务器 |
| 密码 | 只写在 .env,compose 通过 --requirepass、--masterauth 传入 | 单一来源,三台一致 |
| 淘汰策略 | maxmemory 4gb,volatile-lru | 批次号计数器、登录状态不能被淘汰,见第 4 节 |
| 持久化 | AOF 每秒刷盘(带 RDB 前导),关闭 RDB 定时快照 | 重启后恢复数据;减少定时 fork |
三台服务器之间的差异:两个 redis-*.conf 中的 cluster-announce-ip(本机 IP),以及 .env 中的 COMPOSE_PROFILES=init(仅 m1)。
C.2 主机准备(三台都执行)
- 安装 Docker 20.10+ 与 Compose v2。内网无法拉取镜像时,在可联网机器执行
docker pull redis:7.2.11 && docker save -o redis-7.2.11.tar redis:7.2.11,拷贝后docker load -i redis-7.2.11.tar。 - 防火墙只对集群网段放通数据端口与总线端口(总线端口 = 数据端口 + 10000):
for p in 6379 6380 16379 16380; do
firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=172.16.1.0/24 port port=$p protocol=tcp accept"
done
firewall-cmd --reload
- 内核参数由
prepare.sh自动设置:vm.overcommit_memory=1(避免 AOF 重写与全量同步时 fork 失败)、net.core.somaxconn=1024、关闭透明大页(THP,避免延迟抖动与内存膨胀),并写入 sysctl 配置与 systemd 服务,重启后仍生效。容器使用 host 网络,这些参数必须在宿主机设置。
C.3 目录结构与文件清单
/srv/redis/
├── docker-compose.yml # 三台相同
├── .env # 密码与三台 IP;仅 m1 有 COMPOSE_PROFILES=init;权限 600
├── prepare.sh # 三台相同;上传后执行一次
├── conf/redis-6379.conf # 三台仅 cluster-announce-ip 不同
├── conf/redis-6380.conf # 三台仅 cluster-announce-ip 不同
├── scripts/cluster-init.sh # 三台相同;只在 m1 被执行
└── data/
├── 6379/ # 主实例数据:AOF、nodes-6379.conf
└── 6380/ # 副本实例数据:AOF、nodes-6380.conf
所有文件必须为 LF 换行;prepare.sh 会自动转换并设置权限。
docker-compose.yml(三台相同)
x-redis: &redis
image: redis:7.2.11
network_mode: host
restart: unless-stopped
environment:
TZ: Asia/Shanghai
REDISCLI_AUTH: ${REDIS_PASSWORD}
ulimits:
nofile:
soft: 65535
hard: 65535
stop_grace_period: 60s
logging:
driver: json-file
options:
max-size: "100m"
max-file: "5"
services:
redis-6379:
<<: *redis
container_name: redis-6379
command: ["redis-server", "/usr/local/etc/redis/redis.conf",
"--requirepass", "${REDIS_PASSWORD}", "--masterauth", "${REDIS_PASSWORD}"]
volumes:
- ./conf/redis-6379.conf:/usr/local/etc/redis/redis.conf:ro
- ./data/6379:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -p 6379 ping | grep -q PONG"]
interval: 10s
timeout: 3s
retries: 3
redis-6380:
<<: *redis
container_name: redis-6380
command: ["redis-server", "/usr/local/etc/redis/redis.conf",
"--requirepass", "${REDIS_PASSWORD}", "--masterauth", "${REDIS_PASSWORD}"]
volumes:
- ./conf/redis-6380.conf:/usr/local/etc/redis/redis.conf:ro
- ./data/6380:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -p 6380 ping | grep -q PONG"]
interval: 10s
timeout: 3s
retries: 3
# 一次性建集群容器:仅当 .env 中 COMPOSE_PROFILES=init(m1)时启动,执行完即退出
redis-cluster-init:
image: redis:7.2.11
container_name: redis-cluster-init
profiles: ["init"]
network_mode: host
restart: "no"
env_file: .env
environment:
REDISCLI_AUTH: ${REDIS_PASSWORD}
entrypoint: ["/bin/bash", "/opt/redis/cluster-init.sh"]
volumes:
- ./scripts:/opt/redis:ro
depends_on:
- redis-6379
- redis-6380
REDISCLI_AUTH 让容器内的 redis-cli 自动认证,健康检查与运维命令无需在命令行写密码。
.env(m1)
REDIS_PASSWORD=请替换为强密码
REDIS_M1_IP=172.16.1.235
REDIS_M2_IP=172.16.1.244
REDIS_M3_IP=172.16.1.239
# 仅 m1 设置:启动时附带一次性建集群容器 redis-cluster-init
COMPOSE_PROFILES=init
m2、m3 的 .env 与 m1 相同,只删除 COMPOSE_PROFILES=init 这一行。三台的 REDIS_PASSWORD 必须一致(副本用它向主节点认证);密码只使用字母和数字。
conf/redis-6379.conf(m1)
# ---------- 本机差异项(m1) ----------
cluster-announce-ip 172.16.1.235
# ---------- 网络 ----------
port 6379
bind 0.0.0.0
protected-mode no
tcp-backlog 511
tcp-keepalive 300
timeout 0
maxclients 10000
# ---------- 集群 ----------
cluster-enabled yes
cluster-config-file nodes-6379.conf
cluster-node-timeout 5000
cluster-announce-port 6379
cluster-announce-bus-port 16379
cluster-require-full-coverage yes
cluster-replica-validity-factor 10
cluster-allow-reads-when-down no
# ---------- 内存 ----------
maxmemory 4gb
maxmemory-policy volatile-lru
# ---------- 持久化 ----------
dir /data
save ""
appendonly yes
appendfilename "appendonly.aof"
appenddirname "appendonlydir"
appendfsync everysec
aof-use-rdb-preamble yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 256mb
# ---------- 复制 ----------
repl-backlog-size 256mb
client-output-buffer-limit replica 1gb 256mb 60
# ---------- 日志 ----------
loglevel notice
logfile ""
参数说明:
cluster-announce-ip必须填本机网卡上的实际 IP(ip addr可见),不能填 NAT 对外地址。protected-mode no:已设置密码并由防火墙限定来源;保持yes在 host 网络下也可工作,但排障时容易误判。cluster-require-full-coverage yes:有槽位无人负责(某分片主和副本同时故障)时整个集群停止服务,避免部分数据可读写、部分报错。repl-backlog-size 256mb:副本短暂断开后可以增量同步,避免每次都全量复制。maxmemory 4gb只是数据上限,须另外预留复制缓冲、AOF 重写与碎片内存;机器内存不同时按第 9 节预算调整。logfile ""输出到标准输出,用docker logs redis-6379查看。
conf/redis-6380.conf(m1)
与 redis-6379.conf 相同,只替换以下 4 行:
port 6380
cluster-config-file nodes-6380.conf
cluster-announce-port 6380
cluster-announce-bus-port 16380
m2、m3 的配置文件
两个 .conf 与 m1 相同,只修改 cluster-announce-ip:m2 为 172.16.1.244,m3 为 172.16.1.239。
scripts/cluster-init.sh(三台相同,只在 m1 执行)
#!/bin/bash
# 一次性创建 Redis Cluster:3 主 3 从、跨机配对;幂等,可重复执行
log() { echo "[cluster-init] $(date '+%F %T') $*"; }
AUTH=(-a "$REDIS_PASSWORD" --no-auth-warning)
M1=$REDIS_M1_IP; M2=$REDIS_M2_IP; M3=$REDIS_M3_IP
MASTERS=("$M1:6379" "$M2:6379" "$M3:6379")
# 副本 主节点:副本与主节点分处不同服务器
PAIRS=("$M2:6380 $M1:6379" "$M3:6380 $M2:6379" "$M1:6380 $M3:6379")
ALL=("${MASTERS[@]}" "$M1:6380" "$M2:6380" "$M3:6380")
TIMEOUT=${CLUSTER_INIT_TIMEOUT:-1800}
cli() { local hp=$1; shift; redis-cli "${AUTH[@]}" -h "${hp%:*}" -p "${hp#*:}" "$@" | tr -d '\r'; }
cluster_field() { cli "$1" cluster info | awk -F: -v k="$2" '$1 == k { print $2 }'; }
# 1. 等待六个节点可连接
start=$(date +%s)
for n in "${ALL[@]}"; do
until [[ "$(cli "$n" ping 2>/dev/null)" == "PONG" ]]; do
if (( $(date +%s) - start > TIMEOUT )); then log "等待 $n 超时,请检查该节点与防火墙"; exit 1; fi
sleep 5
done
log "$n 已就绪"
done
# 2. 创建三个主分片
if [[ "$(cluster_field "${MASTERS[0]}" cluster_slots_assigned)" == "16384" ]]; then
log "槽位已全部分配,跳过创建主分片"
else
for n in "${ALL[@]}"; do
known=$(cluster_field "$n" cluster_known_nodes)
keys=$(cli "$n" dbsize)
if [[ "$known" != "1" || "$keys" != "0" ]]; then
log "$n 不是空节点(cluster_known_nodes=$known,dbsize=$keys),中止,需人工检查"
exit 1
fi
done
log "创建主分片:${MASTERS[*]}"
redis-cli "${AUTH[@]}" --cluster create "${MASTERS[@]}" --cluster-replicas 0 --cluster-yes || { log "创建失败"; exit 1; }
sleep 5
fi
# 3. 按固定配对加入副本
for p in "${PAIRS[@]}"; do
read -r replica master <<<"$p"
if [[ "$(cluster_field "$replica" cluster_known_nodes)" != "1" ]]; then
log "$replica 已在集群中,跳过"
continue
fi
master_id=$(cli "$master" cluster myid)
log "加入副本 $replica -> $master($master_id)"
redis-cli "${AUTH[@]}" --cluster add-node "$replica" "${MASTERS[0]}" \
--cluster-slave --cluster-master-id "$master_id" || { log "加入 $replica 失败"; exit 1; }
sleep 3
done
# 4. 等待副本同步完成并校验配对
for p in "${PAIRS[@]}"; do
read -r replica master <<<"$p"
for ((i = 0; i < 60; i++)); do
info=$(cli "$replica" info replication)
host=$(awk -F: '$1 == "master_host" { print $2 }' <<<"$info")
link=$(awk -F: '$1 == "master_link_status" { print $2 }' <<<"$info")
[[ "$host" == "${master%:*}" && "$link" == "up" ]] && break
sleep 5
done
if [[ "$link" != "up" ]]; then log "$replica 未与 $master 完成同步"; exit 1; fi
log "$replica 已复制 $master"
done
redis-cli "${AUTH[@]}" --cluster check "${MASTERS[0]}"
log "集群创建完成,cluster_state=$(cluster_field "${MASTERS[0]}" cluster_state)"
prepare.sh(三台相同)
#!/bin/bash
# 上传到服务器后以 root 执行一次:bash prepare.sh
set -e
cd "$(dirname "$0")"
sed -i 's/\r$//' docker-compose.yml .env conf/*.conf scripts/*.sh prepare.sh
chmod 600 .env
chmod 644 docker-compose.yml conf/*.conf
chmod 755 scripts/*.sh prepare.sh
mkdir -p data/6379 data/6380
chown -R 999:999 data
cat > /etc/sysctl.d/99-redis.conf <<'EOF'
vm.overcommit_memory = 1
net.core.somaxconn = 1024
EOF
sysctl -p /etc/sysctl.d/99-redis.conf
cat > /etc/systemd/system/disable-thp.service <<'EOF'
[Unit]
Description=Disable Transparent Huge Pages for Redis
Before=docker.service
[Service]
Type=oneshot
ExecStart=/bin/sh -c 'echo never > /sys/kernel/mm/transparent_hugepage/enabled; echo never > /sys/kernel/mm/transparent_hugepage/defrag'
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now disable-thp.service
docker compose config -q
echo "准备完成,执行 docker compose up -d 启动"
C.4 部署操作步骤
以下 rcli 表示在容器内执行 redis-cli(容器已通过 REDISCLI_AUTH 自动认证),可在三台的 ~/.bashrc 中定义:
rcli() { docker exec -i redis-6379 redis-cli "$@"; }
第 1 步:放置文件并执行准备脚本(三台)
把 m1、m2、m3 对应的目录分别上传到三台的 /srv/redis,然后:
cd /srv/redis && bash prepare.sh
第 2 步:启动 m2、m3
cd /srv/redis && docker compose up -d
docker compose ps # redis-6379、redis-6380 为 running (healthy)
第 3 步:启动 m1 并观察自动建集群
cd /srv/redis && docker compose up -d
docker logs -f redis-cluster-init
启动顺序不强制:m1 先启动时,redis-cluster-init 会等待 m2、m3 就绪(最长 30 分钟)。看到 集群创建完成,cluster_state=ok 后,该容器退出(状态 Exited (0) 属正常)。
第 4 步:核验集群
rcli cluster info | grep -E 'cluster_state|cluster_slots_assigned|cluster_known_nodes|cluster_size'
rcli cluster nodes
预期:cluster_state:ok、cluster_slots_assigned:16384、cluster_known_nodes:6、cluster_size:3。cluster nodes 中三个 master 均为 6379 端口;每个 6380 slave 后面的主节点 ID 对应另一台服务器的 6379,与 C 节开头的配对表一致。
读写验证(-c 表示自动跟随 MOVED 重定向):
rcli -c set ha:test hello EX 60
rcli -c get ha:test
第 5 步:故障切换演练
- 在 m1 执行
docker stop redis-6379。约 5–10 秒后在 m2 执行rcli cluster nodes:原 m1:6379 标记为master,fail,m2:6380 提升为master,cluster_state仍为 ok。 - 在 m1 执行
docker start redis-6379:它自动以slave身份重新加入,复制 m2:6380。 - 恢复原配对:在 m1 执行
docker exec redis-6379 redis-cli cluster failover,m1:6379 重新成为主节点,m2:6380 回到副本。 - 再模拟整机故障:在 m1 执行
docker compose stop,确认集群仍为 ok(m1 的主分片由 m2:6380 接管);然后docker compose start,按第 3 条恢复原配对。
cluster failover 必须在副本上执行,是无数据丢失的受控切换。
C.5 应用配置改造
应用当前按单机方式连接 Redis(spring.redis.host/port/database),切换到 Cluster 后改为:
spring:
redis:
password: ${REDIS_PASSWORD}
timeout: 2s
connect-timeout: 2s
cluster:
nodes: 172.16.1.235:6379,172.16.1.244:6379,172.16.1.239:6379,172.16.1.235:6380,172.16.1.244:6380,172.16.1.239:6380
max-redirects: 3
lettuce:
cluster:
refresh:
adaptive: true
period: 30s
改造注意事项:
- 删除
spring.redis.host、port、database;Cluster 只有 DB 0。 - 必须开启 Lettuce 拓扑刷新(
adaptive与period),否则主从切换后客户端仍连旧主,持续报错直到应用重启。 - 当前代码中的 Lua 脚本(
EpaasTransactionService)只操作单个 key,兼容 Cluster;新增的多 key 操作(MGET、事务、多 key Lua)须保证 key 在同一槽位,可用 hash tag,如{batch}:seq。 - 不要使用
KEYS;需要遍历时对每个主节点分别SCAN。
C.6 日常运维与故障恢复
| 场景 | 操作 | 说明 |
|---|---|---|
| 查看集群状态 | rcli cluster info、rcli cluster nodes、rcli --cluster check 172.16.1.235:6379 | --cluster check 同时检查槽位覆盖与主从关系 |
| 单个实例或整机重启 | docker restart redis-6379,或等待 Docker 自动拉起 | 节点凭 nodes-*.conf 自动回到集群;原主节点回来后为副本 |
| 恢复原主从配对 | 在应为主节点的实例上执行 docker exec <容器> redis-cli cluster failover | 每次故障恢复后执行,保持“主节点分布在三台”的布局 |
| 修改配置 | 一次改一台:修改 .conf 后 docker compose up -d --force-recreate <服务>,确认 cluster_state:ok 后再改下一台 | 先改副本,再对主节点做 failover 后修改 |
| 修改密码 | 三台 .env 同时修改后逐台重建容器;过渡期可用 CONFIG SET requirepass/masterauth 在线修改 | 应用侧同步更新 REDIS_PASSWORD |
| 内存告警 | 查看 rcli info memory 的 used_memory 与 maxmemory | volatile-lru 下无可淘汰 key 时写入报 OOM,需扩容或清理 |
重建单个节点(数据损坏或更换磁盘)
以 m3:6380 为例,其他节点正常:
- 在任一健康节点查出它的节点 ID:
rcli cluster nodes | grep 172.16.1.239:6380。 - 在 m3 执行
docker compose stop redis-6380,清空data/6380/(含nodes-6380.conf)。 - 让其余五个节点忘记旧节点。
cluster forget只在 60 秒内有效,需一次性对所有节点执行完,可在 m1 用循环完成:
OLD_ID=<旧节点ID>
for n in 172.16.1.235:6379 172.16.1.244:6379 172.16.1.239:6379 172.16.1.235:6380 172.16.1.244:6380; do
docker exec redis-6379 redis-cli -h "${n%:*}" -p "${n#*:}" cluster forget "$OLD_ID"
done
- 在 m3 执行
docker compose start redis-6380,然后在 m1 执行docker compose up -d redis-cluster-init重新运行建集群脚本。脚本会跳过已完成的步骤,只把这个空节点按配对重新加入为副本。
如果要重建的是主节点,先在它的副本上执行 cluster failover,让它变成副本后再按上述步骤处理。
两台服务器同时故障
某个分片的主和副本同时不可用时,由于 cluster-require-full-coverage yes,整个集群停止服务。恢复任意一台后集群自动恢复,不要使用 cluster failover takeover 或 --cluster fix 强行接管,以免数据不一致。
C.7 与正文的衔接
- 本附录覆盖 Redis 实例与集群本身;应用配置见 C.5 与第 6 节,缓存使用约定与淘汰策略见第 4 节。
- Redis 不经过 HAProxy,也不依赖 VIP,应用通过 Lettuce 直连六个节点。
- 上线前仍需完成第 9 节的逐台停机演练,确认应用在切换期间的错误率与恢复时间。
附录 D:HAProxy 与 MGR 健康检查代理 Docker 部署手册
本附录给出三台服务器上可直接使用的 HAProxy 配置、MySQL MGR 健康检查代理(9201/9202)与安装脚本。三台的文件完全相同,上传到 /srv/haproxy 后执行 bash install.sh 即可。
| 服务器 | 地址 |
|---|---|
| m1(A) | 172.16.1.235 |
| m2(B) | 172.16.1.244 |
| m3(C) | 172.16.1.239 |
D.1 设计要点
每台服务器运行两个容器,均使用 host 网络:
| 容器 | 镜像 | 职责 |
|---|---|---|
haproxy | haproxy:2.8(LTS) | 应用分发、MySQL 写/只读端口转发、存活检测、统计页 |
mgr-health | mysql:5.7.38(复用 MySQL 已导入的镜像) | 运行 mgr-health.py,在 9201/9202 以 HTTP 返回本机 MGR 成员是否可写/可读 |
健康检查代理复用 mysql:5.7.38 镜像自带的 python2 与 mysql 客户端,服务器无需导入新镜像、无需联网安装软件包。
HAProxy 端口:
| 端口 | 监听地址 | 用途 | 后端与检查 |
|---|---|---|---|
| 18080 | 127.0.0.1 | 应用分发,供本机 Nginx 使用 | 三台 :40001,轮询;TCP 端口检查 |
| 13306 | 127.0.0.1 | MySQL 写入,供本机应用使用 | 三台 :3306,只有通过 9201 检查的当前 PRIMARY 接收流量 |
| 13307 | 127.0.0.1 | MySQL 只读(报表、运维查询) | 三台 :3306,通过 9202 检查的 SECONDARY,最少连接分配 |
| 18404 | 127.0.0.1 | HAProxy 自身存活,供 Keepalived 检查脚本 | 固定返回 200 |
| 18405 | 0.0.0.0 | 统计页,账号密码登录 | 查看各后端 UP/DOWN 状态 |
健康检查代理判定规则(每次 HAProxy 检查时实时查询本机 MySQL):
| 端口 | 返回 200 的条件 | 否则 |
|---|---|---|
| 9201(写) | 本机 MEMBER_STATE=ONLINE;本机是 group_replication_primary_member;super_read_only=0;本机视图中 ONLINE 成员 ≥ 2 | 503,响应正文为原因 |
| 9202(只读) | 本机 ONLINE;本机不是 PRIMARY;ONLINE 成员 ≥ 2;待应用事务积压 ≤ MGR_RO_MAX_QUEUE(默认 100) | 503 |
查询超过 3 秒按失败处理,避免 MySQL 卡住时被误判为健康。“ONLINE 成员 ≥ 2”保证处于少数派分区的成员不会被放流。
关键设计:
mysql_rw使用balance first与on-marked-down shutdown-sessions:切主时旧主被标记 DOWN,存量连接立即断开,连接池重建后连到新主;即使检查瞬间两台同时通过,写流量也只进第一台。- 应用池
timeout server 300s:AI 流式输出(SSE)在两次数据块之间可能间隔较长,避免被 HAProxy 提前截断。 - 应用经 HAProxy 连接 MySQL 时,MySQL 看到的来源地址是 HAProxy 所在服务器的 IP(172.16.1.x),因此业务账户主机应限定为
'172.16.1.%'。
D.2 主机准备(三台都执行)
- 完成附录 B(MySQL MGR)部署,确认
mgr_check账户存在(由01-mgr-init.sh创建)。代理的MGR_CHECK_PASSWORD必须与 MySQL.env中的值一致。 - 镜像:
mysql:5.7.38已随附录 B 导入;另需haproxy:2.8。内网环境在可联网机器执行docker pull haproxy:2.8 && docker save -o haproxy-2.8.tar haproxy:2.8,拷贝后docker load -i haproxy-2.8.tar。 - 防火墙:9201/9202 只允许三台服务器访问(其他服务器的 HAProxy 要检查本机);18405 只对运维网段开放。18080、13306、13307、18404 只监听 127.0.0.1,无需放通。
for p in 9201 9202; do
firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=172.16.1.0/24 port port=$p protocol=tcp accept"
done
firewall-cmd --permanent --add-rich-rule='rule family=ipv4 source address=<运维网段> port port=18405 protocol=tcp accept'
firewall-cmd --reload
- 宿主机需有
curl(安装脚本自检使用)。
D.3 目录结构与文件清单
/srv/haproxy/ # 三台完全相同
├── docker-compose.yml
├── .env # 检查账户密码、统计页账号;权限 600
├── install.sh # 安装脚本:校验、启动、自检
├── haproxy/haproxy.cfg
└── agent/mgr-health.py # 9201/9202 健康检查代理
docker-compose.yml
x-logging: &logging
driver: json-file
options:
max-size: "100m"
max-file: "5"
services:
haproxy:
image: haproxy:2.8
container_name: haproxy
network_mode: host
restart: unless-stopped
env_file: .env
volumes:
- ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
ulimits:
nofile:
soft: 65535
hard: 65535
logging: *logging
mgr-health:
image: mysql:5.7.38
container_name: mgr-health
network_mode: host
restart: unless-stopped
env_file: .env
entrypoint: ["python2", "-u", "/opt/mgr-health/mgr-health.py"]
volumes:
- ./agent:/opt/mgr-health:ro
logging: *logging
.env
# 必须与 MySQL .env 中的 MGR_CHECK_PASSWORD 一致
MGR_CHECK_PASSWORD=请替换
# HAProxy 统计页账号
STATS_USER=admin
STATS_PASSWORD=请替换为强密码
# 只读端口允许的最大待应用事务积压
MGR_RO_MAX_QUEUE=100
haproxy/haproxy.cfg
global
log stdout format raw local0 info
maxconn 20000
defaults
log global
mode tcp
option dontlognull
timeout connect 3s
timeout client 30m
timeout server 30m
timeout check 3s
retries 2
# Keepalived 检查 HAProxy 自身是否存活,不依赖任何后端
frontend haproxy_alive
bind 127.0.0.1:18404
mode http
monitor-uri /alive
listen stats
bind *:18405
mode http
stats enable
stats uri /stats
stats refresh 5s
stats auth "${STATS_USER}:${STATS_PASSWORD}"
listen app_pool
bind 127.0.0.1:18080
mode http
option httplog
timeout http-request 10s
timeout client 300s
timeout server 300s
balance roundrobin
option redispatch
default-server inter 2s fall 3 rise 2
# 当前为 TCP 端口检查;应用引入 actuator 后取消以下两行注释,切换为 HTTP 就绪检查
# option httpchk GET /actuator/health/readiness
# http-check expect status 200
server app1 172.16.1.235:40001 check
server app2 172.16.1.244:40001 check
server app3 172.16.1.239:40001 check
listen mysql_rw
bind 127.0.0.1:13306
mode tcp
balance first
option tcpka
option httpchk GET /
http-check expect status 200
default-server inter 2s fall 2 rise 3 on-marked-down shutdown-sessions
server m1 172.16.1.235:3306 check port 9201
server m2 172.16.1.244:3306 check port 9201
server m3 172.16.1.239:3306 check port 9201
listen mysql_ro
bind 127.0.0.1:13307
mode tcp
balance leastconn
option tcpka
option httpchk GET /
http-check expect status 200
default-server inter 2s fall 2 rise 3 on-marked-down shutdown-sessions
server m1 172.16.1.235:3306 check port 9202
server m2 172.16.1.244:3306 check port 9202
server m3 172.16.1.239:3306 check port 9202
说明:
stats auth中的${STATS_USER}、${STATS_PASSWORD}由 HAProxy 从容器环境变量读取(来自.env)。inter 2s fall 2:PRIMARY 故障后约 4 秒被摘除;新主当选并连续 3 次(约 6 秒)通过 9201 检查后接管。MGR 选主本身另需数秒,写入中断总计约 10–30 秒,与第 2 节一致。timeout client/server 30m适用于 MySQL 连接,应用连接池的空闲回收与最大存活时间须小于 30 分钟,否则会拿到已被 HAProxy 断开的连接。- 应用池
option redispatch与retries 2只在连接后端失败时换节点重试,不会重复发送已送达的请求。
agent/mgr-health.py
# -*- coding: utf-8 -*-
# MGR 健康检查代理:9201 判断本机是否为可写 PRIMARY,9202 判断本机是否为可读 SECONDARY
# 运行于 mysql:5.7.38 镜像(python2 + mysql 客户端),供 HAProxy option httpchk 调用
import os
import signal
import subprocess
import sys
import threading
import time
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
from SocketServer import ThreadingMixIn
MYSQL_HOST = os.environ.get('MGR_MYSQL_HOST', '127.0.0.1')
MYSQL_PORT = os.environ.get('MGR_MYSQL_PORT', '3306')
CHECK_USER = os.environ.get('MGR_CHECK_USER', 'mgr_check')
CHECK_PASSWORD = os.environ['MGR_CHECK_PASSWORD']
RW_PORT = int(os.environ.get('MGR_RW_PORT', '9201'))
RO_PORT = int(os.environ.get('MGR_RO_PORT', '9202'))
RO_MAX_QUEUE = int(os.environ.get('MGR_RO_MAX_QUEUE', '100'))
QUERY_TIMEOUT = float(os.environ.get('MGR_QUERY_TIMEOUT', '3'))
SQL = (
"SELECT m.MEMBER_STATE,"
" IF((SELECT VARIABLE_VALUE FROM performance_schema.global_status"
" WHERE VARIABLE_NAME = 'group_replication_primary_member') = @@server_uuid, 1, 0),"
" @@super_read_only,"
" (SELECT COUNT(*) FROM performance_schema.replication_group_members WHERE MEMBER_STATE = 'ONLINE'),"
" IFNULL((SELECT COUNT_TRANSACTIONS_IN_QUEUE FROM performance_schema.replication_group_member_stats"
" WHERE MEMBER_ID = @@server_uuid), 0)"
" FROM performance_schema.replication_group_members m WHERE m.MEMBER_ID = @@server_uuid"
)
last_result = {}
last_lock = threading.Lock()
def log(msg):
sys.stdout.write('[mgr-health] %s %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), msg))
def query_state():
env = dict(os.environ, MYSQL_PWD=CHECK_PASSWORD)
cmd = ['mysql', '-h', MYSQL_HOST, '-P', MYSQL_PORT, '-u', CHECK_USER, '--protocol=tcp',
'--connect-timeout=2', '-N', '-B', '-e', SQL]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
timer = threading.Timer(QUERY_TIMEOUT, proc.kill)
timer.start()
try:
out, err = proc.communicate()
finally:
timer.cancel()
if proc.returncode != 0:
return None, 'mysql query failed: %s' % (err.strip().replace('\n', ' ') or 'timeout')
fields = out.strip().split('\t')
if len(fields) != 5:
return None, 'member not in group view'
state, is_primary, sro, online, queue = fields
return {'state': state, 'primary': is_primary == '1', 'sro': sro == '1',
'online': int(online), 'queue': int(queue)}, None
def check_rw(s):
if s['state'] != 'ONLINE':
return False, 'state=%s' % s['state']
if not s['primary']:
return False, 'not primary'
if s['sro']:
return False, 'super_read_only=ON'
if s['online'] < 2:
return False, 'online members=%d' % s['online']
return True, 'primary ok'
def check_ro(s):
if s['state'] != 'ONLINE':
return False, 'state=%s' % s['state']
if s['primary']:
return False, 'is primary'
if s['online'] < 2:
return False, 'online members=%d' % s['online']
if s['queue'] > RO_MAX_QUEUE:
return False, 'applier queue=%d' % s['queue']
return True, 'secondary ok, queue=%d' % s['queue']
def make_handler(port, checker):
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
state, err = query_state()
ok, reason = (False, err) if state is None else checker(state)
with last_lock:
if last_result.get(port) != ok:
last_result[port] = ok
log('port %d -> %s (%s)' % (port, 'UP' if ok else 'DOWN', reason))
body = reason + '\n'
self.send_response(200 if ok else 503)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', str(len(body)))
self.send_header('Connection', 'close')
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
pass
return Handler
class Server(ThreadingMixIn, HTTPServer):
daemon_threads = True
allow_reuse_address = True
def serve(port, checker):
Server(('0.0.0.0', port), make_handler(port, checker)).serve_forever()
if __name__ == '__main__':
signal.signal(signal.SIGTERM, lambda *args: sys.exit(0))
rw = threading.Thread(target=serve, args=(RW_PORT, check_rw))
rw.daemon = True
rw.start()
log('listening rw=%d ro=%d, mysql=%s:%s' % (RW_PORT, RO_PORT, MYSQL_HOST, MYSQL_PORT))
serve(RO_PORT, check_ro)
代理只在检查结果变化(UP/DOWN)时输出日志,docker logs mgr-health 可查看切换时间线。
install.sh
#!/bin/bash
# 上传到服务器后以 root 执行:bash install.sh(可重复执行,用于更新配置后重建)
set -e
cd "$(dirname "$0")"
sed -i 's/\r$//' docker-compose.yml .env haproxy/haproxy.cfg agent/mgr-health.py install.sh
chmod 600 .env
chmod 644 docker-compose.yml haproxy/haproxy.cfg agent/mgr-health.py
chmod 755 install.sh
command -v docker >/dev/null || { echo "未安装 Docker"; exit 1; }
docker compose version >/dev/null 2>&1 || { echo "未安装 Docker Compose v2"; exit 1; }
for img in haproxy:2.8 mysql:5.7.38; do
docker image inspect "$img" >/dev/null 2>&1 || docker pull "$img" \
|| { echo "镜像 $img 不存在且无法拉取,请离线导入"; exit 1; }
done
docker compose config -q
set -a; . ./.env; set +a
docker run --rm -e STATS_USER -e STATS_PASSWORD \
-v "$PWD/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro" \
haproxy:2.8 haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg
docker compose up -d --force-recreate
sleep 5
echo "== 自检 =="
for p in 9201 9202; do
printf '%s: ' "$p"
curl -s -m 5 -w ' (HTTP %{http_code})\n' "http://127.0.0.1:$p/" || echo "无响应"
done
printf 'alive: '; curl -s -m 3 -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1:18404/alive || echo "无响应"
docker compose ps
D.4 部署操作步骤
第 1 步:上传并安装(三台)
# 把 HAProxy 目录上传为 /srv/haproxy 后
cd /srv/haproxy && bash install.sh
预期自检结果:当前 PRIMARY 所在服务器 9201 返回 primary ok (HTTP 200)、9202 返回 is primary (HTTP 503);另外两台相反。alive 返回 HTTP 200。
第 2 步:核对三台的健康检查结果
在任意一台执行,确认只有一台 9201 返回 200、两台 9202 返回 200:
for ip in 172.16.1.235 172.16.1.244 172.16.1.239; do
echo "$ip rw=$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://$ip:9201/) ro=$(curl -s -m 5 -o /dev/null -w '%{http_code}' http://$ip:9202/)"
done
第 3 步:通过 HAProxy 端口验证路由
mgr-health 容器自带 mysql 客户端且使用 host 网络,可直接用来测试本机 13306/13307(账户按附录 B 第 5 步创建,主机网段为 172.16.1.%):
# 写端口:应返回当前 PRIMARY 的 server_id(初始为 101),super_read_only=0
docker exec -it mgr-health mysql -h127.0.0.1 -P13306 -uapp_rw -p -e "SELECT @@server_id, @@super_read_only;"
# 只读端口:多次执行应在 102、103 之间分配,super_read_only=1
docker exec -it mgr-health mysql -h127.0.0.1 -P13307 -uops_ro -p -e "SELECT @@server_id, @@super_read_only;"
第 4 步:查看统计页
浏览器打开 http://172.16.1.235:18405/stats,使用 .env 中的统计页账号登录。预期 mysql_rw 只有一行 UP(当前 PRIMARY),mysql_ro 两行 UP,app_pool 中已启动的应用为 UP。
第 5 步:切主演练
- 在当前 PRIMARY 所在服务器执行
docker stop mysql-mgr。 - 在其他服务器循环执行第 3 步的写端口查询,约 10–30 秒后返回新主的
server_id;docker logs mgr-health可看到port 9201 -> UP的时间点。 - 执行
docker start mysql-mgr,原主以 SECONDARY 回到组内,9202 变为 UP,9201 保持 DOWN。
第 6 步:应用接入
应用数据源改为本机 HAProxy 写端口,Nginx 的 /api 转发到本机 18080(见第 6、7 节):
MYSQL_HOST=127.0.0.1
MYSQL_PORT=13306
D.5 日常运维
| 场景 | 操作 | 说明 |
|---|---|---|
| 修改配置 | 编辑 haproxy.cfg 后执行 docker exec haproxy haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg,通过后 docker kill -s HUP haproxy | HUP 触发平滑重载,已有连接不中断;三台逐台修改 |
修改 .env 或代理脚本 | 重新执行 bash install.sh | 会重建两个容器,HAProxy 重启期间本机应用的数据库连接会断开重连 |
| 查看路由状态 | 统计页 18405,或 docker logs mgr-health | 代理日志只记录 UP/DOWN 变化 |
| 应用发布摘流 | 停止该台应用,HAProxy 约 6 秒(inter 2s fall 3)后摘除 | 引入 actuator 后改用就绪检查,可在请求失败前摘除 |
| 切换为 actuator 就绪检查 | 应用引入 spring-boot-starter-actuator 并开启 readiness 探针后,取消 app_pool 中两行注释,按“修改配置”重载 | 需确认 /actuator/health/readiness 无需登录即可访问 |
| 查看 HAProxy 日志 | docker logs haproxy | 应用池为 HTTP 日志,MySQL 端口为 TCP 日志 |
D.6 故障排查
| 现象 | 可能原因 | 处理 |
|---|---|---|
9201/9202 返回 mysql query failed: Access denied | MGR_CHECK_PASSWORD 与 MySQL 不一致,或 mgr_check@'127.0.0.1' 未创建 | 核对两份 .env;在该节点检查 SELECT user, host FROM mysql.user WHERE user='mgr_check' |
9201/9202 返回 member not in group view | 本机 MySQL 未加入复制组 | 按附录 B.6 排查入组 |
| 三台 9201 都是 503 | 组失去多数派或正在选主 | 查看 replication_group_members;两台故障时按第 8 节人工恢复,不要强制放流 |
| 统计页中远端服务器全部 DOWN,本机 UP | 防火墙未放通 9201/9202,或远端代理未启动 | 在本机执行 curl http://<远端IP>:9201/ 定位 |
| 应用报连接被重置,随后恢复 | 切主时 shutdown-sessions 断开旧连接 | 属预期行为;确认连接池开启连接校验与重试 |
| 应用长连接每 30 分钟报错 | 连接池空闲时间超过 HAProxy timeout 30m | 调小连接池最大存活与空闲回收时间 |
应用登录 MySQL 报 Access denied for user 'app_rw'@'172.16.1.x' | 业务账户主机网段不匹配 | 业务账户主机使用 '172.16.1.%' |
| 统计页打不开 | 18405 未对访问方放通,或账号错误 | 检查防火墙与 .env 中的 STATS_USER/STATS_PASSWORD |
D.7 与正文的衔接
- Keepalived 检查脚本调用
http://127.0.0.1:18404/alive,只判断 HAProxy 自身存活,见第 8 节。 - Nginx 的
app_entry以127.0.0.1:18080为主、三台:40001为 backup,见第 7 节(示例中的 8080 按实际端口 40001 替换)。 - 上线前仍需完成第 9 节的入口组件演练与切主演练。
附录 E:Keepalived 业务 VIP 部署手册
本附录给出三台服务器上可直接使用的 Keepalived 配置模板、检查脚本与安装脚本。三台使用同一套文件,上传到 /srv/keepalived 后执行 bash install.sh,脚本根据本机 IP 自动识别角色并生成配置。
| 服务器 | 地址 | 优先级 |
|---|---|---|
| m1(A) | 172.16.1.235 | 150 |
| m2(B) | 172.16.1.244 | 140 |
| m3(C) | 172.16.1.239 | 130 |
| 业务 VIP | 172.16.1.250/24(占位值,部署前在 .env 中改为实际地址) | — |
E.1 设计要点
| 项目 | 做法 | 原因 |
|---|---|---|
| 安装方式 | 系统软件包(dnf/yum/apt)+ systemd | Keepalived 需直接操作宿主机网卡与发送 VRRP 报文,宿主机安装最直接 |
| VRRP 通告 | 单播:unicast_src_ip + unicast_peer | 不依赖网络放行组播;VMware、云平台与部分交换机默认过滤组播,易导致三台都认为自己是 MASTER |
| 抢占 | 三台初始均为 BACKUP,nopreempt | 故障节点恢复后不抢回 VIP,避免来回切换造成二次断连 |
| 检查脚本 | 只检查本机 HAProxy 18404/alive;CHECK_NGINX=true 时再检查本机 Nginx 有响应 | 只判断入口组件自身;不检查应用或数据库,避免后端整体异常时三台同时 FAULT、VIP 无人持有 |
| 故障判定 | 检查 interval 2、fall 2;失败进入 FAULT 并释放 VIP | 组件故障到 VIP 漂移完成约 4–6 秒,与第 8 节一致 |
| ARP 刷新 | 成为 MASTER 后立即发送免费 ARP,之后每 60 秒重发 | 交换机与同网段主机及时更新 VIP 对应的 MAC 地址 |
| 角色识别 | install.sh 按本机 IP 匹配 m1/m2/m3,自动确定优先级、单播源地址、对端地址与网卡名 | 三台同一套文件,避免手工改错 |
.env 中的 CHECK_NGINX 当前为 false:Nginx 尚未部署。部署 Nginx 并确认其监听 0.0.0.0:443(不要只监听某个固定 IP,否则 VIP 漂移过去后无法访问)后,改为 true。
E.2 前置条件
- 已按附录 D 部署 HAProxy,三台
curl http://127.0.0.1:18404/alive返回 200。 - VIP 未被占用:在任一台执行
ping -c 3 <VIP>与arping -c 3 -I <网卡> <VIP>,均应无响应。 - 虚拟化或云平台允许该 VIP 在三台之间漂移:VMware 需允许 MAC 地址更改与伪传输(Forged Transmits);OpenStack 需在三台端口上配置
allowed-address-pairs;公有云一般需使用平台提供的高可用虚拟 IP(HaVip)产品,普通弹性网卡不支持 VRRP。 - 三台之间放通 VRRP(IP 协议号 112,不是 TCP/UDP 端口)。firewalld 由安装脚本自动放通;其他防火墙需手工放通。
- 服务器可通过 yum/dnf/apt 安装 keepalived;内网需配置本地软件源,或提前离线安装好 keepalived 软件包(脚本检测到已安装时跳过安装)。
- 宿主机已安装
curl。
E.3 目录结构与文件清单
/srv/keepalived/ # 三台完全相同
├── .env # VIP、VRRP 组号、认证密码、三台 IP 与优先级、Nginx 检查开关
├── install.sh # 安装脚本:识别角色、安装、生成配置、放通防火墙、启动、自检
├── keepalived.conf.tpl # 配置模板,由 install.sh 渲染为 /etc/keepalived/keepalived.conf
├── check_entry.sh # 检查脚本,安装到 /etc/keepalived/
└── notify.sh # 状态切换通知脚本,安装到 /etc/keepalived/
.env
# 业务 VIP 与掩码(占位值,部署前改为实际未占用的地址)
VIP=172.16.1.250
VIP_PREFIX=24
# VRRP 组号(1-255),同一网段内的不同 Keepalived 集群不能重复
VIRTUAL_ROUTER_ID=51
# VRRP 认证密码,最多 8 个字符,三台一致
AUTH_PASS=请替换
NODE1_IP=172.16.1.235
NODE2_IP=172.16.1.244
NODE3_IP=172.16.1.239
NODE1_PRIORITY=150
NODE2_PRIORITY=140
NODE3_PRIORITY=130
# 允许 VRRP 报文的来源网段(firewalld 规则使用)
VRRP_SOURCE_NET=172.16.1.0/24
# Nginx 部署并监听 0.0.0.0:443 后改为 true
CHECK_NGINX=false
NGINX_CHECK_URL=https://127.0.0.1/
keepalived.conf.tpl
! 由 install.sh 根据 .env 生成;修改 .env 后重新执行 install.sh
global_defs {
router_id @ROUTER_ID@
enable_script_security
script_user root
vrrp_garp_master_delay 1
vrrp_garp_master_refresh 60
}
vrrp_script chk_entry {
script "/etc/keepalived/check_entry.sh"
interval 2
timeout 3
fall 2
rise 2
}
vrrp_instance VI_APP {
state BACKUP
interface @IFACE@
virtual_router_id @VRID@
priority @PRIORITY@
nopreempt
advert_int 1
unicast_src_ip @SRC_IP@
unicast_peer {
@PEERS@
}
authentication {
auth_type PASS
auth_pass @AUTH_PASS@
}
virtual_ipaddress {
@VIP@/@VIP_PREFIX@ dev @IFACE@
}
track_script {
chk_entry
}
notify /etc/keepalived/notify.sh
}
以 m1 为例,渲染后 interface 为 172.16.1.235 所在网卡(如 ens192),priority 150,unicast_src_ip 172.16.1.235,unicast_peer 为 172.16.1.244 与 172.16.1.239。
check_entry.sh
#!/bin/bash
# 只检查本机入口组件自身是否存活,不检查应用或数据库
CHECK_NGINX=false
NGINX_CHECK_URL=https://127.0.0.1/
[ -f /etc/keepalived/check_entry.env ] && . /etc/keepalived/check_entry.env
curl -fsS -m 2 -o /dev/null http://127.0.0.1:18404/alive || exit 1
if [ "$CHECK_NGINX" = "true" ]; then
# 只要 Nginx 有 HTTP 响应即视为存活,不关心状态码
curl -ksS -m 2 -o /dev/null "$NGINX_CHECK_URL" || exit 1
fi
exit 0
install.sh 会把 .env 中的 CHECK_NGINX、NGINX_CHECK_URL 写入 /etc/keepalived/check_entry.env。直接修改该文件,下一次检查(2 秒内)即生效,无需重启 Keepalived。
notify.sh
#!/bin/bash
# Keepalived 状态切换通知:$1=类型 $2=实例名 $3=新状态 $4=优先级
msg="keepalived $2 -> $3 (priority $4)"
logger -t keepalived-notify "$msg"
echo "$(date '+%F %T') $msg" >> /var/log/keepalived-state.log
install.sh
#!/bin/bash
# 上传到服务器后以 root 执行:bash install.sh
# 三台相同,按本机 IP 自动识别 m1/m2/m3;可重复执行(已运行时热加载配置,不释放 VIP)
set -e
cd "$(dirname "$0")"
sed -i 's/\r$//' .env keepalived.conf.tpl check_entry.sh notify.sh install.sh
[ "$(id -u)" -eq 0 ] || { echo "请以 root 执行"; exit 1; }
set -a; . ./.env; set +a
[ ${#AUTH_PASS} -le 8 ] || { echo "AUTH_PASS 最多 8 个字符"; exit 1; }
# 1. 识别本机角色、网卡与对端
LOCAL_IPS=$(ip -o -4 addr show | awk '{split($4, a, "/"); print a[1]}')
ROLE=""
for n in 1 2 3; do
var="NODE${n}_IP"
grep -qx "${!var}" <<<"$LOCAL_IPS" && ROLE=$n
done
[ -n "$ROLE" ] || { echo "本机 IP 不在 NODE1_IP/NODE2_IP/NODE3_IP 中,请检查 .env"; exit 1; }
src_var="NODE${ROLE}_IP"; SRC_IP=${!src_var}
prio_var="NODE${ROLE}_PRIORITY"; PRIORITY=${!prio_var}
IFACE=$(ip -o -4 addr show | awk -v ip="$SRC_IP" '{split($4, a, "/"); if (a[1] == ip) { print $2; exit }}')
PEERS=""
for n in 1 2 3; do
[ "$n" = "$ROLE" ] && continue
var="NODE${n}_IP"
PEERS+=" ${!var}"$'\n'
done
PEERS=${PEERS%$'\n'}
echo "本机角色:m${ROLE},IP=${SRC_IP},网卡=${IFACE},优先级=${PRIORITY}"
# 2. 安装 keepalived 与 curl
if ! command -v keepalived >/dev/null; then
if command -v dnf >/dev/null; then dnf install -y keepalived
elif command -v yum >/dev/null; then yum install -y keepalived
elif command -v apt-get >/dev/null; then apt-get update && apt-get install -y keepalived
else echo "无法识别包管理器,请手工安装 keepalived"; exit 1
fi
fi
command -v curl >/dev/null || { echo "未安装 curl"; exit 1; }
keepalived --version 2>&1 | head -1
# 3. 生成配置与安装脚本
mkdir -p /etc/keepalived
conf=/etc/keepalived/keepalived.conf
[ -f "$conf" ] && cp -a "$conf" "$conf.bak.$(date +%Y%m%d%H%M%S)"
tpl=$(<keepalived.conf.tpl)
tpl=${tpl//@ROUTER_ID@/LB_m${ROLE}}
tpl=${tpl//@IFACE@/$IFACE}
tpl=${tpl//@VRID@/$VIRTUAL_ROUTER_ID}
tpl=${tpl//@PRIORITY@/$PRIORITY}
tpl=${tpl//@SRC_IP@/$SRC_IP}
tpl=${tpl//@PEERS@/$PEERS}
tpl=${tpl//@AUTH_PASS@/$AUTH_PASS}
tpl=${tpl//@VIP_PREFIX@/$VIP_PREFIX}
tpl=${tpl//@VIP@/$VIP}
printf '%s\n' "$tpl" > "$conf"
chmod 640 "$conf"
install -o root -g root -m 700 check_entry.sh notify.sh /etc/keepalived/
printf 'CHECK_NGINX=%s\nNGINX_CHECK_URL=%s\n' "$CHECK_NGINX" "$NGINX_CHECK_URL" > /etc/keepalived/check_entry.env
chmod 600 /etc/keepalived/check_entry.env
# 4. 防火墙放通 VRRP,SELinux 允许检查脚本发起连接
if systemctl is-active --quiet firewalld; then
firewall-cmd --permanent --add-rich-rule="rule family=ipv4 source address=${VRRP_SOURCE_NET} protocol value=vrrp accept" >/dev/null
firewall-cmd --reload >/dev/null
echo "firewalld 已放通 ${VRRP_SOURCE_NET} 的 VRRP"
else
echo "firewalld 未运行:如使用其他防火墙,请手工放通 IP 协议 112(VRRP)"
fi
if command -v getenforce >/dev/null && [ "$(getenforce)" = "Enforcing" ] \
&& getsebool keepalived_connect_any >/dev/null 2>&1; then
setsebool -P keepalived_connect_any 1
fi
# 5. 校验配置并启动
if keepalived --help 2>&1 | grep -q -- '--config-test'; then
keepalived --config-test -f "$conf"
fi
if /etc/keepalived/check_entry.sh; then echo "检查脚本:通过"; else echo "警告:检查脚本未通过,本机将处于 FAULT,不会持有 VIP"; fi
systemctl enable keepalived >/dev/null 2>&1
if systemctl is-active --quiet keepalived; then
systemctl reload keepalived && echo "keepalived 已热加载配置"
else
systemctl start keepalived && echo "keepalived 已启动"
fi
# 6. 自检
sleep 5
if ip -o -4 addr show dev "$IFACE" | grep -qw "$VIP"; then
echo "本机当前持有 VIP ${VIP}(MASTER)"
else
echo "本机当前未持有 VIP ${VIP}(BACKUP 或 FAULT)"
fi
tail -n 3 /var/log/keepalived-state.log 2>/dev/null || true
E.4 部署操作步骤
第 1 步:修改 .env
把 VIP 改为已确认未被占用的地址,按网络实际填写 VIP_PREFIX;确认 VIRTUAL_ROUTER_ID 在本网段内唯一(同网段有其他 Keepalived 集群时,与其组号不同)。
第 2 步:依次安装 m1、m2、m3
# 把 Keepalived 目录上传为 /srv/keepalived 后
cd /srv/keepalived && bash install.sh
先安装 m1:输出 本机角色:m1 ... 优先级=150,约 3–5 秒后 本机当前持有 VIP。再安装 m2、m3,它们应输出 本机当前未持有 VIP。
启动顺序会影响初始 MASTER:由于启用 nopreempt,先启动并通过检查的节点持有 VIP,之后启动的高优先级节点不会抢占。希望 m1 为初始 MASTER 时,按 m1 → m2 → m3 顺序安装。
第 3 步:确认只有一台持有 VIP
三台分别执行:
ip -4 addr | grep -w 172.16.1.250 || echo "未持有 VIP"
tail -n 5 /var/log/keepalived-state.log
预期只有 m1 持有,状态日志为 VI_APP -> MASTER;m2、m3 为 VI_APP -> BACKUP。如果多台同时持有,见 E.6 双 VIP 排查。
第 4 步:切换演练
在第四台机器(或 m2)上持续 ping 172.16.1.250,观察丢包:
- HAProxy 故障:在 m1 执行
docker stop haproxy。约 4–6 秒后 VIP 漂移到 m2(优先级 140),m1 状态日志为FAULT。 - 恢复不抢回:在 m1 执行
docker start haproxy。m1 变为BACKUP,VIP 仍在 m2。 - 整机故障:在 m2 执行
systemctl stop keepalived。VIP 漂移到 m1(剩余节点中优先级最高);然后systemctl start keepalived恢复。 - 检查脚本不误判:停止三台的应用或 MySQL,VIP 不应漂移(检查脚本只看 HAProxy 自身)。
每次切换 ping 丢包应在数个包以内。
E.5 日常运维
| 场景 | 操作 | 说明 |
|---|---|---|
| 查看状态 | ip -4 addr | grep -w <VIP>、tail /var/log/keepalived-state.log、journalctl -u keepalived -n 50 | 持有 VIP 的节点即当前 MASTER |
| 启用 Nginx 检查 | Nginx 部署并监听 0.0.0.0:443 后,三台将 .env 中 CHECK_NGINX 改为 true 并重新执行 bash install.sh | 也可直接修改 /etc/keepalived/check_entry.env,2 秒内生效 |
| 修改 VIP、组号、密码 | 三台修改 .env 后,先对两台 BACKUP 执行 bash install.sh,最后对 MASTER 执行 | 修改 VIP 或组号属于结构变更,建议在低峰期操作并重启 keepalived |
| 手动把 VIP 切走(维护 MASTER) | 在 MASTER 执行 systemctl stop keepalived,维护完成后 systemctl start keepalived | 恢复后该节点为 BACKUP,不抢回 |
| 让 VIP 回到 m1 | 在当前 MASTER 执行 systemctl restart keepalived | VIP 转移到剩余节点中优先级最高者;m1 健康时即为 m1 |
| 重新执行 install.sh | 可重复执行 | keepalived 已运行时使用 reload 热加载,不会释放 VIP |
E.6 故障排查
| 现象 | 可能原因 | 处理 |
|---|---|---|
三台都没有 VIP,状态为 FAULT | 三台检查脚本都失败(HAProxy 未运行;或 CHECK_NGINX=true 但 Nginx 未部署) | 在各台手工执行 /etc/keepalived/check_entry.sh; echo $? 定位 |
| 多台同时持有 VIP(双 VIP) | VRRP 报文不通:防火墙未放通协议 112、平台过滤、virtual_router_id 或 auth_pass 三台不一致 | 在 BACKUP 上执行 tcpdump -ni <网卡> vrrp,应每秒收到 MASTER 的通告;核对三台 .env |
| VIP 已在本机,但其他机器 ping 不通 VIP | 虚拟化平台不允许 IP/MAC 漂移,或交换机 ARP 未更新 | 按 E.2 第 3 条开启平台支持;在 MASTER 执行 arping -U -c 3 -I <网卡> <VIP> |
日志提示 script ... is not executable 或 unsafe permissions | 脚本权限或属主不符合 enable_script_security | 重新执行 install.sh,确认脚本为 root 所有、权限 700 |
| RHEL 上检查脚本单独执行成功,但 keepalived 判定失败 | SELinux 阻止脚本发起网络连接 | setsebool -P keepalived_connect_any 1;查看 ausearch -m avc -ts recent |
本机 IP 不在 NODE1_IP/NODE2_IP/NODE3_IP 中 | .env 中 IP 与服务器网卡地址不一致(例如填了 NAT 对外地址) | 用 ip -4 addr 核对后修改 .env |
| VIP 频繁来回切换 | 检查脚本偶发超时,或网络抖动导致 VRRP 通告丢失 | 查看 journalctl -u keepalived;必要时把 fall 调大为 3 |
E.7 与正文的衔接
- 业务流量入口:浏览器 → VIP:443 → 当前 MASTER 上的 Nginx → 本机 HAProxy 18080 → 三台应用(第 1、7 节)。Nginx 部署前,VIP 只完成漂移,尚无对外服务。
- 应用访问 MySQL(本机 13306)与 Redis(直连六节点)都不经过 VIP,VIP 切换不影响后端连接。
- 上线前仍需完成第 9 节的入口组件演练,包括禁止 systemd/Docker 自动拉起时的 VIP 漂移耗时与请求失败数。
附录 F:Nginx、后端与域名接入注意事项
本附录说明把现有单机测试环境的 Nginx 与后端迁移到本方案时必须调整的地方,F.4 给出可直接使用的 Nginx 配置(交付文件 集群部署/nginx/default.conf)。流量路径:
客户端 → (NAT) → VIP 172.16.1.250:888 → 当前 MASTER 上的 Nginx
→ 本机 HAProxy 127.0.0.1:18080 → 三台应用 172.16.1.x:40001
(本机 HAProxy 不可用时,Nginx 直连三台应用兜底)
F.1 Nginx 配置注意事项
- 三台部署同一份配置。 配置中不出现本机专属内容,任何一台接管 VIP 都能直接服务;修改后三台同步更新并执行
nginx -t && nginx -s reload。 - 监听所有地址,不绑定 VIP。 使用
listen 888;,不要写成listen 172.16.1.250:888;:BACKUP 节点上没有 VIP,绑定会导致 Nginx 启动失败,Keepalived 开启 Nginx 检查后该节点进入FAULT,失去接管能力。确需绑定时必须同时设置内核参数net.ipv4.ip_nonlocal_bind=1,本方案不采用。 - 必须使用 host 网络。 以 Docker 运行时设置
network_mode: host(或直接装在宿主机上)。桥接网络下容器内的127.0.0.1:18080指向容器自身,而不是宿主机上的 HAProxy。原配置中的jkq-back等容器名在 host 网络下无法解析,已改为 upstream。 /api/使用upstream app_entry。 本机 HAProxy 为主,三台应用的 40001 端口为backup;proxy_pass http://app_entry/;末尾的/保留,与原配置一样去掉/api/前缀。只在连接失败或超时时换节点(proxy_next_upstream error timeout),不要开启non_idempotent,避免 POST 重复执行。- 超时与上传大小对齐。
proxy_read_timeout、proxy_send_timeout设为 300 秒,与 HAProxy 应用池timeout server 300s一致,否则 AI 流式输出会被中途断开。client_max_body_size 100M大于应用spring.servlet.multipart.max-file-size: 50MB,超过 50 MB 的文件由应用拒绝;两处限制按业务需要统一调整。 - 静态资源三台一致。 Vue 构建产物放在三台相同路径(
/usr/share/nginx/html/jkq),发布时三台同时更新;index.html不缓存或短缓存,带 hash 的资源文件保留旧版本,避免切换过程中页面引用的资源 404。 /upload/本地目录必须三台一致。 该路径直接读取 Nginx 所在服务器的/files/upload,VIP 漂移后请求落到另一台。新上传文件已走 MinIO(defaultStorage: MINIO),这里只剩历史文件:迁移到 MinIO,或同步到三台 / 挂载同一个 NFS 目录。- 与 Keepalived 联动。 Nginx 在三台都部署并启动后,把 Keepalived
.env中的NGINX_CHECK_URL改为http://127.0.0.1:888/、CHECK_NGINX改为true,三台重新执行bash install.sh。启用前先在每台执行curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:888/确认有响应;否则三台同时FAULT,VIP 全部失效。 - 外部依赖的连通性。
/gpt/、oneapi.com代理到192.168.131.31:3000,/digital/代理到jkqszr.bda.gov.cn。三台服务器(172.16.1.x)都必须能访问这些地址,在每台执行curl -m 5 -I http://192.168.131.31:3000/验证;测试机能通不代表新集群能通。 - 转发头与日志。 Nginx 直接面对客户端时覆写
X-Real-IP、X-Forwarded-For、X-Forwarded-Proto;前面还有 NAT 设备时,Nginx 看到的来源地址取决于 NAT 是否做源地址转换(SNAT 后全部显示为 NAT 设备地址),需要真实客户端 IP 时在 NAT 设备上关闭 SNAT 或改用可传递来源的方式。
F.2 后端配置注意事项
- 端口暴露在宿主机网卡上。 应用监听
40001,HAProxy 的 TCP 检查与 Nginx 兜底都直连172.16.1.x:40001;以 Docker 运行时用 host 网络或-p 40001:40001,并在防火墙放通三台之间的 40001。 - 数据库连接本机 HAProxy。 设置
MYSQL_HOST=127.0.0.1、MYSQL_PORT=13306(只读查询可选 13307)。不要连接 VIP 或某台 MySQL 的真实地址:VIP 只承载 Web 入口,MySQL 切主由 HAProxy 跟随。项目使用 Druid 连接池,已开启test-while-idle与validation-query;切主时 HAProxy 断开旧连接,建议再设置max-evictable-idle-time-millis(或phy-max-use-time)小于 HAProxy 超时,并开启keep-alive: true,保证失效连接被及时丢弃重建。 - Redis 改为 Cluster 配置。 按 C.5 删除
spring.redis.host/port/database,改用spring.redis.cluster.nodes六个节点,并开启 Lettuce 拓扑刷新。项目依赖spring-boot-starter-data-redis,默认客户端为 Lettuce,application.yaml中的jedis.pool配置不生效,可一并删除,连接池改为lettuce.pool(需引入commons-pool2)。 - 三台使用相同的发布包与外部配置。 环境变量(数据库、Redis、MinIO、AI 平台地址与密钥)三台一致,只有与本机相关的值(如日志路径)可以不同;发布时逐台滚动:HAProxy 统计页或停止应用使其 DOWN → 更新 → 启动并确认 UP → 下一台。
- 不依赖本机状态。
- 文件:
defaultStorage保持MINIO,不要切回LOCAL;local.path、distinct.temp-path只能放临时文件,不能作为下载来源。 - 定时任务:启动类开启了
@EnableScheduling,目前仓库中没有@Scheduled任务;后续新增时三台会同时执行,必须用数据库任务认领、Redis 分布式锁或幂等控制。 - 内存状态:
InMemoryTokenAuthenticator、内存限流器等单进程组件当前未启用;启用时状态不会在三台间共享,需改为 Redis 实现。 - 会话:当前接口无状态;将来启用登录会话时按第 5 节接入 Spring Session Redis。
- 文件:
- 获取客户端真实 IP。 请求经过 Nginx 与 HAProxy 两跳,
request.getRemoteAddr()得到的是代理地址。设置server.forward-headers-strategy: framework(或native),并确认 Nginx 传递的X-Forwarded-For可信;按 IP 限流、审计日志都应基于解析后的地址。 - 外部依赖从三台都可达。 MongoDB(
MONGO_HOST默认值192.168.150.142是 NAT 对外地址,需确认 172.16.1.x 能访问,否则改为可达地址)、MinIO、FastGPT、AB 平台等地址,在三台分别验证连通性。 - 健康检查。 项目未引入 actuator,HAProxy 只能做 TCP 检查:端口可连接即视为可用,无法识别应用假死或依赖故障。条件允许时引入
spring-boot-starter-actuator暴露 readiness 端点,把 HAProxy 应用池改为option httpchk GET /actuator/health/readiness。 - 优雅停机。 设置
server.shutdown: graceful与spring.lifecycle.timeout-per-shutdown-phase: 30s,滚动发布时在途请求(尤其是流式对话)不被直接切断。
F.3 使用域名的注意事项
- 域名解析到 VIP 或 NAT 映射地址,不解析到单台服务器。 172.16.1.x 网段内的客户端解析到
172.16.1.250;192.168.150.x 等外部网段解析到 NAT 设备上映射 VIP 的地址。同一域名在内外网需要不同解析结果时,使用内外网分离的 DNS(split DNS)或在内网客户端配置 hosts。 - NAT 映射目标是 VIP。 NAT 设备把对外地址的端口映射到
172.16.1.250:888(启用 HTTPS 后为 443),不要映射到 m1 的地址,否则 VIP 漂移后入口失效。 - 端口与访问地址一致。 继续使用 888 端口时,访问地址必须带端口(
http://域名:888/);希望使用不带端口的地址,改为监听 80/443,并同步调整 NAT 映射与 Keepalived 的NGINX_CHECK_URL。 server_name与 Host 匹配。 当前server_name _作为默认站点接收所有 Host;启用正式域名后建议写成server_name 正式域名;,并保留一个默认站点返回 444 拒绝未知 Host。oneapi.com站点只有在客户端把该域名解析到入口地址、请求携带Host: oneapi.com时才会命中。- HTTPS 证书三台一致。 证书与私钥放在三台相同路径;续期时在一台完成后同步到另外两台并各自 reload,否则 VIP 漂移后可能出现证书过期。启用 HTTPS 后增加 80 跳转 443,Keepalived 的检查地址相应改为
https://127.0.0.1/(检查脚本已带-k,不校验证书)。 - 代理目标中的域名只在启动时解析一次。
proxy_pass https://jkqszr.bda.gov.cn:443/human/在 Nginx 启动或 reload 时解析;三台服务器必须配置可用的 DNS,否则 Nginx 无法启动(启用 Nginx 检查后三台同时FAULT)。目标地址可能变化时,配置resolver <DNS地址> valid=300s;并把目标写入变量后再proxy_pass,改为运行时解析。 - 后端生成的链接使用域名。 应用设置
server.forward-headers-strategy: framework,Nginx 传递Host、X-Forwarded-Proto,重定向地址与回调地址才会带正确的域名和协议;将来接入 OAuth2 等回调时,回调地址登记为域名,不登记 IP。 - 前端使用相对路径。 Vue 的接口地址使用相对路径
/api/,不写死 IP 或端口;页面与接口同域,无需跨域配置。原配置中location /的Access-Control-Allow-*响应头只作用于静态资源,保留无害,不影响接口。 - Cookie 作用域。 启用登录会话后,Cookie 的
Domain与域名一致;从 IP 访问切换到域名访问时,旧 Cookie 不会带上,用户需要重新登录。
F.4 Nginx 完整配置
交付文件:集群部署/nginx/default.conf,三台相同,放到 /etc/nginx/conf.d/default.conf(容器部署时挂载到同一路径),执行 nginx -t 校验后启动。
# 三台服务器使用同一份配置;Nginx 须以 host 网络运行,才能访问本机 HAProxy 127.0.0.1:18080
# 本机 HAProxy 为主,三台应用直连为兜底:HAProxy 故障时在 VIP 漂移前继续服务
upstream app_entry {
server 127.0.0.1:18080 max_fails=1 fail_timeout=5s;
server 172.16.1.235:40001 backup;
server 172.16.1.244:40001 backup;
server 172.16.1.239:40001 backup;
}
server {
# 监听本机所有地址,不要写成 172.16.1.250:888,否则 BACKUP 节点无法启动
listen 888;
server_name _;
root /usr/share/nginx/html/jkq;
index index.html;
location /api/{
client_max_body_size 100M;
proxy_pass http://app_entry/;
proxy_next_upstream error timeout;
proxy_connect_timeout 2s;
# 与 HAProxy 应用池 timeout server 300s 一致,避免 AI 流式输出被提前断开
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /gpt/ {
proxy_pass http://192.168.131.31:3000/api/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /digital/ {
proxy_pass https://jkqszr.bda.gov.cn:443/human/;
# proxy_pass http://192.168.131.16:8099;
proxy_ssl_verify off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# VIP 会在三台之间漂移,三台的 /files/upload 内容必须一致
location ^~ /upload/ {
root /files;
}
location / {
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Headers *;
add_header Access-Control-Allow-Methods "GET, POST, PUT, OPTIONS";
gzip on;
gzip_static on;
gzip_min_length 1k;
gzip_buffers 16 64k;
gzip_http_version 1.1;
gzip_comp_level 9;
gzip_types text/plain text/javascript application/javascript image/jpeg image/gif image/png application/font-woff application/x-javascript text/css application/xml;
gzip_vary on;
try_files $uri /index.html;
}
}
server {
listen 888;
server_name oneapi.com;
root /usr/share/nginx/html/jkq;
index index.html;
location /{
client_max_body_size 100M;
proxy_pass http://192.168.131.31:3000/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
proxy_redirect off;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
相对测试环境原配置的改动:
| 位置 | 原配置 | 修改后 | 原因 |
|---|---|---|---|
/api/ 代理目标 | http://jkq-back:40001/ | http://app_entry/ | 经本机 HAProxy 分发到三台,HAProxy 故障时直连兜底 |
/api/ 超时与重试 | 默认(读超时 60 秒) | 连接 2 秒、读写 300 秒、仅连接失败或超时时换节点 | 快速切换故障节点,不中断流式输出 |
listen | 888 | 不变 | 监听所有地址,三台一致 |
/upload/ | 本机 /files | 不变,增加注释 | 三台目录内容需保持一致 |
其余 location、oneapi.com | — | 不变 | 需确认三台都能访问代理目标 |
部署后验证:
# 三台分别执行:配置校验、首页、接口转发
nginx -t
curl -s -o /dev/null -w 'index %{http_code}\n' http://127.0.0.1:888/
curl -s -o /dev/null -w 'api %{http_code}\n' http://127.0.0.1:888/api/
# 在第四台机器上通过 VIP 访问,并在 HAProxy 统计页(18405)观察请求分布到三台应用
curl -s -o /dev/null -w 'vip %{http_code}\n' http://172.16.1.250:888/
加载评论中...