项目一实施手册:高可用 Web 集群搭建与调优
网络:eth0 = NAT
172.25.254.0/24(网关 172.25.254.2);eth1 = 仅主机192.168.0.0/24域名:统一使用
timinglee.org前置阅读:《00-项目总览与资源规划.md》
目录
- 阅读前必看:主机清单与执行约定
- 第一章 母盘制作与 VMware 网络准备
- 第二章 批量克隆与主机初始化
- 第三章 infra 节点:时间同步 / NFS / 集中日志 / DNS / Redis
- 第四章 web 节点:Nginx + Tomcat + Memcached 集群
- 第五章 db 节点:MySQL 主从(GTID + 半同步)与 VIP 高可用
- 第六章 lb 节点:HAProxy 七层负载 + Keepalived 双主高可用
- 第七章 进阶:用 LVS DR 模式替换 HAProxy
- 第八章 全链路联调与性能压测
- 第九章 故障演练剧本
- 第十章 简历写法与面试问答
- 附录 A MySQL 源码编译(可选)
- 附录 B MHA 高可用集群(可选)
阅读前必看:主机清单与执行约定
本手册每一个章节标题下都标了「本章操作主机」,每一个小节标题下都标了「执行主机」。动手之前先看这个标注,确认自己登录的是哪台机器------多机集群最容易犯的错就是在错的机器上敲了对的命令。
所有命令默认以 root 用户执行。除特别说明外,命令在标注的那台(或那几台)机器上执行。
主机与角色对照
| 主机名 | 业务面(NAT) | 数据面(仅主机) | 角色 | 内存 |
|---|---|---|---|---|
lb1 |
172.25.254.50 | 192.168.0.50 | HAProxy 主 + Keepalived(持有 Web VIP .200) |
1 GB |
lb2 |
172.25.254.60 | 192.168.0.60 | HAProxy 备 + Keepalived(持有监控 VIP .202) |
1 GB |
web1 |
172.25.254.10 | 192.168.0.10 | Nginx + Tomcat + Memcached 节点 1 | 1.5 GB |
web2 |
172.25.254.20 | 192.168.0.20 | Nginx + Tomcat + Memcached 节点 2 + Redis 从节点 | 1.5 GB |
db1 |
172.25.254.30 | 192.168.0.30 | MySQL 主库(持有 DB VIP .201) |
1 GB |
db2 |
172.25.254.40 | 192.168.0.40 | MySQL 从库 + Sentinel 哨兵 | 1 GB |
infra |
172.25.254.100 | 192.168.0.100 | chrony / NFS / rsyslog / DNS / Redis 主节点 | 1 GB |
base-rhel9.8 |
--- | --- | 母盘,只做克隆源,不跑业务 | --- |
| 宿主机 Windows | --- | --- | VMware 操作、ISO 镜像、快照管理 | --- |
VIP 一览 :172.25.254.200(Web 业务)、172.25.254.201(数据库)、172.25.254.202(监控/状态页)
两条阅读约定
-
成对节点只写一遍命令 。
web1/web2、lb1/lb2、db1/db2这类节点的命令完全相同,手册里只写一次,标注会写明"两台都执行"。如果两侧配置有差异(例如 Keepalived 的 priority、MySQL 的主从角色),会明确分开写。 -
标注里的箭头表示操作流向 。例如「
infra(服务端)→ 其余 6 台(客户端)」表示先在infra上配服务端,再逐台去其他机器配客户端。
第一章 母盘制作与 VMware 网络准备
本章操作主机 :
宿主机 Windows→base-rhel9.8母盘
1.1 安装 VMware Workstation 与准备安装镜像
执行主机 :
宿主机 Windows
两个项目一共 11 台虚拟机,全部跑在 VMware Workstation 上。这一节从零开始,把虚拟机软件装好、镜像备好、目录规划好,为后面的母盘制作打地基。
1.1.1 硬件条件自检
先确认三件事,缺任何一个后面都会卡住。
| 检查项 | 要求 | 怎么查 |
|---|---|---|
| CPU 虚拟化 | Intel VT-x 或 AMD-V 已开启 | 任务管理器 → 性能 → CPU → 看右下角「虚拟化:已启用」 |
| 内存 | ≥ 8 GB(本手册按 15 GB 实测规划) | 任务管理器 → 性能 → 内存 |
| 磁盘剩余 | 数据盘 ≥ 60 GB | 这台电脑 → 看各盘剩余空间 |
如果「虚拟化」显示为已禁用 ,需要进 BIOS/UEFI 打开。AMD 平台(Ryzen)叫 SVM Mode,通常藏在 Advanced → CPU Configuration 里;Intel 平台叫 Intel Virtualization Technology / VT-x。改完按 F10 保存重启。
这一步没开的话,虚拟机一上电就会报
This host supports Intel VT-x, but Intel VT-x is disabled,或者能跑但慢得像卡死。这是新手最常见的第一个坑。
1.1.2 安装 VMware Workstation Pro
用 VMware Workstation 17.x(Pro 版,个人使用免费)。
- 双击安装包
VMware-workstation-full-17.x.x-xxxxx.exe,点「下一步」。 - 勾选「我接受许可协议中的条款」。
- 安装位置改到 D 盘 :
D:\VMware\Workstation(C 盘只剩 40 多 GB,要给虚拟机文件留空间)。 - 「启动时检查产品更新」和「加入 VMware 客户体验提升计划」取消勾选,省事。
- 快捷方式两项都勾上。
- 点「安装」,等 2 ~ 3 分钟,完成后点「完成」。
- 重启一次系统(安装过程会装虚拟网卡驱动,不重启可能导致后面虚拟网络编辑器不可用)。
重启后检查:控制面板 → 网络和 Internet → 网络连接,应该能看到两块虚拟网卡:
VMware Network Adapter VMnet1 ← 仅主机模式(Host-Only)
VMware Network Adapter VMnet8 ← NAT 模式
看到这两块网卡才说明装好了。如果没看到,卸载后重新安装一次。
1.1.3 准备 RHEL 9.8 安装镜像
两个项目统一使用 Red Hat Enterprise Linux 9.8 的 DVD ISO(约 8 ~ 10 GB)。
- 从红帽开发者站点(或培训机构/学校提供的镜像源)下载
rhel-9.8-x86_64-dvd.iso。 - 校验完整性------这一步别省,镜像不完整会在安装到 70% 时莫名报错,而且很难排查:
powershell
# 在宿主机上打开 PowerShell,进到 ISO 所在目录
cd D:\iso
Get-FileHash .\rhel-9.8-x86_64-dvd.iso -Algorithm SHA256
把输出的哈希值和下载页公布的 SHA256 逐位比对,完全一致才能用。
-
把 ISO 放在固定路径,后面创建虚拟机会反复指向它:
D:\iso\rhel-9.8-x86_64-dvd.iso
两个不要:不要放桌面(容易被清理),不要用中文路径。虚拟机配置文件里记录的是绝对路径,一移动就变成「无法连接虚拟设备 ide1:0」。
1.1.4 规划虚拟机存放目录
先在 D 盘把目录结构建好,后面所有虚拟机都按这个规则存放,找起来不乱:
D:\vm\
├── base-rhel9.8\ ← 母盘(项目一、项目二共用)
├── proj1-lb1\ ← 项目一:负载均衡 1
├── proj1-lb2\ ← 项目一:负载均衡 2
├── proj1-web1\ ← 项目一:Web 节点 1
├── proj1-web2\ ← 项目一:Web 节点 2
├── proj1-db1\ ← 项目一:数据库主库
├── proj1-db2\ ← 项目一:数据库从库
├── proj1-infra\ ← 项目一:基础服务节点
├── proj2-master\ ← 项目二:K8s 控制面
├── proj2-node1\ ← 项目二:K8s 工作节点 1
├── proj2-node2\ ← 项目二:K8s 工作节点 2
└── proj2-harbor\ ← 项目二:私有镜像仓库
为什么必须放 D 盘:11 台虚拟机即便全部用链接克隆,母盘本体也要 20 ~ 25 GB。C 盘只剩 40 多 GB,既装不下,装满了还会拖慢整个 Windows。
1.2 配置 VMware 虚拟网络
执行主机 :
宿主机 Windows
必须以管理员身份运行 VMware Workstation(否则虚拟网络编辑器改不了)。
打开 编辑 → 虚拟网络编辑器,点右下角「更改设置」获取写权限,然后配置两个网络。
VMnet8(NAT)
| 项目 | 值 |
|---|---|
| 类型 | NAT 模式 |
| 子网 IP | 172.25.254.0 |
| 子网掩码 | 255.255.255.0 |
| 网关 | 172.25.254.2 |
| DHCP | 可以关闭(我们用静态 IP) |
VMnet1(仅主机)
| 项目 | 值 |
|---|---|
| 类型 | 仅主机模式 |
| 子网 IP | 192.168.0.0 |
| 子网掩码 | 255.255.255.0 |
| DHCP | 关闭(我们用静态 IP) |
关闭 DHCP 很重要。如果 DHCP 还在跑,它可能把 192.168.0.100 之类的地址分给你以后要手动指定的机器,造成 IP 冲突,而且这种冲突很难排查。
1.3 创建母盘虚拟机
执行主机 :
宿主机 Windows(1.3.1 ~ 1.3.2)→base-rhel9.8(1.3.3 起,在虚拟机内)
母盘(Base VM)是后面 11 台虚拟机的共同祖先。母盘只做一次,做得越规范,克隆出来的机器越省事。
1.3.1 先定好规格
| 项目 | 值 | 说明 |
|---|---|---|
| 客户机操作系统 | Red Hat Enterprise Linux 9 64 位 | 与 RHEL 9.8 镜像对应 |
| 名称 | base-rhel9.8 |
母盘,后续所有机器从它克隆 |
| 位置 | D:\vm\base-rhel9.8 |
放 D 盘,C 盘只剩 42 GB |
| 固件类型 | UEFI | RHEL 9 官方推荐;若镜像在 UEFI 下引导失败,改回 BIOS 也能用 |
| 安全引导 | 关闭(取消勾选) | 开启会导致后面要装的部分内核模块加载失败 |
| 处理器 | 2 vCPU(处理器数量 1 × 内核数 2) | 安装期给 2 个够快,克隆后按清单调整 |
| 内存 | 2048 MB | 安装期用,克隆后按各机清单调整 |
| 网络适配器 | 两块:网卡1 = NAT,网卡2 = 仅主机 | 母盘就配好双网卡,克隆即继承,省掉逐台加网卡 |
| 磁盘 | 25 GB,精简置备,拆分成多个文件 | 25 GB 是为了同时满足项目二(容器镜像吃空间) |
| 光驱 | 指向 RHEL 9.8 ISO | 安装完不要卸载,后面当本地 yum 源用 |
磁盘为什么给 25 GB :项目一的 db/web 节点 20 GB 够用,但项目二的节点要存大量容器镜像,20 GB 会紧张。精简置备下给 25 GB 实际占用不到 5 GB,多给不吃亏。这样一份母盘两个项目通用,不用为项目二重建。
1.3.2 新建虚拟机向导(逐步对照)
打开 VMware Workstation,按 Ctrl + N 打开新建向导,选**「自定义(高级)」**,然后逐步对照下表。
| 向导步骤 | 选择 / 填写 | 说明 |
|---|---|---|
| 1. 欢迎 | 自定义(高级) | 典型模式不能改固件类型,也不能加第二块网卡 |
| 2. 硬件兼容性 | Workstation 17.x |
与软件版本一致 |
| 3. 安装客户机操作系统 | 稍后安装操作系统 | 先建空壳,再手动挂 ISO。选「安装程序光盘映像文件」会走简易安装,把分区和软件选择都替你决定,反而容易出问题 |
| 4. 客户机操作系统 | Linux → Red Hat Enterprise Linux 9 64 位 |
|
| 5. 命名虚拟机 | 名称 base-rhel9.8;位置 D:\vm\base-rhel9.8 |
位置必须手动改,默认在 C 盘 |
| 6. 固件类型 | UEFI ,并取消勾选「安全引导」 | 见 1.3.1 表 |
| 7. 处理器配置 | 处理器数量 1,每个处理器的内核数量 2 |
合计 2 vCPU |
| 8. 此虚拟机的内存 | 2048 MB |
|
| 9. 网络类型 | 使用网络地址转换(NAT) | 第一块网卡先走 NAT,第二块稍后加 |
| 10. I/O 控制器类型 | LSI Logic(推荐) |
默认即可 |
| 11. 磁盘类型 | SCSI(推荐) |
默认即可 |
| 12. 选择磁盘 | 创建新虚拟磁盘 | |
| 13. 指定磁盘容量 | 最大磁盘大小 25 GB;勾选「将虚拟磁盘拆分成多个文件」 |
不要勾选「立即分配所有磁盘空间」------那会一次性吃满 25 GB 真实空间 |
| 14. 指定磁盘文件 | D:\vm\base-rhel9.8\base-rhel9.8.vmdk |
默认即可 |
| 15. 已准备好创建虚拟机 | 点**「自定义硬件」**,见下 | 这一步别急着点「完成」 |
在「自定义硬件」窗口里补三件事:
- 加第二块网卡 :选中左侧「网络适配器」→ 点「添加」→ 硬件类型选「网络适配器」→ 完成 → 右侧新网卡的「网络连接」选 仅主机模式。
- 挂载安装镜像 :选中「新 CD/DVD (SATA)」→ 右侧选「使用 ISO 映像文件」→ 浏览到
D:\iso\rhel-9.8-x86_64-dvd.iso→ 勾选「启动时连接」。 - 删掉不需要的设备:若向导自动加了「USB 控制器」「声卡」「打印机」,可以选中后「移除」------虚拟机用不到,还能省点资源。
点「关闭」回到向导,再点「完成」。左侧列表里出现 base-rhel9.8。
1.3.3 安装 RHEL 9.8 操作系统
从这一步开始,操作都在虚拟机
base-rhel9.8内进行,不再是宿主机。
选中虚拟机,点「开启此虚拟机」。以下是安装界面的关键选择。
第 1 步:引导菜单
UEFI 引导会出现菜单,选 Install Red Hat Enterprise Linux 9.8,回车。
第 2 步:语言
建议选英文 (English)。报错信息好搜索,也更贴合生产环境。
第 3 步:安装信息摘要(Installation Summary)
这是全部关键配置所在,逐个进去设置:
| 项目 | 设置 | 说明 |
|---|---|---|
| 键盘 | English (US) |
|
| 语言支持 | 英文 | 不用加中文,省空间 |
| 时间和日期 | 时区 Asia/Shanghai |
有外网时可开启网络时间 |
| 安装源 | 默认 Local media(自动识别挂载的 ISO) |
若显示红色感叹号,重新选一次 ISO 路径 |
| 软件选择 | 最小安装(Minimal Install) | 关键:不要选带 GUI 的,能省 3 ~ 4 GB 磁盘和大量内存 |
| 安装目的地 | 自动分区(Automatic),勾选磁盘 | 自动分区会创建 /boot/efi、/boot、/、swap |
| Kdump | 关闭(取消「启用 kdump」) | 省内存。1 GB 内存的节点开启 kdump 会直接吃光 |
| 网络和主机名 | 打开两块网卡;主机名填 base-rhel9.8 |
网卡名此时可能是 ens160/ens192,后面基线会统一改回 eth0/eth1 |
| 安全策略 | 不选 | 实验环境不需要 |
| root 密码 | 设为 lee |
与课程笔记统一,方便记忆 |
| 创建用户 | 可以不建(全程 root 操作) | 建了也不影响 |
手动分区的话要注意 :UEFI 下自动分区会切出一个约 600 MB 的
/boot/efi(EFI 系统分区)。如果你想手动分区,必须保留这个 EFI 分区,否则装完无法引导 。推荐方案:/boot/efi600 MB ~ 1 GB +/boot1 GB +/20 GB +swap2 GB(swap 后面会关掉,也可以只给 1 GB)。
第 4 步:开始安装
点「开始安装」,等待 5 ~ 15 分钟(取决于宿主机磁盘速度)。完成后点「重启系统」。
重启时若卡在 Please remove the installation medium, then press ENTER,直接回车即可(ISO 还挂在光驱上是正常的,后面要用)。
1.3.4 安装后的首次自检
第一次登录:用户名 root,密码 lee。
逐条执行,确认母盘基本可用:
bash
# 1. 确认系统版本 ------ 写简历要用这个版本号
cat /etc/redhat-release
# Red Hat Enterprise Linux release 9.8 (Ootpa)
# 2. 确认内核版本
uname -r
# 5.14.0-687.x.el9_8.x86_64
# 3. 确认两块网卡都在(名字可能还是 ens160/ens192,正常,后面基线统一改)
ip -br link
# lo UNKNOWN 00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP>
# ens160 UP 00:0c:29:xx:xx:xx <BROADCAST,MULTICAST,UP,LOWER_UP>
# ens192 UP 00:0c:29:xx:xx:xx <BROADCAST,MULTICAST,UP,LOWER_UP>
# 4. 确认光驱被识别(后面要当本地 yum 源用)
lsblk
# 应该能看到 sr0
# 5. 确认能解析域名(NAT 网卡通外网)
ping -c 2 www.baidu.com
排错方向:
- 第 3 步只看到一块网卡 → 回 VMware「虚拟机设置」确认第二块网卡存在且为「仅主机模式」。
- 第 5 步不通 → 检查 NAT 网卡网关(
ip route)和 DNS(cat /etc/resolv.conf)。
安装虚拟机工具(推荐):
bash
dnf install -y open-vm-tools
systemctl enable --now vmtoolsd
装完之后宿主机与虚拟机之间可以复制粘贴文本、鼠标无缝切换,操作舒服很多。
给刚装好的干净系统打第一个快照:
VMware 菜单 虚拟机 → 快照 → 拍摄快照,名称填 v1-installed-2026。
这个快照的作用:万一后面基线配置改废了,可以一键退回「刚装完系统」的状态,不用重装。做完基线配置、打上正式快照后要把它删掉------快照会持续占用磁盘,也拖慢虚拟机性能。
1.3.5 关机
bash
poweroff
两个注意:不要用「挂起」代替关机;不要在开机状态给虚拟机改内存/CPU(改不了)。要改硬件配置必须先彻底关机。
1.4 母盘基线配置
执行主机 :
base-rhel9.8(母盘)
以下 9 步是所有虚拟机的共同基线。做完之后打快照,之后所有克隆机继承这些配置,省掉大量重复工作。
1.4.1 把网卡名固定为 eth0 / eth1
RHEL9 默认用 ens160 / ens192 这种可预测命名,双网卡环境下很容易搞混。统一改回 eth0 / eth1。
bash
# 查看当前网卡名
ip -br link
# 关闭可预测网卡命名
grubby --update-kernel ALL --args net.ifnames=0
# 重建 grub 配置并重启
grub2-mkconfig -o /boot/grub2/grub.cfg
reboot
重启后验证:
bash
ip -br link
# lo UNKNOWN 00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP>
# eth0 UP 00:0c:29:xx:xx:xx <BROADCAST,MULTICAST,UP,LOWER_UP>
# eth1 UP 00:0c:29:xx:xx:xx <BROADCAST,MULTICAST,UP,LOWER_UP>
关键点:eth0 一定是网卡1(NAT) ,eth1 一定是网卡2(仅主机) 。用 nmcli device show eth0 看连接配置,或者对比 VMware 里的 MAC 地址确认。如果顺序反了,在 VMware 里调整网卡顺序即可。
1.4.2 关闭 SELinux
bash
vim /etc/sysconfig/selinux
# 修改为:
SELINUX=disabled
grubby --update-kernel ALL --args selinux=0
reboot
验证:
bash
getenforce
# Disabled
面试注意:企业生产环境不建议 关闭 SELinux。这里的理由是实验环境反复调目录权限会大量消耗时间。面试如果被问到,正确答法是「实验环境关闭以便聚焦运维链路,生产环境会保留 SELinux 并用
restorecon/semanage做策略放行」。这句话要背下来。
1.4.3 关闭并屏蔽 firewalld
本项目用数据面网络隔离 + Keepalived做访问控制。为了减少变量先关掉 firewalld,文档 3.7 节会专门做一次「用 firewalld 富规则放行业务端口」的补充实验,让你不至于丢掉这项技能。
bash
systemctl disable --now firewalld
systemctl mask firewalld
1.4.4 配置本地 yum 源(离线可用)
母盘光驱里就是 RHEL9.8 的完整 RPM 仓库,用它做源最稳,不受网络影响。
bash
mkdir -p /rhel9.8
mount /dev/cdrom /rhel9.8/
# mount: /rhel9.8: WARNING: source write-protected, mounted read-only.
# 开机自动挂载
echo "mount /dev/cdrom /rhel9.8/" >> /etc/rc.d/rc.local
chmod +x /etc/rc.d/rc.local
ls /rhel9.8/
# AppStream BaseOS EFI EULA GPL images isolinux media.repo RPM-GPG-KEY-*
写仓库文件:
bash
cd /etc/yum.repos.d/
# 清掉可能存在的无效源
mkdir -p /root/repo.bak && mv *.repo /root/repo.bak/ 2>/dev/null
vim rhel9.repo
ini
[AppStream]
name = AppStream
baseurl = file:///rhel9.8/AppStream
gpgcheck = 0
[BaseOS]
name = BaseOS
baseurl = file:///rhel9.8/BaseOS
gpgcheck = 0
验证:
bash
dnf clean all
dnf repolist
dnf list gcc
# gcc.x86_64 11.5.0-14.el9 AppStream
1.4.5 配置 EPEL 源
EPEL 是网络源,需要能上网。母盘要测一次 NAT 是否通。
bash
ping -c 2 172.25.254.2 # 通网关
ping -c 2 www.baidu.com # 通公网
如果网关通但公网不通,检查 VMware 的 NAT 设置和「NAT 设置」里的网关地址。
bash
vim /etc/yum.repos.d/epel.repo
ini
[epel]
name = epel
baseurl = https://mirrors.aliyun.com/epel/9/Everything/x86_64/
gpgcheck = 0
bash
dnf makecache
dnf repolist
dnf list ansible
# ansible.noarch 1:7.7.0-1.el9 epel
1.4.6 安装运维常用工具链
bash
dnf install -y \
vim wget curl lsof net-tools telnet bind-utils tree unzip zip \
htop iotop sysstat tcpdump nmap-ncat bash-completion \
httpd-tools socat rsync chrony
# 编译工具链(后面 Nginx / Redis 源码编译要用)
dnf install -y gcc gcc-c++ make openssl-devel pcre2-devel zlib-devel \
pcre-devel automake autoconf libtool
1.4.7 部署 vmset.sh 网络配置脚本
这是你笔记里的脚本,非常好用,直接复用。它一次性完成:主机名、/etc/hosts、IP、网关、DNS。
bash
cat > /bin/vmset.sh <<'EOF'
#!/bin/bash
[ "$#" -lt "3" ] && {
echo "error: Please input device deviceip and hostname following $0"
exit 1
}
ifconfig $1 &> /dev/null || {
echo $1 is not exist
exit 2
}
CONNECTION=`nmcli connection show | awk "/$1/"'{print $1}'`
[ -n "$CONNECTION" ] && {
nmcli connection delete $CONNECTION
}
sleep 1
hostnamectl hostname $3
cat > /etc/hosts <<HOSTEOF
127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6
$2 $3
HOSTEOF
if [ "$4" != "noroute" ]
then
nmcli connection add type ethernet ifname $1 con-name $1 ipv4.method manual ipv4.addresses $2/24 ipv4.dns 8.8.8.8 ipv4.gateway 172.25.254.2
nmcli connection reload
nmcli connection up $1
else
nmcli connection add type ethernet ifname $1 con-name $1 ipv4.method manual ipv4.addresses $2/24
nmcli connection reload
nmcli connection up $1
fi
ip addr show $1
hostname
EOF
chmod +x /bin/vmset.sh
用法:
bash
vmset.sh eth0 172.25.254.50 lb1 # 带网关(业务面用这个)
vmset.sh eth1 192.168.0.50 lb1 noroute # 不带网关(数据面用这个)
为什么数据面不配网关?一台机器只能有一条默认路由。业务面(NAT)负责出公网,数据面(仅主机)只做内网互通,不需要默认路由。这是双网卡的标准做法,面试常问。
1.4.8 关闭 swap
MySQL 和后续 K8s 都要求关闭 swap。开着 swap 会导致数据库出现不可控的响应延迟抖动。
bash
systemctl disable --now swap.target
systemctl mask swap.target
sed -i '/swap/s/^/#/' /etc/fstab
swapon -s # 无输出即为关闭成功
free -h
1.4.9 实现所有克隆机互相免密
这是你笔记里的绝招,用来一次性解决全网免密。
bash
# 1. 在母盘生成密钥对(一路回车)
ssh-keygen
# 2. 先把公钥装到本机的 root 授权
ssh-copy-id -i /root/.ssh/id_rsa.pub root@localhost
# 3. 关键一步:把 .ssh 目录放进用户骨架目录
cp -rp /root/.ssh/ /etc/skel/
原理:/etc/skel/ 是新用户家目录的模板。放进它之后,母盘克隆出去的每一台机器、每一个新建用户的 home 目录都会自动带上这对密钥和 authorized_keys,从而天然实现全网免密。
验证(母盘上):
bash
ssh root@localhost hostname
# 不需要密码,直接返回主机名
安全提示:这会把私钥分发到所有克隆机的新用户家目录,实验环境很方便,生产环境绝不能这么做。面试可以主动提这个对比,是加分项。
1.5 母盘统计与打快照
执行主机 :
base-rhel9.8(母盘)+宿主机 Windows(VMware 快照操作)
1.5.1 记录基线信息
bash
# 记录基线版本信息,写简历要用
cat /etc/redhat-release
uname -r
df -h /
记录:Red Hat Enterprise Linux 9.8,内核 5.14.0-687.x.el9_8.x86_64。
1.5.2 拍摄正式快照
先关机------快照记录的是关机状态,后续克隆最稳定:
bash
poweroff
在宿主机 VMware 菜单 虚拟机 → 快照 → 拍摄快照:
| 字段 | 填写 |
|---|---|
| 名称 | base-clean-2026 |
| 描述 | RHEL9.8 minimal + 基线9步 + 免密 + 本地源,克隆源 |
点「拍摄快照」。左侧虚拟机列表里这台的快照树会多出一个节点。
1.5.3 验证快照可用(别跳过)
拍完快照要实际回滚一次确认它真的能用------很多人做到第七章才发现克隆源快照是坏的,只能从头重来。
VMware 菜单 虚拟机 → 快照 → 快照管理器 → 选中 base-clean-2026 → 点「转到」→ 确认回滚。
回滚后开机确认基线还在:
bash
cat /etc/redhat-release # 版本正常
ip -br link # 应该是 eth0 / eth1,不是 ens160
sestatus # 应该是 disabled
1.5.4 清理中间快照
v1-installed-2026(1.3.4 拍的那个)已经不需要了------它记录的是「刚装完系统、基线还没配」的状态,而 base-clean-2026 才是真正的克隆源。
在快照管理器里选中 v1-installed-2026 → 点「删除」,提示合并磁盘时确认。
为什么不保留多个快照 :快照不是备份,它记录的是磁盘增量差异。每多一个快照,VMware 写入时就要多维护一层 delta 文件------虚拟机越跑越慢,磁盘占用也持续增长。
快照纪律:功能验证完立刻删,只保留一个确实要用的最新快照。这条纪律在后面做故障演练时会反复用到。
再次关机:
bash
poweroff
母盘到此完成。今后不要直接启动母盘做实验,它只作为克隆源。母盘越干净,克隆出来的机器越可靠。
第二章 批量克隆与主机初始化
本章操作主机 :
宿主机 Windows(克隆)→ 7 台克隆机(初始化)
2.1 克隆 7 台虚拟机
执行主机 :
宿主机 Windows(VMware 克隆操作)
母盘已经打好快照 base-clean-2026,现在用链接克隆批量生出 7 台。
为什么必须用链接克隆 :7 台机器如果都用「完整克隆」,每台独占 20 ~ 25 GB,合计 140 ~ 175 GB,D 盘装不下。链接克隆共享母盘的只读磁盘块,每台增量只有几百 MB,7 台合计约 5 ~ 8 GB。
克隆向导(每台走一遍,共 7 次):
| 向导步骤 | 选择 / 填写 | 说明 |
|---|---|---|
| 0. 准备 | 先关闭母盘电源 | 克隆要求源虚拟机处于关机或快照状态 |
| 1. 入口 | 右键 base-rhel9.8 → 管理 → 克隆 |
|
| 2. 克隆来源 | 虚拟机中的当前快照 → 选 base-clean-2026 |
关键:从快照克隆,MAC 地址才会重新生成 |
| 3. 克隆类型 | 创建链接克隆 | 界面会提示"链接克隆依赖于父虚拟机" |
| 4. 名称和位置 | 见下表,位置都在 D:\vm\ 下 |
|
| 5. 完成 | 点「完成」,等 10 ~ 30 秒 | 链接克隆很快,不复制磁盘数据 |
必须记住的一件事 :链接克隆依赖母盘文件。母盘
D:\vm\base-rhel9.8和整个D:\vm目录以后都不能移动、重命名或删除,否则 7 台克隆机全部无法开机。要迁移就把整个目录一起搬。
依次克隆 7 台,名称和位置如下。
| 序号 | 名称 | 位置 |
|---|---|---|
| 1 | lb1 |
D:\vm\proj1-lb1 |
| 2 | lb2 |
D:\vm\proj1-lb2 |
| 3 | web1 |
D:\vm\proj1-web1 |
| 4 | web2 |
D:\vm\proj1-web2 |
| 5 | db1 |
D:\vm\proj1-db1 |
| 6 | db2 |
D:\vm\proj1-db2 |
| 7 | infra |
D:\vm\proj1-infra |
克隆完成后,逐台按总览表格调整 vCPU 与内存(在虚拟机关机状态下改)。
| 主机 | vCPU | 内存 |
|---|---|---|
| lb1 / lb2 | 1 | 768 MB |
| web1 / web2 | 2 | 1536 MB |
| db1 / db2 | 1 | 1024 MB |
| infra | 1 | 1024 MB |
如果 VMware 不允许设置 768 MB,用 1024 MB 也行,只是总内存会到 8.5 GB。内存实在紧张时把 lb2 平时挂起。
2.2 逐台配置网络与主机名
执行主机 :
lb1、lb2、web1、web2、db1、db2、infra(7 台依次执行)
每台机器都要做 。以 lb1 为例(开机后用 root 登录):
bash
# 业务面:NAT,带网关
vmset.sh eth0 172.25.254.50 lb1
# 数据面:仅主机,不带网关
vmset.sh eth1 192.168.0.50 lb1 noroute
其余 6 台对照下表执行。
| 主机 | 业务面命令 | 数据面命令 |
|---|---|---|
| lb1 | vmset.sh eth0 172.25.254.50 lb1 |
vmset.sh eth1 192.168.0.50 lb1 noroute |
| lb2 | vmset.sh eth0 172.25.254.60 lb2 |
vmset.sh eth1 192.168.0.60 lb2 noroute |
| web1 | vmset.sh eth0 172.25.254.10 web1 |
vmset.sh eth1 192.168.0.10 web1 noroute |
| web2 | vmset.sh eth0 172.25.254.20 web2 |
vmset.sh eth1 192.168.0.20 web2 noroute |
| db1 | vmset.sh eth0 172.25.254.30 db1 |
vmset.sh eth1 192.168.0.30 db1 noroute |
| db2 | vmset.sh eth0 172.25.254.40 db2 |
vmset.sh eth1 192.168.0.40 db2 noroute |
| infra | vmset.sh eth0 172.25.254.100 infra |
vmset.sh eth1 192.168.0.100 infra noroute |
确认 IP 生效:
bash
# 在每台上执行
ip -br a
# eth0 UP 172.25.254.50/24
# eth1 UP 192.168.0.50/24
vmset.sh会把/etc/hosts重置为只包含自己。所以下一步要统一写全局 hosts。
2.3 统一的 hosts 文件
执行主机 :
lb1(生成并分发)→ 其余 6 台(接收)
在 lb1 上生成一份完整 hosts,然后全网分发:
bash
cat > /etc/hosts <<'EOF'
127.0.0.1 localhost localhost.localdomain localhost4 localhost4.localdomain4
::1 localhost localhost.localdomain localhost6 localhost6.localdomain6
# ===== 项目一:业务面 NAT 172.25.254.0/24 =====
172.25.254.50 lb1 lb1.timinglee.org
172.25.254.60 lb2 lb2.timinglee.org
172.25.254.10 web1 web1.timinglee.org
172.25.254.20 web2 web2.timinglee.org
172.25.254.30 db1 db1.timinglee.org
172.25.254.40 db2 db2.timinglee.org
172.25.254.100 infra infra.timinglee.org dns.timinglee.org
# ===== 项目一:数据面 仅主机 192.168.0.0/24 =====
192.168.0.50 lb1-lan
192.168.0.60 lb2-lan
192.168.0.10 web1-lan
192.168.0.20 web2-lan
192.168.0.30 db1-lan
192.168.0.40 db2-lan
192.168.0.100 infra-lan
# ===== VIP =====
172.25.254.200 www.timinglee.org app.timinglee.org api.timinglee.org
172.25.254.201 db-vip.timinglee.org
172.25.254.202 status.timinglee.org
EOF
分发:
bash
for i in 60 10 20 30 40 100
do
scp /etc/hosts root@172.25.254.$i:/etc/hosts
done
验证免密与连通性(在 lb1 上):
bash
for i in 10 20 30 40 50 60 100; do
echo -n "$i: "; ssh -o StrictHostKeyChecking=no root@172.25.254.$i hostname
done
期望输出:
10: web1
20: web2
30: db1
40: db2
50: lb1
60: lb2
100: infra
如果某一台要求输密码,说明该机的
/etc/skel继承没生效。补救方法:在母盘上重新ssh-keygen+cp -rp /root/.ssh/ /etc/skel/,或者直接ssh-copy-id一次到那台机器。
2.4 数据面连通性验证
执行主机 :
lb1(发起验证)
bash
# 从 lb1 逐台 ping 数据面
for i in 10 20 30 40 50 60 100; do
ping -c1 -W1 192.168.0.$i > /dev/null && echo "192.168.0.$i OK" || echo "192.168.0.$i FAIL"
done
期望全部 OK。数据面不通的常见原因:
- 仅主机网络的 DHCP 还在跑,分了一个同网段地址导致冲突 → 关掉 VMnet1 的 DHCP。
- Windows 上的
VMware Network Adapter VMnet1网卡被禁用 → 在网络连接里启用。 vmset.sh的第四参数写错了(noroute敲成了norouter)→ 检查ip route,会发现多了一条默认路由。
打快照 :给 7 台都打一个 stage1-network-ok 快照。后面任何一步搞坏,回滚到这个点比重新配置快得多。
第三章 infra 节点:基础服务
本章操作主机 :
infra(服务端)为主,web1/web2/db2作为客户端参与
infra(172.25.254.100 / 192.168.0.100)承担五件事:时间同步、NFS 共享存储、集中日志、DNS、Redis 缓存主节点。
为什么把这些放一台?它们在真实企业里通常也是独立的「基础服务机」,而且共同特征是负载极低,合并到一台虚拟机不浪费内存。
3.1 时间同步服务器(chrony)
执行主机 :
infra(时间服务器)→ 其余 6 台(客户端)
VRRP、MySQL 主从、K8s 证书全都对时间一致性敏感。必须先把时间统一。
bash
dnf install -y chrony
vim /etc/chrony.conf
服务端配置(关键两行):
ini
# 注释掉默认的公网源: pool 2.rhel.pool.ntp.org iburst
# 允许所有客户端同步
allow 0.0.0.0/0
# 即便本机未同步到上游,也对外提供时间(内网无外网时的关键)
local stratum 10
# 上游时间源(有外网时保留)
pool ntp.aliyun.com iburst
bash
systemctl enable --now chronyd
systemctl restart chronyd
# 验证
chronyc sources -v
chronyc tracking
客户端节点(除 infra 外的 6 台)修改:
bash
vim /etc/chrony.conf
# 注释 pool 行,改成:
pool 192.168.0.100 iburst
批量执行(在 infra 上一次性推送):
bash
for i in 50 60 10 20 30 40
do
ssh root@172.25.254.$i "sed -i 's|^pool .*|# &|' /etc/chrony.conf; \
grep -q '^pool 192.168.0.100' /etc/chrony.conf || echo 'pool 192.168.0.100 iburst' >> /etc/chrony.conf; \
systemctl restart chronyd; sleep 1; chronyc sources -v | tail -3"
done
验证:
bash
ssh root@172.25.254.10 "chronyc sources -v | grep '\^*'"
# ^* infra-lan 2 6 377 12 +1234ns[ +56us] +/- ..ms
时间同步没做好,后面 Keepalived 会出现「两台都在 MASTER」的脑裂现象,MySQL 主从会报错。这一步别跳过。
3.2 NFS 共享存储
执行主机 :
infra(NFS 服务端)→web1、web2(客户端)
要解决的问题:web1 / web2 的静态资源、用户上传目录必须是同一份。否则用户在 web1 上传了文件,请求被负载均衡分到 web2 时就读不到。这是 Web 集群必须解决的经典问题。
3.2.1 服务端(infra)
bash
dnf install -y nfs-utils rpcbind
systemctl enable --now nfs-server
# 创建共享目录结构
mkdir -p /nfs/web/static
mkdir -p /nfs/web/upload
mkdir -p /nfs/web/app
# 权限:让 web 节点上的 nginx / tomcat 用户能写
chmod 2777 /nfs/web/static /nfs/web/upload /nfs/web/app
vim /etc/exports
ini
/nfs/web/static 192.168.0.0/24(rw,sync,no_root_squash,no_subtree_check)
/nfs/web/upload 192.168.0.0/24(rw,sync,no_root_squash,no_subtree_check)
/nfs/web/app 192.168.0.0/24(rw,sync,no_root_squash,no_subtree_check)
bash
exportfs -rv
# exporting 192.168.0.0/24:/nfs/web/app
# exporting 192.168.0.0/24:/nfs/web/static
# exporting 192.168.0.0/24:/nfs/web/upload
# 验证
showmount -e 192.168.0.100
参数解释(面试会问):
rw读写;sync同步写(数据安全,性能略低);async异步写(快,宕机可能丢数据)。no_root_squash允许客户端 root 保持 root 权限,方便运维,但生产环境有安全风险 ,更严谨的做法是root_squash+ 匹配 UID。no_subtree_check关闭子树检查,避免文件重命名时出现stale file handle报错。- 只导出给 192.168.0.0/24,不导出给业务网段------这就是双网卡隔离的价值。
3.2.2 客户端(web1 / web2)
bash
dnf install -y nfs-utils
showmount -e 192.168.0.100
mkdir -p /data/nfs/{static,upload,app}
mount 192.168.0.100:/nfs/web/static /data/nfs/static
mount 192.168.0.100:/nfs/web/upload /data/nfs/upload
mount 192.168.0.100:/nfs/web/app /data/nfs/app
写入 /etc/fstab 实现开机自动挂载:
bash
cat >> /etc/fstab <<'EOF'
192.168.0.100:/nfs/web/static /data/nfs/static nfs defaults,_netdev 0 0
192.168.0.100:/nfs/web/upload /data/nfs/upload nfs defaults,_netdev 0 0
192.168.0.100:/nfs/web/app /data/nfs/app nfs defaults,_netdev 0 0
EOF
mount -a
df -h | grep nfs
验证跨机共享:
bash
# 在 web1 写
echo "hello from web1 $(date)" > /data/nfs/upload/test.txt
# 在 web2 读
cat /data/nfs/upload/test.txt
# hello from web1 2026-09-13 ...
这就是共享存储生效的证明,截图保存。
3.2.3 进阶:用 autofs 实现按需自动挂载(加分项)
/etc/fstab 挂载的问题:NFS 服务端宕机时客户端会卡在启动阶段,_netdev 只能部分缓解。autofs 解决得更彻底。
bash
dnf install -y autofs
vim /etc/auto.master
# 追加一行
/- /etc/auto.nfs
vim /etc/auto.nfs
/nfs/static -rw,soft,intr 192.168.0.100:/nfs/web/static
/nfs/upload -rw,soft,intr 192.168.0.100:/nfs/web/upload
/nfs/app -rw,soft,intr 192.168.0.100:/nfs/web/app
systemctl enable --now autofs
ls /nfs/static # 第一次访问时自动挂载
mount | grep nfs
# 闲置 300 秒后自动卸载
autofs 的价值:按需挂载、空闲卸载,NFS 服务端故障时不会阻塞客户端。
-soft参数让 IO 超时后返回错误而不是无限等待,避免应用线程被卡死。
3.3 集中日志服务器(rsyslog)
执行主机 :
infra(日志服务器)→ 其余 6 台(客户端)
作用:所有节点的日志统一汇总到 infra,一台机器就能排查整个集群。这是真实运维最常用的手段。
3.3.1 服务端(infra)
bash
vim /etc/rsyslog.conf
添加/取消注释:
# 加载 UDP 接收模块
module(load="imudp")
input(type="imudp" port="514")
# 也支持 TCP(更可靠,不丢日志)
module(load="imtcp")
input(type="imtcp" port="514")
# 按来源 IP 分目录存放
$template RemoteLogs,"/var/log/remote/%FROMHOST-IP%/%programname%.log"
*.* ?RemoteLogs
& stop
bash
systemctl enable --now rsyslog
systemctl restart rsyslog
# 验证监听
ss -antlupe | grep 514
# udp UNCONN 0 0 0.0.0.0:514
# tcp LISTEN 0 25 0.0.0.0:514
3.3.2 客户端(其余 6 台)
bash
cat >> /etc/rsyslog.conf <<'EOF'
# 发送所有日志到 infra(@@ 表示 TCP,@ 表示 UDP)
*.* @@192.168.0.100:514
EOF
批量下发(在 infra 上):
bash
for i in 50 60 10 20 30 40
do
ssh root@172.25.254.$i "grep -q '192.168.0.100:514' /etc/rsyslog.conf || echo '*.* @@192.168.0.100:514' >> /etc/rsyslog.conf; systemctl restart rsyslog; echo done $i"
done
@= UDP(快但可能丢),@@= TCP(可靠)。集中日志用 TCP 更合适,这是常见面试题。
3.3.3 验证
bash
# 在 web1 上造一条日志
ssh root@172.25.254.10 "logger 'TEST-MSG-FROM-WEB1'"
# 在 infra 上查
ls /var/log/remote/
# 192.168.0.10 192.168.0.20 ...
grep -r "TEST-MSG-FROM-WEB1" /var/log/remote/
# /var/log/remote/192.168.0.10/root.log:... TEST-MSG-FROM-WEB1
目录名会是
192.168.0.10(客户端用数据面网卡发过去的源 IP)。这正是我们希望看到的------日志走数据面,不占业务面带宽。
3.3.4 日志轮转(防止撑爆磁盘)
bash
cat > /etc/logrotate.d/timinglee-remote <<'EOF'
/var/log/remote/*/*.log {
daily
rotate 7
missingok
notifempty
compress
delaycompress
copytruncate
}
EOF
copytruncate是应对 rsyslog 持有文件句柄的关键参数,不加会轮转失败。
3.4 DNS 服务器(bind)
执行主机 :
infra
作用 :让集群内部可以用域名访问而不是记 IP,也让 www.timinglee.org 这类域名在集群内正式可解析。
3.4.1 安装与主配置
bash
dnf install -y bind bind-utils
systemctl enable --now named
vim /etc/named.conf
修改 options 段的关键项:
options {
listen-on port 53 { any; };
listen-on-v6 port 53 { ::1; };
directory "/var/named";
allow-query { any; };
recursion yes;
forwarders { 114.114.114.114; 8.8.8.8; };
dnssec-validation no;
};
3.4.2 正向解析区域
bash
vim /etc/named.rfc1912.zones
# 追加到文件末尾
zone "timinglee.org" IN {
type master;
file "timinglee.org.zone";
allow-update { none; };
};
bash
cd /var/named/
cp -p named.localhost timinglee.org.zone
vim timinglee.org.zone
$TTL 1D
@ IN SOA dns.timinglee.org. admin.timinglee.org. (
2026091301 ; serial
1D ; refresh
1H ; retry
1W ; expire
3H ) ; minimum
NS dns.timinglee.org.
dns A 172.25.254.100
www A 172.25.254.200
app A 172.25.254.200
api A 172.25.254.200
status A 172.25.254.202
db-vip A 172.25.254.201
web1 A 172.25.254.10
web2 A 172.25.254.20
db1 A 172.25.254.30
db2 A 172.25.254.40
lb1 A 172.25.254.50
lb2 A 172.25.254.60
serial每次改完 zone 文件都要递增(这里用了日期 + 序号 2026091301),否则从库不会同步。这是最经典的踩坑点。
bash
chown root:named /var/named/timinglee.org.zone
named-checkzone timinglee.org /var/named/timinglee.org.zone
# zone timinglee.org/IN: loaded serial 2026091301
# OK
systemctl restart named
3.4.3 验证
bash
dig www.timinglee.org @172.25.254.100 +short
# 172.25.254.200
dig app.timinglee.org @192.168.0.100 +short
# 172.25.254.200
# 反向验证:查一个不存在的域名
dig notexist.timinglee.org @172.25.254.100 +short
# (空)
3.4.4 让所有节点使用这个 DNS
注意 :vmset.sh 把 DNS 直接写进了 NetworkManager 连接配置(ipv4.dns 8.8.8.8),所以直接改 /etc/resolv.conf 会被 NetworkManager 覆盖。正确做法是改连接配置:
bash
# 在 infra 上批量推送
for i in 50 60 10 20 30 40
do
ssh root@172.25.254.$i "nmcli connection modify eth0 ipv4.dns '192.168.0.100 8.8.8.8'; nmcli connection up eth0; sleep 1; cat /etc/resolv.conf | grep nameserver"
done
验证:
bash
ssh root@172.25.254.10 "dig www.timinglee.org +short"
# 172.25.254.200
3.4.5 多向解析(view,进阶)
企业内网中,「内网客户端解析到内网地址,外网客户端解析到公网地址」是一个常见需求。bind 用 view 实现。
bash
vim /etc/named.conf
把原有的 zone "." 和 include "/etc/named.rfc1912.zones"; 注释掉,改成:
view "lan" {
match-clients { 192.168.0.0/24; };
recursion yes;
zone "." IN {
type hint;
file "named.ca";
};
include "/etc/named.rfc1912.lan";
};
view "wan" {
match-clients { any; };
recursion yes;
zone "." IN {
type hint;
file "named.ca";
};
include "/etc/named.rfc1912.zones";
};
include "/etc/named.root.key";
bash
cp -p /etc/named.rfc1912.zones /etc/named.rfc1912.lan
# 在 lan 版本的 zone 文件里把 www 指向内网地址
vim /var/named/timinglee.org.lan.zone
# www A 192.168.0.50
view必须定义在最外层,两个 view 的match-clients不能重叠(否则第一条匹配生效),并且zone "."的 hint 必须在每个 view 里都定义一份。这是踩坑点。
3.5 Redis 缓存主节点
执行主机 :
infra(Redis 主)+web2(Redis 从)+infra/web2/db2(Sentinel 哨兵)
作用:作为业务数据缓存(首页热门数据、商品详情等),减轻 MySQL 压力。
Redis 7.x 源码编译,编译约 1 分钟,资源占用低。
3.5.1 源码编译安装
bash
dnf install -y make gcc initscripts tcl
cd /root
wget https://download.redis.io/releases/redis-7.4.8.tar.gz
tar zxf redis-7.4.8.tar.gz
cd redis-7.4.8/
make -j2
make install
redis-server --version
# Redis server v=7.4.8
3.5.2 安装成系统服务
bash
cd utils/
vim install_server.sh
必须注释掉 systemd 检测块 (否则脚本直接 exit 1):
bash
#bail if this system is managed by systemd
#_pid_1_exe="$(readlink -f /proc/1/exe)"
#if [ "${_pid_1_exe##*/}" = systemd ]
#then
# echo "This systems seems to use systemd."
# echo "Please take a look at the provided example service unit files in this directory, and adapt them. Sorry."
# exit 1
#fi
bash
./install_server.sh
# Please select the redis port for this instance: [6379] → 回车
# Please select the redis config file name: [/etc/redis/6379.conf] → 回车
# Please select the redis log file name: [/var/log/redis_6379.log] → 回车
# Please select the data directory: [/var/lib/redis/6379] → 回车
# Please select the redis executable path: [/usr/local/bin/redis-server] → 回车
systemctl daemon-reload
systemctl start redis_6379
systemctl enable redis_6379
3.5.3 生产化配置
bash
vim /etc/redis/6379.conf
ini
# 注释掉默认只监听本地
#bind 127.0.0.1 -::1
bind 0.0.0.0
# 关闭保护模式(因为已改 bind)
protected-mode no
# 设置密码
requirepass lee
# 主从认证密码(从节点需要)
masterauth lee
# 最大内存限制,配合淘汰策略(小内存环境必须设)
maxmemory 128mb
maxmemory-policy allkeys-lru
# 降低持久化频率(实验环境减少 IO)
save 900 1
appendonly no
# 管理命令重命名(安全加固,防止误操作)
rename-command FLUSHALL ""
rename-command FLUSHDB ""
bash
systemctl restart redis_6379
# 验证
redis-cli -a lee ping
# PONG (会有一条密码明文警告,正常)
redis-cli -a lee info replication
# role:master
# connected_slaves:0
内存警告处理:Redis 会提示
overcommit_memory建议设为 1。执行:
bashecho "vm.overcommit_memory = 1" >> /etc/sysctl.conf sysctl -p
rename-command的意义:把FLUSHALL、CONFIG这类高危命令改名或禁用,防止误操作或被入侵后一键清库。这是 Redis 安全加固的标准动作,面试会问。
3.5.4 Redis 主从复制
在 web2 上用同样方式编译安装 Redis,然后配置为从节点。
bash
vim /etc/redis/6379.conf
ini
bind 0.0.0.0
protected-mode no
requirepass lee
masterauth lee
maxmemory 128mb
maxmemory-policy allkeys-lru
# 关键:指定主节点(走数据面)
replicaof 192.168.0.100 6379
# 从节点只读(默认就是,显式声明更清晰)
replica-read-only yes
bash
systemctl restart redis_6379
redis-cli -a lee info replication | head -5
# role:slave
# master_host:192.168.0.100
# master_port:6379
# master_link_status:up ← 关键
在 infra 上确认:
bash
redis-cli -a lee info replication
# role:master
# connected_slaves:1
# slave0:ip=192.168.0.20,port=6379,state=online,offset=...,lag=0 ← 关键
数据同步验证:
bash
# infra 上写
redis-cli -a lee set site timinglee
# OK
# web2 上读
ssh root@172.25.254.20 "redis-cli -a lee get site"
# "timinglee"
# web2 上写(应该被拒绝)
ssh root@172.25.254.20 "redis-cli -a lee set x 1"
# (error) READONLY You can't write against a read only replica.
3.5.5 Sentinel 哨兵(三节点自动故障切换)
哨兵需要 3 个 节点才能形成仲裁(quorum = 2)。部署在 infra、web2、db2 上。
准备配置文件(在 infra 上):
bash
cp /root/redis-7.4.8/sentinel.conf /etc/redis/sentinel.conf
vim /etc/redis/sentinel.conf
ini
bind 0.0.0.0
port 26379
daemonize yes
pidfile "/var/run/redis-sentinel.pid"
logfile "/var/log/redis-sentinel.log"
dir "/var/lib/redis/sentinel"
# 哨兵自身访问密码(生产环境建议开启,和下面ExecStop对应)
requirepass lee
# 监控主节点:mymaster 是自定义名字,2 = quorum(需要 2 个哨兵同意才判定客观下线)
sentinel monitor mymaster 192.168.0.100 6379 2
# 主节点认证密码
sentinel auth-pass mymaster lee
# 10 秒连不上判定主观下线
sentinel down-after-milliseconds mymaster 10000
# 故障转移后同时同步新主节点的从节点数
sentinel parallel-syncs mymaster 1
# 整个故障转移的超时时间(毫秒)
sentinel failover-timeout mymaster 180000
# 关闭保护模式(允许外网/跨机访问)
protected-mode no
quorum必须是(节点数/2) + 1。3 个哨兵 → quorum = 2。2 个哨兵 → quorum = 2,但此时任何一个挂了就无法完成切换,所以生产环境至少要 3 个哨兵。这是必问点。
分发配置:
bash
scp /etc/redis/sentinel.conf root@192.168.0.20:/etc/redis/sentinel.conf
scp /etc/redis/sentinel.conf root@192.168.0.40:/etc/redis/sentinel.conf
三台都创建 systemd 服务:
bash
for i in 100 20 40
do
ssh root@192.168.0.$i "cat > /lib/systemd/system/redis-sentinel.service <<'SF'
[Unit]
Description=Redis Sentinel
After=network.target
[Service]
Type=forking
ExecStart=/usr/local/bin/redis-sentinel /etc/redis/sentinel.conf
ExecStop=/usr/local/bin/redis-cli -p 26379 -a lee shutdown
PIDFile=/var/run/redis-sentinel.pid
TimeoutStopSec=30
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
SF
systemctl daemon-reload
systemctl enable --now redis-sentinel
sleep 1
ss -antlupe | grep 26379"
done
验证:
bash
# 主节点信息
redis-cli -p 26379 sentinel master mymaster
# num-slaves 1
# num-other-sentinels 2
# quorum 2
# 其他哨兵
redis-cli -p 26379 sentinel sentinels mymaster
# 当前主节点
redis-cli -p 26379 sentinel get-master-addr-by-name mymaster
# 192.168.0.100
# 6379
故障演练:
bash
# 停掉主节点 Redis(在 infra 上)
systemctl stop redis_6379
# 观察任意一台哨兵的日志
tail -f /var/log/redis-sentinel.log
# +sdown master mymaster 192.168.0.100 6379
# +odown master mymaster 192.168.0.100 6379 #quorum 2/2
# +try-failover master mymaster 192.168.0.100 6379
# +elected-leader master mymaster 192.168.0.100 6379
# +failover-state-select-slave master mymaster 192.168.0.100 6379
# +selected-slave slave 192.168.0.20:6379
# +promoted-slave slave 192.168.0.20:6379
# +switch-master mymaster 192.168.0.100 6379 192.168.0.20 6379 ← 主节点已切换
# 确认
redis-cli -p 26379 sentinel get-master-addr-by-name mymaster
# 192.168.0.20
# 6379
# 恢复原主(它会以从节点身份自动加入)
systemctl start redis_6379
redis-cli -a lee info replication | head -3
# role:slave
重点结论(面试必答) :Sentinel 会自动改写被降级节点的
redis.conf,把replicaof指向新的主节点。所以你不需要手工改配置。这个自动改写是 Sentinel 最有价值的特性之一。
3.6 用 firewalld 富规则放行(补充技能)
执行主机 :
infra(示例,其余节点同理)
前面关了 firewalld 是因为实验要减少变量。但 firewalld 是简历上的技能点,这里补一份规则清单,让你面试能讲。
bash
# 放行服务
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --permanent --add-service=mysql
# 放行端口(NFS 用)
firewall-cmd --permanent --add-port=2049/tcp
firewall-cmd --permanent --add-port=111/tcp
firewall-cmd --permanent --add-port=20048/tcp
firewall-cmd --permanent --add-port=26379/tcp
firewall-cmd --permanent --add-port=11211/tcp
firewall-cmd --permanent --add-port=514/tcp
firewall-cmd --permanent --add-port=514/udp
# 富规则:只允许数据面访问 3306,并记录日志
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.0.0/24" port port="3306" protocol="tcp" accept'
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.168.0.0/24" port port="3306" protocol="tcp" log prefix="MYSQL-ACCESS " level="info" accept'
# 富规则:拒绝业务面访问 3306(数据库不能从业务面进来)
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.25.254.0/24" port port="3306" protocol="tcp" reject'
# 生效
firewall-cmd --reload
firewall-cmd --list-all
firewall-cmd --list-rich-rules
# 移除某条规则
firewall-cmd --permanent --remove-rich-rule='rule family="ipv4" source address="172.25.254.0/24" port port="3306" protocol="tcp" reject'
firewall-cmd --reload
富规则(rich rule)四要素速记:
rule family source/destination port protocol accept/reject/drop log。不要在本次项目里真的开启 firewalld------7 台机器逐一配规则会让后续调试极其痛苦。企业里这些规则由 Ansible 统一推送或云安全组管理。这个认知本身就是面试答案。
打快照 :stage2-infra-ok。
第四章 web 节点:Nginx + Tomcat + Memcached 集群
本章操作主机 :
web1、web2(两台命令完全一致)
web1(172.25.254.10 / 192.168.0.10)和 web2(172.25.254.20 / 192.168.0.20)配置完全一致,只有 Tomcat 的 MSM 配置有一处差异(4.5.3 节)。
4.1 架构说明
执行主机:本节为架构说明,无需执行命令
客户端 → lb1/lb2 (HAProxy 七层)
↓ 轮询
web1 / web2
├── Nginx :80 ── 静态资源直接返回(读 NFS)
└── Nginx :80 ──→ proxy_pass → Tomcat :8080(动态 JSP)
↓
Memcached (web1+web2) 共享 Session
动静分离的价值(面试必答):静态请求(图片/CSS/JS)由 Nginx 直接返回,不经过 Tomcat;Tomcat 只处理动态请求。Tomcat 的并发压力下降一个数量级,因为它不用再为每个图片请求分配一个 Java 线程。
面试官问「为什么要在 Tomcat 前面放 Nginx」,不要只说「反向代理」,要说这三条:
- 动静分离:静态资源由 Nginx 的 epoll 模型处理,并发能力比 Tomcat 的线程模型高一个数量级。
- 负载均衡:Nginx 把请求分发到 Tomcat 集群。
- 安全隔离:Tomcat 不直接暴露给客户端,可以隐藏内部端口和版本信息。
4.2 创建运行用户
执行主机 :
web1、web2
bash
useradd -s /sbin/nologin -M nginx
useradd -s /sbin/nologin -M tomcat
4.3 Nginx 源码编译
执行主机 :
web1、web2
bash
dnf install -y gcc openssl-devel pcre2-devel zlib-devel make
cd /root
wget https://nginx.org/download/nginx-1.28.1.tar.gz
tar zxf nginx-1.28.1.tar.gz
cd nginx-1.28.1/
先隐藏版本号再编译(比编译完再改更省一步):
bash
sed -i 's|#define NGINX_VERSION .*|#define NGINX_VERSION ""|' src/core/nginx.h
sed -i 's|#define NGINX_VER .*|#define NGINX_VER "TIMINGLEE/" NGINX_VERSION|' src/core/nginx.h
grep -n "NGINX_VER\|NGINX_VERSION" src/core/nginx.h | head -5
编译:
bash
./configure \
--prefix=/usr/local/nginx \
--user=nginx \
--group=nginx \
--with-http_ssl_module \
--with-http_v2_module \
--with-http_realip_module \
--with-http_stub_status_module \
--with-http_gzip_static_module \
--with-pcre \
--with-stream \
--with-stream_ssl_module \
--with-stream_realip_module
make -j2
make install
注意每个
--with-*后面都要有反斜杠换行,最后一行不能有反斜杠。这是最常见的语法错误。
配置环境变量:
bash
echo 'export PATH=$PATH:/usr/local/nginx/sbin' >> ~/.bash_profile
source ~/.bash_profile
nginx -V 2>&1 | head -1
# nginx version: TIMINGLEE/ ← 版本号已隐藏
隐藏版本号的价值:默认响应头会暴露
Server: nginx/1.28.1,攻击者可以据此查 CVE 匹配攻击工具。隐藏后无法判断版本。这是「你做过的安全加固」的实打实证据。
4.3.1 systemd 托管
bash
cat > /lib/systemd/system/nginx.service <<'EOF'
[Unit]
Description=The NGINX HTTP and reverse proxy server
After=syslog.target network-online.target remote-fs.target nss-lookup.target
Wants=network-online.target
[Service]
Type=forking
PIDFile=/usr/local/nginx/logs/nginx.pid
ExecStartPre=/usr/local/nginx/sbin/nginx -t
ExecStart=/usr/local/nginx/sbin/nginx
ExecReload=/usr/local/nginx/sbin/nginx -s reload
ExecStop=/bin/kill -s QUIT $MAINPID
PrivateTmp=true
LimitNOFILE=100000
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now nginx
# 验证
ss -antlp | grep ':80'
curl -I 127.0.0.1
4.3.2 Nginx 主配置
bash
vim /usr/local/nginx/conf/nginx.conf
nginx
user nginx;
worker_processes auto;
# 2 核 CPU,绑到 0、1 号核
worker_cpu_affinity 01 10;
error_log logs/error.log warn;
pid logs/nginx.pid;
events {
worker_connections 10240;
use epoll;
accept_mutex on;
multi_accept on;
}
http {
include mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'upstream_time=$upstream_response_time request_time=$request_time';
access_log logs/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
keepalive_requests 1000;
# 隐藏版本号(编译期已改,这里是双保险)
server_tokens off;
# gzip 压缩
gzip on;
gzip_comp_level 4;
gzip_min_length 1k;
gzip_buffers 32 4k;
gzip_types text/plain text/css text/xml application/xml application/json application/javascript application/x-javascript image/svg+xml;
gzip_vary on;
gzip_static on;
gzip_disable "MSIE [1-6]\.";
# 客户端 body 大小(配合上传功能)
client_max_body_size 50m;
# 后端 Tomcat 集群
upstream tomcat_cluster {
# 用 cookie 做会话粘滞(配了 MSM 后其实可以去掉这一行)
hash $cookie_JSESSIONID consistent;
server 192.168.0.10:8080 max_fails=3 fail_timeout=15s;
server 192.168.0.20:8080 max_fails=3 fail_timeout=15s;
keepalive 32;
}
# 站点子配置
include /usr/local/nginx/conf/conf.d/*.conf;
}
关键参数:
worker_processes auto让 Nginx 按 CPU 核数自动起 worker。worker_cpu_affinity 01 10是把 2 个 worker 分别绑到 CPU0 和 CPU1,减少上下文切换。用ps axo pid,cmd,psr | grep worker可以验证绑核生效。use epoll是 Linux 下的最高效事件模型(默认就是,显式声明更清楚)。keepalive 32让 Nginx 与后端 Tomcat 之间复用长连接,减少 TCP 三次握手开销。必须配合proxy_http_version 1.1和proxy_set_header Connection "",否则不生效。
4.3.3 站点配置
bash
mkdir -p /usr/local/nginx/conf/conf.d/
mkdir -p /webdir/timinglee.org/{www,app,error}/{html,logs}
vim /usr/local/nginx/conf/conf.d/www.conf
nginx
server {
listen 80;
server_name www.timinglee.org;
root /webdir/timinglee.org/www/html;
index index.html index.htm;
access_log /webdir/timinglee.org/www/logs/access.log main;
error_log /webdir/timinglee.org/www/logs/error.log warn;
# ========== 静态资源:Nginx 直接返回,走 NFS ==========
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|mp4)$ {
root /data/nfs/static;
expires 7d;
add_header Cache-Control "public, max-age=604800";
access_log off;
}
# ========== 上传文件:走 NFS 共享目录 ==========
location /upload/ {
alias /data/nfs/upload/;
autoindex on;
autoindex_exact_size off;
autoindex_localtime on;
}
# ========== 动态请求:转发到 Tomcat 集群 ==========
location ~* \.(jsp|do|action)$ {
proxy_pass http://tomcat_cluster;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
proxy_next_upstream error timeout http_502 http_503 http_504;
}
# ========== 健康检查接口(给 HAProxy / Keepalived 用) ==========
location = /health {
access_log off;
default_type text/plain;
return 200 "web-ok\n";
}
# ========== Nginx 状态页(监控用) ==========
location = /nginx_status {
stub_status;
allow 172.25.254.0/24;
allow 192.168.0.0/24;
deny all;
}
# ========== 错误页 ==========
error_page 404 500 502 503 504 /error.html;
location = /error.html {
root /webdir/timinglee.org/error/html;
internal;
}
# ========== 防盗链 ==========
location ~* \.(jpg|png|gif|jpeg)$ {
valid_referers none blocked server_names *.timinglee.org;
if ($invalid_referer) {
return 403;
}
}
}
proxy_next_upstream的意义:当后端返回 502/503/504 或超时时,Nginx 自动把请求转给下一台后端。这是「后端有机器挂掉时用户无感知」的关键配置。注意不要加http_500------500 是应用逻辑错误,换一台机器也是 500,重试没意义。
4.3.4 创建页面与静态资源
bash
cat > /webdir/timinglee.org/www/html/index.html <<'EOF'
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>timinglee 企业级集群</title></head>
<body>
<h1>企业级高可用 Web 集群运行中</h1>
<p>本页面由 Nginx 直接返回(静态资源层)</p>
<p><a href="/info.jsp">查看 Tomcat 集群节点信息</a></p>
<p><a href="/css/main.css">测试静态资源缓存</a></p>
</body>
</html>
EOF
cat > /webdir/timinglee.org/error/html/error.html <<'EOF'
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>服务暂时不可用</title></head>
<body style="text-align:center;padding-top:100px;font-family:sans-serif">
<h1>页面暂时无法访问</h1>
<p>请稍后重试</p>
</body>
</html>
EOF
# 页面里带上主机名,方便验证负载均衡
hostname > /webdir/timinglee.org/www/html/hostname.html
# 静态资源目录
mkdir -p /data/nfs/static/{images,css,js}
echo "body{font-family:sans-serif;margin:40px}" > /data/nfs/static/css/main.css
dd if=/dev/zero of=/data/nfs/static/bigfile.txt bs=1M count=10
bash
nginx -t
systemctl reload nginx
4.3.5 验证
bash
curl http://192.168.0.10/hostname.html
# web1
curl http://192.168.0.20/hostname.html
# web2
curl -I http://192.168.0.10/css/main.css
# HTTP/1.1 200 OK
# Server: TIMINGLEE/ ← 版本号已隐藏
# Cache-Control: public, max-age=604800
curl http://192.168.0.10/nginx_status
# Active connections: 1
# server accepts handled requests
# 5 5 5
# Reading: 0 Writing: 1 Waiting: 0
# 确认 worker 绑核生效
ps axo pid,cmd,psr | grep "nginx: worker"
# 1701 nginx: worker process 0
# 1702 nginx: worker process 1
4.3.6 系统级并发调优
这一节是面试高频考点,因为「Too many open files」是你笔记里真实踩过的坑。
bash
vim /etc/security/limits.conf
# 追加
* soft nofile 100000
* hard nofile 100000
* soft noproc 100000
* hard noproc 100000
root soft nofile 100000
root hard nofile 100000
bash
cat > /etc/security/limits.d/nginx.conf <<'EOF'
nginx soft nofile 100000
nginx hard nofile 100000
EOF
vim /etc/sysctl.conf
# 追加
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_fin_timeout = 15
net.core.netdev_max_backlog = 65535
fs.file-max = 1000000
bash
sysctl -p
systemctl restart nginx
验证:
bash
cat /proc/$(cat /usr/local/nginx/logs/nginx.pid)/limits | grep "open files"
# Max open files 100000 100000 files
参数解释:
somaxconn是 accept 队列长度,默认 128,高并发下会成为瓶颈。tcp_tw_reuse允许复用 TIME_WAIT 状态的连接,缓解端口耗尽。ip_local_port_range扩大可用本地端口范围(默认只有 32768 ~ 60999,约 2.8 万个,高并发下不够)。fs.file-max是系统级的总文件句柄上限。limits.conf对 systemd 管理的服务无效 ,所以必须额外写/etc/security/limits.d/nginx.conf,或者用LimitNOFILE写在 service 文件里。这是最容易踩的坑。
压测验证(可选):
bash
dnf install -y httpd-tools
# 低并发基线
ab -n 10000 -c 100 http://192.168.0.10/hostname.html
# 高并发(测试调优效果)
ab -n 100000 -c 5000 http://192.168.0.10/hostname.html
# 如果出现 socket: Too many open files (24),说明 limits 没生效
ulimit -n
cat /proc/$(cat /usr/local/nginx/logs/nginx.pid)/limits | grep "open files"
4.4 Tomcat 部署
执行主机 :
web1、web2
bash
dnf install -y java-1.8.0-openjdk java-1.8.0-openjdk-devel
java -version
# openjdk version "1.8.0_xxx"
cd /root
wget https://mirrors.huaweicloud.com/apache/tomcat/tomcat-9/v9.0.117/bin/apache-tomcat-9.0.117.tar.gz
tar zxf apache-tomcat-9.0.117.tar.gz -C /usr/local/
cd /usr/local/
mv apache-tomcat-9.0.117 tomcat
chown -R tomcat:tomcat /usr/local/tomcat
Tomcat 用 JDK 8 是因为你的课程笔记用的就是 JDK 8,而且 JDK 8 在小内存下占用更低。生产环境现在多用 JDK 11 或 17,面试可以提这个差异。
4.4.1 JVM 内存参数(1.5 GB 内存节点的关键设置)
小内存环境的重点是限制 JVM 堆大小,否则 Tomcat 会吃掉全部内存导致 OOM 甚至拖死整台虚拟机。
bash
vim /usr/local/tomcat/bin/setenv.sh
bash
#!/bin/bash
export JAVA_HOME=/etc/alternatives/jre
export CATALINA_HOME=/usr/local/tomcat
# 堆内存:初始 256m,最大 512m(1.5GB 内存节点的合理值)
export JAVA_OPTS="-Xms256m -Xmx512m \
-XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=128m \
-XX:+UseG1GC \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/usr/local/tomcat/logs/ \
-Djava.security.egd=file:/dev/./urandom \
-Dfile.encoding=UTF-8 \
-Duser.timezone=Asia/Shanghai"
bash
chmod +x /usr/local/tomcat/bin/setenv.sh
chown tomcat:tomcat /usr/local/tomcat/bin/setenv.sh
参数解读(面试会问):
-Xms与-Xmx相等是最佳实践,避免堆动态扩缩带来的停顿。这里资源紧张所以留了扩缩空间(256m → 512m)。MaxMetaspaceSize必须限制,否则类加载器泄漏会把内存吃光(Metaspace 默认无上限,用的是本地内存)。UseG1GC在 4 GB 以下堆表现最好,低延迟。JDK 8 默认是 ParallelGC。HeapDumpOnOutOfMemoryError是排障救命的,OOM 时自动导出堆快照便于分析。/dev/./urandom加速 Session ID 生成,绕开 JDK 早期版本的熵池阻塞问题(/dev/random在熵不足时会阻塞)。
4.4.2 systemd 托管
bash
cat > /usr/local/tomcat/conf/tomcat.conf <<'EOF'
JAVA_HOME=/etc/alternatives/jre
CATALINA_HOME=/usr/local/tomcat
EOF
cat > /lib/systemd/system/tomcat.service <<'EOF'
[Unit]
Description=Apache Tomcat Web Application Container
After=syslog.target network.target
Wants=network.target
[Service]
Type=forking
EnvironmentFile=/usr/local/tomcat/conf/tomcat.conf
ExecStart=/usr/local/tomcat/bin/startup.sh
ExecStop=/usr/local/tomcat/bin/shutdown.sh
User=tomcat
Group=tomcat
PrivateTmp=true
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now tomcat
# 验证
ss -antlp | grep -E ':(8080|8005)'
curl -I 127.0.0.1:8080
4.4.3 部署测试应用
写一个能打印主机名和 SessionID 的 JSP,用来验证负载均衡和会话共享。
bash
cat > /usr/local/tomcat/webapps/ROOT/info.jsp <<'EOF'
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ page import="java.net.InetAddress" %>
<%
String hostname = InetAddress.getLocalHost().getHostName();
String ip = InetAddress.getLocalHost().getHostAddress();
String sessionId = session.getId();
Integer count = (Integer) session.getAttribute("visitCount");
if (count == null) { count = 0; }
count++;
session.setAttribute("visitCount", count);
if (session.getAttribute("node") == null) {
session.setAttribute("node", hostname);
}
%>
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Tomcat 集群节点信息</title></head>
<body style="font-family:sans-serif;margin:40px">
<h2>Tomcat 节点信息(用于验证集群与会话共享)</h2>
<table border="1" cellpadding="8" cellspacing="0">
<tr><td>后端主机名</td><td><b><%= hostname %></b></td></tr>
<tr><td>后端 IP</td><td><%= ip %></td></tr>
<tr><td>SessionID</td><td><%= sessionId %></td></tr>
<tr><td>会话访问次数</td><td><b><%= count %></b></td></tr>
<tr><td>会话首次创建于</td><td><%= session.getAttribute("node") %></td></tr>
</table>
</body>
</html>
EOF
chown tomcat:tomcat /usr/local/tomcat/webapps/ROOT/info.jsp
在两台机器上都放一份:
bash
scp /usr/local/tomcat/webapps/ROOT/info.jsp root@192.168.0.20:/usr/local/tomcat/webapps/ROOT/
ssh root@192.168.0.20 "chown tomcat:tomcat /usr/local/tomcat/webapps/ROOT/info.jsp"
验证(通过 Nginx 访问):
bash
curl http://192.168.0.10/info.jsp | grep -E "后端主机名|SessionID|会话访问次数"
打快照 :stage3-web-base-ok。
4.5 Memcached 会话共享(MSM 方案)
执行主机 :
web1、web2
要解决的问题 :用户登录后 Session 存在 Tomcat 的本地 JVM 内存里。如果下一个请求被负载均衡分到另一台 Tomcat,就找不到 Session,用户被迫重新登录。这在企业里是不能接受的故障。
解决方案:用 Memcached 集中存放 Session,两台 Tomcat 都从 Memcached 读写 Session。这就是 MSM(memcached-session-manager)。
4.5.1 部署 Memcached(两台都装)
bash
dnf install -y memcached
vim /etc/sysconfig/memcached
bash
PORT="11211"
USER="memcached"
MAXCONN="4096"
CACHESIZE="128"
OPTIONS="-l 0.0.0.0 -m 128 -c 4096"
CACHESIZE在小内存环境不要设太大,128 MB 足够存几十万个 Session(一个 Session 通常 1 ~ 5 KB)。
bash
systemctl enable --now memcached
ss -antlupe | grep memcached
# 验证
echo stats | nc 127.0.0.1 11211 | head -10
两台互相验证:
bash
# 在 web1 上验证能连到 web2
echo stats | nc 192.168.0.20 11211 | grep curr_items
# curr_items 0
4.5.2 部署 MSM 依赖的 JAR 包
MSM 需要一组 jar 包(memcached-session-manager 及其序列化器依赖)。你课程资料里的 jar.zip 就是它。
bash
# 假设 jar.zip 已通过 scp 上传到 /root
unzip /root/jar.zip -d /root/msm/
ls /root/msm/jar/
需要的核心包:
| JAR | 作用 |
|---|---|
memcached-session-manager-2.3.2.jar |
MSM 核心 |
memcached-session-manager-tc9-2.3.2.jar |
Tomcat 9 适配器 |
spymemcached-2.12.3.jar |
Memcached 客户端 |
kryo-3.0.3.jar |
Kryo 序列化器(性能最好) |
kryo-serializers-0.45.jar |
Kryo 扩展 |
minlog-1.3.0.jar |
日志依赖 |
reflectasm-1.11.9.jar |
反射加速 |
asm-9.7.1.jar |
字节码操作 |
msm-kryo-serializer-2.3.2.jar |
MSM Kryo 集成 |
objenesis-3.3.jar |
对象实例化 |
bash
cp /root/msm/jar/*.jar /usr/local/tomcat/lib/
chown tomcat:tomcat /usr/local/tomcat/lib/*.jar
# 同步到 web2
scp /root/msm/jar/*.jar root@192.168.0.20:/usr/local/tomcat/lib/
ssh root@192.168.0.20 "chown tomcat:tomcat /usr/local/tomcat/lib/*.jar"
如果
jar.zip找不到,可以从 Maven 中央仓库逐个下载,或者从你课程实验包里再取一份。MSM 版本必须与 Tomcat 主版本匹配(tc9 对应 Tomcat 9),否则启动时会报 ClassNotFound。
4.5.3 配置 Tomcat 使用 MSM
bash
vim /usr/local/tomcat/conf/context.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<Context>
<WatchedResource>WEB-INF/web.xml</WatchedResource>
<WatchedResource>WEB-INF/tomcat-web.xml</WatchedResource>
<WatchedResource>${catalina.base}/conf/web.xml</WatchedResource>
<Manager className="de.javakaffee.web.msm.MemcachedBackupSessionManager"
memcachedNodes="n1:192.168.0.10:11211,n2:192.168.0.20:11211"
failoverNodes="n1"
requestUriIgnorePattern=".*\.(ico|png|gif|jpg|css|js)$"
transcoderFactoryClass="de.javakaffee.web.msm.serializer.kryo.KryoTranscoderFactory"
sessionBackupAsync="false"
sessionBackupTimeout="1000"
copyCollectionsForSerialization="false"
/>
</Context>
关键的差异:
| 主机 | memcachedNodes |
failoverNodes |
|---|---|---|
| web1 | n1:192.168.0.10:11211,n2:192.168.0.20:11211 |
n1 |
| web2 | n1:192.168.0.10:11211,n2:192.168.0.20:11211 |
n2 |
failoverNodes 表示「本机不把 Session 备份到哪个节点」。web1 不备份到 n1(也就是它自己),所以它备份到 n2;web2 不备份到 n2,备份到 n1。这样两台互为备份,任何一台挂掉,Session 都还在对端。
在 web1 上执行:
bash
scp /usr/local/tomcat/conf/context.xml root@192.168.0.20:/usr/local/tomcat/conf/context.xml
ssh root@192.168.0.20 "sed -i 's/failoverNodes=\"n1\"/failoverNodes=\"n2\"/' /usr/local/tomcat/conf/context.xml; grep failoverNodes /usr/local/tomcat/conf/context.xml"
# failoverNodes="n2"
重启两台 Tomcat:
bash
# 两台都执行
systemctl restart tomcat
# 看启动日志确认 MSM 加载
grep -i "memcached" /usr/local/tomcat/logs/catalina.out | tail -20
# 期望包含:MemcachedSessionManager ... started
如果启动失败,先看
catalina.out里的ClassNotFoundException,多半是 jar 包版本不匹配或漏拷贝。
4.5.4 验证会话零丢失(核心演示)
第 1 步:从 web1 拿一个 SessionID
bash
curl -c /tmp/cookie.txt http://192.168.0.10/info.jsp | grep -E "后端主机名|SessionID|会话访问次数"
# 后端主机名 web1
# SessionID 3A8F2C1D...
# 会话访问次数 1
第 2 步:带着同一个 Cookie 请求 web2(模拟负载均衡切到另一台)
bash
curl -b /tmp/cookie.txt http://192.168.0.20/info.jsp | grep -E "后端主机名|SessionID|会话访问次数|会话首次创建于"
# 后端主机名 web2 ← 请求确实换到了另一台!
# SessionID 3A8F2C1D... ← SessionID 没变!
# 会话访问次数 2 ← 计数从 1 变 2,Session 状态被保留
# 会话首次创建于 web1 ← 证明它在 web1 创建,被 web2 读到了
这一组输出就是「会话零丢失」的直接证据。截图保存,简历和面试都要用。
第 3 步:验证 Memcached 里确实有数据
bash
echo "stats" | nc 127.0.0.1 11211 | grep -E "curr_items|get_hits|cmd_set|bytes"
# curr_items 1
# get_hits ...
# cmd_set ...
第 4 步(最强验证):直接停掉 web1 的 Tomcat
bash
# 先在 web1 创建会话
curl -c /tmp/c2.txt http://192.168.0.10/info.jsp | grep SessionID
# SessionID ABC123...
# 停掉 web1 的 Tomcat
systemctl stop tomcat
# 用同一个 Cookie 访问 web2
curl -b /tmp/c2.txt http://192.168.0.20/info.jsp | grep -E "SessionID|会话访问次数"
# SessionID ABC123... ← 依然不变
# 会话访问次数 2 ← 会话状态继续累加
结论:后端 Tomcat 宕机,用户完全无感知,不需要重新登录。 这就是「会话零丢失」的完整证明链,也是简历上最有说服力的一条。
4.6 通过 Nginx 验证动静分离
执行主机 :
web1、web2(验证可从lb1发起)
bash
# 静态:Nginx 直接返回
curl -I http://192.168.0.10/css/main.css
# HTTP/1.1 200 OK
# Server: TIMINGLEE/
# Cache-Control: public, max-age=604800
# 动态:经过 proxy_pass 到 Tomcat
curl -I http://192.168.0.10/info.jsp
# HTTP/1.1 200 OK
# Server: TIMINGLEE/
对比 Tomcat 访问日志更直观:
bash
# 清空日志后触发两种请求
> /usr/local/tomcat/logs/localhost_access_log.$(date +%Y-%m-%d).txt
curl -s http://192.168.0.10/css/main.css > /dev/null
curl -s http://192.168.0.10/info.jsp > /dev/null
cat /usr/local/tomcat/logs/localhost_access_log.$(date +%Y-%m-%d).txt
# 只有 info.jsp 出现了,main.css 没有 → 静态请求没进 Tomcat
4.7 Nginx 平滑升级与回滚(可选进阶)
执行主机 :
web1、web2
这个技能面试含金量很高,因为你课程笔记里完整做过。
4.7.1 编译新版本
bash
cd /root
wget https://nginx.org/download/nginx-1.29.4.tar.gz
tar zxf nginx-1.29.4.tar.gz
cd nginx-1.29.4/
# 同样隐藏版本号
sed -i 's|#define NGINX_VERSION .*|#define NGINX_VERSION ""|' src/core/nginx.h
sed -i 's|#define NGINX_VER .*|#define NGINX_VER "TIMINGLEE/" NGINX_VERSION|' src/core/nginx.h
./configure --prefix=/usr/local/nginx --user=nginx --group=nginx \
--with-http_ssl_module --with-http_v2_module --with-http_realip_module \
--with-http_stub_status_module --with-http_gzip_static_module \
--with-pcre --with-stream --with-stream_ssl_module --with-stream_realip_module
make -j2
注意:只
make,不要make install。make install会覆盖配置文件和站点目录。平滑升级只需要替换二进制文件。
4.7.2 平滑升级
bash
# 备份旧二进制
cd /usr/local/nginx/sbin/
cp -p nginx nginx.old
# 替换(\cp 绕过可能的 alias)
\cp -f /root/nginx-1.29.4/objs/nginx /usr/local/nginx/sbin/nginx
# 先验证新二进制可用
/usr/local/nginx/sbin/nginx -t
# 记录旧 master PID
OLD_PID=$(cat /usr/local/nginx/logs/nginx.pid)
echo $OLD_PID
# 向旧 master 发 USR2,拉起新 master
kill -USR2 $OLD_PID
# 观察:新旧 master 并存
ps aux | grep "nginx: master" | grep -v grep
ls /usr/local/nginx/logs/
# nginx.pid nginx.pid.oldbin
# 验证服务正常(此时新版本在服务)
curl -s http://192.168.0.10/hostname.html
# 优雅关闭旧 worker(旧 master 还在,随时可以回滚)
kill -WINCH $OLD_PID
# 观察一段时间确认无误后,完整关闭旧 master
kill -QUIT $OLD_PID
4.7.3 回滚演练
bash
#场景 A:旧 master 仅 WINCH、未 QUIT(标准回滚)
# 前提:升级后已执行 kill -WINCH $OLD_PID,旧master还在(nginx.pid.oldbin存在)
# 1. 确认双master PID
OLD_MASTER_PID=$(cat /usr/local/nginx/logs/nginx.pid.oldbin)
NEW_MASTER_PID=$(cat /usr/local/nginx/logs/nginx.pid)
echo "旧master: $OLD_MASTER_PID | 新master: $NEW_MASTER_PID"
# 2. 给旧master发HUP,重新拉起旧版本worker
kill -HUP $OLD_MASTER_PID
sleep 1
# 3. 验证旧worker已启动、业务正常
ps aux | grep "nginx: worker process" | grep -v grep
curl -s http://192.168.0.10/hostname.html
# 4. 给新master发WINCH,优雅关闭新版本worker
kill -WINCH $NEW_MASTER_PID
sleep 1
# 5. 确认流量全部切回旧版本后,彻底退出新master
kill -QUIT $NEW_MASTER_PID
# 6. 验证回滚结果
ps aux | grep "nginx: master" | grep -v grep
curl -I http://192.168.0.10/hostname.html | grep Server
# 7. 清理残留文件
rm -f /usr/local/nginx/logs/nginx.pid.oldbin
#场景 B:旧 master 已经被 QUIT
# 1. 备份当前新二进制
cd /usr/local/nginx/sbin/
cp -p nginx nginx.new
# 2. 换回旧版本二进制
\cp -f nginx.old nginx
# 3. 给当前运行的新master发USR2,启动旧版本master
CUR_PID=$(cat /usr/local/nginx/logs/nginx.pid)
kill -USR2 $CUR_PID
sleep 1
# 4. 确认旧版本master启动成功
ps aux | grep "nginx: master" | grep -v grep
# 5. 关闭旧(新版本)master的worker
OLD_NEW_PID=$(cat /usr/local/nginx/logs/nginx.pid.oldbin)
kill -WINCH $OLD_NEW_PID
# 6. 确认无误后退出新版本master
kill -QUIT $OLD_NEW_PID
# 7. 验证+清理
curl -I http://192.168.0.10/hostname.html | grep Server
rm -f /usr/local/nginx/logs/nginx.pid.oldbin
信号速记(面试必答):
USR2→ 拉起新 master(平滑升级第一步)WINCH→ 优雅关闭 worker(不再接受新连接,处理完现有请求后退出)HUP→ 重新加载配置 / 重新拉起 workerQUIT→ 优雅退出 masterUSR1→ 重新打开日志文件(配合 logrotate)TERM/INT→ 立即退出
打快照 :stage4-web-final-ok。
第五章 db 节点:MySQL 主从与 VIP 高可用
本章操作主机 :
db1(主库)、db2(从库)
db1(172.25.254.30 / 192.168.0.30)为主库,db2(172.25.254.40 / 192.168.0.40)为从库,且 db2 同时持有数据库 VIP 172.25.254.201。
5.1 为什么用 RPM 装 MySQL
执行主机:本节为选型说明,无需执行命令
你之前用源码编译 MySQL 8.3.0。那套流程在小内存虚拟机上有个硬问题:编译过程需要 3 ~ 4 GB 内存和约 15 GB 磁盘,你的 db 节点只有 1 GB 内存,编译必然失败(会被 OOM Killer 杀掉)。
所以本项目用 AppStream 里的 MySQL 8.0 官方包,安装只要 1 分钟,占用约 400 MB。源码编译的完整步骤放在 附录 A,你可以在需要展示「会编译」时单独做一次(临时把内存调到 3 GB)。
5.2 安装与安全初始化(db1 / db2 都做)
执行主机 :
db1、db2
bash
dnf install -y mysql-server mysql
systemctl enable --now mysqld
# 查看初始临时密码
grep "temporary password" /var/log/mysqld.log
# 安全初始化
mysql_secure_installation
# 输入临时密码后依次回答:
# 是否启用密码强度校验插件? n(实验环境)
# 修改 root 密码? y → 设为 lee
# 移除匿名用户? y
# 禁止 root 远程登录? y
# 删除 test 库? y
# 重载权限表? y
# 验证
mysql -uroot -plee -e "select version();"
# 8.0.x
生产环境做法:
validate_password插件应该开启,密码要满足长度 + 大小写 + 数字 + 符号。面试被问到就说「实验环境为了简化流程关闭了,生产环境会保留并设置validate_password.policy=MEDIUM」。
5.3 主库配置(db1)
执行主机 :
db1(主库)
bash
vim /etc/my.cnf.d/mysql-server.cnf
ini
[mysqld]
# ===== 基础 =====
server-id=30
datadir=/var/lib/mysql
socket=/var/lib/mysql/mysql.sock
symbolic-links=0
# ===== 二进制日志(复制的核心) =====
log-bin=mysql-bin
binlog_format=ROW
binlog_row_image=FULL
expire_logs_days=7
max_binlog_size=256M
sync_binlog=1
# ===== GTID =====
gtid_mode=ON
enforce_gtid_consistency=ON
log_slave_updates=ON
# ===== 字符集 =====
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
# ===== 小内存调优(1GB 内存节点的关键!) =====
innodb_buffer_pool_size=256M
innodb_log_file_size=64M
innodb_flush_log_at_trx_commit=1
max_connections=200
table_open_cache=400
performance_schema=OFF
skip-name-resolve
# ===== 认证插件(兼容旧客户端) =====
default_authentication_plugin=mysql_native_password
# ===== 慢查询日志 =====
slow_query_log=ON
slow_query_log_file=/var/log/mysql-slow.log
long_query_time=1
log_queries_not_using_indexes=OFF
bash
systemctl restart mysqld
关键参数解释(面试必问):
innodb_buffer_pool_size一般是物理内存的 50% ~ 70%。1 GB 内存的机器给 256 MB 是安全的(再大就可能被 OOM Killer 杀)。performance_schema=OFF能省约 150 MB 内存,代价是失去细粒度性能诊断。小内存环境值得。sync_binlog=1+innodb_flush_log_at_trx_commit=1是「双 1 配置」,保证崩溃不丢数据,是金融级的标准。代价是每次提交都要刷盘,性能下降。可以这样答:「本项目为保证数据可靠性选了双 1;纯日志类的写入场景可以调成 2 和 0 换取吞吐」。binlog_format=ROW是主从复制最安全的方式,避免了 STATEMENT 格式下now()、uuid()、limit语句在从库执行结果不一致的问题。skip-name-resolve禁止 MySQL 做反向 DNS 解析,避免每次连接都查一次 DNS,高并发下这是明显的性能收益。
未使用索引的慢查询 :log_queries_not_using_indexes=OFF 是因为开了会让慢日志爆炸。生产环境按需开启。
5.4 从库配置(db2)
执行主机 :
db2(从库)
ini
[mysqld]
server-id=40
# ===== 基础 =====
datadir=/var/lib/mysql
socket=/var/lib/mysql/mysql.sock
symbolic-links=0
# ===== 二进制日志(GTID复制必备,从库也要开启) =====
log-bin=mysql-bin
binlog_format=ROW
binlog_row_image=FULL
binlog_expire_logs_seconds=604800
max_binlog_size=256M
sync_binlog=1
# ===== GTID 核心参数(必须显式开启) =====
gtid_mode=ON
enforce_gtid_consistency=ON
log_slave_updates=ON
# ===== 字符集 =====
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
# ===== 小内存调优 =====
innodb_buffer_pool_size=256M
innodb_log_file_size=64M
innodb_flush_log_at_trx_commit=1
max_connections=200
table_open_cache=400
performance_schema=OFF
skip-name-resolve
# ===== 认证插件 =====
default_authentication_plugin=mysql_native_password
# ===== 慢查询日志 =====
slow_query_log=ON
slow_query_log_file=/var/log/mysql-slow.log
long_query_time=1
log_queries_not_using_indexes=OFF
# ===== 从库专属:多线程并行回放 =====
replica_parallel_type=LOGICAL_CLOCK
replica_parallel_workers=4
replica_preserve_commit_order=ON
# ===== 从库专属:中继日志自动恢复 =====
relay_log_recovery=ON
# ===== 从库专属:只读防误写 =====
read_only=ON
super_read_only=ON
server-id是复制的身份证,全网必须唯一 。本项目用「IP 最后一段」作为 server-id(.30 → 30,.40 → 40),这是你笔记里的习惯,也是最佳实践。
replica_preserve_commit_order=ON很重要:并行回放会导致从库提交顺序与主库不一致,开启这个参数能保证最终顺序一致,避免基于顺序的逻辑出错。
bash
systemctl restart mysqld
5.5 主库创建复制账号与业务库
执行主机 :
db1(主库)
在 db1 上:
bash
mysql -uroot -plee
sql
-- 复制专用账号(只给复制权限,最小权限原则)
CREATE USER 'repl'@'192.168.0.%' IDENTIFIED WITH mysql_native_password BY 'lee';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'192.168.0.%';
-- 业务账号(给应用用,权限要收敛到具体库)
CREATE USER 'appuser'@'192.168.0.%' IDENTIFIED WITH mysql_native_password BY 'lee';
GRANT SELECT, INSERT, UPDATE, DELETE ON timinglee.* TO 'appuser'@'192.168.0.%';
-- 监控账号(MHA / 半同步检查用)
CREATE USER 'monitor'@'192.168.0.%' IDENTIFIED WITH mysql_native_password BY 'lee';
GRANT REPLICATION CLIENT, PROCESS ON *.* TO 'monitor'@'192.168.0.%';
-- 备份账号
CREATE USER 'backup'@'192.168.0.%' IDENTIFIED WITH mysql_native_password BY 'lee';
GRANT SELECT, RELOAD, LOCK TABLES, REPLICATION CLIENT, SHOW VIEW, EVENT, TRIGGER ON *.* TO 'backup'@'192.168.0.%';
FLUSH PRIVILEGES;
-- 业务库
CREATE DATABASE timinglee DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE timinglee;
CREATE TABLE users (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(128),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE orders (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(12,2) NOT NULL DEFAULT 0.00,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_user (user_id),
KEY idx_created (created_at)
) ENGINE=InnoDB;
INSERT INTO users (username, email) VALUES ('timinglee','admin@timinglee.org'),('lee','lee@timinglee.org');
INSERT INTO orders (user_id, amount, status) VALUES (1, 199.00, 1),(2, 299.00, 0);
SELECT * FROM users;
-- 记录当前 GTID 位置
SHOW MASTER STATUS\G
为什么必须
IDENTIFIED WITH mysql_native_password?MySQL 8 默认认证插件是caching_sha2_password,老版本客户端和部分复制组件不支持,会报认证失败。这是你笔记里真实踩过的坑,面试很值得讲。
5.6 从库启动复制
执行主机 :
db2(从库)
在 db2 上:
bash
mysql -uroot -plee
sql
-- GTID 方式启动复制(不需要指定 binlog file 和 pos)
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='192.168.0.30',
SOURCE_PORT=3306,
SOURCE_USER='repl',
SOURCE_PASSWORD='lee',
SOURCE_AUTO_POSITION=1,
GET_SOURCE_PUBLIC_KEY=1;
START REPLICA;
SHOW REPLICA STATUS\G
必须确认这两行都是 Yes:
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
同时确认:
Retrieved_Gtid_Set: <与主库一致>
Executed_Gtid_Set: <与主库一致>
Seconds_Behind_Source: 0
Last_IO_Error:
Last_SQL_Error:
排错顺序(面试常考):
Replica_IO_Running: No→ 网络 / 账号 / 防火墙问题。看Last_IO_Error。Replica_SQL_Running: No→ 数据冲突或权限问题。看Last_SQL_Error。Seconds_Behind_Source不断增大 → 从库性能不足,考虑开并行回放或升级硬件。- 认证报错 → 检查是否用了
caching_sha2_password,以及GET_SOURCE_PUBLIC_KEY=1是否加上。- GTID 模式下
AUTO_POSITION=1,不需要手工指定 binlog 位置,这是 GTID 相对传统复制最大的优势:主库故障切换后不用重新找位点。
5.7 启用半同步复制
执行主机 :
db1、db2
半同步 的意义:主库提交事务后,至少等一个从库确认收到 binlog才向客户端返回成功。这样主库宕机时数据不会丢。性能略有下降,可靠性大幅提升。
动态加载插件(db1 和 db2 都做):
sql
-- db1(主库)
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
SET GLOBAL rpl_semi_sync_master_timeout = 3000;
-- db2(从库)
INSTALL PLUGIN rpl_semi_sync_slave SONAME 'semisync_slave.so';
SET GLOBAL rpl_semi_sync_slave_enabled = 1;
永久生效(写进配置文件,重启不丢):
bash
# db1
cat >> /etc/my.cnf.d/mysql-server.cnf <<'EOF'
plugin-load-add=rpl_semi_sync_master=semisync_master.so
rpl_semi_sync_master_enabled=ON
rpl_semi_sync_master_timeout=3000
EOF
bash
# db2
cat >> /etc/my.cnf.d/mysql-server.cnf <<'EOF'
plugin-load-add=rpl_semi_sync_slave=semisync_slave.so
rpl_semi_sync_slave_enabled=ON
EOF
从库必须重启 IO 线程,半同步才真正生效(这是最容易漏的一步):
sql
-- db2
STOP REPLICA IO_THREAD;
START REPLICA IO_THREAD;
验证:
sql
-- db1
SHOW VARIABLES LIKE 'rpl_semi_sync%';
SHOW STATUS LIKE 'Rpl_semi_sync%';
-- Rpl_semi_sync_master_status ON ← 关键
-- Rpl_semi_sync_master_clients 1 ← 有一个从库在响应
-- Rpl_semi_sync_master_yes_tx >0 ← 有事务走了半同步
半同步降级演练:
sql
-- 在 db2 上停掉 IO 线程,模拟从库故障
STOP REPLICA IO_THREAD;
bash
# 在 db1 上写入,会明显变慢(等待 3 秒超时)
time mysql -uroot -plee -e "INSERT INTO timinglee.users(username) VALUES('semisync_test');"
# real 0m3.0xxs ← 等了 3 秒超时
# 观察状态自动降级
mysql -uroot -plee -e "SHOW STATUS LIKE 'Rpl_semi_sync_master_status';"
# Rpl_semi_sync_master_status OFF ← 已自动降级为异步复制
sql
-- 恢复
-- db2 上:
START REPLICA IO_THREAD;
bash
# db1 上观察自动恢复
mysql -uroot -plee -e "SHOW STATUS LIKE 'Rpl_semi_sync_master_status';"
# Rpl_semi_sync_master_status ON
这是半同步最值得讲的特性 :从库无响应时,主库会在
rpl_semi_sync_master_timeout超时后自动降级为异步复制,保证业务不被阻塞;从库恢复后又自动升回半同步。这体现了「可用性优先、降级可用」的工程思维。
5.8 复制验证与延迟测试
执行主机 :
db1、db2
bash
# 主库写入
mysql -uroot -plee -e "
INSERT INTO timinglee.users (username,email) VALUES ('test_$(date +%s)','t@timinglee.org');
SELECT COUNT(*) AS master_count FROM timinglee.users;"
# 从库读取
mysql -uroot -plee -e "
SELECT COUNT(*) AS slave_count FROM timinglee.users;
SHOW REPLICA STATUS\G" | grep -E "slave_count|Seconds_Behind_Source|Running"
期望:master_count == slave_count,Seconds_Behind_Source: 0。
从库只读验证:
bash
mysql -uroot -plee -e "INSERT INTO timinglee.users(username) VALUES('x');"
# ERROR 1290 (HY000): The MySQL server is running with the --read-only option
# so it cannot execute this statement
主从延迟压测:
bash
cat > /root/repl_lag_test.sh <<'EOF'
#!/bin/bash
# 持续写入并观测主从延迟
for i in $(seq 1 200); do
mysql -uroot -plee -e "INSERT INTO timinglee.orders(user_id,amount) VALUES($((RANDOM%2+1)),$((RANDOM%100)).00);" 2>/dev/null
done
echo "=== 主库条数 ==="
mysql -uroot -plee -e "SELECT COUNT(*) FROM timinglee.orders;" 2>/dev/null
echo "=== 从库条数 & 延迟 ==="
mysql -umonitor -plee -h192.168.0.40 -e "SELECT COUNT(*) FROM timinglee.orders; SHOW REPLICA STATUS\G" 2>/dev/null \
| grep -E "COUNT\(\*\)|Seconds_Behind_Source|^[[:space:]]*[0-9]+[[:space:]]*$"
EOF
chmod +x /root/repl_lag_test.sh
/root/repl_lag_test.sh
5.9 延迟复制(实战价值很高的技能)
执行主机 :
db1、db2
延迟复制常用于防误删 :从库故意慢 5 分钟,如果主库被误 DROP TABLE / DELETE,你有 5 分钟时间在从库上把数据捞出来。
sql
-- db2
STOP REPLICA;
CHANGE REPLICATION SOURCE TO SOURCE_DELAY = 300;
START REPLICA;
SHOW REPLICA STATUS\G
-- SQL_Delay: 300
-- SQL_Remaining_Delay: 299
演练:
bash
# db1 上误删数据
mysql -uroot -plee -e "DELETE FROM timinglee.users WHERE username='lee';"
# db2 上:数据还在(延迟 300 秒还没回放)
mysql -uroot -plee -e "SELECT * FROM timinglee.users WHERE username='lee';"
# 数据还在! ← 抢救成功
# 立刻在 db2 上导出
mysqldump -uroot -plee --single-transaction --set-gtid-purged=OFF timinglee > /root/rescue.sql
ls -lh /root/rescue.sql
scp /root/rescue.sql root@192.168.0.30:/root/
# db1上:用备份恢复 db1
mysql -uroot -plee timinglee < /root/rescue.sql
mysql -uroot -plee -e "SELECT * FROM timinglee.users WHERE username='lee';"
#db2上:取消延迟,恢复正常复制
STOP REPLICA;
CHANGE REPLICATION SOURCE TO SOURCE_DELAY = 0;
START REPLICA;
面试表达:「我用延迟复制做了一个误删数据的兜底方案,从库故意延迟 5 分钟,主库误操作时可以从延迟从库快速恢复数据。」这个答案会明显比只讲主从复制的候选人强。
5.10 备份策略(XtraBackup 或 mysqldump)
执行主机 :
db1(备份源;恢复验证也在db1上做)
5.10.1 mysqldump 逻辑备份(轻量,适合小数据量)
bash
cat > /root/mysql_backup.sh <<'EOF'
#!/bin/bash
# MySQL 全量逻辑备份脚本
BACKUP_DIR=/backup/mysql
DATE=$(date +%F_%H%M)
KEEP_DAYS=7
mkdir -p ${BACKUP_DIR}
mysql -uroot -plee \
-e "SHOW MASTER STATUS\G" > ${BACKUP_DIR}/master_status_${DATE}.txt
mysqldump -uroot -plee \
--single-transaction \
--master-data=2 \
--flush-logs \
--routines --triggers --events \
--all-databases \
--set-gtid-purged=ON \
2>/dev/null | gzip > ${BACKUP_DIR}/all_${DATE}.sql.gz
# 清理旧备份
find ${BACKUP_DIR} -name "*.sql.gz" -mtime +${KEEP_DAYS} -delete
echo "[$(date '+%F %T')] backup done: all_${DATE}.sql.gz ($(du -h ${BACKUP_DIR}/all_${DATE}.sql.gz | cut -f1))"
EOF
chmod +x /root/mysql_backup.sh
/root/mysql_backup.sh
# 定时任务:每天凌晨 2 点
cat > /etc/cron.d/mysql-backup <<'EOF'
0 2 * * * root /root/mysql_backup.sh >> /var/log/mysql-backup.log 2>&1
EOF
关键参数:
--single-transaction:InnoDB 下不锁表的一致性快照(这是最优选,不会阻塞业务)。--master-data=2:在备份里记录 binlog 位置(注释形式),便于搭建新从库。GTID 模式下配合--set-gtid-purged=ON。--flush-logs:备份前切一次 binlog,便于后续做增量恢复。
5.10.2 恢复演练(必须真的做一次)
bash
# 模拟故障:删掉一个库
mysql -uroot -plee -e "DROP DATABASE timinglee;"
mysql -uroot -plee -e "SHOW DATABASES;"
# timinglee 不见了
# 恢复
zcat /backup/mysql/all_*.sql.gz \
| sed -e '/GTID_PURGED/d' -e '/GTID_EXECUTED/d' \
| mysql -uroot -plee
# 验证
mysql -uroot -plee -e "SHOW DATABASES; SELECT COUNT(*) FROM timinglee.users;"
面试金句 :「备份的价值不在于备份本身,而在于能恢复。我做过完整的恢复演练,确认备份文件可用。」这句话能立刻把你和只会敲
mysqldump的候选人区分开。
5.11 数据库 VIP 高可用(Keepalived)
执行主机 :
db1、db2
数据库需要一个漂浮的入口地址,主库宕机时自动把流量引导到从库。
注意:真正的 MySQL 主从自动切换需要 MHA 或 MGR。这里先做 VIP 漂移(5.11 节),MHA 的完整方案见附录 B(因为 MHA 0.58 在 RHEL9 上需要打 Perl 补丁,且需要额外一台管理机)。
5.11.1 安装 Keepalived(db1 / db2)
bash
dnf install -y keepalived
5.11.2 db1(MASTER)配置
bash
vim /etc/keepalived/keepalived.conf
conf
global_defs {
router_id db1
vrrp_skip_check_adv_addr
vrrp_garp_interval 1
vrrp_gna_interval 1
vrrp_mcast_group4 224.0.0.45
}
# 健康检查:MySQL 实例是否存活
vrrp_script chk_mysql {
script "/etc/keepalived/scripts/check_mysql.sh"
interval 2
weight -40
fall 3
rise 2
timeout 5
user root
}
vrrp_instance DB_VIP {
state MASTER
interface eth0
virtual_router_id 52
priority 100
advert_int 1
authentication {
auth_type PASS
auth_pass 1111
}
virtual_ipaddress {
172.25.254.201/24 dev eth0 label eth0:1
}
track_script {
chk_mysql
}
notify_master "/etc/keepalived/scripts/notify.sh master"
notify_backup "/etc/keepalived/scripts/notify.sh backup"
notify_fault "/etc/keepalived/scripts/notify.sh fault"
}
5.11.3 db2(BACKUP)配置
conf
global_defs {
router_id db2
vrrp_skip_check_adv_addr
vrrp_garp_interval 1
vrrp_gna_interval 1
vrrp_mcast_group4 224.0.0.45
}
vrrp_script chk_mysql {
script "/etc/keepalived/scripts/check_mysql.sh"
interval 2
weight -40
fall 3
rise 2
timeout 5
user root
}
vrrp_instance DB_VIP {
state BACKUP
interface eth0
virtual_router_id 52
priority 90
advert_int 1
authentication {
auth_type PASS
auth_pass 1111
}
virtual_ipaddress {
172.25.254.201/24 dev eth0 label eth0:1
}
track_script {
chk_mysql
}
notify_master "/etc/keepalived/scripts/notify.sh master"
notify_backup "/etc/keepalived/scripts/notify.sh backup"
notify_fault "/etc/keepalived/scripts/notify.sh fault"
}
注意 :
virtual_router_id必须相同(52),priority必须不同。第一层 lb 用的是 51 和 53,所以这里用 52 避免冲突。VIP 也不同(.201 vs lb 的 .200/.202)。
5.11.4 健康检查脚本
bash
mkdir -p /etc/keepalived/scripts/
cat > /etc/keepalived/scripts/check_mysql.sh <<'EOF'
#!/bin/bash
# MySQL 实例存活检查:能读到 server-id 就算健康
mysql -uroot -plee -e "SELECT @@server_id;" > /dev/null 2>&1
exit $?
EOF
cat > /etc/keepalived/scripts/notify.sh <<'EOF'
#!/bin/bash
STATE=$1
LOG=/var/log/keepalived-notify.log
echo "$(date '+%F %T') $(hostname) state changed to $STATE" >> ${LOG}
logger -t keepalived "DB VIP state -> ${STATE} on $(hostname)"
exit 0
EOF
chmod +x /etc/keepalived/scripts/*.sh
5.11.5 启动与验证
bash
# 语法校验(非常重要,写错一个字符 keepalived 直接起不来)
keepalived -t -f /etc/keepalived/keepalived.conf
systemctl enable --now keepalived
# 日志分离
sed -i 's|^KEEPALIVED_OPTIONS.*|KEEPALIVED_OPTIONS="-D -S 6"|' /etc/sysconfig/keepalived
grep -q "local6" /etc/rsyslog.conf || echo "local6.* /var/log/keepalived.log" >> /etc/rsyslog.conf
systemctl restart rsyslog keepalived
验证:
bash
# db1 上应该看到 VIP
ssh root@172.25.254.30 "ip a show eth0 | grep 172.25.254.201"
# inet 172.25.254.201/24 scope global secondary eth0:1
# 从别的机器(不是 db1/db2)通过 VIP 访问
ssh root@172.25.254.10 "mysql -uappuser -plee -h172.25.254.201 -e 'SELECT @@server_id;'"
# 30 ← 当前是 db1
重要陷阱 :不要在 db1 或 db2 本机访问 VIP 来测漂移。本机访问自己持有的 VIP 走本地回环,看不出效果。必须从第三方机器测。
5.11.6 故障漂移验证
bash
# 在 db1 上停掉 MySQL(模拟数据库故障)
ssh root@172.25.254.30 "systemctl stop mysqld"
# keepalived 在健康检查失败 3 次后降权,VIP 漂到 db2
# 大约 6 ~ 8 秒
# 在 web1 上验证
ssh root@172.25.254.10 "mysql -uappuser -plee -h172.25.254.201 -e 'SELECT @@server_id;'"
# 40 ← 已经切到 db2!
bash
# 查看 db2 上的 VIP
ssh root@172.25.254.40 "ip a show eth0 | grep 172.25.254.201"
# 查看告警日志
ssh root@172.25.254.40 "tail -5 /var/log/keepalived-notify.log"
# 2026-09-13 ... db2 state changed to master
恢复:
bash
ssh root@172.25.254.30 "systemctl start mysqld"
sleep 5
# 因为 db1 priority 更高(100 > 90),它会抢回 VIP
ssh root@172.25.254.30 "ip a show eth0 | grep 172.25.254.201"
但要注意 :VIP 回来了,MySQL 的角色没有变 。db1 还是主库、db2 还是从库,架构是一致的,所以这里能正常工作。
如果真实场景中主库硬件损坏、需要把从库提升为主库,靠 VIP 漂移是不够的------从库仍然是只读的,写请求会失败。这就是为什么需要 MHA / MGR。 这句话一定要能讲出来,它证明你真正理解了这个方案的边界。
打快照 :stage5-db-ok。
第六章 lb 节点:HAProxy + Keepalived 高可用
本章操作主机 :
lb1、lb2(两台命令完全一致)
lb1(172.25.254.50 / 192.168.0.50)和 lb2(172.25.254.60 / 192.168.0.60)构成对外入口。
6.1 架构与思路
执行主机:本节为架构说明,无需执行命令
客户端
│ 访问 172.25.254.200(VIP)
▼
┌──────────────┐
│ Keepalived │ ← VRRP 协议选主,持有 VIP
├──────────────┤
│ HAProxy │ ← 七层负载,健康检查,ACL
└──────┬───────┘
│ 轮询 / 一致性哈希
┌─────┴─────┐
▼ ▼
web1 web2
为什么要 Keepalived + HAProxy 两层?(面试必问,务必答全)
- HAProxy 只做负载均衡,它没有解决「自己挂了怎么办」的问题------它是单点。
- Keepalived 通过 VRRP 协议让两台机器共享一个 VIP,谁活着谁持有 VIP,从而解决 HAProxy 本身的单点。
- 也可以直接把 Nginx 装在 lb 上,用 Nginx 做七层负载 + Keepalived 做 VIP。换成 HAProxy 的理由 :健康检查更细(
inter/fall/rise三维度可调)、有原生状态页、支持socat热更新权重、nbthread多线程模型在高并发下更稳,而且错误页定制更灵活。
双主模式的意义:lb1 持有 Web VIP(.200),lb2 持有监控 VIP(.202)。两台都在干活,不是一主一闲。这是真实企业里常见的做法,资源利用率 100%。
6.2 HAProxy 安装与配置
执行主机 :
lb1、lb2
bash
dnf install -y haproxy
haproxy -v
# HAProxy version 2.4.x
6.2.1 haproxy.cfg 完整配置
bash
vim /etc/haproxy/haproxy.cfg
cfg
# ======================== 全局配置 ========================
global
log 127.0.0.1 local2
chroot /var/lib/haproxy
pidfile /var/run/haproxy.pid
maxconn 4000
user haproxy
group haproxy
daemon
stats socket /var/lib/haproxy/stats mode 600 level admin
stats timeout 2m
# 多线程(2 核用 2 线程;注意不能同时使用 nbproc)
nbthread 2
cpu-map 1 0
# ======================== 默认配置 ========================
defaults
mode http
log global
option httplog
option dontlognull
option http-server-close
option forwardfor except 127.0.0.0/8
option redispatch
retries 3
timeout http-request 10s
timeout queue 1m
timeout connect 10s
timeout client 1m
timeout server 1m
timeout http-keep-alive 10s
timeout check 10s
maxconn 3000
# 自定义错误页
errorfile 503 /etc/haproxy/errors/503.http
errorfile 502 /etc/haproxy/errors/502.http
# ======================== 状态页 ========================
listen stats
mode http
bind 0.0.0.0:4321
stats enable
log global
stats refresh 5
stats uri /status
stats realm HAProxy\ Statistics
stats auth admin:lee
stats admin if TRUE
# ======================== Web 业务入口 ========================
frontend web_front
bind *:80
mode http
# ---- ACL 规则 ----
# 1) 恶意扫描器拦截
acl bad_agent hdr_sub(User-Agent) -i -m reg (scanner|crawler|nikto|sqlmap|masscan|nmap)
http-request deny if bad_agent
# 2) 黑名单 IP
acl black_ip src 172.25.254.222
http-request deny if black_ip
# 3) 白名单模式(只允许内网访问管理路径)
acl admin_path path_beg -i /admin
acl intranet src 192.168.0.0/24 172.25.254.0/24
http-request deny if admin_path !intranet
# 4) 限流:单 IP 每秒最多 100 请求(防爬虫/防刷)
stick-table type ip size 100k expire 30s store http_req_rate(10s)
http-request track-sc0 src
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 1000 }
# 5) 按域名分流
acl host_www hdr_dom(host) -i www.timinglee.org
acl host_app hdr_dom(host) -i app.timinglee.org
acl host_api hdr_dom(host) -i api.timinglee.org
use_backend be_webcluster if host_www
use_backend be_webcluster if host_app
use_backend be_api if host_api
# 6) 按文件后缀做动静分离
acl url_static path_end -i .css .js .png .jpg .jpeg .gif .ico .woff .woff2 .svg .mp4
use_backend be_static if url_static
default_backend be_webcluster
# ======================== 后端:Web 集群 ========================
backend be_webcluster
mode http
balance roundrobin
option httpchk GET /health
http-check expect status 200
# 会话保持:插入自定义 Cookie
cookie SERVERID insert nocache indirect
# 透传真实客户端 IP
option forwardfor
server web1 192.168.0.10:80 cookie w1 check inter 2s fall 3 rise 2 weight 2
server web2 192.168.0.20:80 cookie w2 check inter 2s fall 3 rise 2 weight 2
# 兜底页面(所有后端都挂时启用)
server sorry 192.168.0.50:8080 backup
# ======================== 后端:纯静态资源 ========================
backend be_static
mode http
balance roundrobin
cookie SERVERID insert nocache indirect
option httpchk GET /health
server web1 192.168.0.10:80 cookie w1 check inter 2s fall 3 rise 2
server web2 192.168.0.20:80 cookie w2 check inter 2s fall 3 rise 2
# ======================== 后端:API(接口服务) ========================
backend be_api
mode http
balance leastconn
option httpchk GET /health
server web1 192.168.0.10:80 check inter 2s fall 3 rise 2
server web2 192.168.0.20:80 check inter 2s fall 3 rise 2
# ======================== 四层:数据库读写入口 ========================
listen mysql_read
bind *:3307
mode tcp
balance roundrobin
option tcpka
server db1 192.168.0.30:3306 check inter 3s fall 3 rise 2
server db2 192.168.0.40:3306 check inter 3s fall 3 rise 2
配置详解(面试重点):
balance roundrobin是动态算法 ,支持socat热改权重。static-rr和first是静态算法 ,不支持 热改权重(会报Backend is using a static LB algorithm and only accepts weights '0%' and '100%')。check inter 2s fall 3 rise 2:每 2 秒检查一次,连续失败 3 次标记下线,连续成功 2 次标记上线。cookie SERVERID insert nocache indirect:HAProxy 自己插入一个名为 SERVERID 的 Cookie 来记录后端选择。nocache让客户端不缓存这个 Cookie,indirect让后端看不到它。option forwardfor:插入X-Forwarded-For头,让后端能拿到真实客户端 IP。httpchk GET /health:用 HTTP 健康检查而不是 TCP 检查,能识别「端口在但应用返回 500」的情况。这是 HAProxy 比 LVS 强的地方之一。leastconn适合长连接场景(如 API 的耗时请求),roundrobin适合短连接。redispatch:后端连接失败时重新分发到另一台。但要注意,如果请求已经发出去了,redispatch会导致重复请求,所以只建议对幂等请求使用。
6.2.2 自定义错误页
bash
mkdir -p /etc/haproxy/errors
cat > /etc/haproxy/errors/503.http <<'EOF'
HTTP/1.0 503 Service Unavailable
Cache-Control: no-cache
Connection: close
Content-Type: text/html;charset=UTF-8
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>服务暂时不可用</title></head>
<body style="text-align:center;padding-top:100px;font-family:sans-serif">
<h1>服务暂时不可用</h1>
<p>正在紧急恢复中,请稍后重试</p>
</body></html>
EOF
cat > /etc/haproxy/errors/502.http <<'EOF'
HTTP/1.0 502 Bad Gateway
Cache-Control: no-cache
Connection: close
Content-Type: text/html;charset=UTF-8
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>网关错误</title></head>
<body style="text-align:center;padding-top:100px;font-family:sans-serif">
<h1>网关错误</h1>
<p>后端服务响应异常,请稍后重试</p>
</body></html>
EOF
格式极其严格 :第一行必须是
HTTP/1.0 <状态码> <原因短语>,然后是响应头,空一行之后才是 HTML 内容。少一个空行或状态码写错,HAProxy 会拒绝启动。
6.2.3 配置日志
bash
# 让 rsyslog 接收 local2 的日志
vim /etc/rsyslog.conf
取消注释或添加:
module(load="imudp")
input(type="imudp" port="514")
local2.* /var/log/haproxy.log
bash
systemctl restart rsyslog
mkdir -p /var/lib/haproxy
chown haproxy:haproxy /var/lib/haproxy
haproxy -c -f /etc/haproxy/haproxy.cfg
# Configuration file is valid
systemctl enable --now haproxy
ss -antlp | grep -E ':(80|4321|3307)'
6.2.4 部署 sorryserver(兜底页面)
在 lb1 上:
bash
dnf install -y httpd
sed -i 's/^Listen 80/Listen 8080/' /etc/httpd/conf/httpd.conf
cat > /var/www/html/index.html <<'EOF'
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>系统维护中</title></head>
<body style="text-align:center;padding-top:100px;font-family:sans-serif">
<h1>服务正在维护中</h1>
<p>请稍后重试</p>
</body></html>
EOF
systemctl enable --now httpd
ss -antlp | grep 8080
sorryserver 的价值:当所有后端都不可用时,用户看到的是一个友好的提示页,而不是浏览器默认的 502/503 错误页。这是「用户体验意识」的体现,面试可以主动提。
6.2.5 验证 HAProxy 负载
bash
# 轮询验证
for i in {1..6}; do curl -s http://192.168.0.50/hostname.html; done
# web1 web2 web1 web2 web1 web2
# 通过 lb1 的 IP 访问
for i in {1..4}; do curl -s http://172.25.254.50/hostname.html; done
# 通过域名访问
curl -s -H "Host: www.timinglee.org" http://172.25.254.50/hostname.html
# 会话保持验证:带 Cookie 的两次请求落到同一台
curl -s -c /tmp/hp.txt http://172.25.254.50/hostname.html
curl -s -b /tmp/hp.txt http://172.25.254.50/hostname.html
# 两次应该是同一个主机名
grep SERVERID /tmp/hp.txt
权重热更新(socat):
bash
dnf install -y socat
# 查看当前权重
echo "get weight be_webcluster/web1" | socat stdio /var/lib/haproxy/stats
# 2 (initial 2)
# 动态改权重(不需要重启!)
echo "set weight be_webcluster/web1 5" | socat stdio /var/lib/haproxy/stats
echo "set weight be_webcluster/web2 1" | socat stdio /var/lib/haproxy/stats
# 验证流量分配变了
for i in {1..12}; do curl -s http://172.25.254.50/hostname.html; done
# 会明显偏向 web1
# 下线/上线后端(做灰度发布的基础)
echo "disable server be_webcluster/web2" | socat stdio /var/lib/haproxy/stats
echo "enable server be_webcluster/web2" | socat stdio /var/lib/haproxy/stats
# 查看完整状态
echo "show stat" | socat stdio /var/lib/haproxy/stats | cut -d, -f1,2,18 | column -s, -t
如果没有加
level admin,会报Permission denied。这是最常见的坑,你笔记里也记录了。另外注意stats socket的用户权限------systemctl restart haproxy后/var/lib/haproxy/里的 socket 文件会被重建,权限跟着配置走。
状态页访问 :浏览器打开 http://172.25.254.50:4321/status,账号 admin / 密码 lee。
6.2.6 IP 透传验证
bash
# 在后端 web1 上确认收到了真实客户端 IP
curl -s http://172.25.254.50/hostname.html > /dev/null
grep -E "X-Forwarded-For|X-Real-IP" /webdir/timinglee.org/www/logs/access.log | tail -3
日志格式中的 $http_x_forwarded_for 会显示真实客户端 IP(比如 172.25.254.1,那是你的 Windows 宿主机上的 VMnet8 地址)。
面试延伸:七层透传用
X-Forwarded-For+option forwardfor;四层透传必须用proxy_protocol(HAProxy 的send-proxy配合后端的proxy_protocol)。四层透传时如果 HAProxy 还是mode http,后端会报 502------这是你笔记里记录过的坑。
6.3 Keepalived 双主高可用
执行主机 :
lb1、lb2
6.3.1 关键内核参数
HAProxy 需要绑定一个当前主机可能没有的 VIP(备份机平时不持有 VIP)。必须开启地址非本地绑定:
bash
# lb1 和 lb2 都执行
echo "net.ipv4.ip_nonlocal_bind = 1" >> /etc/sysctl.conf
sysctl -p
sysctl net.ipv4.ip_nonlocal_bind
# net.ipv4.ip_nonlocal_bind = 1
不开这个参数,HAProxy 启动时会报
Cannot bind socket (Cannot assign requested address) [0.0.0.0:80],因为备份机上并没有 VIP 这个地址。更优雅的替代方案是 HAProxy 配置里用
bind *:80(监听所有地址),配合 Keepalived。本项目用ip_nonlocal_bind是因为要明确绑定 VIP,便于理解。两种做法都要能讲。
6.3.2 lb1 配置(Web VIP 主 / 监控 VIP 备)
bash
dnf install -y keepalived
vim /etc/keepalived/keepalived.conf
conf
global_defs {
router_id lb1
vrrp_skip_check_adv_addr
vrrp_garp_interval 1
vrrp_gna_interval 1
vrrp_mcast_group4 224.0.0.44
# 新增:启用脚本安全机制
enable_script_security
# 指定脚本以 root 身份运行(匹配你脚本里 killall、端口检测的权限需求)
script_user root
}
# 进程级健康检查:HAProxy 是否存活
vrrp_script chk_haproxy {
script "/etc/keepalived/scripts/check_haproxy.sh"
interval 2
weight -30
fall 2
rise 2
timeout 3
user root
}
# ===== 实例一:Web VIP(lb1 为主) =====
vrrp_instance WEB_VIP {
state MASTER
interface eth0
virtual_router_id 51
priority 100
advert_int 1
authentication {
auth_type PASS
auth_pass 1111
}
virtual_ipaddress {
172.25.254.200/24 dev eth0 label eth0:0
}
track_script {
chk_haproxy
}
notify_master "/etc/keepalived/scripts/notify.sh master WEB_VIP"
notify_backup "/etc/keepalived/scripts/notify.sh backup WEB_VIP"
notify_fault "/etc/keepalived/scripts/notify.sh fault WEB_VIP"
}
# ===== 实例二:监控/状态页 VIP(lb1 为备) =====
vrrp_instance STATS_VIP {
state BACKUP
interface eth0
virtual_router_id 53
priority 90
advert_int 1
authentication {
auth_type PASS
auth_pass 1111
}
virtual_ipaddress {
172.25.254.202/24 dev eth0 label eth0:2
}
track_script {
chk_haproxy
}
}
6.3.3 lb2 配置(与 lb1 镜像对称)
conf
global_defs {
router_id lb2
vrrp_skip_check_adv_addr
vrrp_garp_interval 1
vrrp_gna_interval 1
vrrp_mcast_group4 224.0.0.44
# 新增:启用脚本安全机制
enable_script_security
# 指定脚本以 root 身份运行(匹配你脚本里 killall、端口检测的权限需求)
script_user root
}
vrrp_script chk_haproxy {
script "/etc/keepalived/scripts/check_haproxy.sh"
interval 2
weight -30
fall 2
rise 2
timeout 3
user root
}
# ===== 实例一:Web VIP(lb2 为备) =====
vrrp_instance WEB_VIP {
state BACKUP
interface eth0
virtual_router_id 51
priority 90
advert_int 1
preempt_delay 10
authentication {
auth_type PASS
auth_pass 1111
}
virtual_ipaddress {
172.25.254.200/24 dev eth0 label eth0:0
}
track_script {
chk_haproxy
}
notify_master "/etc/keepalived/scripts/notify.sh master WEB_VIP"
notify_backup "/etc/keepalived/scripts/notify.sh backup WEB_VIP"
notify_fault "/etc/keepalived/scripts/notify.sh fault WEB_VIP"
}
# ===== 实例二:监控/状态页 VIP(lb2 为主) =====
vrrp_instance STATS_VIP {
state MASTER
interface eth0
virtual_router_id 53
priority 100
advert_int 1
authentication {
auth_type PASS
auth_pass 1111
}
virtual_ipaddress {
172.25.254.202/24 dev eth0 label eth0:2
}
track_script {
chk_haproxy
}
}
preempt_delay 10的作用:BACKUP 变 MASTER 后,如果原 MASTER 恢复,延迟 10 秒再让出 VIP。这是为了防抖动------避免原 MASTER 因为短暂的网络闪断恢复后立刻抢回 VIP,造成来回切换、连接反复中断。这个参数很能体现经验。
6.3.4 健康检查脚本
bash
mkdir -p /etc/keepalived/scripts/
cat > /etc/keepalived/scripts/check_haproxy.sh <<'EOF'
#!/bin/bash
# 方式一:检查进程是否存在(最轻量)
killall -0 haproxy 2>/dev/null || exit 1
# 方式二:检查状态页端口是否响应(更严格,推荐生产用)
# curl -s -o /dev/null -m 2 http://127.0.0.1:4321/status || exit 2
exit 0
EOF
cat > /etc/keepalived/scripts/notify.sh <<'EOF'
#!/bin/bash
STATE=$1
INSTANCE=$2
LOG=/var/log/keepalived-notify.log
echo "$(date '+%F %T') $(hostname) [${INSTANCE}] -> ${STATE}" >> ${LOG}
logger -t keepalived "[${INSTANCE}] state -> ${STATE}"
# 可扩展为邮件告警
# echo "VIP 状态变化: ${INSTANCE} -> ${STATE}" | mail -s "Keepalived Alert" admin@timinglee.org
exit 0
EOF
chmod +x /etc/keepalived/scripts/*.sh
两台都放好后:
bash
# 语法检查(非常重要,写错一个字符 keepalived 直接不起来)
keepalived -t -f /etc/keepalived/keepalived.conf
# 没有输出 = 语法正确
systemctl enable --now keepalived
# 日志分离
sed -i 's|^KEEPALIVED_OPTIONS.*|KEEPALIVED_OPTIONS="-D -S 6"|' /etc/sysconfig/keepalived
grep -q "local6" /etc/rsyslog.conf || echo "local6.* /var/log/keepalived.log" >> /etc/rsyslog.conf
systemctl restart rsyslog keepalived
6.3.5 验证双主状态
bash
# lb1 上
ssh root@172.25.254.50 "ip -br a show eth0"
# eth0 UP 172.25.254.50/24 172.25.254.200/24
# lb2 上
ssh root@172.25.254.60 "ip -br a show eth0"
# eth0 UP 172.25.254.60/24 172.25.254.202/24
双主模式下两台各自持有不同的 VIP 。lb1 持有业务 VIP(.200),lb2 持有监控 VIP(.202)。这样两台机器都在提供服务,资源利用率 100%。这正是你笔记里
Keepalived双主模式.md的做法。
验证 VRRP 通告:
bash
tcpdump -i eth0 -nn host 224.0.0.44 -c 6
# IP 172.25.254.50 > 224.0.0.44: VRRPv2, Advertisement, vrid 51, prio 100, intvl 1s
# IP 172.25.254.60 > 224.0.0.44: VRRPv2, Advertisement, vrid 51, prio 90, intvl 1s
# IP 172.25.254.60 > 224.0.0.44: VRRPv2, Advertisement, vrid 53, prio 100, intvl 1s
# IP 172.25.254.50 > 224.0.0.44: VRRPv2, Advertisement, vrid 53, prio 90, intvl 1s
这就是双主的直接证据:vrid 51 上 lb1 优先级 100、lb2 是 90;vrid 53 上正好相反。截图保存。
6.3.6 业务 VIP 故障漂移验证
bash
# 第 1 步:确认当前 VIP 在 lb1
ssh root@172.25.254.50 "ip a show eth0 | grep 172.25.254.200"
# inet 172.25.254.200/24 scope global secondary eth0:0
# 第 2 步:从 web1 通过 VIP 访问,确认业务正常
ssh root@172.25.254.10 "curl -s http://172.25.254.200/hostname.html"
# web1 或 web2
# 第 3 步:模拟 lb1 故障
ssh root@172.25.254.50 "systemctl stop keepalived"
# 第 4 步:立刻在 lb2 上观察(漂移应该在 3 秒内完成)
ssh root@172.25.254.60 "ip a show eth0 | grep 172.25.254.200"
# inet 172.25.254.200/24 scope global secondary eth0:0 ← 已经漂到 lb2
# 第 5 步:业务不中断
ssh root@172.25.254.10 "curl -s http://172.25.254.200/hostname.html"
# 依然正常返回
# 第 6 步:查看告警记录
ssh root@172.25.254.60 "tail -5 /var/log/keepalived-notify.log"
# ... lb2 [WEB_VIP] -> master
用量化数据证明切换时间:
bash
# 在 web1 上持续探测(每 0.2 秒一次)
cat > /root/vip_watch.sh <<'EOF'
#!/bin/bash
FAIL=0
for i in $(seq 1 150); do
if curl -s -m 1 -o /dev/null http://172.25.254.200/health; then
echo "$(date +%T.%3N) OK"
[ $FAIL -gt 0 ] && { echo ">>> 恢复,共失败 $FAIL 次(约 $((FAIL*2))00 ms)"; FAIL=0; }
else
echo "$(date +%T.%3N) FAIL"
FAIL=$((FAIL+1))
fi
sleep 0.2
done
EOF
chmod +x /root/vip_watch.sh
bash
# 一个终端跑监控
ssh root@172.25.254.10 "/root/vip_watch.sh"
# 另一个终端制造故障
ssh root@172.25.254.50 "systemctl stop keepalived"
正常情况下应该只有 2 ~ 5 次失败 (约 400 ms ~ 1 s)。这就是「高可用切换时间」的量化指标,可以写进简历。
恢复:
bash
ssh root@172.25.254.50 "systemctl start keepalived"
sleep 3
# 因为 lb1 priority 更高(100 > 90),它会抢回 VIP
ssh root@172.25.254.50 "ip a show eth0 | grep 172.25.254.200"
6.3.7 HAProxy 进程级故障漂移(vrrp_script 的价值)
这一节证明 vrrp_script 的意义:Keepalived 活得好好的,但 HAProxy 挂了。这种情况靠 VRRP 自身是发现不了的,必须靠脚本。
bash
# 在 lb1 上只停 HAProxy,keepalived 继续运行
ssh root@172.25.254.50 "systemctl stop haproxy"
# 脚本每 2 秒检查一次,连续失败 2 次后权重 100 - 30 = 70,低于 lb2 的 90
sleep 8
# 验证 VIP 已漂到 lb2
ssh root@172.25.254.60 "ip a show eth0 | grep 172.25.254.200"
# 172.25.254.200 出现了
ssh root@172.25.254.10 "curl -s http://172.25.254.200/hostname.html"
# 业务依然正常
这是本项目最值得讲的一个设计点。 面试时这样表达:
「Keepalived 的 VRRP 协议只能检测主机是否存活,检测不了上面跑的应用是否正常。所以我用
vrrp_script每 2 秒执行一次killall -0 haproxy,连续失败 2 次就把本机优先级降 30,让它低于备份节点,从而触发 VIP 漂移。这样就实现了应用级的健康检查,而不只是主机级。」
恢复:
bash
ssh root@172.25.254.50 "systemctl start haproxy"
sleep 10
ssh root@172.25.254.50 "ip a show eth0 | grep 172.25.254.200"
6.3.8 脑裂防护
脑裂:两台机器都认为自己是 MASTER,同时持有 VIP。会导致流量随机乱飘、数据不一致。
产生原因:
- 心跳网络中断(VRRP 组播包过不去),但两台机器本身都还活着。
- 优先级配置错误(两台
priority相同)。 - 时间不同步导致判定异常。
防护手段:
bash
# 1) 时间同步(前面已做,验证一下)
for i in 50 60; do echo "=== lb$i ==="; ssh root@172.25.254.$i "chronyc tracking | grep -E 'Reference ID|System time|Leap'"; done
# 2) 检查优先级确实不同
for i in 50 60; do echo "=== lb$i ==="; ssh root@172.25.254.$i "grep -E 'state|priority|virtual_router_id' /etc/keepalived/keepalived.conf"; done
增加一个「双活检测」脚本,检测到对端也有 VIP 时主动让出:
bash
cat > /etc/keepalived/scripts/check_split_brain.sh <<'EOF'
#!/bin/bash
# 如果本机持有 VIP,且对端也持有 VIP,则判定为脑裂,本机主动让出
MY_VIP=$(ip a show eth0 | grep -c "172.25.254.200")
if [ "$MY_VIP" -gt 0 ]; then
case "$(hostname)" in
lb1) PEER="172.25.254.60" ;;
lb2) PEER="172.25.254.50" ;;
*) exit 0 ;;
esac
if timeout 2 ssh -o ConnectTimeout=1 -o BatchMode=yes root@$PEER \
"ip a show eth0 | grep -q 172.25.254.200" 2>/dev/null; then
logger -t keepalived "检测到脑裂!本机($(hostname))主动让出 VIP"
exit 1 # 非 0 → 降权 → 让出 VIP
fi
fi
exit 0
EOF
chmod +x /etc/keepalived/scripts/check_split_brain.sh
conf
# 挂到 vrrp_instance 里
vrrp_script chk_splitbrain {
script "/etc/keepalived/scripts/check_split_brain.sh"
interval 5
weight -60
fall 2
rise 2
timeout 5
user root
}
vrrp_instance WEB_VIP {
# ... 原有配置 ...
track_script {
chk_haproxy
chk_splitbrain
}
}
生产环境更常用的脑裂防护是第三方仲裁(fencing) :加一台「仲裁机」,两台 lb 都向它注册,只有拿到锁的才是 MASTER。也可以用共享存储或带外管理口(IPMI)做 fencing。
这个认知层面的回答,比只会说「配好优先级」要强得多。
6.3.9 日志分离与轮转
bash
# lb1 / lb2 都做
# 1) HAProxy 日志到独立文件
grep -q "haproxy.log" /etc/rsyslog.conf || echo "local2.* /var/log/haproxy.log" >> /etc/rsyslog.conf
# 2) Keepalived 日志到独立文件
sed -i 's|^KEEPALIVED_OPTIONS.*|KEEPALIVED_OPTIONS="-D -S 6"|' /etc/sysconfig/keepalived
grep -q "local6" /etc/rsyslog.conf || echo "local6.* /var/log/keepalived.log" >> /etc/rsyslog.conf
systemctl restart rsyslog keepalived haproxy
# 3) logrotate 防止日志撑爆磁盘
cat > /etc/logrotate.d/timinglee <<'EOF'
/var/log/haproxy.log
/var/log/keepalived.log
/var/log/keepalived-notify.log {
daily
rotate 7
missingok
notifempty
compress
copytruncate
}
EOF
# 4) 验证日志分流生效
logger -t haproxy-test "log test"
tail -3 /var/log/haproxy.log
tail -3 /var/log/keepalived.log
6.3.10 全站 HTTPS(SSL 卸载)
SSL 卸载的意义:HTTPS 加解密很消耗 CPU。在 HAProxy 上集中做一次加解密,后端 Web 节点继续用 HTTP,节省 Web 层算力。这是企业标准做法。
bash
mkdir -p /etc/haproxy/certs
cd /etc/haproxy/certs/
openssl req -newkey rsa:2048 -nodes -sha256 -days 365 \
-keyout timinglee.org.key \
-x509 \
-subj "/C=CN/ST=Shaanxi/L=Xian/O=timinglee/OU=linux/CN=www.timinglee.org" \
-out timinglee.org.crt
# 合成 pem(HAProxy 要求 key + crt 合并成一个文件)
cat timinglee.org.key timinglee.org.crt > timinglee.pem
chmod 600 timinglee.pem
ls -l
在 haproxy.cfg 中把原来的 frontend web_front 替换为下面两个:
cfg
# ======================== HTTP:全部跳转到 HTTPS ========================
frontend web_front_http
bind *:80
mode http
redirect scheme https code 301 if !{ ssl_fc }
# ======================== HTTPS:真正对外服务 ========================
frontend web_front_https
bind *:443 ssl crt /etc/haproxy/certs/timinglee.pem alpn h2,http/1.1
# 只启用安全的 TLS 版本与加密套件(安全加固)
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384
mode http
option httplog
option forwardfor
http-request set-header X-Forwarded-Proto https
acl host_status hdr_dom(host) -i status.timinglee.org
use_backend be_stats if host_status
acl bad_agent hdr_sub(User-Agent) -i -m reg (scanner|nikto|sqlmap|masscan|nmap)
http-request deny if bad_agent
acl host_www hdr_dom(host) -i www.timinglee.org
acl host_app hdr_dom(host) -i app.timinglee.org
use_backend be_webcluster if host_www
use_backend be_webcluster if host_app
acl url_static path_end -i .css .js .png .jpg .jpeg .gif .ico .woff .woff2 .svg .mp4
use_backend be_static if url_static
default_backend be_webcluster
bash
haproxy -c -f /etc/haproxy/haproxy.cfg
systemctl restart haproxy
验证:
bash
# HTTP 应该返回 301
curl -I http://172.25.254.50/
# HTTP/1.1 301 Moved Permanently
# location: https://172.25.254.50/
# HTTPS 应该正常(-k 跳过自签证书校验)
curl -kI https://172.25.254.50/
# HTTP/2 200
# server: TIMINGLEE/
# 查看协议版本与加密套件
curl -kI --tlsv1.2 https://172.25.254.50/ -v 2>&1 | grep -E "SSL connection|ALPN|TLS"
# 验证弱协议被拒绝
curl -k --tlsv1.0 https://172.25.254.50/ 2>&1 | head -2
# 应该握手失败(因为 ssl-min-ver TLSv1.2)
面试延伸:
- SSL 卸载(SSL Termination):代理层解密,后端明文。优点:后端省 CPU;缺点:代理到后端这段是明文,需要内网可信。
- SSL 透传(SSL Pass-through):四层转发,不解密。后端自己处理证书。代理看不到内容,做不了七层路由。
- SSL 桥接(SSL Bridging):代理解密后再重新加密到后端。最安全但最耗 CPU。
打快照 :stage6-ha-final-ok。
第七章 进阶:用 LVS DR 模式替换 HAProxy
本章操作主机 :
lb1、lb2(调度器);web1、web2(真实服务器 RS)
这一章是可选加分项。做完之后你的技术栈里就有了 LVS,简历上能写「掌握四层负载均衡 LVS(NAT / DR / TUN / FULLNAT)」。
7.1 为什么加这一层
执行主机:本节为说明,无需执行命令
- HAProxy 是七层负载,能理解 HTTP,可以做基于 URL、Cookie、Header 的精细调度,但七层必须完整解析请求,吞吐上限受 CPU 限制。
- LVS 是四层负载,工作在传输层,只改包头不改内容,性能高一个数量级(单台 LVS 能扛几十万并发连接),但做不了七层精细控制。
企业标准架构 = LVS(四层)+ Nginx/HAProxy(七层)+ 应用。四层先做粗粒度分流,七层再做精细路由。这是面试里能明显拉开差距的一段。
7.2 LVS DR 模式原理
执行主机:本节为原理说明,无需执行命令
DR(Direct Routing,直接路由)模式的精髓:
┌──────────────────────────┐
客户端 ──请求──→ VIP ──→│ LVS 调度器(lb1/lb2) │
│ 只改目标 MAC,不改 IP │
└───────────┬──────────────┘
│ 转发给某台 RS 的 MAC
┌─────────┴─────────┐
▼ ▼
web1 (lo:VIP) web2 (lo:VIP)
│ │
└───── 直接回包给客户端 ─────┘
(响应不经过调度器)
三个关键点(面试必答):
- 请求经过调度器,响应不经过调度器。调度器只做「改 MAC 转发」,回包由 RS 直接发给客户端。所以调度器不会成为带宽瓶颈------这是 DR 模式性能最高的原因。
- VIP 必须同时配置在调度器(对外)和每台 RS 的 lo 网卡上(/32)。因为回包时源 IP 必须是 VIP,客户端才认。
- RS 必须做 ARP 抑制。否则 RS 会响应 VIP 的 ARP 请求(它 lo 上有这个 IP),导致客户端的 ARP 表把 VIP 指向 RS 而不是调度器,调度就失效了。
7.3 环境准备
执行主机 :
lb1、lb2
先在 lb1 / lb2 上停掉 HAProxy(避免抢 80 端口):
bash
# lb1 和 lb2
systemctl stop haproxy
systemctl disable haproxy
7.4 在 RS 上配置 VIP 与 ARP 抑制
执行主机 :
web1、web2(真实服务器 RS)
在 web1 和 web2 上都执行。
7.4.1 lo 网卡上添加 VIP
bash
cd /etc/NetworkManager/system-connections/
cp -p eth0.nmconnection lo.nmconnection
vim lo.nmconnection
ini
[connection]
id=lo
type=loopback
interface-name=lo
[ethernet]
[ipv4]
method=manual
address1=127.0.0.1/8
address2=172.25.254.200/32
[ipv6]
addr-gen-mode=eui64
method=ignore
[proxy]
bash
nmcli connection reload
nmcli connection up lo
# 验证
ip a show lo
# inet 127.0.0.1/8 scope host lo
# inet 172.25.254.200/32 scope global lo ← VIP 已配置
7.4.2 ARP 抑制(DR 模式最关键的一步)
bash
cat > /etc/sysctl.d/90-lvs-dr.conf <<'EOF'
net.ipv4.conf.all.arp_ignore = 1
net.ipv4.conf.all.arp_announce = 2
net.ipv4.conf.lo.arp_ignore = 1
net.ipv4.conf.lo.arp_announce = 2
EOF
sysctl -p /etc/sysctl.d/90-lvs-dr.conf
参数含义(面试必答):
arp_ignore = 1:只回答「目标 IP 是本接口 IP」的 ARP 请求。这样当别人问「谁是 172.25.254.200」时,RS 不会用 lo 上的地址去应答(因为请求是从 eth0 进来的,而 VIP 在 lo 上)。arp_announce = 2:发送 ARP 通告时,只使用与本接口地址在同一子网的源地址。防止 RS 用 lo 上的 VIP 作为源去通告。
不做的后果:VIP 会被多台 RS 同时宣称持有,客户端的 ARP 表混乱,请求时通时不通------这是最经典的 DR 模式故障。
7.4.3 验证 ARP 抑制生效
bash
# 在 web1 上看 ARP 通告行为
tcpdump -i eth0 -nn arp -c 10
# 在别处 ping VIP 时,应该只有调度器响应 ARP(从客户端 arp -n 验证)
7.5 配置 LVS 调度器
执行主机 :
lb1、lb2(调度器)
在 lb1 上(lb2 稍后由 Keepalived 自动同步规则):
bash
dnf install -y ipvsadm
7.5.1 DR 模式规则
bash
# 清空旧规则
ipvsadm -C
# 添加虚拟服务(VIP:80,调度算法 wrr)
ipvsadm -A -t 172.25.254.200:80 -s wrr
# 添加真实服务器,-g 表示 DR(直接路由)模式
ipvsadm -a -t 172.25.254.200:80 -r 192.168.0.10:80 -g -w 1
ipvsadm -a -t 172.25.254.200:80 -r 192.168.0.20:80 -g -w 1
# 查看
ipvsadm -Ln
期望输出:
IP Virtual Server version 1.2.1 (size=4096)
Prot LocalAddress:Port Scheduler Flags
-> RemoteAddress:Port Forward Weight ActiveConn InActConn
TCP 172.25.254.200:80 wrr
-> 192.168.0.10:80 Route 1 0 0
-> 192.168.0.20:80 Route 1 0 0
Route就是 DR 模式的标识。NAT 模式显示Masq,TUN 模式显示Tunnel。
7.5.2 测试
bash
# 从 web1 或任意第三方机器上测试(注意不要在 lb1/lb2 本机测)
for i in {1..8}; do curl -s http://172.25.254.200/hostname.html; done
# web1 web2 web1 web2 ...
注意端口问题:RS 上现在有 Nginx 监听 80,同时也可能被 HAProxy 占用过。确保 lb1/lb2 上的 HAProxy 已停止。
7.5.3 权重调整与持久连接
bash
# 改权重(临时调整流量分配)
ipvsadm -e -t 172.25.254.200:80 -r 192.168.0.10:80 -g -w 3
# 验证流量倾斜
for i in {1..12}; do curl -s http://172.25.254.200/hostname.html; done
# 持久连接(同一客户端 N 秒内固定到同一台 RS)
ipvsadm -E -t 172.25.254.200:80 -s rr -p 30
# 查看持久连接表
ipvsadm -Lnc
# pro expire state source virtual destination
# TCP 00:29 ESTABLISHED 172.23.254.1:54321 172.25.254.200:80 192.168.0.10:80
-p 30表示 30 秒内同一源 IP 的连接都固定到同一台 RS,实现会话粘滞。这对没有做 Session 共享的老系统是必要的。项目里我们做了 MSM 会话共享,所以其实不需要持久连接------但面试会问,你要能讲。
7.5.4 规则持久化
bash
# 保存规则
ipvsadm-save -n > /etc/sysconfig/ipvsadm
cat /etc/sysconfig/ipvsadm
# 让 ipvsadm 服务开机自动恢复规则
systemctl enable --now ipvsadm.service
# 验证:清空后重启服务
ipvsadm -C
ipvsadm -Ln # 空
systemctl restart ipvsadm
ipvsadm -Ln # 规则回来了
7.5.5 用防火墙标记统一多端口调度(进阶)
当 RS 同时提供 80 和 443 时,两个虚拟服务各自独立轮询,会导致同一客户端访问 80 和 443 落到不同 RS,会话不一致。用 iptables mangle 打标记 + ipvsadm -f 解决。
bash
# 给 80 和 443 的包打上同一个标记 6666
iptables -t mangle -A PREROUTING -d 172.25.254.200 -p tcp -m multiport --dports 80,443 -j MARK --set-mark 6666
# 用防火墙标记创建虚拟服务
ipvsadm -A -f 6666 -s rr -p 30
ipvsadm -a -f 6666 -r 192.168.0.10:0 -g
ipvsadm -a -f 6666 -r 192.168.0.20:0 -g
ipvsadm -Ln
# FWM 6666 rr persistent 30
# -> 192.168.0.10:0 Route 1 0 0
# -> 192.168.0.20:0 Route 1 0 0
FWM(FireWall Mark)是 LVS 的一个高级用法。端口写0表示不限制端口(因为标记已经限定了)。这也是你笔记里利用火墙标记解决轮询错误.md的内容。
7.6 用 Keepalived 管理 LVS(企业级做法)
执行主机 :
lb1、lb2
手工敲 ipvsadm 命令不适合生产------规则不会自动同步,也没法做 RS 健康检查。企业做法是用 Keepalived 直接管理 IPVS,它会:
- 自动在两台调度器间同步 LVS 规则(通过 VRRP 通告携带)。
- 对每台 RS 做健康检查,挂掉的自动从 IPVS 表里摘除。
7.6.1 前端虚拟服务也要配 VIP
前端(lb1/lb2)的 keepalived.conf 里已经有 WEB_VIP 实例(6.3.2 节),VIP 172.25.254.200 已经配好。RS 上的 lo 也配了同一个 VIP(7.4.1 节)。这是 DR 模式的完整形态。
7.6.2 在 Keepalived 里定义 virtual_server
在 lb1 和 lb2 的 keepalived.conf 末尾 (或写到 /etc/keepalived/conf.d/webipvs.conf)追加:
conf
virtual_server 172.25.254.200 80 {
delay_loop 6
lb_algo wrr
lb_kind DR
persistence_timeout 30
protocol TCP
real_server 192.168.0.10 80 {
weight 1
HTTP_GET {
url {
path /health
status_code 200
}
connect_timeout 2
retry 3
delay_before_retry 1
}
}
real_server 192.168.0.20 80 {
weight 1
HTTP_GET {
url {
path /health
status_code 200
}
connect_timeout 2
retry 3
delay_before_retry 1
}
}
}
用 include 拆分更利于维护:
bash
mkdir -p /etc/keepalived/conf.d
# 把上面的 virtual_server 块写到 /etc/keepalived/conf.d/webipvs.conf
# 然后在 keepalived.conf 的 global_defs 后面加一行:
# include /etc/keepalived/conf.d/*.conf
bash
keepalived -t -f /etc/keepalived/keepalived.conf
systemctl restart keepalived
7.6.3 验证 Keepalived 管理的 IPVS
bash
# 自动生成了 IPVS 规则
ipvsadm -Ln
# TCP 172.25.254.200:80 wrr persistent 30
# -> 192.168.0.10:80 Route 1 0 0
# -> 192.168.0.20:80 Route 1 0 0
# 规则自动同步到了备份节点
ssh root@172.25.254.60 "ipvsadm -Ln"
# 一样的内容
# 健康检查生效:停掉 web2 的 Nginx
ssh root@172.25.254.20 "systemctl stop nginx"
sleep 10
# web2 被自动摘除
ipvsadm -Ln
# 只剩 192.168.0.10 了!
ssh root@172.25.254.10 "curl -s http://172.25.254.200/hostname.html"
# web1 ← 流量全部落到 web1,业务不中断
# 恢复
ssh root@172.25.254.20 "systemctl start nginx"
sleep 10
ipvsadm -Ln
# web2 自动回来了
这是 LVS + Keepalived 最有价值的特性 :调度器自己会做 RS 的健康检查,并动态增删 IPVS 表项。对比 HAProxy 的表现形式不同,但目的相同。
注意:不要在 lb1 或 lb2 本机用
curl访问 VIP,会走本地回环导致检测不出效果。这是你笔记里明确记录过的坑。
7.6.4 四层 vs 七层对比实验(面试杀手锏)
这是本项目的终极实验 :在同一个 VIP 上,先跑 LVS 四层,再跑 HAProxy 七层,用 ab 压测对比。
bash
dnf install -y httpd-tools
# ===== 测 LVS DR(四层)=====
# 确认当前是 LVS 在服务
ipvsadm -Ln | head -5
systemctl stop haproxy
ab -n 50000 -c 500 -k http://172.25.254.200/hostname.html
# 记录 Requests per second 和 Time per request
# ===== 测 HAProxy(七层)=====
# 停掉 LVS 规则,启动 HAProxy
ipvsadm -C
systemctl start haproxy
ss -antlp | grep ':80'
ab -n 50000 -c 500 -k http://172.25.254.200/hostname.html
# 记录数据
预期结论:LVS 因为只改 MAC 不做 HTTP 解析,静态短连接场景下 QPS 通常比 HAProxy 高 20% ~ 50%;但 HAProxy 能做基于 URL/Cookie/Header 的七层路由,功能强得多。
面试黄金答法 :
「我在项目里同时部署了 LVS 和 HAProxy 做对比压测。LVS 四层在纯转发场景 QPS 更高,因为只改 MAC 不解析报文;HAProxy 七层虽然略慢,但能做基于域名、URL、Cookie 的精细调度,还有原生状态页和热更新能力。所以企业架构通常是 LVS 做四层入口扛量、HAProxy/Nginx 做七层做业务路由,两者配合,而不是二选一。」
打快照 :stage7-lvs-ok。
第八章 全链路联调与性能压测
本章操作主机:按顺序涉及全部 7 台,每个小节已单独标注
8.1 完整启动顺序
执行主机 :按顺序:
infra→db1/db2→web1/web2→lb1/lb2
必须按这个顺序启动,否则会出现「应用起了但连不上数据库」之类的伪故障。
bash
# 1. infra(基础服务最先)
ssh root@172.25.254.100 "systemctl start chronyd nfs-server rsyslog named redis_6379 redis-sentinel"
# 2. db(数据层)
ssh root@172.25.254.30 "systemctl start mysqld keepalived"
ssh root@172.25.254.40 "systemctl start mysqld keepalived redis-sentinel"
# 3. web(应用层)
for i in 10 20; do
ssh root@172.25.254.$i "systemctl start memcached tomcat nginx"
done
ssh root@172.25.254.20 "systemctl start redis-sentinel"
# 4. lb(接入层最后)
for i in 50 60; do
ssh root@172.25.254.$i "systemctl start keepalived"
done
一键健康检查脚本:
bash
cat > /root/cluster_check.sh <<'SH'
#!/bin/bash
GREEN='\033[0;32m'; RED='\033[0;31m'; YEL='\033[0;33m'; NC='\033[0m'
ok(){ echo -e "${GREEN}[ OK ]${NC} $1"; }
bad(){ echo -e "${RED}[FAIL]${NC} $1"; }
warn(){ echo -e "${YEL}[WARN]${NC} $1"; }
echo "=========== 项目一 集群健康检查 $(date '+%F %T') ==========="
echo "--- 1. 网络连通性 ---"
for h in 172.25.254.100 172.25.254.30 172.25.254.40 172.25.254.10 172.25.254.20 172.25.254.50 172.25.254.60; do
ping -c1 -W1 $h >/dev/null 2>&1 && ok "ping $h" || bad "ping $h"
done
echo "--- 2. 基础服务 ---"
ssh -o ConnectTimeout=2 root@172.25.254.100 "systemctl is-active --quiet chronyd" && ok "chrony" || bad "chrony"
ssh -o ConnectTimeout=2 root@172.25.254.100 "systemctl is-active --quiet nfs-server" && ok "nfs" || bad "nfs"
ssh -o ConnectTimeout=2 root@172.25.254.100 "systemctl is-active --quiet named" && ok "dns" || bad "dns"
ssh -o ConnectTimeout=2 root@172.25.254.100 "systemctl is-active --quiet rsyslog" && ok "rsyslog" || bad "rsyslog"
echo "--- 3. 数据库 ---"
mysql -uroot -plee -h192.168.0.30 -e "SELECT 1" >/dev/null 2>&1 && ok "MySQL db1" || bad "MySQL db1"
mysql -uroot -plee -h192.168.0.40 -e "SELECT 1" >/dev/null 2>&1 && ok "MySQL db2" || bad "MySQL db2"
mysql -uroot -plee -h192.168.0.40 -e "SHOW REPLICA STATUS\G" 2>/dev/null | grep -q "Replica_IO_Running: Yes" && ok "主从 IO 线程" || bad "主从 IO 线程"
mysql -uroot -plee -h192.168.0.40 -e "SHOW REPLICA STATUS\G" 2>/dev/null | grep -q "Replica_SQL_Running: Yes" && ok "主从 SQL 线程" || bad "主从 SQL 线程"
mysql -uroot -plee -h192.168.0.30 -e "SHOW STATUS LIKE 'Rpl_semi_sync_master_status'\G" 2>/dev/null | grep -q "ON" && ok "半同步复制" || warn "半同步复制(可能为 OFF)"
echo "--- 4. Redis ---"
redis-cli -a lee -h192.168.0.100 ping 2>/dev/null | grep -q PONG && ok "Redis master" || bad "Redis master"
redis-cli -a lee -h192.168.0.20 ping 2>/dev/null | grep -q PONG && ok "Redis slave" || bad "Redis slave"
redis-cli -p 26379 -h192.168.0.100 sentinel get-master-addr-by-name mymaster >/dev/null 2>&1 && ok "Redis Sentinel" || bad "Redis Sentinel"
echo "--- 5. Web 层 ---"
for i in 10 20; do
curl -s -m 2 http://192.168.0.$i/health >/dev/null && ok "Nginx web$i" || bad "Nginx web$i"
curl -s -m 2 http://192.168.0.$i:8080/ >/dev/null && ok "Tomcat web$i" || bad "Tomcat web$i"
done
echo "--- 6. 接入层 ---"
for i in 50 60; do
ssh -o ConnectTimeout=2 root@172.25.254.$i "systemctl is-active --quiet haproxy" && ok "HAProxy lb$i" || bad "HAProxy lb$i"
ssh -o ConnectTimeout=2 root@172.25.254.$i "systemctl is-active --quiet keepalived" && ok "Keepalived lb$i" || bad "Keepalived lb$i"
done
echo "--- 7. VIP 归属 ---"
ssh -o ConnectTimeout=2 root@172.25.254.50 "ip a show eth0 | grep -q 172.25.254.200" && ok "Web VIP 在 lb1" || warn "Web VIP 不在 lb1"
ssh -o ConnectTimeout=2 root@172.25.254.60 "ip a show eth0 | grep -q 172.25.254.202" && ok "Stats VIP 在 lb2" || warn "Stats VIP 不在 lb2"
ssh -o ConnectTimeout=2 root@172.25.254.30 "ip a show eth0 | grep -q 172.25.254.201" && ok "DB VIP 在 db1" || warn "DB VIP 不在 db1"
echo "--- 8. 端到端业务 ---"
for i in 1 2 3 4; do
R=$(curl -s -m 2 http://172.25.254.200/hostname.html)
[ -n "$R" ] && ok "VIP 业务请求 -> $R" || bad "VIP 业务请求无响应"
done
echo "--- 9. DNS 解析 ---"
dig www.timinglee.org @192.168.0.100 +short | grep -q 172.25.254.200 && ok "DNS 解析 www" || bad "DNS 解析 www"
echo "--- 10. 集中日志 ---"
ssh -o ConnectTimeout=2 root@172.25.254.100 "ls /var/log/remote/ | wc -l" | awk '{if($1>=5) print "\033[0;32m[ OK ]\033[0m 已收集 "$1" 个节点的日志"; else print "\033[0;31m[FAIL]\033[0m 只收集到 "$1" 个节点"}'
echo "=========== 检查完成 ==========="
SH
chmod +x /root/cluster_check.sh
/root/cluster_check.sh
8.2 端到端业务链路验证
执行主机 :任意客户端节点发起(推荐
web1),链路经过全部 7 台
8.2.1 完整链路打点
bash
cat > /root/e2e_test.sh <<'SH'
#!/bin/bash
# 端到端链路:客户端 -> VIP -> HAProxy -> Nginx -> Tomcat -> Memcached/MySQL/NFS
VIP=172.25.254.200
echo "=== 1. DNS 解析 ==="
echo "www.timinglee.org -> $(dig www.timinglee.org @172.25.254.100 +short)"
echo "=== 2. HTTPS 跳转 ==="
curl -sI http://$VIP/ | grep -E "^HTTP|^location"
echo "=== 3. 负载均衡(静态页面,10 次)==="
for i in $(seq 1 10); do curl -s -m 2 http://$VIP/hostname.html; done
echo ""
echo "=== 4. 动静分离验证 ==="
echo "--- 静态资源(Nginx 直出)---"
curl -sI http://$VIP/css/main.css | grep -E "^HTTP|^Server|^Cache-Control"
echo "--- 动态页面(Tomcat 处理)---"
curl -s http://$VIP/info.jsp | grep -E "后端主机名|SessionID"
echo "=== 5. 会话共享(跨节点)==="
curl -s -c /tmp/e2e.txt http://$VIP/info.jsp | grep -E "后端主机名|SessionID|会话访问次数"
sleep 1
curl -s -b /tmp/e2e.txt http://$VIP/info.jsp | grep -E "后端主机名|SessionID|会话访问次数"
echo "=== 6. NFS 共享存储 ==="
echo "nfs-test-$(date +%s)" > /data/nfs/upload/e2e.txt
[ -f /data/nfs/upload/e2e.txt ] && echo "NFS 写入成功: $(cat /data/nfs/upload/e2e.txt)"
echo "=== 7. 数据库读写(经 VIP)==="
mysql -uappuser -plee -h172.25.254.201 -e "SELECT @@server_id AS backend_server_id, COUNT(*) AS user_count FROM timinglee.users;" 2>/dev/null
echo "=== 8. 反向代理到后端的 IP 透传 ==="
ssh root@172.25.254.10 "tail -1 /webdir/timinglee.org/www/logs/access.log"
SH
chmod +x /root/e2e_test.sh
/root/e2e_test.sh
8.2.2 预期输出示例
=== 1. DNS 解析 ===
www.timinglee.org -> 172.25.254.200
=== 2. HTTPS 跳转 ===
HTTP/1.1 301 Moved Permanently
location: https://172.25.254.200/
=== 3. 负载均衡(静态页面,10 次)===
web2
web1
web2
web1
...
=== 4. 动静分离验证 ===
--- 静态资源(Nginx 直出)---
HTTP/1.1 200 OK
Server: TIMINGLEE/
Cache-Control: public, max-age=604800
--- 动态页面(Tomcat 处理)---
后端主机名 web1
SessionID C3F9A2B1D4E5...
=== 5. 会话共享(跨节点)===
后端主机名 web1
SessionID C3F9A2B1D4E5...
会话访问次数 1
后端主机名 web2 ← 换节点了
SessionID C3F9A2B1D4E5... ← SessionID 不变!
会话访问次数 2
=== 6. NFS 共享存储 ===
NFS 写入成功: nfs-test-1757745000
=== 7. 数据库读写(经 VIP)===
backend_server_id user_count
30 2
=== 8. 反向代理到后端的 IP 透传 ===
172.25.254.1 - - [13/Sep/2026:14:30:00 +0800] "GET /hostname.html HTTP/1.1" 200 5 "-" "curl/7.61.1" "172.25.254.1" upstream_time=0.001 request_time=0.001
这一份输出就是完整的项目证明,务必截图保存。
8.3 性能压测与优化对比
执行主机 :
web1(压测机);被压对象是lb1上的 Web VIP
8.3.1 压测基线(不加缓存的动态请求)
bash
# 在 web1 上安装压测工具
dnf install -y httpd-tools
# 造一个有一定计算量的 PHP/JSP 页面作为压测目标
# 这里用一个简单页面,重点是测吞吐
ssh root@172.25.254.10 "curl -s -o /dev/null -w '%{http_code}' http://192.168.0.10/info.jsp"
bash
# 基线压测(直接访问单台 Nginx + Tomcat)
ab -n 10000 -c 200 http://192.168.0.10/info.jsp
记录关键指标:
Requests per second: xxxx.xx [#/sec] (mean)
Time per request: xxx.xxx [ms] (mean)
Failed requests: x
8.3.2 Nginx proxy_cache 缓存加速对比(核心数据)
这是本项目最有说服力的量化数据。
第 1 步:在 web1 上配置 proxy_cache
bash
mkdir -p /usr/local/nginx/proxy_cache
chown nginx:nginx /usr/local/nginx/proxy_cache
vim /usr/local/nginx/conf/nginx.conf
在 http 块中添加:
nginx
# 代理缓存配置
proxy_cache_path /usr/local/nginx/proxy_cache levels=1:2:2 keys_zone=proxycache:20m inactive=120s max_size=1g;
proxy_temp_path /usr/local/nginx/proxy_cache/temp;
修改站点配置里的动态 location:
nginx
location ~* \.(jsp|do|action)$ {
proxy_pass http://tomcat_cluster;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# ===== 缓存配置 =====
proxy_cache proxycache;
proxy_cache_key $request_uri;
proxy_cache_valid 200 302 301 10m;
proxy_cache_valid any 1m;
proxy_cache_min_uses 1;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
proxy_cache_lock on;
# 加一个响应头便于判断是否命中缓存
add_header X-Cache-Status $upstream_cache_status;
}
bash
nginx -t && systemctl reload nginx
# 验证缓存生效
curl -sI http://192.168.0.10/info.jsp | grep X-Cache-Status
# X-Cache-Status: MISS ← 第一次
curl -sI http://192.168.0.10/info.jsp | grep X-Cache-Status
# X-Cache-Status: HIT ← 第二次命中缓存!
这里为了演示缓存效果先对 JSP 做缓存。生产环境不要缓存带用户信息的动态页面 ,否则会把 A 用户的页面返回给 B 用户,这是严重的数据泄露事故。正确做法是缓存不区分用户 的数据接口(如商品列表、排行榜),或者在
proxy_cache_key里带上用户标识。
第 2 步:压测对比
bash
# ===== 未开缓存(先把上面缓存配置注释掉、reload)=====
nginx -t && systemctl reload nginx
ab -n 10000 -c 50 http://192.168.0.10/info.jsp
# Requests per second: 731.10 [#/sec] (mean) ← 你的笔记里的实测数据
# Failed requests: 9963
# ===== 开启缓存 =====
nginx -t && systemctl reload nginx
ab -n 10000 -c 50 http://192.168.0.10/info.jsp
# Requests per second: 2290.76 [#/sec] (mean) ← 提升约 3.1 倍
# Failed requests: 0
这组数据(QPS 731 → 2290,提升 3.1 倍;失败请求 9963 → 0)就是简历上最有价值的一行。 它是你自己压测出来的,面试官问细节你答得上。
第 3 步:观察缓存目录结构
bash
tree /usr/local/nginx/proxy_cache/
# /usr/local/nginx/proxy_cache/
# └── 1
# └── af
# └── 15
# └── e251273eb74a8ee3f661a7af00915af1
8.3.3 各层压测对比汇总表
把下面这张表填满,就是你的项目性能报告。
| 测试场景 | 并发 | QPS | 平均响应时间 | 失败数 |
|---|---|---|---|---|
直连 Nginx 静态页(hostname.html) |
500 | |||
直连 Nginx + Tomcat 动态页(info.jsp) |
50 | 731 | 9963 | |
| Nginx + Tomcat + proxy_cache | 50 | 2290 | 0 | |
| 经 HAProxy 七层 → Nginx 静态 | 500 | |||
| 经 LVS DR 四层 → Nginx 静态 | 500 | |||
| 经 VIP 全链路 → 动态页 | 200 |
测试脚本:
bash
cat > /root/bench.sh <<'SH'
#!/bin/bash
TARGET=${1:-http://192.168.0.10/hostname.html}
N=${2:-50000}
C=${3:-500}
echo "=== 压测目标: $TARGET 请求数: $N 并发: $C ==="
ab -n $N -c $C -k $TARGET 2>/dev/null | grep -E \
"Complete requests|Failed requests|Requests per second|Time per request|Transfer rate|Non-2xx"
SH
chmod +x /root/bench.sh
# 用法
/root/bench.sh http://192.168.0.10/hostname.html 50000 500 # Nginx 静态
/root/bench.sh http://172.25.254.200/hostname.html 50000 500 # 经 VIP 全链路
8.4 数据一致性验证
执行主机 :
db1(写入)、db2(读取校验)
bash
cat > /root/consistency_test.sh <<'SH'
#!/bin/bash
# 数据一致性验证:主库写 -> 从库读 -> 校验一致
echo "=== 数据一致性验证 ==="
for i in $(seq 1 20); do
mysql -uroot -plee -h192.168.0.30 -e \
"INSERT INTO timinglee.orders(user_id,amount,status) VALUES($((RANDOM%2+1)),$RANDOM.00,1);" 2>/dev/null
done
sleep 2
M=$(mysql -uroot -plee -N -h192.168.0.30 -e "SELECT COUNT(*) FROM timinglee.orders;" 2>/dev/null)
S=$(mysql -uroot -plee -N -h192.168.0.40 -e "SELECT COUNT(*) FROM timinglee.orders;" 2>/dev/null)
echo "主库订单数: $M"
echo "从库订单数: $S"
if [ "$M" = "$S" ]; then
echo "结果: 数据一致 [通过]"
else
echo "结果: 数据不一致 [失败]"
exit 1
fi
echo ""
echo "=== 主从延迟 ==="
mysql -uroot -plee -h192.168.0.40 -e "SHOW REPLICA STATUS\G" 2>/dev/null | \
grep -E "Seconds_Behind_Source|Replica_IO_Running|Replica_SQL_Running"
echo ""
echo "=== 半同步状态 ==="
mysql -uroot -plee -h192.168.0.30 -e "SHOW STATUS LIKE 'Rpl_semi_sync%';" 2>/dev/null
SH
chmod +x /root/consistency_test.sh
/root/consistency_test.sh
8.5 压力下的高可用验证(综合演练)
执行主机 :
web1(压测)+lb1/lb2(制造故障)
目标:在持续压测的情况下,依次干掉各个组件,验证业务不中断。
bash
# 终端 1:持续压测
ssh root@172.25.254.10 "ab -n 200000 -c 100 -k http://172.25.254.200/hostname.html"
在压测进行中,依次在另一个终端 执行下面的动作,观察 ab 输出中的 Failed requests 数量:
| 步骤 | 动作 | 期望结果 |
|---|---|---|
| 1 | ssh root@172.25.254.20 "systemctl stop tomcat" |
失败请求数极少(HAProxy 健康检查摘除后有短暂窗口) |
| 2 | ssh root@172.25.254.20 "systemctl start tomcat" |
恢复正常 |
| 3 | ssh root@172.25.254.50 "systemctl stop haproxy" |
失败 2 ~ 5 个请求(VIP 漂移窗口),之后恢复 |
| 4 | ssh root@172.25.254.50 "systemctl start haproxy" |
恢复正常 |
| 5 | ssh root@172.25.254.30 "systemctl stop mysqld" |
静态页不受影响;动态页可能报错(因为 Tomcat 连不上库) |
| 6 | ssh root@172.25.254.30 "systemctl start mysqld" |
恢复正常 |
把每一步的 Failed requests 数字记下来,这就是「高可用能力」的量化证据。
步骤 5 会暴露一个真实问题:当主库宕机、VIP 漂到从库后,从库是只读的,写操作会失败。这正是需要 MHA/MGR 的原因。面试时主动指出这个局限,比强行说「我的架构万无一失」要可信得多。
第九章 故障演练剧本
本章操作主机:按剧本涉及不同节点,每个演练已单独标注
把这一章当成面试前的实战彩排 。每个故障你都要能:制造 → 观察 → 定位 → 修复 → 复述。
9.1 演练一:Web 节点整体宕机
执行主机 :在
web1上制造故障;在lb1、web2上观察
制造:
bash
ssh root@172.25.254.10 "systemctl stop nginx tomcat"
观察:
bash
# 业务不中断,流量全部落到 web2
for i in {1..10}; do curl -s http://172.25.254.200/hostname.html; done
# 全是 web2
# HAProxy 状态页显示 web1 为 DOWN
ssh root@172.25.254.50 "echo 'show stat' | socat stdio /var/lib/haproxy/stats | grep web1 | cut -d',' -f1,2,18,41"
定位思路 :查看 HAProxy 日志确认健康检查失败 → 查看 web1 的 systemctl status nginx → 查看 /usr/local/nginx/logs/error.log。
修复:
bash
ssh root@172.25.254.10 "systemctl start nginx tomcat"
sleep 5
ssh root@172.25.254.50 "echo 'show stat' | socat stdio /var/lib/haproxy/stats | grep web1 | cut -d',' -f1,2,18,41"
# 状态恢复为 UP
9.2 演练二:Tomcat 进程假死(端口在但应用不响应)
执行主机 :在
web1上制造故障;在lb1上观察
这是最阴险的故障:进程活着、端口监听着,但应用不响应请求。纯 TCP 健康检查发现不了。
制造:
bash
# 让 Tomcat 线程池打满(或者简单点:用 iptables 丢弃 8080 的包,模拟应用无响应)
ssh root@172.25.254.20 "iptables -I INPUT -p tcp --dport 8080 -j DROP"
观察:
bash
# TCP 连接测试还是通的
ssh root@172.25.254.20 "ss -antlp | grep 8080"
# LISTEN
# 但 HTTP 请求超时
ssh root@172.25.254.20 "curl -m 3 -sI http://127.0.0.1:8080/ ; echo exit=\$?"
# Nginx 层面会报 502/504
curl -sI http://172.25.254.200/info.jsp
# HTTP/1.1 504 Gateway Time-out
修复与预防:
bash
ssh root@172.25.254.20 "iptables -D INPUT -p tcp --dport 8080 -j DROP"
关键教训 :Nginx 的
upstream健康检查是被动 的(max_fails/fail_timeout),只有请求失败才会标记下线。要主动检测,需要:
- Nginx Plus 的
health_check指令(商业版)。- 用
nginx_upstream_check_module第三方模块(开源方案)。- 换成 HAProxy 或 LVS+Keepalived 做主动健康检查。
这个知识点非常能体现深度,面试主动讲出来会加分。
9.3 演练三:数据库主库宕机
执行主机 :在
db1上制造故障;在db2、web1上观察
制造:
bash
ssh root@172.25.254.30 "systemctl stop mysqld"
观察:
bash
# 数据库 VIP 漂移到 db2(约 6 ~ 8 秒)
ssh root@172.25.254.40 "ip a show eth0 | grep 172.25.254.201"
# 读操作正常
ssh root@172.25.254.10 "mysql -uappuser -plee -h172.25.254.201 -e 'SELECT COUNT(*) FROM timinglee.users;'"
# 写操作失败(因为 db2 是只读从库)
ssh root@172.25.254.10 "mysql -uappuser -plee -h172.25.254.201 -e \"INSERT INTO timinglee.users(username) VALUES('fail_test');\""
# ERROR 1290 (HY000): The MySQL server is running with the --read-only option ...
修复 :ssh root@172.25.254.30 "systemctl start mysqld"(VIP 会自动抢回)。
真正的解决:需要 MHA 或 MGR 把从库提升为主库。见附录 B。
9.4 演练四:脑裂(VIP 在两台上同时存在)
执行主机 :在
lb1、lb2上制造并观察
制造:
bash
# 用 iptables 阻断 VRRP 组播(模拟心跳网络中断)
ssh root@172.25.254.50 "iptables -I INPUT -p vrrp -j DROP; iptables -I OUTPUT -p vrrp -j DROP"
ssh root@172.25.254.60 "iptables -I INPUT -p vrrp -j DROP; iptables -I OUTPUT -p vrrp -j DROP"
sleep 5
# 现在两台都认为对方挂了,都会成为 MASTER
ssh root@172.25.254.50 "ip a show eth0 | grep 172.25.254.200"
ssh root@172.25.254.60 "ip a show eth0 | grep 172.25.254.200"
# 两台都有!← 脑裂现象
观察现象:
bash
# 反复请求 VIP,会出现不可预测的响应(有时走 lb1 的 HAProxy,有时走 lb2)
for i in {1..10}; do curl -s -m 1 http://172.25.254.200/hostname.html; done
修复:
bash
for i in 50 60; do
ssh root@172.25.254.$i "iptables -D INPUT -p vrrp -j DROP; iptables -D OUTPUT -p vrrp -j DROP"
done
sleep 5
# 恢复单主
ssh root@172.25.254.50 "ip a show eth0 | grep 172.25.254.200"
预防手段回顾 :时间同步、优先级差异化、
preempt_delay、双活检测脚本、第三方仲裁(fencing)。
9.5 演练五:NFS 服务端宕机
执行主机 :在
infra上制造故障;在web1上观察
制造:
bash
ssh root@172.25.254.100 "systemctl stop nfs-server"
观察:
bash
# 客户端访问 NFS 目录会卡住(用了硬挂载)
timeout 5 ls /data/nfs/upload/ ; echo "exit=$?"
# exit=124 ← 超时
# 静态资源请求失败
curl -sI http://172.25.254.200/css/main.css
# 502 或 504
定位与修复:
bash
# 查看客户端挂载状态
ssh root@172.25.254.10 "mount | grep nfs"
ssh root@172.25.254.10 "showmount -e 192.168.0.100"
# clnt_create: RPC: Unable to receive
# 恢复服务端
ssh root@172.25.254.100 "systemctl start nfs-server"
sleep 3
# 客户端可能需要重新挂载
ssh root@172.25.254.10 "umount -f /data/nfs/static 2>/dev/null; mount -a"
教训 :NFS 硬挂载(
hard)会在服务端故障时无限等待,导致应用线程被卡死。应该:
- 用
soft+intr挂载,超时后返回错误而不是阻塞。- 或者用 autofs 按需挂载。
- 生产环境用 DRBD + Keepalived 做高可用 NFS,或者直接用分布式存储(CephFS / GlusterFS)。
这个延伸回答是很强的加分项。
9.6 演练六:磁盘写满
执行主机 :在
web1上制造故障
制造:
bash
# 在 web1 上写满一个小分区(危险操作,仅在测试机做)
ssh root@172.25.254.10 "df -h / "
# 造大文件占满(用一个循环写到报错)
ssh root@172.25.254.10 "fallocate -l 5G /tmp/bigfile 2>/dev/null || dd if=/dev/zero of=/tmp/bigfile bs=1M count=3000"
观察:
bash
# 系统日志报错
ssh root@172.25.254.10 "dmesg | tail -5"
# Nginx 写入 access.log 失败
ssh root@172.25.254.10 "tail -5 /usr/local/nginx/logs/error.log"
# [crit] ... write() failed (28: No space left on device)
# 服务可能拒绝新连接
curl -sI http://192.168.0.10/
修复:
bash
ssh root@172.25.254.10 "rm -f /tmp/bigfile; df -h /"
ssh root@172.25.254.10 "systemctl restart nginx"
预防:logrotate(前面已配)+ 磁盘使用率告警 + 日志级别不要设成 debug。
9.7 演练七:MySQL 主键冲突导致复制中断
执行主机 :在
db2、db1上制造故障并修复
制造:
bash
# 在从库直接插入一条与主库将来会冲突的数据
ssh root@172.25.254.40 "mysql -uroot -plee -e \"SET GLOBAL super_read_only=0; INSERT INTO timinglee.users(id,username) VALUES(100,'conflict'); SET GLOBAL super_read_only=1;\""
# 主库也插入相同 ID
ssh root@172.25.254.30 "mysql -uroot -plee -e \"INSERT INTO timinglee.users(id,username) VALUES(100,'conflict_master');\""
观察:
bash
ssh root@172.25.254.40 "mysql -uroot -plee -e 'SHOW REPLICA STATUS\G' | grep -E 'Replica_SQL_Running:|Last_SQL_Error:'"
# Replica_SQL_Running: No
# Last_SQL_Error: Could not execute Write_rows event on table timinglee.users;
# Duplicate entry '100' for key 'users.PRIMARY'
修复(GTID 模式下的标准做法):
bash
mysql -uroot -plee # 在 db2 上
sql
-- 1. 停掉复制
STOP REPLICA;
-- 2. 查看出错的 GTID
SHOW REPLICA STATUS\G
-- Retrieved_Gtid_Set: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:1-15
-- Executed_Gtid_Set: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:1-14
-- 3. 跳过出错的那个事务(空事务方式)
SET GTID_NEXT='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:15';
BEGIN;
COMMIT;
SET GTID_NEXT='AUTOMATIC';
-- 4. 重新启动复制
START REPLICA;
-- 5. 验证
SHOW REPLICA STATUS\G
-- Replica_SQL_Running: Yes
这是 DBA 面试的经典题目。要点:
- 传统复制用
SET GLOBAL sql_slave_skip_counter = 1跳过,但 GTID 模式下这个参数是禁止的。- GTID 模式必须用「空事务」方式:
SET GTID_NEXT→BEGIN→COMMIT→SET GTID_NEXT='AUTOMATIC'。- 跳过事务会导致主从数据永久不一致,必须事后用
pt-table-checksum校验并pt-table-sync修复。
第十章 简历写法与面试问答
本章操作主机:无需在虚拟机内操作
10.1 项目描述模板(可直接粘进简历)
执行主机:文档编写,无需在虚拟机内操作
要点:STAR 结构 + 量化数据 + 技术关键词密度 + 个人贡献明确。
企业级高可用 Web 应用集群与数据库高可用平台 2026.07 -- 2026.09
技术栈:RHEL9 / Nginx / Tomcat / Memcached / MySQL / Redis / NFS / LVS / HAProxy / Keepalived / rsyslog
项目背景:针对单体 Web 应用存在的单点故障、静态资源与动态请求争抢 Tomcat 线程、
会话无法跨节点共享、数据库单点等四类问题,设计并落地一套七层 + 四层的高可用架构。
架构设计:客户端 → Keepalived VIP(172.25.254.200) 双主高可用 → HAProxy 七层负载
→ Nginx 动静分离 + Tomcat 集群 → Memcached 会话共享 → MySQL 主从(GTID+半同步)
→ Redis 主从 + Sentinel 缓存高可用 → NFS 共享存储 → rsyslog 集中日志
核心工作:
• 基于 RHEL9 母盘 + 链接克隆构建 7 节点双网卡环境(业务面 172.25.254.0/24 / 数据面 192.168.0.0/24),
通过数据面隔离 MySQL 复制与 NFS 流量,业务面只承载客户端请求,降低互相干扰。
• Nginx 源码编译部署并隐藏版本号(Server 头由 nginx/1.28.1 改为自定义标识),
配置动静态分离、gzip、proxy_cache、防盗链、stub_status 监控;
通过 proxy_cache 将动态接口 QPS 从 731 提升至 2290(提升 3.1 倍),压测失败请求从 9963 降至 0。
• 部署 Tomcat 集群并基于 MSM(memcached-session-manager)实现会话集中存储,
web1/web2 的 failoverNodes 配置互为备份;实测携带同一 SessionID 跨节点访问时
会话计数持续累加、后端 Tomcat 宕机用户无需重新登录,实现会话零丢失。
• 搭建 MySQL 8.0 主从复制(GTID + ROW 格式),启用半同步复制插件,
主库超时后自动降级为异步复制、从库恢复后自动升回,兼顾可靠性与可用性;
配置延迟复制(SOURCE_DELAY=300)作为误删数据的兜底恢复方案。
• 基于 Keepalived VRRP 实现 HAProxy 与数据库 VIP 双主高可用;
使用 vrrp_script 对 HAProxy 进程做应用级健康检查(2s 间隔 / 失败 2 次降权 30),
实测主机级故障 VIP 切换丢包 2 ~ 5 个、耗时 < 1s;进程级故障同样可触发漂移。
• 部署 LVS DR 模式四层负载并与 HAProxy 做对比压测,验证 LVS 在纯转发场景的吞吐优势,
通过 arp_ignore/arp_announce 抑制 ARP 冲突,用 Keepalived 托管 virtual_server 实现
RS 健康检查与规则的跨节点自动同步。
• 搭建 NFS 共享存储 + autofs 按需挂载,解决集群多节点静态资源与上传文件的一致性问题;
部署 rsyslog 集中日志(TCP 514,按来源 IP 分目录)与 chrony 全网时间同步;
编写一键集群健康检查脚本(覆盖 10 类 40+ 检查项)与端到端链路验证脚本。
10.2 面试高频问题与答法
执行主机:文档编写,无需在虚拟机内操作
10.2.1 架构设计类
Q1:为什么要 Keepalived + HAProxy 两层,直接用 Keepalived + Nginx 不行吗?
可以,但各有取舍。保留两层的原因:
- 分工清晰。Keepalived 只负责「谁是主」,解决单点;HAProxy 只负责「怎么分发」。
- HAProxy 的健康检查维度更细(
inter/fall/rise独立可调),还有原生状态页和socat热更新。 - 换负载均衡器时只动 HAProxy,不动 Keepalived,变更风险小。
如果只要一个 80 端口做简单轮询,Keepalived + Nginx 完全够用。
Q2:为什么用双主而不是主备?
主备模式下备份机平时闲置,资源利用率 50%。双主模式下 lb1 持有业务 VIP、lb2 持有监控 VIP,两台都在干活,资源利用率 100%。代价是配置稍复杂,且两个 VRRP 实例的 virtual_router_id 必须不同。
Q3:VIP 漂移时正在传输的连接会怎样?
会断。VRRP 只负责把 VIP 从一台机器挪到另一台,不存在连接状态迁移。所以:
- 短连接(HTTP 类)影响很小,用户重试即可。
- 长连接(WebSocket、数据库连接池)会全部断开,需要客户端重连。
- 要减少影响:设置较短的
advert_int(1s)加快检测、用preempt_delay防抖动、业务层做重试。
要做到连接不丢,需要会话同步方案(如 LVS 的 sync daemon + ipvsadm --start-daemon,或 HAProxy 的 peers 配置)。这个可以做延伸回答。
Q4:数据面为什么单独用一个网段?
三个理由:
- 带宽隔离。MySQL 主从复制、NFS 读写、日志上报都是大流量,和客户端请求抢带宽会导致业务抖动。
- 安全隔离。数据库和 NFS 只监听内网网卡,从业务面根本访问不到,等于天然的一道防火墙。
- 故障隔离。VMware 的 NAT 网络出问题不影响数据面复制,反之亦然。
10.2.2 Nginx 类
Q5:location 的匹配优先级?
= 精确匹配 优先级最高,匹配上直接返回
^~ 前缀匹配 匹配上后不再尝试正则
~ 正则匹配(区分大小写) 按配置文件中的出现顺序
~* 正则匹配(不区分大小写) 按配置文件中的出现顺序
(空)普通前缀匹配 记住最长前缀匹配,然后继续尝试正则
完整优先级:= > ^~ > ~ / ~* > 普通前缀。
注意一个反直觉点 :普通前缀匹配会先记住最长匹配,但如果后面有正则能匹配上,正则优先 。只有 ^~ 才能阻止正则的检查。
Q6:root 和 alias 的区别?
-
root:会用「root 值 + location 值」拼出最终路径。nginxlocation /images/ { root /data/www; } # 访问 /images/a.png → /data/www/images/a.png -
alias:直接用 alias 值替换 location 部分。nginxlocation /images/ { alias /data/pics/; } # 访问 /images/a.png → /data/pics/a.png
关键坑:alias 匹配目录时,location 和 alias 都必须以 / 结尾,否则路径拼接会出错。
Q7:rewrite 的 break 和 last 区别?
break:重写后不再重新匹配 location,停留在当前 location 继续执行剩余指令。last:重写后重新走一遍 location 匹配,可能进入另一个 location。
你的笔记里有一个绝佳的验证例子:/break/index.html 用 break 时返回 test1(停在当前 location),用 last 时返回 test1 end page(重新匹配到了 /test1 这个 location)。这个例子直接讲给面试官,很有说服力。
Q8:proxy_cache 怎么配,怎么验证命中?
nginx
proxy_cache_path /path levels=1:2:2 keys_zone=mycache:20m inactive=120s max_size=1g;
location / {
proxy_cache mycache;
proxy_cache_key $request_uri;
proxy_cache_valid 200 302 10m;
proxy_cache_valid any 1m;
add_header X-Cache-Status $upstream_cache_status; # 关键:加这个头才能看到命中状态
}
$upstream_cache_status 的取值:MISS / HIT / EXPIRED / STALE / UPDATING / REVALIDATED / BYPASS。
生产注意 :不要缓存带用户身份的动态页面,否则会把 A 用户的数据返回给 B 用户。要缓存的话,proxy_cache_key 里必须带上用户标识或干脆只缓存公共数据接口。
Q9:Nginx 平滑升级的原理?
Nginx 的信号机制:
- 备份旧二进制 → 替换新二进制。
kill -USR2 <旧master PID>:旧 master 用新二进制拉起一个新 master,旧 master 把自己的 PID 文件改名为nginx.pid.oldbin。- 此时新旧两组 master + worker 并存,新 worker 开始接管新连接。
kill -WINCH <旧master PID>:旧 worker 处理完现有请求后优雅退出,旧 master 保留(便于回滚)。- 观察一段时间没问题,
kill -QUIT <旧master PID>彻底关闭旧 master。 - 要回滚就在第 4 步之后
kill -HUP <旧master PID>,旧 master 重新拉起 worker。
10.2.3 负载均衡类
Q10:LVS 的四种模式区别?
| 模式 | 转发方式 | 请求路径 | 响应路径 | 是否需改 RS | 是否支持跨网段 | 性能 |
|---|---|---|---|---|---|---|
| NAT | 改目标 IP | 经 VS | 经 VS | 网关指向 VS | 是 | 最低(VS 是瓶颈) |
| DR | 改目标 MAC | 经 VS | 不经 VS | lo 加 VIP + ARP 抑制 | 否(同二层) | 最高 |
| TUN | 加 IP 隧道头 | 经 VS | 不经 VS | 需支持隧道 | 是 | 高 |
| FULLNAT | 同时改源和目标 IP | 经 VS | 经 VS | 无需改 | 是 | 中 |
生产最常用 DR(性能最好,但要求 RS 与 VS 在同二层),跨机房或 RS 异构时用 FULLNAT。
Q11:DR 模式为什么必须做 ARP 抑制?
DR 模式下 VIP 同时存在于调度器和所有 RS 的 lo 上。如果 RS 不做抑制,当有人 ARP 询问「谁是 172.25.254.200」时,RS 也会用自己的 lo 地址去应答,导致客户端的 ARP 表把 VIP 映射到某一台 RS 的 MAC。这样请求就绕过调度器直达 RS,负载均衡彻底失效。
参数:
arp_ignore=1:只回答目标 IP 是本接口 IP 的 ARP 请求(拒绝用 lo 上的 VIP 应答)。arp_announce=2:发送 ARP 通告时只用与本接口同子网的源地址(不用 lo 上的 VIP 作源)。
Q12:HAProxy 的 8 种负载算法,静态和动态的区别?
| 算法 | 类型 | 说明 |
|---|---|---|
static-rr |
静态 | 加权轮询,不支持热改权重,不支持慢启动 |
first |
静态 | 优先第一台,达到 maxconn 才用第二台 |
roundrobin |
动态 | 平滑加权轮询,支持热改权重 |
leastconn |
动态 | 最少连接数,适合长连接 |
source |
哈希 | 按源 IP 哈希,同一客户端固定同一台 |
uri |
哈希 | 按 URI 哈希(缓存友好) |
url_param |
哈希 | 按 URL 参数哈希 |
hdr |
哈希 | 按请求头哈希(如 User-Agent) |
静态算法的权重只能在配置文件里改并 reload;动态算法可以用 socat 实时改,这就是为什么生产用 roundrobin 而不是 static-rr。
四种哈希算法都要加 hash-type consistent 才是一致性哈希,否则节点增减会导致大量缓存失效。
10.2.4 数据库类
Q13:GTID 复制和传统复制的区别?
- 传统复制 :从库记录
MASTER_LOG_FILE+MASTER_LOG_POS(二进制日志文件名 + 偏移量)。 - GTID 复制 :每个事务有一个全局唯一 ID(
server_uuid:transaction_id),从库只需MASTER_AUTO_POSITION=1,由 MySQL 自动找到缺失的事务。
GTID 的优势:
- 主从切换后不用重新找位点,从库自动补齐差异 ------ 这是最大的价值。
- 从库可以多源复制,GTID 自动去重。
failover后不会出现「重复或遗漏事务」的经典问题。
启用的前提:gtid_mode=ON、enforce_gtid_consistency=ON、log_slave_updates=ON(级联复制需要)。且不能使用 CREATE TABLE ... SELECT 这类非确定性语句。
Q14:半同步和异步复制的区别?
| 维度 | 异步 | 半同步 |
|---|---|---|
| 主库返回时机 | 写 binlog 后立即返回 | 至少一个从库 ack 收到 binlog 后才返回 |
| 数据安全性 | 主库宕机可能丢最后几个事务 | 不丢(前提是从库 ack 过) |
| 性能 | 高 | 略低(多一次网络往返) |
| 从库故障时 | 无影响 | 超时后自动降级为异步 |
关键点:半同步是「至少一个从库收到」,不是「从库执行完毕」 。所以它保证的是 binlog 不丢,不是数据不丢(从库可能还没 apply)。要做到「至少一个从库执行完毕」,需要 rpl_semi_sync_master_wait_for_slave_count=N 配合 AFTER_SYNC 模式。
Q15:MySQL 主从延迟怎么解决?
- 开并行回放 :
replica_parallel_type=LOGICAL_CLOCK+replica_parallel_workers=4~16。 - 一主多从:把读压力分散到多个从库,每个从库的复制量不变但服务能力提升。
- 业务层容错:写后立刻读走主库(缓存路由),或者关键业务强制读主库。
- 半同步 + 延迟监控 :
Seconds_Behind_Source超阈值告警。 - 根因治理:主库大事务拆分(一次删 100 万行改成批量删)、避免全表更新。
- 硬件:从库用 SSD、binlog 与数据分盘。
注意:
Seconds_Behind_Source在多线程回放时可能不准(它只反映 SQL 线程最后执行事务的时间差),更准确的做法是pt-heartbeat或对比 GTID 差值。
Q16:Redis 主从、Sentinel、Cluster 分别解决什么问题?
| 方案 | 解决的问题 | 不解决 |
|---|---|---|
| 主从复制 | 数据冗余、读扩展 | 主节点单点,不会自动切换 |
| Sentinel | 主节点单点、自动故障切换 | 单机容量上限(数据量还是受一台内存限制) |
| Cluster | 水平扩展(数据分片)、高并发写入 | 跨 slot 的多 key 操作受限 |
Sentinel 的三条关键配置:quorum(判定客观下线需要的票数)、down-after-milliseconds(主观下线阈值)、failover-timeout(切换超时)。
Cluster 的 16384 个槽位是固定的,三主默认各分 5461 / 5462 / 5461。扩容新节点后必须 reshard 才有槽,否则不存数据。
10.2.5 系统与排障类
Q17:Too many open files 怎么解决?
bash
# 1. 看当前限制
ulimit -n
cat /proc/<pid>/limits | grep "open files"
# 2. 改用户级限制(对通过 shell 启动的进程有效)
vim /etc/security/limits.conf
* soft nofile 100000
* hard nofile 100000
# 3. 对 systemd 管理的服务,limits.conf 无效!必须加在 service 文件里
vim /lib/systemd/system/nginx.service
[Service]
LimitNOFILE=100000
# 或者写一份单独的 limits.d
cat > /etc/security/limits.d/nginx.conf <<EOF
nginx soft nofile 100000
nginx hard nofile 100000
EOF
# 4. 改系统级上限
sysctl -w fs.file-max=1000000
vim /etc/sysctl.conf
fs.file-max = 1000000
# 5. 重启服务并验证
systemctl daemon-reload && systemctl restart nginx
cat /proc/$(cat /usr/local/nginx/logs/nginx.pid)/limits | grep "open files"
关键知识点 :limits.conf 对 systemd 服务不生效。这是最常见的踩坑点,一定要能说出来。
Q18:Linux 上服务起不来,怎么排查?
标准排查顺序:
- 看服务状态 :
systemctl status xxx(看退出码和最近日志)。 - 看日志 :
journalctl -u xxx -n 100 --no-pager,以及服务自己的日志文件。 - 看端口占用 :
ss -antlp | grep :PORT,lsof -i:PORT。 - 看配置文件语法 :
nginx -t、haproxy -c -f、keepalived -t -f。 - 手工前台启动 :绕过 systemd,比如
/usr/local/nginx/sbin/nginx -g 'daemon off;',能直接看到报错。 - 看权限 :文件属主、目录权限、SELinux(
getenforce/ausearch -m avc -ts recent)。 - 看资源 :
free -h(内存)、df -h(磁盘)、ulimit -n(句柄)。
Q19:系统负载高怎么定位?
bash
# 1. 先看整体
uptime # load average
top -bn1 | head -20
vmstat 1 5 # r 队列长度、si/so 换页、wa IO 等待
# 2. 判断瓶颈类型
# r 大、us/sy 高 → CPU 瓶颈
# wa 高、b 大 → IO 瓶颈
# si/so 非 0 → 内存不足在换页
# sy 特别高 → 上下文切换过多或系统调用频繁
# 3. 定位到进程
pidstat -u -d -r 1 5 # 按进程看 CPU/IO/内存
pidstat -w 1 5 # 上下文切换
iostat -x 1 5 # 磁盘指标(await、%util)
# 4. 定位到线程
top -H -p <pid>
# 把线程 ID 转 16 进制
printf "%x\n" <tid>
jstack <pid> | grep -A 30 <hex_tid> # Java 应用
pstack <pid> # C/C++ 应用
# 5. 定位到系统调用
strace -f -p <pid> -T -tt 2>&1 | head -50
区分 load average 和 CPU 使用率:load 包含「运行中」和「不可中断等待(IO)」两类。所以 load 高但 CPU 使用率低,通常说明卡在 IO 上。
Q20:一个请求从浏览器到数据库,中间经历了什么?
这是综合能力题,答好能显著加分。结合本项目答:
- 浏览器 DNS 解析
www.timinglee.org→ 得到 VIP172.25.254.200。 - 请求到达 VIP。VIP 由 Keepalived 通过 VRRP 持有,当前在 lb1 上。
- HAProxy 监听 80/443,做 SSL 卸载(如果是 https)、ACL 匹配、健康检查确认后端可用。
- HAProxy 按
roundrobin算法选一台后端(或用 SERVERID cookie 做会话保持),转发到192.168.0.10:80,并加上X-Forwarded-For头。 - Nginx 收到请求:如果是静态资源(
.css/.js/.png),直接从 NFS 挂载的/data/nfs/static读取返回,并加上expires缓存头;如果是.jsp,proxy_pass到tomcat_clusterupstream。 - Tomcat 处理 JSP:从 Memcached 读取 Session(MSM 机制);需要数据时通过数据面连接到 MySQL VIP
172.25.254.201:3306或 HAProxy 的 3307 端口。 - MySQL VIP 由 Keepalived 持有,当前在 db1(主库)上,主库处理读写并通过 binlog 半同步复制到 db2。
- 响应沿原路返回:Tomcat → Nginx → HAProxy → 客户端。注意 LVS DR 模式下响应不经过调度器,但本项目用的是 HAProxy 七层,响应要经过 HAProxy。
- 全程日志:各节点通过 rsyslog 把日志发到 infra 的
/var/log/remote/<源IP>/下。
面试时讲这条链路,每经过一层就主动带出你知道的优化点(Nginx 缓存、HAProxy 健康检查、MSM 会话共享、MySQL 半同步、数据面隔离)。这是展示技术深度最好的机会。
附录 A MySQL 源码编译(可选)
本章操作主机 :
db1(建议临时把内存调到 3 GB)
如果面试需要展示「会源码编译 MySQL」,可以单独做一次。前提:把 db1 的内存临时调到 3 GB,磁盘扩到 30 GB。
bash
# 1. 安装依赖
dnf install -y cmake3 gcc gcc-c++ git bison openssl-devel ncurses-devel \
systemd-devel rpcgen libtirpc-devel make
# 如果 AppStream 里没有 gcc-toolset-12,用默认 gcc 即可
dnf install -y gcc-toolset-12-gcc gcc-toolset-12-gcc-c++ 2>/dev/null || true
# 2. 下载(约 448 MB)
cd /root
wget https://downloads.mysql.com/archives/get/p/23/file/mysql-boost-8.3.0.tar.gz
tar zxf mysql-boost-8.3.0.tar.gz
cd mysql-8.3.0/
mkdir build && cd build/
# 3. 编译(-j2 不要用 -j8,1~3 GB 内存下并行度太高会被 OOM Killer 杀掉)
cmake3 .. \
-DCMAKE_INSTALL_PREFIX=/usr/local/mysql \
-DMYSQL_DATADIR=/data/mysql \
-DMYSQL_UNIX_ADDR=/data/mysql/mysql.sock \
-DWITH_INNOBASE_STORAGE_ENGINE=1 \
-DWITH_EXTRA_CHARSETS=all \
-DDEFAULT_CHARSET=utf8mb4 \
-DDEFAULT_COLLATION=utf8mb4_unicode_ci \
-DWITH_BOOST=bundled \
-DWITH_SSL=system \
-DWITH_DEBUG=OFF \
-DSYSTEMD=ON \
-DSYSTEMD_SERVICE_DIR=/usr/lib/systemd/system
make -j2 # 约 30 ~ 60 分钟
make install
bash
# 4. 环境变量与用户
echo 'export PATH=$PATH:/usr/local/mysql/bin' >> ~/.bash_profile
source ~/.bash_profile
useradd -r -s /sbin/nologin -M mysql
mkdir -p /data/mysql
chown mysql:mysql /data/mysql
# 5. 初始化
mysqld --initialize --user=mysql
# 记录输出的临时密码
# 6. 配置文件
cat > /etc/my.cnf <<'EOF'
[mysqld]
datadir=/data/mysql
socket=/data/mysql/mysql.sock
symbolic-links=0
server-id=30
log-bin=mysql-bin
gtid_mode=ON
enforce_gtid_consistency=ON
log_slave_updates=ON
EOF
# 7. systemd 服务(注意 --defaults-file 必须是第一个参数!)
cat > /lib/systemd/system/mysqld.service <<'EOF'
[Unit]
Description=MySQL 8.3 Database Server
After=network.target syslog.target
[Service]
Type=notify
User=mysql
Group=mysql
ExecStart=/usr/local/mysql/bin/mysqld --defaults-file=/etc/my.cnf
ExecReload=/usr/local/mysql/bin/mysqladmin --defaults-file=/etc/my.cnf shutdown
TimeoutSec=300
PrivateTmp=true
LimitNOFILE=65535
LimitNPROC=65535
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now mysqld
# 8. 安全初始化
mysql_secure_installation
# 9. 验证
mysql -uroot -plee -e "SELECT VERSION();"
# 8.3.0
几个必知的坑:
--defaults-file=/etc/my.cnf必须是ExecStart的第一个参数,否则 systemd 下会报参数顺序错误。make -j2不要调高。编译 Mysql 单个编译单元内存占用很大,-j8在 3 GB 内存下必然被 OOM Killer 杀掉。- RHEL9 需要
libtirpc-devel和rpcgen,否则 cmake 阶段会报Could NOT find RPC。- 编译完要
make install,产物在/usr/local/mysql(不是build目录里直接跑)。
附录 B MHA 高可用集群(可选)
本章操作主机 :
infra(管理机);db1、db2(数据节点)
MHA(Master High Availability)实现 MySQL 主从的自动故障切换 + 从库提升。这是 VIP 漂移方案的必要补充。
前提:需要额外的管理机(可以复用 infra,但内存要多给 512 MB)。且在 RHEL9 上 MHA 0.58 需要打 Perl 补丁。
B.1 准备主从(GTID 模式)
执行主机 :
db1、db2
MHA 要求:
ini
# 所有 MySQL 节点
gtid_mode=ON
enforce_gtid_consistency=ON
log_slave_updates=ON
log-bin=mysql-bin
relay_log_purge=ON
主库和从库都创建管理员账号:
sql
CREATE USER 'mha'@'192.168.0.%' IDENTIFIED WITH mysql_native_password BY 'lee';
GRANT ALL PRIVILEGES ON *.* TO 'mha'@'192.168.0.%' WITH GRANT OPTION;
FLUSH PRIVILEGES;
B.2 安装 MHA(在管理机 infra 上)
执行主机 :
infra(管理机)
bash
dnf install -y perl perl-DBD-MySQL perl-CPAN
# 用 cpan 装依赖模块
cpan
cpan[1]> install Config::Tiny
cpan[2]> install Log::Dispatch
cpan[3]> install Mail::Sender
cpan[4]> install Parallel::ForkManager
cpan[5]> exit
bash
# 安装 MHA(el7 的包在 RHEL9 上需要 --nodeps)
rpm -ivh mha4mysql-manager-0.58-0.el7.centos.noarch.rpm \
mha4mysql-node-0.58-0.el7.centos.noarch.rpm --nodeps
# 所有 MySQL 节点装 node 包
for i in 30 40
do
scp mha4mysql-node-0.58-0.el7.centos.noarch.rpm root@172.25.254.$i:/mnt/
ssh root@172.25.254.$i "rpm -ivh /mnt/mha4mysql-node-0.58-0.el7.centos.noarch.rpm --nodeps"
done
B.3 RHEL9 兼容补丁(必须打,否则报版本号解析错误)
执行主机 :
infra、db1、db2
bash
vim /usr/share/perl5/vendor_perl/MHA/NodeUtil.pm
把 parse_mysql_major_version 函数改为:
perl
sub parse_mysql_major_version($) {
my $str = shift;
my @nums = $str =~ m/(\d+)/g;
my $result = sprintf( '%03d%03d', $nums[0]//0, $nums[1]//0);
return $result;
}
原因:原函数用
sprintf('%03d%03d', $str =~ m/(\d+)/g)一次性取所有数字,MySQL 8.3.0 会取出 3 个数字导致参数不匹配。这是 MHA 在 RHEL9 上的经典问题。
B.4 MHA 配置文件
执行主机 :
infra(管理机)
bash
mkdir -p /etc/masterha /etc/masterha/scripts
vim /etc/masterha/app1.cnf
ini
[server default]
manager_workdir=/etc/masterha
manager_log=/etc/masterha/mha.log
user=mha
password=lee
ssh_user=root
repl_user=repl
repl_password=lee
master_binlog_dir=/var/lib/mysql
remote_workdir=/tmp
ping_interval=2
# 二次检查(防止网络抖动误判),-s 后面是其他存活节点的 IP
secondary_check_script = masterha_secondary_check -s 172.25.254.40 -s 172.25.254.100
# VIP 漂移脚本
master_ip_failover_script=/etc/masterha/scripts/master_ip_failover
master_ip_online_change_script=/etc/masterha/scripts/master_ip_online_change
# 故障告警脚本
report_script=/etc/masterha/scripts/send_report
[server1]
hostname=172.25.254.30
port=3306
candidate_master=1
check_repl_delay=0
[server2]
hostname=172.25.254.40
port=3306
candidate_master=1
check_repl_delay=0
candidate_master=1:允许这台被提升为主库。check_repl_delay=0:提升时不检查复制延迟(否则延迟大的从库不会被选为候选)。生产环境应谨慎使用,因为可能提升一个数据落后的从库。secondary_check_script:从多个节点二次确认主库真的挂了,避免「网络分区导致误切换」。
B.5 VIP 漂移脚本
执行主机 :
infra(管理机)
bash
cat > /etc/masterha/scripts/master_ip_failover <<'EOF'
#!/usr/bin/env perl
use strict;
use warnings FATAL => 'all';
my (
$command, $ssh_user, $orig_master_host, $orig_master_ip,
$orig_master_port, $new_master_host, $new_master_ip,
$new_master_port
);
my $vip = '172.25.254.201/24';
my $key = '1';
my $ssh_start_vip = "/sbin/ip addr add $vip dev eth0 label eth0:1";
my $ssh_stop_vip = "/sbin/ip addr del $vip dev eth0";
if ($command eq "stop") {
print "Disabling the VIP on old master: $orig_master_host \n";
&stop_vip();
exit 0;
}
elsif ($command eq "start") {
print "Enabling the VIP - $vip on the new master: $new_master_host \n";
&start_vip();
exit 0;
}
elsif ($command eq "status") {
print "Checking the Status of the script.. OK \n";
exit 0;
}
else {
&usage();
exit 1;
}
sub start_vip() {
`ssh $ssh_user\@$new_master_host \" $ssh_start_vip \"`;
}
sub stop_vip() {
return 0 unless ($ssh_user);
`ssh $ssh_user\@$orig_master_host \" $ssh_stop_vip \"`;
}
sub usage {
print "Usage: master_ip_failover --command=start|stop|status "
. "--ssh_user=root --orig_master_host=hostname "
. "--orig_master_ip=ip --orig_master_port=port "
. "--new_master_host=hostname --new_master_ip=ip "
. "--new_master_port=port\n";
}
EOF
chmod +x /etc/masterha/scripts/master_ip_failover
B.6 检查与启动
执行主机 :
infra(管理机)
bash
# SSH 免密检查
masterha_check_ssh --conf=/etc/masterha/app1.cnf
# 复制健康检查
masterha_check_repl --conf=/etc/masterha/app1.cnf
# 期望看到:GTID failover mode = 1
# MySQL Replication Health is OK.
# 查看当前状态
masterha_check_status --conf=/etc/masterha/app1.cnf
# app1 (pid:xxxx) is running(0:PING_OK), master:172.25.254.30
# 手动给主库加上 VIP
ssh root@172.25.254.30 "ip a a 172.25.254.201/24 dev eth0 label eth0:1"
# 启动 MHA 管理进程(生产用 systemd 托管)
nohup masterha_manager --conf=/etc/masterha/app1.cnf \
--remove_dead_master_conf --ignore_last_failover \
> /var/log/mha-manager.log 2>&1 &
B.7 故障切换演练
执行主机 :
infra(观察)+db1(制造故障)
bash
# 制造主库故障
ssh root@172.25.254.30 "systemctl stop mysqld"
# 观察 MHA 日志
tail -f /etc/masterha/mha.log
# ----- Failover Report -----
# app1: MySQL Master failover 172.25.254.30 to 172.25.254.40 succeeded
# Master 172.25.254.30 is down!
# Check MHA Manager logs at /etc/masterha/mha.log for details.
# Started automated(non-interactive) failover.
# Selected 172.25.254.40 as a new master.
# 172.25.254.40: OK: Applying all logs succeeded.
# 172.25.254.40: RESET SLAVE ALL; issued
# Generating relay diff files from the latest slave succeeded.
# 172.25.254.40: Resetting slave info succeeded.
# Master failover to 172.25.254.40(172.25.254.40:3306) completed successfully.
# 验证新主库
ssh root@172.25.254.40 "mysql -uroot -plee -e 'SELECT @@server_id; SHOW REPLICA STATUS\G' | head -5"
# 40
# role 从 slave 变成 master
# 验证 VIP 已漂移
ssh root@172.25.254.40 "ip a show eth0 | grep 172.25.254.201"
# 验证可写(这是 VIP 漂移方案做不到的)
ssh root@172.25.254.10 "mysql -uappuser -plee -h172.25.254.201 -e \"INSERT INTO timinglee.users(username) VALUES('after_failover');\""
# 成功!因为新主库不是只读的
B.8 故障恢复(原主库重新加入)
执行主机 :
db1、db2、infra
bash
# 1. 启动原主库
ssh root@172.25.254.30 "systemctl start mysqld"
# 2. 清掉 MHA 的锁文件(不删无法再次切换!)
rm -f /etc/masterha/app1.failover.complete
# 3. 把原主库变成新主库的从库
ssh root@172.25.254.30 "mysql -uroot -plee -e \"
STOP REPLICA;
RESET REPLICA ALL;
SET GLOBAL gtid_purged='';
CHANGE REPLICATION SOURCE TO
SOURCE_HOST='192.168.0.40',
SOURCE_USER='repl',
SOURCE_PASSWORD='lee',
SOURCE_AUTO_POSITION=1;
START REPLICA;\""
# 4. 验证
ssh root@172.25.254.30 "mysql -uroot -plee -e 'SHOW REPLICA STATUS\G' | grep -E 'Running|Behind'"
必知的坑 :
/etc/masterha/app1.failover.complete这个锁文件在每次故障切换后生成,不删除就无法再次切换(MHA 会直接退出)。这是 MHA 最经典的踩坑点。
B.9 MHA vs VIP 高可用对比
执行主机:本节为对比说明,无需执行命令
| 维度 | Keepalived VIP 漂移 | MHA |
|---|---|---|
| 能做的 | 把入口地址挪到从库 | 把从库提升为主库,再挪 VIP |
| 写操作 | 切换后仍失败(从库只读) | 切换后可正常写 |
| 数据一致性 | 不保证 | 会尝试从最新从库补齐差异日志 |
| 复杂度 | 低(两台机器即可) | 高(需要管理机 + Perl 依赖 + 补丁) |
| 现代替代 | --- | MGR(组复制)、Orchestrator、云 RDS 自动切换 |
面试结论:「VIP 漂移解决的是入口问题,MHA 解决的是角色切换问题。只做 VIP 漂移的话,主库宕机后虽然入口切了,但从库是只读的,写请求依然失败。所以完整方案需要 MHA 或 MGR。我两个都做了,所以清楚它们的边界。」