上一篇 下一篇 分享链接 返回 返回顶部

在香港机房用 MySQL 8 + Redis 做读写分离——主从延迟治理、回滚预案与全流程部署优化指南

发布人:Minchunlin 发布时间:2025-09-27 09:28 阅读量:176


凌晨两点,我在香港荃湾机房紧盯着监控大屏:订单明细的读查询 p99 突然从 38ms 爬到 420ms,ProxySQL 的 Seconds_Behind_Source 曲线像一根刺,猛地抬头到 12 秒。那一瞬间,我明白这不是网络抖动那么简单——香港本地的主从延迟被一次批量写入“打爆”了。
那晚我做了两件事:第一,把读流量在 30 秒内“拉回”强一致(强制走主库);第二,连夜把整套 MySQL + Redis 读写分离的链路,按“延迟治理 + 回滚预案”重搭了一遍。下面是一份完整的、我自己踩过坑的实操手册。

拓扑与硬件:别让瓶颈埋在铁皮里

机房:香港荃湾 T 级机房(双路供电 + N+1 制冷)

网络:同机房同架(ToR 交换机 10GbE,跨机柜延迟 < 0.1ms)

主库(Writer):

  • 1U,AMD EPYC 7313(16C/32T)
  • 256GB DDR4 ECC
  • 两块 3.84TB 企业级 NVMe(RAID1,硬件阵列卡带电容)
  • 双口 10GbE
  • 只读库(Reader)×2:同配置(NVMe 容量 1.92TB)
  • Redis 主从 + Sentinel(3 个 Sentinel,2 台 Redis 实例):
  • 32GB 内存,NVMe 960GB,AOF 开启
  • 负载均衡:ProxySQL 2.5(独立虚机,4C/8G,双网卡)

说明:业务历史原因运行在 CentOS 7(是的,已经 EOL)。文中按 CentOS 7 操作,但我实际同时准备了 Rocky/Alma 8/9 的替代方案。如果你正新建环境,建议上 RHEL/Alma/Rocky 8+。

部署概览(一步到位总览图)

MySQL 主从:MySQL 8.0,GTID、ROW 模式、半同步(降低丢数据风险),只读库并行复制。

ProxySQL:写入指向主库,读请求优先只读库,自动依据复制延迟/健康探测调整路由,支持“强制走主”的开关和“会话粘滞”。

Redis:读缓存(写穿/删缓存策略),Key-Tag 级联失效,Sentinel 保高可用。

延迟治理:pt-heartbeat + ProxySQL Lag 策略 + 业务“读你所写”粘滞(可选 GTID 等待)。

回滚预案:

  • 延迟只读库(延迟 10 分钟)
  • XtraBackup 全量/增量 + Binlog 持续归档(PITR)
  • 快速“闪回”流程(基于 binlog 反向 SQL 或延迟库导出回放)

压测与验收:Sysbench + 自定义读写混合压测,记录优化前后指标。

1)MySQL 安装与基础配置(CentOS 7)

1.1 安装 MySQL 8.0 社区版(RPM 仓库)

# 官方仓库 RPM
rpm -Uvh https://repo.mysql.com/mysql80-community-release-el7-7.noarch.rpm
yum-config-manager --enable mysql80-community
yum install -y mysql-community-server

systemctl enable mysqld
systemctl start mysqld

# 首次登录重置 root 密码
grep 'temporary password' /var/log/mysqld.log
mysql_secure_installation

1.2 主库 my.cnf(关键项)

/etc/my.cnf(仅展示关键片段)

[mysqld]
server_id=1
log_bin=binlog
binlog_format=ROW
gtid_mode=ON
enforce_gtid_consistency=ON
log_slave_updates=ON
binlog_expire_logs_seconds=604800
binlog_row_image=FULL

# 刷新策略:牺牲一点吞吐换数据安全
innodb_flush_log_at_trx_commit=1
sync_binlog=1
innodb_flush_method=O_DIRECT

# 写集合追踪,利于并行复制
transaction_write_set_extraction=XXHASH64
binlog_transaction_dependency_tracking=WRITESET

# InnoDB 缓冲池等
innodb_buffer_pool_size=64G
innodb_io_capacity=4000
innodb_flush_neighbors=0
thread_cache_size=64

# 半同步复制插件
plugin_load_add='semisync_master=semisync_master.so;semisync_slave=semisync_slave.so'
rpl_semi_sync_master_enabled=ON
rpl_semi_sync_master_timeout=250  # ms

# 元数据表
master_info_repository=TABLE
relay_log_info_repository=TABLE

# 连接与字符集(按需)
max_connections=4000
character-set-server=utf8mb4
collation-server=utf8mb4_0900_ai_ci

1.3 只读库 my.cnf(并行复制 + 只读)

[mysqld]
server_id=2        # 每台只读库不同
read_only=ON
super_read_only=ON
relay_log_recovery=ON

# MySQL 8 推荐使用 replica_* 同义词(也兼容 slave_*)
replica_parallel_workers=8
replica_preserve_commit_order=ON
# 兼容变量(某些版本仍用 slave_*)
slave_parallel_workers=8
slave_preserve_commit_order=ON

# 半同步从端
plugin_load_add='semisync_master=semisync_master.so;semisync_slave=semisync_slave.so'
rpl_semi_sync_slave_enabled=ON

1.4 建立主从(基于 GTID)

在主库创建复制账号:

CREATE USER 'repl'@'10.%' IDENTIFIED BY 'StrongPass#123';
GRANT REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'repl'@'10.%';
FLUSH PRIVILEGES;

在只读库执行(以主库 10.0.0.10 为例):

CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='10.0.0.10',
  SOURCE_USER='repl',
  SOURCE_PASSWORD='StrongPass#123',
  SOURCE_AUTO_POSITION=1;

START REPLICA;
SHOW REPLICA STATUS\G

并行复制小贴士:replica_parallel_workers 大于 0 才会启用,WRITESET 追踪能显著提升应用并行度,业务多表写入时效果尤佳。

2)ProxySQL:智能读写分离与延迟兜底

2.1 安装 & 管理入口

yum install -y proxysql
systemctl enable proxysql && systemctl start proxysql
mysql -u admin -p -h 127.0.0.1 -P 6032   # 默认 admin/admin

2.2 定义 hostgroups(10 写,20 读)

-- 后端 MySQL 实例
INSERT INTO mysql_servers(hostgroup_id,hostname,port,max_connections) VALUES
(10,'10.0.0.10',3306,2000),   -- writer
(20,'10.0.0.11',3306,2000),   -- reader1
(20,'10.0.0.12',3306,2000);   -- reader2

-- 监控账号(到各 MySQL)
INSERT INTO mysql_users(username,password,default_hostgroup,active) VALUES
('app','AppPass#123',10,1);

-- 将 ProxySQL 与复制关系绑定(自动识别只读)
INSERT INTO mysql_replication_hostgroups
(writer_hostgroup,reader_hostgroup,check_type,comment)
VALUES (10,20,'read_only','rw split');

LOAD MYSQL SERVERS TO RUNTIME; SAVE MYSQL SERVERS TO DISK;
LOAD MYSQL USERS TO RUNTIME;   SAVE MYSQL USERS TO DISK;
LOAD MYSQL QUERY RULES TO RUNTIME; SAVE MYSQL QUERY RULES TO DISK;

2.3 读写规则 + 会话粘滞(读你所写)

-- 1) 写语句 → 写组
INSERT INTO mysql_query_rules (rule_id,active,match_digest,destination_hostgroup,apply)
VALUES
(100,1,'^\\s*(INSERT|UPDATE|DELETE|REPLACE|ALTER|CREATE|DROP)\\b',10,1);

-- 2) 显式强一致(SQL 注释触发) → 写组
INSERT INTO mysql_query_rules(rule_id,active,match_pattern,destination_hostgroup,apply,flagOUT)
VALUES
(110,1,'/\\*force_master\\*/',10,1,1);

-- 3) 事务内读 → 写组(防止事务读到旧数据)
INSERT INTO mysql_query_rules(rule_id,active,match_digest,destination_hostgroup,apply)
VALUES
(120,1,'^\\s*SELECT.*FOR\\s+UPDATE',10,1);

-- 4) 其他 SELECT → 读组
INSERT INTO mysql_query_rules(rule_id,active,match_digest,destination_hostgroup,apply)
VALUES
(130,1,'^\\s*SELECT\\b',20,1);

LOAD MYSQL QUERY RULES TO RUNTIME; SAVE MYSQL QUERY RULES TO DISK;

2.4 延迟感知:自动“回拉到主”

ProxySQL 会定期从只读库读取 Seconds_Behind_Source。我们设定阈值 mysql-monitor_slave_lag_when_null_ms 与脚本联动,把滞后的只读节点临时下线或把读路由回主。

简易策略(思路):

  • Seconds_Behind_Source > 1s:该只读库权重降为 0(不接新查询)。
  • 所有只读库都 > 1s:全量 SELECT 回写组(强一致保护)。
  • 恢复 < 500ms 持续 60s:逐步放量恢复。

示例:用调度器更新权重(伪 SQL,很多团队用外部脚本+REST/6032 API 实现):

-- 假设我们把 reader 的 weight 存在 mysql_servers 的 'weight' 字段,脚本周期性调整
UPDATE mysql_servers SET weight=0 WHERE hostgroup_id=20 AND status='SHUNNED'; -- 滞后标记
LOAD MYSQL SERVERS TO RUNTIME; SAVE MYSQL SERVERS TO DISK;

生产建议:配合 pt-heartbeat 写入心跳表,比 Seconds_Behind_Source 更敏感、稳定。

3)Redis:缓存不是魔法,失效才是核心

3.1 安装与 Sentinel

yum install -y redis
# redis.conf 关键项
bind 0.0.0.0
protected-mode no
requirepass StrongRedis#123
appendonly yes
appendfsync everysec
maxmemory 24gb
maxmemory-policy allkeys-lru
notify-keyspace-events Ex

Sentinel(3 个):

port 26379
sentinel monitor myredis 10.0.0.13 6379 2
sentinel auth-pass myredis StrongRedis#123
sentinel down-after-milliseconds myredis 5000
sentinel failover-timeout myredis 60000

3.2 Key 设计与失效策略

  • 写穿(Write-through):写 MySQL 成功后,同步更新/删除对应 Redis Key。
  • 删缓存优先:更新路径:先 DELETE cache → 再 UPDATE DB → 成功后不立即回填,读时懒加载(避免并发下缓存脏读)。
  • Key-Tag:集合/列表页使用 Tag(集合 Set 记录成员 Key),批量变更时按 Tag 清理。

示例(Python 伪代码):

def update_order(order_id, payload):
    # 1) 先删缓存
    redis.delete(f"order:{order_id}")
    # 2) 写DB(事务)
    with mysql.begin() as tx:
        tx.execute("UPDATE orders SET ... WHERE id=%s", (order_id,))
    # 3) 可选:发一个Pub/Sub让边缘节点做旁路预热
    redis.publish("cache_invalidate", f"order:{order_id}")

3.3 “读你所写”在缓存层的两种做法

会话粘滞:用户在一次写后 N 秒内的读强制走主库(通过 ProxySQL 注释或路由),并且跳过缓存。简单可靠。

时间水位:把“上次写入时间戳”放到 Redis(user:{uid}:last_write_ts),读时若缓存数据 data_ts < last_write_ts + epsilon,则绕过缓存或强制读主库。适合评论/订单这种强一致需求不高但又要尽量走缓存的场景。

4)延迟治理:从“看得见”到“控得住”

4.1 心跳与监控

心跳表(每秒主库写一次当前时间),只读库读:

CREATE DATABASE if not exists meta;
CREATE TABLE meta.heartbeat(
  id TINYINT PRIMARY KEY,
  ts TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB;

INSERT INTO meta.heartbeat(id, ts) VALUES (1, NOW(6))
  ON DUPLICATE KEY UPDATE ts=NOW(6);

只读库延迟 = NOW(6) - meta.heartbeat.ts。结合 Prometheus 导出(或 pt-heartbeat)打点,ProxySQL 依据此指标控权重。

4.2 复制参数微调(我在香港机房的有效组合)

  • replica_parallel_workers=8~16:多表写入业务收益大。
  • replica_preserve_commit_order=ON:读一致性更好。
  • rpl_semi_sync_master_enabled=ON + rpl_semi_sync_slave_enabled=ON:至少保证 1 个只读库收到并刷盘 再 ack,降低主机宕机丢事务风险。
  • sync_binlog=1 + innodb_flush_log_at_trx_commit=1:牺牲写吞吐换可靠;配合 NVMe 性能可接受。

4.3 阶段化流量保护

  • 绿色:延迟 < 100ms,全部 SELECT 走只读库。
  • 黄色:100ms ~ 1s,热点表/请求转写,或强制注释 /*force_master*/。
  • 红色:> 1s,读全回写组,触发限流/降级(关闭重报表、延后风控离线计算)。

5)回滚预案:真出事时的“后悔药”

5.1 延迟只读库(Delayed Replica)

我固定保留一台只读库 延迟 10 分钟 应急:

STOP REPLICA SQL_THREAD;
CHANGE REPLICATION FILTER REPLICATE_WILD_IGNORE_TABLE = ('meta.heartbeat'); -- 可选
CHANGE REPLICATION SOURCE TO SOURCE_DELAY=600;
START REPLICA SQL_THREAD;

出事故(误删/脏数据)时,可以在延迟窗口内从这台库导出“正确版本”的数据,定点还原。

5.2 PITR(Point-in-Time Recovery)

  • XtraBackup:周全量 + 日增量。
  • binlog 归档:落到对象存储(本地 + 异地)。
  • 恢复:还原到最新全量 + 增量后,用 mysqlbinlog --start-datetime/--stop-datetime 回放到事故前一刻。

示例:

# 导出
xtrabackup --backup --target-dir=/backup/full-$(date +%F)
# 恢复
xtrabackup --prepare --target-dir=/backup/full-2025-09-26
xtrabackup --copy-back --target-dir=/backup/full-2025-09-26
# 回放 binlog 到 02:13:40 前
mysqlbinlog --start-datetime="2025-09-27 00:00:00" \
            --stop-datetime="2025-09-27 02:13:40" \
            /var/lib/mysql/binlog.* | mysql -u root -p

5.3 反向 SQL(快速“闪回”)

对于误删/误更新单表,结合 binlog 生成反向 SQL(如开源 binlog2sql 一类工具),在 非业务高峰 回放。
注意:务必在演练库先 dry-run,并与缓存失效/ProxySQL 路由配合,防止再次脏读。

6)应用层的三档一致性(可选方案组合)

强一致(订单/支付)

  • 写后 2~3 秒内读:强制走主(会话粘滞)。
  • 所有 SELECT ... FOR UPDATE、事务内读:走主。
  • 有界陈旧(资料页/列表)
  • 只读库延迟阈值 200ms;超阈则该请求走主。
  • 缓存 ttl=30~60s,但遇到命中 last_write_ts 则绕过缓存。

读你所写(进阶 GTID 等待)

  • 写事务结束后获取本事务 GTID(可通过审计插件或业务侧记录 binlog pos),读时在只读库执行 SELECT WAIT_FOR_EXECUTED_GTID_SET(gtid, 1),失败则回主。
  • 成本略高,适合少量关键路径。

7)压测与优化前后指标(摘自我现场记录)

指标 优化前 优化后
写入 p99(ms) 28 31(开启 sync_binlog=1 后略升)
读查询 p95(ms) 62 24
高峰复制延迟峰值(s) 12 < 0.8
延迟>1s 触发次数/日 5~7 0~1
Redis 命中率 68% 86%
只读库 CPU 峰值 82% 65%(并行复制 + 路由优化)

关键收益来自:并行复制 + WRITESET、半同步稳住主库、ProxySQL 延迟感知、缓存正确失效。

8)常见坑与我的解决过程

坑 1:只改 slave_parallel_workers 不生效
解决:重启复制通道或实例,并确认 binlog_transaction_dependency_tracking=WRITESET 在主上已启用。

坑 2:Seconds_Behind_Source 偶发 0
解决:并行复制下该指标不稳定,上心跳表,以真实业务维度衡量延迟。

坑 3:删缓存顺序
解决:先删缓存再写库,失败回滚后要补偿重建缓存或写回。并发高时用分布式锁/队列避免“缓存穿透雪崩”。

坑 4:半同步导致写抖动
解决:rpl_semi_sync_master_timeout 调到 250ms,并保证至少一台只读库 NVMe 性能可靠;网络层 10GbE、MTU 9000 有助平滑。

坑 5:延迟只读库忘记排除心跳表
解决:延迟库可以过滤心跳表或独立 schema,避免可观察指标“乱跳”。

坑 6:CentOS 7 包源 EOL
解决:锁定 tested 版本 RPM,置灰自动升级;同时准备容器化或 Rocky/Alma 迁移剧本。

9)运维手册:上线 checklist(可直接套用)

  •  主从均启用 GTID、ROW、WRITESET;只读库并行复制 8+。
  •  半同步开关已验证,主库超时合理,至少一只读库稳定 ACK。
  •  ProxySQL 规则到位:DML→写组;事务内读→写组;SELECT→读组;/*force_master*/ 生效。
  •  延迟监控:心跳表/pt-heartbeat + Prometheus 告警,阈值 200ms/1s 两级。
  •  Redis:AOF everysec、Key-Tag、删缓存优先;Sentinel 故障演练通过。
  •  回滚:延迟只读库 10min 在位;XtraBackup 最近一次全量 + binlog 归档可验证恢复。
  •  压测:峰值 1.5× 业务流量通过;延迟保护策略自动触发且可回退。

那次夜里,我在机柜门缝看见自己满脸油光——但图表从 12 秒回落到 200ms 以内的时候,心里松了一口气。
读写分离不是把“读流量丢给从库”那么简单;真正的难点在主从延迟发生时如何“控住局面”,在错误发生时如何“快速回到正确”。
今天的这套 MySQL + Redis 方案,在香港机房的网络与 NVMe 条件下跑得很稳。我也把所有“救火动作”写成了自动化脚本和演练文档。这样下一次凌晨两点,我希望我只需要检查一次告警,然后——去喝一杯热咖啡。

附录:关键配置片段(可直接复制)

ProxySQL 只读库健康阈值(示意)

-- 读库延迟超过 1000ms 标记 SHUNNED(脚本轮询)
-- 实际生产推荐配合外部脚本调用 6032 API 动态调整 'status' / 'weight'

心跳采集(Prometheus Exporter 伪 SQL)

SELECT TIMESTAMPDIFF(MICROSECOND, ts, NOW(6))/1000 AS repl_lag_ms FROM meta.heartbeat WHERE id=1;

Redis Pub/Sub 失效

# 订阅端
redis-cli -a StrongRedis#123 SUBSCRIBE cache_invalidate

XtraBackup 定时任务(示例)

# /etc/cron.d/backup
0 3 * * 0 root xtrabackup --backup --target-dir=/backup/full-$(date +\%F)
0 3 * * 1-6 root xtrabackup --backup --incremental-basedir=/backup/full-$(date -d "last sunday" +\%F) --target-dir=/backup/inc-$(date +\%F)

目录结构
全文