OpenBlog

关于本站社区留言

©Open Blog · Powered by @Eumenides

  1. 网站首页
  2. 运维部署
  3. 单机房三服务器高可用部署方案
运维部署

单机房三服务器高可用部署方案

三台应用同时服务;MySQL 采用 Group Replication(MGR)单主模式的一主两从;Redis 采用原生 Cluster 三主三从,不使用 Sentinel。入口由 Nginx、HAProxy 与 Keepalived 组成。

运维部署2026-10-16 11:11:5914 次浏览0 次收藏
叙利亚小陀螺原创访问权限 1
JavaRedis数据库

单机房三服务器高可用部署方案

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.1110.10.10.1210.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 :6379R2 :6379R3 :6379
Redis 副本r3 :6380 ← R3r1 :6380 ← R1r2 :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不直接接触应用,只决定由哪台的入口承接用户请求
NginxTLS 终止、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 状态并回滚阻塞事务,便于健康检查及时摘除。

建组与日常启动

  1. 在 M1 上配置 group_replication_recovery 通道凭据,临时执行 SET GLOBAL group_replication_bootstrap_group=ON; START GROUP_REPLICATION; SET GLOBAL group_replication_bootstrap_group=OFF;。
  2. 在 M2、M3 上依次执行 START GROUP_REPLICATION,确认 replication_group_members 中三成员均为 ONLINE。
  3. 日常启动通过受控脚本入组,健康检查通过后放流。禁止多台同时 bootstrap。
  4. 全组停机后恢复:先比较各成员 gtid_executed,选择事务最完整的成员 bootstrap,其余成员再入组;不能随意强制重建。

健康检查代理

每台部署一个本机健康检查代理(小型 HTTP 服务或 xinetd 脚本),只允许 HAProxy 访问,健康返回 HTTP 200,异常返回 503:

端口放流条件失败动作
9201(写)本机 MEMBER_STATE=ONLINE;本机是 group_replication_primary_member;super_read_only=OFF;本机视图中 ONLINE 成员数 ≥ 2HAProxy 摘除并关闭存量连接;代理不执行任何升主操作
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]

创建步骤

  1. 先启动三台 :6379 与三台 :6380,使用固定内网地址;配置 cluster-enabled yes、独立 cluster-config-file、明确的 cluster-announce-ip/port/bus-port。
  2. 只使用三个 :6379 创建主分片集群,再通过 redis-cli --cluster add-node 的 --cluster-slave、--cluster-master-id 参数逐个加入副本。按实际 node ID 指定配对,不依赖自动分配猜测物理位置。
  3. 通过 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

  1. 浏览器通过唯一 HTTPS 域名发起登录;App-A 生成的授权请求及 state 保存到共享 Session。若使用 OIDC,同时验证 nonce;按提供方能力使用 PKCE。
  2. 第三方认证完成后回调同一域名,可能被分配到 App-B;App-B 通过 Session Cookie 从 Redis 读取对应状态并校验,完成授权码交换,建立认证上下文。
  3. 后续请求可以落到任何 App;三台版本、序列化配置和命名空间一致,不以 ip_hash 或粘性会话作为正确性前提。
  4. 本地退出删除共享会话、清理 Cookie;全局退出需按提供方注销能力接入。

启用时必须共享的状态

内容处理方式
HttpSession / SecurityContextSpring 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 超时,依赖 JDBC4 isValid 校验,关键写操作有限重试。
  • 定时任务避免三台重复执行:采用数据库任务认领与幂等控制;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 故障时,按以下三层依次处理:

  1. systemd 自动重启:Nginx 与 HAProxy 的 systemd 单元配置 Restart=on-failure、RestartSec=2s,进程偶发崩溃时一般数秒内恢复,VIP 不漂移。
  2. Nginx 兜底(仅 HAProxy 故障):本机 HAProxy 不可连接时,Nginx 按第 7 节 app_entry 的 backup 配置直接转发到三台 App,覆盖 VIP 漂移前的检测窗口。
  3. 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 网络
单机房成本和网络复杂度较低无法抵御断电、火灾、运营商或整机房事故

上线前必须完成的验收

  1. 核对硬件、磁盘容量、数据规模、峰值流量和软件小版本(本文参数要求 MySQL 5.7.24 及以上,现网 5.7.38 满足;三个成员须使用相同小版本);备份现网并准备回退窗口。
  2. 检查表引擎、主键、外键,验证 MGR 兼容性;通过 13306 读写成功,直连 SECONDARY 执行 INSERT/UPDATE/DELETE 被 super_read_only 拒绝。
  3. 切主演练:停止当前 PRIMARY,验证新主选举结果、HAProxy 9201 切换耗时、存量连接关闭以及应用连接池自动恢复;再让旧主重新入组为 SECONDARY。
  4. 网络分区演练:隔离一台成员,确认少数派不再通过 9201 检查、多数派继续写入。
  5. 入口组件演练:在 VIP 节点分别停止 HAProxy、Nginx(含禁止 systemd 重启的场景),验证 Nginx 兜底、VIP 漂移耗时与请求失败数;再停止全部 App,确认 VIP 不会因检查脚本误判而无人持有。
  6. 逐台停机并叠加峰值负载,观察 VIP、MGR、Redis、应用的恢复;测试全组停机后按 GTID 的受控恢复。
  7. (启用登录时)登录发起在 A、回调落 B、后续落 C;测试刷新令牌并发、退出、会话过期、Redis 切换及滚动发布中的序列化兼容。
  8. 配置机房外备份:定期全量+持续保留 binlog,并验证指定时间点恢复。

10. 实施顺序与参考资料

建议实施顺序

  1. 基础设施:统一时间、主机名、网络策略、证书、目录、账号、磁盘与资源限额,验证 VIP 漂移。
  2. 数据库:按附录 B 在三台以 Docker 各启动一个 MySQL 5.7.38 实例,自动建立 MGR 单主组并验证选主顺序;部署 9201/9202 健康检查代理。保留原数据库回退能力,切换时避免双边继续写入。
  3. Redis:按附录 C 以 Docker 建立三个分片主与明确配对的三个副本,验证全部槽位和单主机故障。
  4. 应用:数据源指向本机 13306、接入 Redis Cluster 客户端;完成定时任务与文件存储改造。
  5. 接入:同步 Vue 产物和 Nginx 配置,接入三台 HAProxy,配置 Keepalived。
  6. 验收与切流:完成第 9 节演练后再迁移流量;切换后监控 MGR 成员状态、应用队列、切主事件、Redis 内存及 JVM 指标。
  7. (可选)按需启用 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.4M1 :3306(初始 PRIMARY)M2 :3306M3 :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 升级注意事项

  1. 升级路径: 8.4 只支持从 8.0 原地升级,5.7 不能直接原地升到 8.4。可选先原地升级到 8.0 再到 8.4,或新建 8.4 集群后逻辑导出导入(util.dumpInstance / util.loadDump)并切换。
  2. 预检: 使用 MySQL Shell util.checkForServerUpgrade() 检查保留字冲突、废弃语法、分区、字符集等问题。
  3. 认证插件: 8.4 默认 caching_sha2_password,且 mysql_native_password 默认不再加载。应用驱动需支持新插件,或显式启用旧插件作为过渡。
  4. 驱动: mysql:mysql-connector-java 替换为 com.mysql:mysql-connector-j 8.x,JDBC URL 中的 serverTimezone、useSSL 等参数按新版本复核。
  5. SQL 兼容: 核对 sql_mode 默认值、utf8 → utf8mb4 默认字符集与排序规则变化、GROUP BY 隐式排序移除、查询缓存移除等对现有 MyBatis SQL 的影响。
  6. 参数变化: 5.7 的 group_replication_ip_whitelist、master_info_repository 等参数在 8.x 已更名或移除,由 AdminAPI 统一管理,不要照搬正文的 my.cnf。
  7. 回退: 升级前全量备份并保留 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: hostMGR 需以宿主机真实 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.shgroup_replication_start_on_boot=OFF,由脚本统一控制,避免初始化阶段的临时实例误启组复制
防止重复建组只有同时满足:MGR_BOOTSTRAP_NODE=true、入组失败、数据目录无 .mgr_bootstrapped 标记、gtid_executed 为空,才执行 bootstrapbootstrap 一生只发生一次;已有数据的成员永远不会自行新建组,杜绝脑裂
业务库与账户不使用 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 主机准备(三台都执行)

  1. 操作系统时间同步(chrony),三台时差控制在 1 秒内;时区 Asia/Shanghai。
  2. 安装 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。
  3. 防火墙只对三台所在网段放通 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
  1. 建目录并设置属主(镜像内 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
  1. 若 SELinux 为 enforcing,在 B.3 compose 各挂载项末尾加 :z(如 ./data:/var/lib/mysql:z)。
  2. 生成一次组名 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 步:切主演练

  1. 在服务器 A 执行 docker stop mysql-mgr,在 B 上查询成员状态:预期 M2 成为 PRIMARY(weight 60 最高),M3 为 SECONDARY。
  2. 在服务器 A 执行 docker start mysql-mgr,日志出现 入组成功;M1 以 SECONDARY 身份回到组内,不会抢回主节点。
  3. 如需让 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,会停在“入组失败重试”。按以下步骤恢复:

  1. 在三台分别执行 mysql-exec <<< "SELECT @@GLOBAL.gtid_executed;",记录结果。
  2. 选择 GTID 集合最完整(包含其他成员全部事务)的成员 X。无法判断哪台更完整时,停下来人工核对,不要猜测。
  3. 在 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
  1. 其余两台仍在重试时会自动入组;如果重试已用尽,执行 docker restart mysql-mgr。
  2. 按 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 ignoredmy.cnf 权限过宽chmod 644 conf/my.cnf
数据目录 Permission denied宿主目录属主不对chown -R 999:999 data logs

重建单个成员(以 M3 为例,另外两台正常)

组内 binlog 仍完整(集群建立不足 7 天)时,清空数据目录后直接启动即可自动追平,只需执行下面第 1 步和第 6 步。否则按全部步骤操作:

  1. 在 M3:docker compose down,将数据目录移走备份(mv data data.bak.$(date +%F)),执行 mkdir data && chown 999:999 data。
  2. 把 M3 .env 中的 MGR_JOIN_RETRY 临时改为 0,防止导入期间脚本自动入组;然后执行 docker compose up -d,等待日志出现 mysqld 已就绪(init 脚本会重新创建 mgr_repl、mgr_check 和恢复通道)。
  3. 在一个 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
  1. 在 M3 清空本地 GTID 后导入。导入文件中的 SET @@GLOBAL.GTID_PURGED 会把 M3 的 GTID 集合设为导出时刻的集群进度:
mysql-exec -e "RESET MASTER;"
mysql-exec < /srv/mysql/rebuild.sql
  1. 业务账户存放在 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
  1. 把 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 账户)、HAProxy 13306 / 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.235R1 :6379r3 :6380,复制 m3:6379
m2(B)172.16.1.244R2 :6379r1 :6380,复制 m1:6379
m3(C)172.16.1.239R3 :6379r2 :6380,复制 m2:6379

每个分片的主和副本都在不同服务器上,任意一台宕机时,它上面的主分片由另一台的副本接管。

C.1 设计要点

项目做法原因
镜像redis:7.2.11,三台同一镜像7.2 为最后的 BSD 许可版本线;7.2.11 包含 2025 年 10 月 Lua 远程代码执行漏洞(CVE-2025-49844)修复
网络network_mode: hostCluster 节点需以真实 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 主机准备(三台都执行)

  1. 安装 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。
  2. 防火墙只对集群网段放通数据端口与总线端口(总线端口 = 数据端口 + 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
  1. 内核参数由 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 步:故障切换演练

  1. 在 m1 执行 docker stop redis-6379。约 5–10 秒后在 m2 执行 rcli cluster nodes:原 m1:6379 标记为 master,fail,m2:6380 提升为 master,cluster_state 仍为 ok。
  2. 在 m1 执行 docker start redis-6379:它自动以 slave 身份重新加入,复制 m2:6380。
  3. 恢复原配对:在 m1 执行 docker exec redis-6379 redis-cli cluster failover,m1:6379 重新成为主节点,m2:6380 回到副本。
  4. 再模拟整机故障:在 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 与 maxmemoryvolatile-lru 下无可淘汰 key 时写入报 OOM,需扩容或清理

重建单个节点(数据损坏或更换磁盘)

以 m3:6380 为例,其他节点正常:

  1. 在任一健康节点查出它的节点 ID:rcli cluster nodes | grep 172.16.1.239:6380。
  2. 在 m3 执行 docker compose stop redis-6380,清空 data/6380/(含 nodes-6380.conf)。
  3. 让其余五个节点忘记旧节点。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
  1. 在 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 网络:

容器镜像职责
haproxyhaproxy:2.8(LTS)应用分发、MySQL 写/只读端口转发、存活检测、统计页
mgr-healthmysql:5.7.38(复用 MySQL 已导入的镜像)运行 mgr-health.py,在 9201/9202 以 HTTP 返回本机 MGR 成员是否可写/可读

健康检查代理复用 mysql:5.7.38 镜像自带的 python2 与 mysql 客户端,服务器无需导入新镜像、无需联网安装软件包。

HAProxy 端口:

端口监听地址用途后端与检查
18080127.0.0.1应用分发,供本机 Nginx 使用三台 :40001,轮询;TCP 端口检查
13306127.0.0.1MySQL 写入,供本机应用使用三台 :3306,只有通过 9201 检查的当前 PRIMARY 接收流量
13307127.0.0.1MySQL 只读(报表、运维查询)三台 :3306,通过 9202 检查的 SECONDARY,最少连接分配
18404127.0.0.1HAProxy 自身存活,供 Keepalived 检查脚本固定返回 200
184050.0.0.0统计页,账号密码登录查看各后端 UP/DOWN 状态

健康检查代理判定规则(每次 HAProxy 检查时实时查询本机 MySQL):

端口返回 200 的条件否则
9201(写)本机 MEMBER_STATE=ONLINE;本机是 group_replication_primary_member;super_read_only=0;本机视图中 ONLINE 成员 ≥ 2503,响应正文为原因
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 主机准备(三台都执行)

  1. 完成附录 B(MySQL MGR)部署,确认 mgr_check 账户存在(由 01-mgr-init.sh 创建)。代理的 MGR_CHECK_PASSWORD 必须与 MySQL .env 中的值一致。
  2. 镜像: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。
  3. 防火墙: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
  1. 宿主机需有 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 步:切主演练

  1. 在当前 PRIMARY 所在服务器执行 docker stop mysql-mgr。
  2. 在其他服务器循环执行第 3 步的写端口查询,约 10–30 秒后返回新主的 server_id;docker logs mgr-health 可看到 port 9201 -> UP 的时间点。
  3. 执行 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 haproxyHUP 触发平滑重载,已有连接不中断;三台逐台修改
修改 .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 deniedMGR_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.235150
m2(B)172.16.1.244140
m3(C)172.16.1.239130
业务 VIP172.16.1.250/24(占位值,部署前在 .env 中改为实际地址)—

E.1 设计要点

项目做法原因
安装方式系统软件包(dnf/yum/apt)+ systemdKeepalived 需直接操作宿主机网卡与发送 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 前置条件

  1. 已按附录 D 部署 HAProxy,三台 curl http://127.0.0.1:18404/alive 返回 200。
  2. VIP 未被占用:在任一台执行 ping -c 3 <VIP> 与 arping -c 3 -I <网卡> <VIP>,均应无响应。
  3. 虚拟化或云平台允许该 VIP 在三台之间漂移:VMware 需允许 MAC 地址更改与伪传输(Forged Transmits);OpenStack 需在三台端口上配置 allowed-address-pairs;公有云一般需使用平台提供的高可用虚拟 IP(HaVip)产品,普通弹性网卡不支持 VRRP。
  4. 三台之间放通 VRRP(IP 协议号 112,不是 TCP/UDP 端口)。firewalld 由安装脚本自动放通;其他防火墙需手工放通。
  5. 服务器可通过 yum/dnf/apt 安装 keepalived;内网需配置本地软件源,或提前离线安装好 keepalived 软件包(脚本检测到已安装时跳过安装)。
  6. 宿主机已安装 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,观察丢包:

  1. HAProxy 故障:在 m1 执行 docker stop haproxy。约 4–6 秒后 VIP 漂移到 m2(优先级 140),m1 状态日志为 FAULT。
  2. 恢复不抢回:在 m1 执行 docker start haproxy。m1 变为 BACKUP,VIP 仍在 m2。
  3. 整机故障:在 m2 执行 systemctl stop keepalived。VIP 漂移到 m1(剩余节点中优先级最高);然后 systemctl start keepalived 恢复。
  4. 检查脚本不误判:停止三台的应用或 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 keepalivedVIP 转移到剩余节点中优先级最高者;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 配置注意事项

  1. 三台部署同一份配置。 配置中不出现本机专属内容,任何一台接管 VIP 都能直接服务;修改后三台同步更新并执行 nginx -t && nginx -s reload。
  2. 监听所有地址,不绑定 VIP。 使用 listen 888;,不要写成 listen 172.16.1.250:888;:BACKUP 节点上没有 VIP,绑定会导致 Nginx 启动失败,Keepalived 开启 Nginx 检查后该节点进入 FAULT,失去接管能力。确需绑定时必须同时设置内核参数 net.ipv4.ip_nonlocal_bind=1,本方案不采用。
  3. 必须使用 host 网络。 以 Docker 运行时设置 network_mode: host(或直接装在宿主机上)。桥接网络下容器内的 127.0.0.1:18080 指向容器自身,而不是宿主机上的 HAProxy。原配置中的 jkq-back 等容器名在 host 网络下无法解析,已改为 upstream。
  4. /api/ 使用 upstream app_entry。 本机 HAProxy 为主,三台应用的 40001 端口为 backup;proxy_pass http://app_entry/; 末尾的 / 保留,与原配置一样去掉 /api/ 前缀。只在连接失败或超时时换节点(proxy_next_upstream error timeout),不要开启 non_idempotent,避免 POST 重复执行。
  5. 超时与上传大小对齐。 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 的文件由应用拒绝;两处限制按业务需要统一调整。
  6. 静态资源三台一致。 Vue 构建产物放在三台相同路径(/usr/share/nginx/html/jkq),发布时三台同时更新;index.html 不缓存或短缓存,带 hash 的资源文件保留旧版本,避免切换过程中页面引用的资源 404。
  7. /upload/ 本地目录必须三台一致。 该路径直接读取 Nginx 所在服务器的 /files/upload,VIP 漂移后请求落到另一台。新上传文件已走 MinIO(defaultStorage: MINIO),这里只剩历史文件:迁移到 MinIO,或同步到三台 / 挂载同一个 NFS 目录。
  8. 与 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 全部失效。
  9. 外部依赖的连通性。 /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/ 验证;测试机能通不代表新集群能通。
  10. 转发头与日志。 Nginx 直接面对客户端时覆写 X-Real-IP、X-Forwarded-For、X-Forwarded-Proto;前面还有 NAT 设备时,Nginx 看到的来源地址取决于 NAT 是否做源地址转换(SNAT 后全部显示为 NAT 设备地址),需要真实客户端 IP 时在 NAT 设备上关闭 SNAT 或改用可传递来源的方式。

F.2 后端配置注意事项

  1. 端口暴露在宿主机网卡上。 应用监听 40001,HAProxy 的 TCP 检查与 Nginx 兜底都直连 172.16.1.x:40001;以 Docker 运行时用 host 网络或 -p 40001:40001,并在防火墙放通三台之间的 40001。
  2. 数据库连接本机 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,保证失效连接被及时丢弃重建。
  3. 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)。
  4. 三台使用相同的发布包与外部配置。 环境变量(数据库、Redis、MinIO、AI 平台地址与密钥)三台一致,只有与本机相关的值(如日志路径)可以不同;发布时逐台滚动:HAProxy 统计页或停止应用使其 DOWN → 更新 → 启动并确认 UP → 下一台。
  5. 不依赖本机状态。
    • 文件:defaultStorage 保持 MINIO,不要切回 LOCAL;local.path、distinct.temp-path 只能放临时文件,不能作为下载来源。
    • 定时任务:启动类开启了 @EnableScheduling,目前仓库中没有 @Scheduled 任务;后续新增时三台会同时执行,必须用数据库任务认领、Redis 分布式锁或幂等控制。
    • 内存状态:InMemoryTokenAuthenticator、内存限流器等单进程组件当前未启用;启用时状态不会在三台间共享,需改为 Redis 实现。
    • 会话:当前接口无状态;将来启用登录会话时按第 5 节接入 Spring Session Redis。
  6. 获取客户端真实 IP。 请求经过 Nginx 与 HAProxy 两跳,request.getRemoteAddr() 得到的是代理地址。设置 server.forward-headers-strategy: framework(或 native),并确认 Nginx 传递的 X-Forwarded-For 可信;按 IP 限流、审计日志都应基于解析后的地址。
  7. 外部依赖从三台都可达。 MongoDB(MONGO_HOST 默认值 192.168.150.142 是 NAT 对外地址,需确认 172.16.1.x 能访问,否则改为可达地址)、MinIO、FastGPT、AB 平台等地址,在三台分别验证连通性。
  8. 健康检查。 项目未引入 actuator,HAProxy 只能做 TCP 检查:端口可连接即视为可用,无法识别应用假死或依赖故障。条件允许时引入 spring-boot-starter-actuator 暴露 readiness 端点,把 HAProxy 应用池改为 option httpchk GET /actuator/health/readiness。
  9. 优雅停机。 设置 server.shutdown: graceful 与 spring.lifecycle.timeout-per-shutdown-phase: 30s,滚动发布时在途请求(尤其是流式对话)不被直接切断。

F.3 使用域名的注意事项

  1. 域名解析到 VIP 或 NAT 映射地址,不解析到单台服务器。 172.16.1.x 网段内的客户端解析到 172.16.1.250;192.168.150.x 等外部网段解析到 NAT 设备上映射 VIP 的地址。同一域名在内外网需要不同解析结果时,使用内外网分离的 DNS(split DNS)或在内网客户端配置 hosts。
  2. NAT 映射目标是 VIP。 NAT 设备把对外地址的端口映射到 172.16.1.250:888(启用 HTTPS 后为 443),不要映射到 m1 的地址,否则 VIP 漂移后入口失效。
  3. 端口与访问地址一致。 继续使用 888 端口时,访问地址必须带端口(http://域名:888/);希望使用不带端口的地址,改为监听 80/443,并同步调整 NAT 映射与 Keepalived 的 NGINX_CHECK_URL。
  4. server_name 与 Host 匹配。 当前 server_name _ 作为默认站点接收所有 Host;启用正式域名后建议写成 server_name 正式域名;,并保留一个默认站点返回 444 拒绝未知 Host。oneapi.com 站点只有在客户端把该域名解析到入口地址、请求携带 Host: oneapi.com 时才会命中。
  5. HTTPS 证书三台一致。 证书与私钥放在三台相同路径;续期时在一台完成后同步到另外两台并各自 reload,否则 VIP 漂移后可能出现证书过期。启用 HTTPS 后增加 80 跳转 443,Keepalived 的检查地址相应改为 https://127.0.0.1/(检查脚本已带 -k,不校验证书)。
  6. 代理目标中的域名只在启动时解析一次。 proxy_pass https://jkqszr.bda.gov.cn:443/human/ 在 Nginx 启动或 reload 时解析;三台服务器必须配置可用的 DNS,否则 Nginx 无法启动(启用 Nginx 检查后三台同时 FAULT)。目标地址可能变化时,配置 resolver <DNS地址> valid=300s; 并把目标写入变量后再 proxy_pass,改为运行时解析。
  7. 后端生成的链接使用域名。 应用设置 server.forward-headers-strategy: framework,Nginx 传递 Host、X-Forwarded-Proto,重定向地址与回调地址才会带正确的域名和协议;将来接入 OAuth2 等回调时,回调地址登记为域名,不登记 IP。
  8. 前端使用相对路径。 Vue 的接口地址使用相对路径 /api/,不写死 IP 或端口;页面与接口同域,无需跨域配置。原配置中 location / 的 Access-Control-Allow-* 响应头只作用于静态资源,保留无害,不影响接口。
  9. 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 秒、仅连接失败或超时时换节点快速切换故障节点,不中断流式输出
listen888不变监听所有地址,三台一致
/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/

互动

分享到:

空豆
2026-10-16 11:11:59浏览 14收藏 0

加载评论中...

    目录

    当前正文没有可提取的标题。