Skip to content

异地多活与单元化 ​

本场景回答:系统跨越多个地域提供服务时,怎样选择数据归属、控制复制延迟、执行切流,并通过演练证明故障时只丢可接受的数据且能在目标时间内恢复。

1. 先选择故障模式,而不是先宣布“多活” ​

“多活”可能指三种不同方案。方案名称相同,但可用性、数据风险和改造量不同。

方案正常状态故障后主要风险
Active-Passive备用地域不承接业务流量提升为写入口切换编排、备用环境新鲜度
Active-Active各地域同时承接流量扩大其他地域的流量路由一致性、跨地域复制
Multi-Master多个节点同时接受相同数据写入各节点继续写入冲突检测、合并与回放

异地多活通常优先采用单元化 Active-Active:同一份全局数据按业务键划入唯一单元,每个单元只写自己的主数据;不同单元处理不同用户或租户。这样可以把“多个可写主库”降为“每个数据分区只有一个写主”,避免把系统直接升级成任意节点都能写入的全局 Multi-Master。

先为以下四类数据分别确定策略:

数据类型示例推荐策略
用户强一致数据账户余额、订单状态单元归属,读写都路由到写主
可容忍延迟的数据商品库存、搜索索引异步复制或异步聚合
全局配置字段字典、灰度开关全局配置服务或多副本发布
单元共享数据风控名单、商品目录只读副本或独立共享服务

不要先回答“同步还是异步复制”,先写清三个约束:

  1. 哪些业务数据在区域故障时允许丢失;
  2. 从发现故障到恢复服务最多允许多长时间;
  3. 哪些跨地域读写必须保持强一致,哪些可以接受最终一致。

将结果记录为验收项,而不是只写在方案文档里:

text
数据集:账户余额
可接受 RPO:0 个已确认交易
目标 RTO:由业务方签字的恢复时间
一致性要求:切流后读到本地已确认写入
验收方式:故障注入 + 指定交易查询 + 双向账务核对
失败处理:无法满足时缩小多活范围,不用异步复制冒充零丢失

2. 为数据选择稳定的单元归属 ​

单元划分要让同一业务事务尽量在一个地域内完成。常见划分键包括用户 ID、租户 ID、订单主体 ID 或组织 ID。选择时依次检查:

  1. 写路径集中:同一用户或订单的读写是否能稳定落到同一单元;
  2. 热点分布:划分键是否会造成单个单元显著更热;
  3. 迁移能力:键范围能否按小批范围逐步搬迁;
  4. 降级能力:读取全局共享数据时能否单独降级;
  5. 未知键兜底:无归属的新记录写到哪里,后续是否可迁回;
  6. 关联一致性:订单、支付和用户资料是否使用同一主体键或有明确跨单元协议。

下面用用户 ID 的连续范围划分三个单元。具体范围、权重和地域名称应由流量与容量模型决定:

yaml
# 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;
  • 新用户进入指定单元,之后通过控制面更新归属;
  • 对无法推导归属的请求返回明确错误并触发人工处理。

为每个单元保存可观测的归属表。控制面配置和应用实际计算结果必须可对照:

sql
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;

归属变更需要使用版本号防止旧路由覆盖新路由:

sql
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 或负载均衡、不校验应用数据归属,容易让请求到达正确地域却访问错误数据。

text
客户端
  │ token 中的 user_id / 租户标识
  ▼
全局入口 ──按归属规则──▶ 单元入口
                           │
                           ├─ 本单元主库:强一致读写
                           ├─ 本单元只读副本:可容忍延迟的读
                           └─ 共享服务:配置、全局目录

请求进入应用后至少执行三项检查:

  1. token 或请求头中的主体身份是否可信;
  2. 计算出的单元是否与入口单元一致;
  3. 数据库中的 unit_name 是否与请求主体一致。

示例伪代码如下,具体错误码应沿用项目约定:

python
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 可能为空的匿名请求;
  • 定时任务按主体键重新计算归属。

执行单元迁移时,按“停止新写入—复制增量—校验—更新归属—放行新写入”的顺序进行。迁移状态保存在控制面,应用每次请求读取最新状态;不要通过长时间睡眠或人工观察代替状态机。

text
DRAINING
  │ 新请求拒绝或重定向到旧单元
  ▼
COPYING ──校验行数、校验和、业务关键字段──▶ VERIFYING
                                                    │
                                                    ├─ 失败:保留旧归属,补偿增量
                                                    └─ 通过:原子更新 unit_membership
                                                                     │
                                                                     ▼
                                                                  ACTIVE

4. 复制数据并监控真实延迟 ​

4.1 为 MySQL 建立可恢复复制链路 ​

GTID 能让副本根据已执行事务集合请求缺失事务,减少人工拼接 binlog 文件和位置。源库和副本启用 GTID 自动定位时,需要 gtid_mode=ON 和 enforce_gtid_consistency=ON。模式迁移要遵守官方规定的启动顺序,不能在生产上直接把两个变量同时切换为 ON。

复制链路准备完成后,至少验证以下内容:

sql
-- 在源库和副本分别执行
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。

建立如下告警信号:

sql
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 定期做可恢复性验证 ​

复制在运行不代表复制可恢复。每次演练都应完成:

  1. 按生产配置拉起一个临时副本;
  2. 连接源库并自动定位到指定 GTID;
  3. 等待目标 GTID 集合在副本出现;
  4. 对关键表执行行数、校验和和业务状态抽样;
  5. 记录恢复耗时、失败原因和修复动作。

全表校验和可能影响生产,应优先在只读副本或业务低峰执行,并记录负载变化。无法抽样校验的表,应有备份恢复或回放方案。

5. 阻止跨地域写冲突 ​

单元化的核心规则是:数据归属单元是写入口的唯一来源。即便路由系统错误,数据库也不应轻易接受另一地域的同分区写入。

订单归属校验可与业务事务放在同一条 SQL 中:

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 切流前准备 ​

  1. 确认故障范围是单元、地域还是全局控制面;
  2. 打开只读维护入口,记录当前流量比例和在线请求;
  3. 检查目标单元容量:连接数、CPU、内存、复制延迟、队列积压;
  4. 检查目标副本 Replica_IO_Running=Yes、Replica_SQL_Running=Yes;
  5. 比较故障分片的源 GTID 与目标已执行 GTID;
  6. 确认配置版本、密钥、消息消费者和定时任务已经指向目标单元;
  7. 记录故障开始时间,作为 RTO 计时起点。

6.2 切流执行 ​

text
1. 冻结故障分片的写入口,但保留健康只读请求
2. 排空正在执行的本地事务,等待连接关闭
3. 将目标副本追到批准的 GTID 位置
4. 将目标分片状态从 STANDBY 原子更新为 ACTIVE
5. 通过控制面切换 user_id / tenant_id 路由,不直接改临时脚本
6. 恢复入口并执行只读冒烟检查
7. 执行受控写入:创建订单、幂等重试、查询主键
8. 打开消息消费、延迟任务和对账任务
9. 观察错误率、延迟、复制差值、积压和资金类差异

受控写入必须验证以下结果:

sql
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 阶段终止任务归属状态、增量差、锁新旧单元均可继续,任务可重入

演练结果写入以下记录,禁止只写“成功”:

text
演练编号:DRILL-MA-007
计划故障模式:区域入口不可写
故障注入时间:
首个告警时间:
路由切换开始时间:
首次健康时间:
数据损失核对:
回滚或停止条件:
发现缺陷:
责任人与修复版本:
复测日期:

定期复测关键问题:

  1. 切流负责人不在场时,值班人员能否按 Runbook 执行;
  2. 控制面故障时,是否有带版本签名的只读回退配置;
  3. 消息消费者是否仍会把任务发往旧单元;
  4. 数据库连接是否真正刷新到新主库;
  5. 业务对账能否找出切流期间的漏单和重复副作用;
  6. 故障恢复后,反向迁移是否保留新单元新增数据。

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 理论。消息在单元迁移期间的不丢不重保证见消息可靠性闭环。

Released under the MIT License.