异地多活与单元化
本场景回答:系统跨越多个地域提供服务时,怎样选择数据归属、控制复制延迟、执行切流,并通过演练证明故障时只丢可接受的数据且能在目标时间内恢复。
1. 先选择故障模式,而不是先宣布“多活”
“多活”可能指三种不同方案。方案名称相同,但可用性、数据风险和改造量不同。
| 方案 | 正常状态 | 故障后 | 主要风险 |
|---|---|---|---|
| Active-Passive | 备用地域不承接业务流量 | 提升为写入口 | 切换编排、备用环境新鲜度 |
| Active-Active | 各地域同时承接流量 | 扩大其他地域的流量 | 路由一致性、跨地域复制 |
| Multi-Master | 多个节点同时接受相同数据写入 | 各节点继续写入 | 冲突检测、合并与回放 |
异地多活通常优先采用单元化 Active-Active:同一份全局数据按业务键划入唯一单元,每个单元只写自己的主数据;不同单元处理不同用户或租户。这样可以把“多个可写主库”降为“每个数据分区只有一个写主”,避免把系统直接升级成任意节点都能写入的全局 Multi-Master。
先为以下四类数据分别确定策略:
| 数据类型 | 示例 | 推荐策略 |
|---|---|---|
| 用户强一致数据 | 账户余额、订单状态 | 单元归属,读写都路由到写主 |
| 可容忍延迟的数据 | 商品库存、搜索索引 | 异步复制或异步聚合 |
| 全局配置 | 字段字典、灰度开关 | 全局配置服务或多副本发布 |
| 单元共享数据 | 风控名单、商品目录 | 只读副本或独立共享服务 |
不要先回答“同步还是异步复制”,先写清三个约束:
- 哪些业务数据在区域故障时允许丢失;
- 从发现故障到恢复服务最多允许多长时间;
- 哪些跨地域读写必须保持强一致,哪些可以接受最终一致。
将结果记录为验收项,而不是只写在方案文档里:
数据集:账户余额
可接受 RPO:0 个已确认交易
目标 RTO:由业务方签字的恢复时间
一致性要求:切流后读到本地已确认写入
验收方式:故障注入 + 指定交易查询 + 双向账务核对
失败处理:无法满足时缩小多活范围,不用异步复制冒充零丢失2. 为数据选择稳定的单元归属
单元划分要让同一业务事务尽量在一个地域内完成。常见划分键包括用户 ID、租户 ID、订单主体 ID 或组织 ID。选择时依次检查:
- 写路径集中:同一用户或订单的读写是否能稳定落到同一单元;
- 热点分布:划分键是否会造成单个单元显著更热;
- 迁移能力:键范围能否按小批范围逐步搬迁;
- 降级能力:读取全局共享数据时能否单独降级;
- 未知键兜底:无归属的新记录写到哪里,后续是否可迁回;
- 关联一致性:订单、支付和用户资料是否使用同一主体键或有明确跨单元协议。
下面用用户 ID 的连续范围划分三个单元。具体范围、权重和地域名称应由流量与容量模型决定:
# routing/units.yaml
units:
- name: unit-a
region: cn-east
key: user
ranges:
- [0, 99999999]
- name: unit-b
region: cn-north
key: user
ranges:
- [100000000, 199999999]
- name: unit-c
region: cn-south
key: user
ranges:
- [200000000, 299999999]
unknown_key_policy: reject未知用户写入不能默默落到默认单元。先明确选择:
- 可生成时生成一个带单元前缀的 ID;
- 新用户进入指定单元,之后通过控制面更新归属;
- 对无法推导归属的请求返回明确错误并触发人工处理。
为每个单元保存可观测的归属表。控制面配置和应用实际计算结果必须可对照:
CREATE TABLE unit_membership (
user_id BIGINT NOT NULL,
unit_name VARCHAR(32) NOT NULL,
version BIGINT NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (user_id),
INDEX idx_unit (unit_name)
) ENGINE=InnoDB;归属变更需要使用版本号防止旧路由覆盖新路由:
UPDATE unit_membership
SET unit_name = 'unit-b', version = version + 1, updated_at = UTC_TIMESTAMP(6)
WHERE user_id = 100001 AND version = 12;受影响行数为 0 时,不继续切换该用户的写入;应重新读取归属和版本,确认是并发变更还是配置错误。
3. 让请求稳定到达所属单元
路由必须同时存在于接入层和应用层。只改 DNS 或负载均衡、不校验应用数据归属,容易让请求到达正确地域却访问错误数据。
客户端
│ token 中的 user_id / 租户标识
▼
全局入口 ──按归属规则──▶ 单元入口
│
├─ 本单元主库:强一致读写
├─ 本单元只读副本:可容忍延迟的读
└─ 共享服务:配置、全局目录请求进入应用后至少执行三项检查:
- token 或请求头中的主体身份是否可信;
- 计算出的单元是否与入口单元一致;
- 数据库中的
unit_name是否与请求主体一致。
示例伪代码如下,具体错误码应沿用项目约定:
def resolve_unit(user_id: int, entry_unit: str) -> str:
expected = membership_store.get_unit(user_id)
if expected is None:
raise RoutingError("unknown_unit_membership")
if expected != entry_unit:
raise RoutingError("unit_mismatch")
return expected
def load_order(user_id: int, order_id: str) -> Order:
unit = resolve_unit(user_id, current_entry_unit())
order = order_repository(unit).find_for_user(order_id, user_id)
if order is None or order.user_id != user_id:
raise OrderNotFound()
return order会话粘性只是减少跨地域跳转,不能代替单元归属校验。以下请求必须显式处理:
- 登录、登出和 token 续签;
- 用户从旧单元迁移到新单元;
- 重试请求与原请求到达不同单元;
- 用户 ID 可能为空的匿名请求;
- 定时任务按主体键重新计算归属。
执行单元迁移时,按“停止新写入—复制增量—校验—更新归属—放行新写入”的顺序进行。迁移状态保存在控制面,应用每次请求读取最新状态;不要通过长时间睡眠或人工观察代替状态机。
DRAINING
│ 新请求拒绝或重定向到旧单元
▼
COPYING ──校验行数、校验和、业务关键字段──▶ VERIFYING
│
├─ 失败:保留旧归属,补偿增量
└─ 通过:原子更新 unit_membership
│
▼
ACTIVE4. 复制数据并监控真实延迟
4.1 为 MySQL 建立可恢复复制链路
GTID 能让副本根据已执行事务集合请求缺失事务,减少人工拼接 binlog 文件和位置。源库和副本启用 GTID 自动定位时,需要 gtid_mode=ON 和 enforce_gtid_consistency=ON。模式迁移要遵守官方规定的启动顺序,不能在生产上直接把两个变量同时切换为 ON。
复制链路准备完成后,至少验证以下内容:
-- 在源库和副本分别执行
SELECT @@gtid_mode, @@enforce_gtid_consistency;
SHOW REPLICA STATUS\G
-- 关注
-- Replica_IO_Running: Yes
-- Replica_SQL_Running: Yes
-- Retrieved_Gtid_Set
-- Executed_Gtid_Set
-- Seconds_Behind_Source若配置了 SOURCE_AUTO_POSITION=1,恢复副本连接时由 GTID 集合计算待传输事务;启用条件仍是源库和副本都按要求开启 GTID。自动定位不等于“永远不丢数据”,它解决的是从哪个事务继续复制,不是复制发生前的承诺。
4.2 根据 RPO 选择复制方式
| 方式 | 提交返回前 | 故障切换时 | 代价 |
|---|---|---|---|
| 异步复制 | 不等待副本确认 | 可能丢失已提交但未复制的事务 | 延迟低,RPO 不为 0 |
| 半同步复制 | 等待约定数量副本确认事务收到 | 降低已确认事务丢失概率,但要检查插件状态 | 提交延迟增加,超时可能退化 |
| 全同步复制 | 等待副本完成执行 | 一致性更强 | 跨地域时延迟与可用性代价显著 |
MySQL 半同步插件按接收确认工作,不保证事务在副本上执行完成;配置时还要记录超时后的退化行为。监控不能只看 Seconds_Behind_Source,因为单个大事务可能让时间差掩盖尚未执行的 GTID。
建立如下告警信号:
SELECT
@@GLOBAL.source_uuid AS source_uuid,
@@GLOBAL.gtid_executed AS gtid_executed,
TIMESTAMPDIFF(SECOND, replica_status.time, NOW()) AS status_age;
-- 实际部署中从 performance_schema.replication_connection_status
-- 和 SHOW REPLICA STATUS 的实时结果采集,不直接依赖缓存变量。至少同时监控:
- 复制 I/O、SQL 线程是否运行;
- 源库与副本 GTID 集合差值;
- 延迟持续增长还是短时抖动;
- 半同步当前是否处于半同步状态,以及未确认提交数;
- 副本错误码、最后错误和重试耗时;
- 从复制开始到应用可见的端到端业务延迟。
4.3 定期做可恢复性验证
复制在运行不代表复制可恢复。每次演练都应完成:
- 按生产配置拉起一个临时副本;
- 连接源库并自动定位到指定 GTID;
- 等待目标 GTID 集合在副本出现;
- 对关键表执行行数、校验和和业务状态抽样;
- 记录恢复耗时、失败原因和修复动作。
全表校验和可能影响生产,应优先在只读副本或业务低峰执行,并记录负载变化。无法抽样校验的表,应有备份恢复或回放方案。
5. 阻止跨地域写冲突
单元化的核心规则是:数据归属单元是写入口的唯一来源。即便路由系统错误,数据库也不应轻易接受另一地域的同分区写入。
订单归属校验可与业务事务放在同一条 SQL 中:
START TRANSACTION;
UPDATE orders
SET status = 'PAID',
paid_at = UTC_TIMESTAMP(6)
WHERE order_no = 'ORD-20261011-001'
AND user_id = 100001
AND unit_name = 'unit-a'
AND status = 'UNPAID';
-- 影响行数为 0 时不得继续执行副作用。
INSERT INTO payment_effects(
effect_id,
order_no,
type,
payload,
created_at
) VALUES (
UUID(),
'ORD-20261011-001',
'PAYMENT_RECORDED',
JSON_OBJECT('amount', 100),
UTC_TIMESTAMP(6)
);
COMMIT;跨单元业务采用以下模式之一:
- 路由到单一写主:所有与主体相关的读写先定位到同一单元;
- 异步协作:调用方通过 Outbox 发布事件,由归属单元幂等处理;
- 两阶段提交或强一致存储:只用于少量全局强一致操作,接受延迟和故障域耦合;
- 显式冲突协议:仅对确实允许多方写入的数据定义版本、条件更新和合并规则。
不要默认 Last-Write-Wins。时间戳可能来自不同节点,网络延迟会让“最后写入”无法代表业务意图。账户余额、订单状态、库存扣减和权限配置应使用状态守卫、唯一约束或版本号;若必须合并日志,保留冲突记录、旧值、新值和处理结果。
跨地域读操作也要分类:
- 写后读:回到本单元读主,或使用能证明单调读的一致性令牌;
- 近实时报表:允许异步副本,并展示数据时间;
- 审计与对账:读取明确的 GTID 或事件时间,不把不同副本的“当前值”拼接成同一时刻。
6. 执行区域故障切流
下面的 Runbook 以“华东单元不可写,将用户范围迁到华北单元”为例。实际命令由部署平台提供,但状态、校验和回滚必须保留。
6.1 切流前准备
- 确认故障范围是单元、地域还是全局控制面;
- 打开只读维护入口,记录当前流量比例和在线请求;
- 检查目标单元容量:连接数、CPU、内存、复制延迟、队列积压;
- 检查目标副本
Replica_IO_Running=Yes、Replica_SQL_Running=Yes; - 比较故障分片的源 GTID 与目标已执行 GTID;
- 确认配置版本、密钥、消息消费者和定时任务已经指向目标单元;
- 记录故障开始时间,作为 RTO 计时起点。
6.2 切流执行
1. 冻结故障分片的写入口,但保留健康只读请求
2. 排空正在执行的本地事务,等待连接关闭
3. 将目标副本追到批准的 GTID 位置
4. 将目标分片状态从 STANDBY 原子更新为 ACTIVE
5. 通过控制面切换 user_id / tenant_id 路由,不直接改临时脚本
6. 恢复入口并执行只读冒烟检查
7. 执行受控写入:创建订单、幂等重试、查询主键
8. 打开消息消费、延迟任务和对账任务
9. 观察错误率、延迟、复制差值、积压和资金类差异受控写入必须验证以下结果:
SELECT order_no, user_id, unit_name, status, created_at
FROM orders
WHERE order_no = 'DRILL-20261011-001';
SELECT callback_id, order_no, processed_at, result
FROM payment_callbacks
WHERE order_no = 'DRILL-20261011-001';同一业务请求重放一次,确认状态没有被重复推进,消息消费者也没有产生重复副作用。
6.3 回滚条件
出现以下任一情况,不继续扩大切流:
- 目标单元复制停止或 GTID 集合未覆盖批准位置;
- 业务关键字段出现校验差异;
- 冒烟写入失败或幂等重放产生重复记录;
- 错误率、延迟、积压超过预先批准的停止条件;
- 控制面路由更新成功但应用仍访问错误归属;
- 资金、订单状态或权限数据出现无法解释的偏差。
回滚时反向执行:关闭目标写入口,恢复原入口版本,清理目标测试记录,再发布诊断事件。如果目标库已经承接真实业务写入,不能直接把状态改回 STANDBY;应进入应用平滑切换数据库的反向切流流程,先比较新增 GTID 和业务差异。
7. 用故障演练验收方案
每项故障都要有前置条件、注入动作、观测指标、停止条件和恢复动作。
| 演练 | 注入方式 | 必须观测 | 通过标准 |
|---|---|---|---|
| 单地域断网 | 断开单元间网络或下线路由 | 连接错误、跨域调用、复制状态 | 本单元业务可继续或按计划降级 |
| 主库不可用 | 提升副本或停止主实例 | GTID 覆盖、切换时间、新写入 | RPO/RTO 在批准范围内 |
| 控制面配置丢失 | 回退一个路由版本 | 路由命中、单元一致性错误 | 请求不进入错误归属 |
| 复制延迟 | 注入慢事务或限速 | GTID 差值、读陈旧比例 | 触发降级或停止迁移 |
| 单元流量倾斜 | 调整入口权重 | CPU、连接数、P95 延迟 | 自动回压或恢复健康权重 |
| 数据迁移中断 | 在 COPYING 阶段终止任务 | 归属状态、增量差、锁 | 新旧单元均可继续,任务可重入 |
演练结果写入以下记录,禁止只写“成功”:
演练编号:DRILL-MA-007
计划故障模式:区域入口不可写
故障注入时间:
首个告警时间:
路由切换开始时间:
首次健康时间:
数据损失核对:
回滚或停止条件:
发现缺陷:
责任人与修复版本:
复测日期:定期复测关键问题:
- 切流负责人不在场时,值班人员能否按 Runbook 执行;
- 控制面故障时,是否有带版本签名的只读回退配置;
- 消息消费者是否仍会把任务发往旧单元;
- 数据库连接是否真正刷新到新主库;
- 业务对账能否找出切流期间的漏单和重复副作用;
- 故障恢复后,反向迁移是否保留新单元新增数据。
8. 上线检查单
设计阶段
- 已按数据类型记录 RPO、RTO 和一致性要求;
- 已选择稳定划分键,未知归属有明确处理;
- 每条写路径都能找到唯一写主;
- 跨单元请求有身份、路由和数据归属三重校验;
- 全局配置、共享数据和单元数据已经区分;
- 复制方式与数据允许丢失范围一致;
- 会话、消息、定时任务和后台脚本都使用单元路由。
上线前
- GTID 模式和复制线程已检查;
- GTID 差值、延迟和半同步状态已接入监控;
- 单元归属版本更新具备原子条件;
- 滚动发布同时兼容新旧路由和旧数据格式;
- 回滚步骤已覆盖目标单元已写入数据的情况;
- 关键表有行数、校验和和业务状态抽样方法。
故障切换后
- RTO 起止时间已记录;
- 冒烟写入与幂等重放已通过;
- 消息、延迟任务、对账任务已恢复;
- 复制差值和业务差异在停止条件内;
- 未完成迁移、未知归属和失败重试已有清单;
- 已安排反向迁移和故障复盘。
9. 面试追问速答
Q1:多活、双活和双写有什么区别?
多活是业务能力描述,双活描述两个部署点都可工作,双写强调多个节点同时接受写入。工程上最关键的问题仍是“同一业务键在任一时刻是否有唯一写主”。单元化通过分区归属获得多地域并行处理能力,而不是让任意主库接收相同数据。
Q2:同步复制能否保证区域故障零数据丢失?
不能只凭“同步”名称下结论。MySQL 半同步插件等待副本接收确认,超时可能退化,且不保证副本已完成执行。要先定义故障边界,再验证确认条件、退化行为和切换时 GTID 覆盖。若业务要求已确认写入零丢失,需要选择能证明该条件的拓扑并通过故障注入验收。
Q3:为什么不能把 Last-Write-Wins 当作默认冲突方案?
LWW 只依赖时间顺序,无法判断哪个值符合业务语义。跨地域时钟、网络延迟和节点重试都可能让错误更新获胜。账户、订单、权限和库存应采用唯一约束、状态守卫、版本号或由归属单元串行处理;日志类数据才适合按规则合并。
Q4:流量切过去后请求仍读错数据,怎么定位?
按链路依次检查:入口单元、token 中主体身份、控制面归属版本、应用计算结果、数据库记录的 unit_name。在每一层记录相同关联 ID 和单元标签。最常见的根因是只切负载均衡、没有刷新应用路由缓存,或旧连接仍指向旧主库。
Q5:异步复制延迟突然增大,应该降流量还是直接切库?
先判断延迟来源:正常大事务、副本落后、复制中断、目标不可写,还是监控数据过期。扩大切流可能继续扩大 GTID 差和数据风险。应根据停止条件先冻结迁移、保留现有入口,完成诊断后再选择追平、回滚或只切读流量。
Q6:怎么设计异地多活的故障演练?
每次演练覆盖一个明确故障模式,提前写清指标、停止条件和恢复动作。至少验证区域断网、主库故障、路由配置回退、复制延迟、流量倾斜和迁移中断。记录告警到恢复时间、GTID 差、业务差异与回滚动作,复测修复项才算完成。
10. 相关章节
跨地域切流前后的数据库状态检查见应用平滑切换数据库,冗余、故障隔离和故障演练框架见高可用设计,一致性取舍见 CAP 理论与 BASE 理论。消息在单元迁移期间的不丢不重保证见消息可靠性闭环。