ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从高可用到异地多活:构建99.99%可用性电商系统的架构实战

从高可用到异地多活:构建99.99%可用性电商系统的架构实战 大家好我是专注后端架构实战的老王。最近在帮一个电商团队做系统升级老板在需求会上直接拍板“这次重构系统可用性必须达到99.99%” 会议室瞬间安静大家心里都清楚这意味着一年里系统不可用时间不能超过52分钟。对于核心交易链路这不仅是技术挑战更是业务生死线。单机房、主从备份的传统高可用方案在机房级故障面前不堪一击。这时“异地多活”就成了必须啃下的硬骨头。本文将围绕“异地多活”架构从核心概念、设计原则到实战落地完整拆解一套可落地的方案。无论你是正在为“四个九”发愁的架构师还是希望深入理解分布式系统容灾的开发者都能从中找到从理论到代码的完整路径。我们会重点探讨数据同步、流量调度、分布式ID等核心难题的避坑实践让你少走弯路。1. 背景与核心概念为什么需要异地多活在深入技术细节之前我们必须先搞清楚到底什么是异地多活它和传统的高可用、灾备有什么区别1.1 从高可用到异地多活高可用High Availability, HA通常指在单个数据中心机房内通过冗余如主从、集群来消除单点故障保证服务持续可用。目标是应对服务器、网络设备、进程等单点故障。例如MySQL主从复制、Redis Sentinel、NginxKeepalived等都属于此范畴。同城灾备/双活在同城或近距离100km建立另一个数据中心通过高速专线进行数据同步。当主中心故障时可以快速切换至备中心。这能应对机房级别的故障但无法应对城市级灾难如大规模停电、自然灾害。异地多活Multi-Site Active-Active在相隔较远通常100km的多个地理区域如北京、上海、广州部署多个数据中心每个中心都对外提供完整的服务同时处理一部分用户流量。数据在多个中心之间双向同步。其核心目标是同时解决机房级和城市级故障实现真正意义上的业务永续。简单来说高可用防止一台机器挂掉。同城双活防止一个机房挂掉。异地多活防止一座城市挂掉。1.2 异地多活的核心价值与挑战价值更高的可用性单一机房故障流量可分钟级切换至其他机房业务影响极小是实现99.99%甚至99.999%可用性的基石。更好的用户体验用户可就近接入降低网络延迟。例如华南用户访问广州机房华东用户访问上海机房。容灾与备份天然具备数据异地备份和灾难恢复能力。挑战数据一致性这是最大的挑战。网络延迟跨地域通常几十到上百毫秒导致无法实现强一致性CP必须转向最终一致性AP。流量调度与路由如何将用户请求精准地路由到其“归属”的机房故障时如何快速、无损地切换流量分布式事务一个业务操作涉及多个机房的数据更新如何保证事务全局唯一ID在多个机房同时产生数据如何保证ID全局唯一、趋势递增且避免冲突数据同步与冲突处理双向同步下如何解决数据冲突例如同一商品在两个机房被同时修改理解了这些我们才能有的放矢地进行架构设计。2. 设计原则与核心模式在动手之前先确立几个关键的设计原则它们是异地多活架构的“宪法”。2.1 核心设计原则业务可扩展性优先不是所有业务都适合或需要异地多活。优先对核心、读多写少、数据维度清晰的业务进行改造如用户、商品信息。对于强一致性要求的业务如支付、库存扣减需特殊设计或暂缓。数据最终一致性放弃跨地域的强一致性接受秒级或分钟级的延迟通过异步复制和冲突解决机制达成最终一致。单元化Set架构这是实现异地多活的关键模式。将系统按某个维度通常是用户划分为独立的单元Set。每个单元包含完整的业务服务和数据部署在一个机房内。用户的所有请求都在其所属单元内闭环处理极大减少了跨机房调用。故障隔离与快速切换一个单元的故障不应影响其他单元。通过全局流量调度系统可以实现单元级故障的快速隔离与流量切换。2.2 常见的多活模式同城双活Active-Active in Same City低延迟可近似实现强一致性是迈向异地多活的良好过渡。两地三中心两个同城双活中心一个异地灾备中心。灾备中心平时只读或离线故障时启用。三地五中心更复杂的部署模式可用性更高但成本和复杂度也呈指数级增长。对于绝大多数互联网业务两地三中心或简单的两地双活是性价比最高的起点。我们的实战将围绕一个简化的“两地双活”场景展开假设我们在北京BJ和上海SH各有一个数据中心共同服务全国用户。3. 环境准备与架构蓝图在编码之前我们需要明确技术选型和环境假设。3.1 技术栈与版本说明本文示例将使用以下技术栈但核心思想适用于任何语言和框架服务框架Spring Boot 2.7.x数据库MySQL 8.0 每个机房独立实例数据同步Canal基于MySQL Binlog的增量订阅消费组件 Kafka消息队列配置中心Nacos 2.x 用于管理数据源、单元路由等配置流量网关Spring Cloud Gateway 演示路由逻辑生产环境常用NginxLua或云厂商全局负载均衡器分布式ID生成器Leaf美团开源或自定义Snowflake变种重要提示版本号请根据你的实际生产环境调整。本文重点在于演示架构模式和核心代码逻辑。3.2 系统架构蓝图下图描绘了我们即将构建的简易两地双活架构的核心组件与数据流用户请求 | v [ 全局负载均衡器 (DNS/HTTP) ] | -------------------------------------- | | | v v v 北京网关 上海网关 ...(其他单元网关) | | v v [北京业务单元] [上海业务单元] | | v v 北京MySQL ---[CanalKafka同步]--- 上海MySQL ^ ^ | | [单元内服务] [单元内服务]核心流程用户请求到达全局负载均衡器GLB。GLB根据流量调度规则如用户ID哈希、地理位置将请求转发到对应的机房网关北京或上海。网关将请求路由到该机房内的业务服务集群。业务服务读写本机房的数据库。任何一个机房的数据库变更都会通过Canal捕获Binlog发送到Kafka。另一个机房的消费者从Kafka拉取消息并在本地数据库执行从而完成数据异步同步。接下来我们将分步实现这个蓝图中的关键环节。4. 实战一单元化路由与流量调度流量如何正确路由是异地多活的第一道关卡。我们采用基于用户IDUID的单元化路由。4.1 定义单元与路由规则假设我们简单地将用户ID长整型按奇偶划分奇数UID用户 - 北京单元bj偶数UID用户 - 上海单元sh在实际项目中路由维度可能是用户ID、店铺ID、订单ID哈希等规则会更复杂如一致性哈希并存储在配置中心。首先在Nacos中配置单元路由规则># 单元路由规则配置 route: rules: # 规则类型mod (取模), range (范围), hash (哈希) - type: mod dimension: userId # 路由维度 divisor: 2 # 除数 # 余数到单元的映射 mapping: 0: sh # 余数0 - 上海 1: bj # 余数1 - 北京 defaultCell: bj # 默认单元用于无法识别的请求4.2 实现路由解析器我们需要一个组件在网关或服务内部根据请求信息如Header中的UID解析出目标单元。// 文件路径common/src/main/java/com/example/multisite/router/CellRouter.java Component Slf4j public class CellRouter { Autowired private NacosConfigService configService; /** * 根据用户ID计算所属单元 * param userId 用户ID * return 单元标识如 bj, sh */ public String routeByUserId(Long userId) { // 1. 从配置中心获取路由规则实际应缓存此处简化 RouteRule rule loadRouteRuleFromNacos(); if (mod.equals(rule.getType())) { int remainder (int) (userId % rule.getDivisor()); String cell rule.getMapping().get(remainder); if (cell ! null) { log.debug(用户 {} 被路由到单元 {}, userId, cell); return cell; } } // 其他规则类型... log.warn(未找到用户 {} 的路由规则使用默认单元: {}, userId, rule.getDefaultCell()); return rule.getDefaultCell(); } /** * 从HTTP请求中解析单元信息用于网关或Filter */ public String resolveCell(HttpServletRequest request) { // 优先从Header中获取明确指定的单元用于调试或强制路由 String forcedCell request.getHeader(X-Target-Cell); if (StringUtils.isNotBlank(forcedCell)) { return forcedCell; } // 从登录态或Token中解析用户ID Long userId extractUserIdFromRequest(request); if (userId ! null) { return routeByUserId(userId); } // 无法识别用户根据地理位置或其他策略路由如IP return routeByGeoIP(request); } private Long extractUserIdFromRequest(HttpServletRequest request) { // 实现从JWT Token或Cookie中解析用户ID的逻辑 // 此处返回示例ID String uidHeader request.getHeader(X-User-Id); return StringUtils.isNumeric(uidHeader) ? Long.parseLong(uidHeader) : null; } private String routeByGeoIP(HttpServletRequest request) { // 简化版根据IP前缀粗略判断 String ip getClientIp(request); if (ip.startsWith(61.129) || ip.startsWith(180.168)) { // 上海IP段示例 return sh; } // 默认返回北京单元 return bj; } // ... 其他辅助方法如 loadRouteRuleFromNacos, getClientIp 等 }4.3 在Spring Cloud Gateway中应用路由网关需要根据解析出的单元将请求转发到对应机房的后端服务。# 文件路径gateway/src/main/resources/application.yml spring: cloud: gateway: routes: - id: bj_user_service uri: lb://user-service-bj # 北京单元用户服务 predicates: - Path/api/user/** - HeaderX-Target-Cell, bj # 强制路由头 filters: - name: CellRouteFilter # 自定义过滤器用于动态路由 - id: sh_user_service uri: lb://user-service-sh # 上海单元用户服务 predicates: - Path/api/user/** - HeaderX-Target-Cell, sh filters: - CellRouteFilter # 动态路由规则无强制头时走默认路由逻辑 - id: dynamic_cell_route uri: lb://user-service-default predicates: - Path/api/user/** filters: - CellRouteFilter自定义过滤器CellRouteFilter会调用CellRouter.resolveCell方法并重写请求的URI将其指向正确的后端服务集群。// 文件路径gateway/src/main/java/com/example/multisite/filter/CellRouteFilter.java Component Slf4j public class CellRouteFilter implements GlobalFilter, Ordered { Autowired private CellRouter cellRouter; Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); // 将ServerHttpRequest适配为HttpServletRequest需转换此处简化逻辑 String targetCell cellRouter.resolveCell(adaptRequest(request)); // 根据targetCell重写请求URL例如将 /api/user/xxx 重写为指向 http://user-service-{cell}/api/user/xxx URI originalUri request.getURI(); String newPath /api/user; // 根据路由规则构造新路径 // 构建新的URI指向目标单元的服务 URI newUri UriComponentsBuilder.fromUri(originalUri) .host(user-service- targetCell) // 服务名 .port(null) .build() .toUri(); ServerHttpRequest newRequest request.mutate().uri(newUri).build(); log.info(将请求路由到单元: {}, 新URI: {}, targetCell, newUri); return chain.filter(exchange.mutate().request(newRequest).build()); } Override public int getOrder() { return -1; // 高优先级 } }通过以上步骤我们实现了最基本的单元化流量路由。生产环境中全局负载均衡器如F5、AWS Global Accelerator、阿里云GTM会承担第一层路由其原理类似但性能和高可用能力更强。5. 实战二分布式ID生成策略在单数据中心我们常用数据库自增ID或Snowflake算法。在异地多活环境下必须保证多个机房同时生成的ID全局唯一、趋势递增且尽可能避免冲突。5.1 Snowflake算法改造标准的Snowflake算法结构是1位符号位 41位时间戳 10位工作机器ID 12位序列号。 其中10位机器IDworkerId是问题的关键。在异地多活中我们需要赋予每个机房、每个服务实例一个唯一的workerId。解决方案将10位workerId拆分为两部分datacenterId(数据中心ID例如 3位可表示最多8个机房)workerId(工作节点ID例如 7位每个机房内最多128个实例)例如workerId (datacenterId 7) | actualWorkerId// 文件路径common/src/main/java/com/example/multisite/id/CrossDcSnowflakeIdGenerator.java public class CrossDcSnowflakeIdGenerator { // 起始时间戳 (2024-01-01) private final long twepoch 1704067200000L; // 位数分配 private final long datacenterIdBits 3L; // 数据中心ID占3位 private final long workerIdBits 7L; // 工作节点ID占7位 private final long sequenceBits 12L; // 序列号占12位 // 最大值 private final long maxDatacenterId -1L ^ (-1L datacenterIdBits); // 7 private final long maxWorkerId -1L ^ (-1L workerIdBits); // 127 // 移位偏移量 private final long workerIdShift sequenceBits; // 12 private final long datacenterIdShift sequenceBits workerIdBits; // 19 private final long timestampLeftShift sequenceBits workerIdBits datacenterIdBits; // 22 private final long sequenceMask -1L ^ (-1L sequenceBits); // 4095 private long datacenterId; private long workerId; private long sequence 0L; private long lastTimestamp -1L; public CrossDcSnowflakeIdGenerator(long datacenterId, long workerId) { if (datacenterId maxDatacenterId || datacenterId 0) { throw new IllegalArgumentException(datacenterId 超出范围 (0- maxDatacenterId )); } if (workerId maxWorkerId || workerId 0) { throw new IllegalArgumentException(workerId 超出范围 (0- maxWorkerId )); } this.datacenterId datacenterId; this.workerId workerId; } public synchronized long nextId() { long timestamp timeGen(); if (timestamp lastTimestamp) { // 时钟回拨处理策略等待、抛出异常或使用扩展位 throw new RuntimeException(时钟回拨拒绝生成ID。回拨毫秒数: (lastTimestamp - timestamp)); } if (lastTimestamp timestamp) { sequence (sequence 1) sequenceMask; if (sequence 0) { // 同一毫秒内序列号用尽等待下一毫秒 timestamp tilNextMillis(lastTimestamp); } } else { sequence 0L; } lastTimestamp timestamp; return ((timestamp - twepoch) timestampLeftShift) | (datacenterId datacenterIdShift) | (workerId workerIdShift) | sequence; } protected long tilNextMillis(long lastTimestamp) { long timestamp timeGen(); while (timestamp lastTimestamp) { timestamp timeGen(); } return timestamp; } protected long timeGen() { return System.currentTimeMillis(); } }关键点datacenterId需要在部署时通过环境变量或配置中心指定如北京1上海2确保全局唯一。workerId可以在机房内通过分布式协调服务如ZooKeeper、Nacos分配或使用IP地址哈希。时钟回拨是Snowflake的重大挑战。生产环境必须使用NTP服务同步时钟并在代码中实现回拨处理策略如等待、记录告警、使用备用workerId等。5.2 使用Leaf-Segment方案备选如果担心时钟问题可以考虑美团开源的Leaf它提供了基于数据库号段的ID生成方式对跨机房友好。每个机房独立申请号段通过设置不同的step步长和初始值来避免冲突。例如北京机房tagbj_usermax_id0step2000上海机房tagsh_usermax_id1step2000这样北京生成的ID是偶数段0,1,2,...上海生成的是奇数段1,3,5,...天然隔离。6. 实战三数据双向同步与冲突解决这是异地多活最复杂的一环。我们使用Canal Kafka实现MySQL的异步数据同步。6.1 架构与部署Canal Server部署在每个机房的MySQL旁伪装成Slave读取Binlog。Kafka集群建议部署在独立的第三机房或两个机房各部署一套通过MirrorMaker同步作为可靠的消息通道。Canal Client / Adapter部署在消费机房订阅Kafka中的Binlog消息并应用到本地数据库。拓扑北京MySQL --Canal-- Kafka(Topic: bj-binlog) --Canal Client-- 上海MySQL 上海MySQL --Canal-- Kafka(Topic: sh-binlog) --Canal Client-- 北京MySQL6.2 Canal Server配置# 文件路径canal/conf/example/instance.properties (北京机房) # 数据库地址 canal.instance.master.address127.0.0.1:3306 canal.instance.dbUsernamecanal canal.instance.dbPasswordcanal canal.instance.connectionCharsetUTF-8 # 需要同步的库表白名单 canal.instance.filter.regexmultisite_db\\..* # 排除不需要同步的系统表 canal.instance.filter.black.regexmysql\\.slave_.* # MQ配置 (发送到Kafka) canal.mq.topicbj-binlog canal.mq.serverskafka-broker1:9092,kafka-broker2:9092 # 按表名分区保证同一张表的变更顺序 canal.mq.partitionHash.*\\..*:$pkId$6.3 冲突检测与解决策略双向同步必然带来冲突。例如用户在北京和上海几乎同时修改了昵称。解决思路避免冲突这是上策。通过单元化让同一用户的数据修改只发生在其主场单元。对于全局数据如商品库存采用“单点写多点读”模式指定一个主机房负责写其他机房异步同步。检测与解决对于无法避免的冲突需制定解决策略。“最后写入获胜”LWW为每条记录增加一个时间戳或版本号字段。同步时比较时间戳保留最新的。问题依赖各机房时钟同步可能丢数据。“业务优先级”预先定义业务规则。例如优先保留来自“主单元”的修改或优先保留“支付成功”状态覆盖“待支付”状态。“人工干预”将冲突记录写入冲突日志表由运营或系统定期处理。适用于低频但重要的操作。实现示例 - 基于“业务时间戳”的解决 在数据库表中增加一个last_modified字段使用一个全局单调递增的版本号服务如Redis分布式锁自增来生成而不是本地时间戳。-- 用户表结构示例 CREATE TABLE user ( id bigint(20) NOT NULL COMMENT 分布式ID, name varchar(255) DEFAULT NULL, cell varchar(10) DEFAULT NULL COMMENT 所属单元如bj, sh, last_modified bigint(20) NOT NULL DEFAULT 0 COMMENT 最后修改版本号, PRIMARY KEY (id), KEY idx_cell (cell) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;在应用层更新数据时需要获取一个全局递增的版本号可通过一个中心化的版本服务或利用分布式ID生成器的单调递增性。// 文件路径service/src/main/java/com/example/multisite/service/impl/UserServiceImpl.java Service public class UserServiceImpl implements UserService { Autowired private VersionService versionService; // 全局版本号服务 Transactional public boolean updateUser(Long userId, String newName) { // 1. 获取新的全局版本号 long newVersion versionService.nextVersion(); // 2. 更新数据并检查版本 int rows userMapper.updateNameWithVersion(userId, newName, newVersion); if (rows 0) { // 更新失败可能记录不存在或本地版本不是最新的在同步间隙被其他机房更新 // 这里可以触发一次强一致性读取从中心化存储或延迟等待后重试或抛出冲突异常 throw new DataConflictException(用户数据已过期请刷新后重试); } return true; } }对应的Mapper SQL!-- 文件路径service/src/main/resources/mapper/UserMapper.xml -- update idupdateNameWithVersion UPDATE user SET name #{newName}, last_modified #{newVersion} WHERE id #{userId} AND last_modified #{newVersion} !-- 只有本地版本更旧时才更新 -- /update在数据同步组件Canal Client中应用Binlog时也需要遵循同样的版本比较逻辑只有当日志中的last_modified大于本地记录的last_modified时才执行更新。7. 常见问题与排查思路在落地异地多活过程中你会遇到无数坑。下表总结了一些典型问题及应对策略问题现象可能原因排查思路与解决方案数据同步延迟大1. 网络带宽不足或抖动。2. Kafka堆积。3. Canal Client消费慢。4. 目标库性能瓶颈。1. 监控网络延迟和带宽。2. 检查Kafka Lag增加消费者数量或分区。3. 优化Canal Client的批处理逻辑避免单条提交。4. 检查目标库的CPU、IO和锁情况。同步数据出现循环复制A机房的数据同步到B机房又被B机房的Canal当成新数据同步回A机房。1. 在Binlog事件或消息体中增加来源标记如_source_cellbj。2. 同步组件识别到来源标记与本地机房相同时丢弃该事件。分布式ID冲突1. 各机房datacenterId或workerId配置重复。2. 时钟回拨导致ID重复。1. 严格检查各环境配置通过配置中心统一管理。2. 实现时钟回拨监控与告警采用Leaf-Segment等不依赖时钟的方案。流量切换后用户状态丢失用户Session或缓存数据只存在原机房。1. 会话数据存储到全局缓存如Redis Cluster跨机房部署。2. 使用无状态服务将状态信息加密后放在客户端如JWT Token。跨机房调用超时激增服务间RPC调用未做单元化隔离大量请求跨机房。1. 完善单元化路由确保请求在单元内闭环。2. 对于必须的跨机房调用设置合理的超时和熔断策略如Hystrix、Sentinel。3. 使用数据冗余代替实时RPC调用。数据冲突导致业务逻辑错乱冲突解决策略不当或未生效。1. 复盘冲突场景优化业务设计尽可能从源头避免冲突。2. 加强冲突检测日志对发生的冲突进行告警和人工复盘。3. 对于金融等强一致性场景考虑使用分布式锁或路由到主机房写的方案。8. 最佳实践与工程建议基于实战经验总结出以下关键建议帮助你在项目中平稳落地异地多活。8.1 分阶段实施灰度推进不要试图一次性将所有业务改造成多活。遵循以下路径第0步同城高可用。确保单机房内服务无状态、数据库有主从。第1步数据同步先行。先搭建好跨机房的数据同步通道CanalKafka验证数据同步的准确性和延迟。只读业务可以率先接入流量切换到新机房验证读能力。第2步单业务单元化试点。选择一个核心但逻辑相对简单的业务如用户服务进行完整的单元化改造。包括数据库拆分、服务路由、ID生成、数据同步验证。第3步流量灰度切换。通过DNS或网关将小部分用户如1%的流量切到新单元观察监控指标错误率、延迟、数据一致性。第4步全量推广与多业务联动。逐步将其他业务接入单元化体系并处理好业务间的跨单元调用。8.2 监控与可观测性体系建设没有监控的多活就是“盲人骑瞎马”。必须建立全方位的监控基础设施监控各机房网络延迟、带宽、丢包率。数据同步监控Canal延迟、Kafka Lag、同步错误率。业务监控各单元接口成功率、延迟、业务错误码分布。数据一致性校验定期全量或抽样对比双机房关键数据产出一致性报告。流量调度监控全局负载均衡器状态、各单元流量比例、切换日志。8.3 定期容灾演练“从没故障过的容灾方案”是最不可靠的。必须定期进行演练预案演练模拟机房故障执行流量切换预案记录切换时间和操作步骤。混沌工程在生产环境非高峰时段注入故障如模拟网络分区、数据库慢查询观察系统自愈能力和对业务的影响。复盘与优化每次演练后必须复盘优化预案和工具链。8.4 文档与团队认知异地多活涉及运维、开发、DBA、测试多个团队。必须确保架构文档清晰维护最新的架构图、组件说明、数据流向图。运维手册详尽包括日常维护命令、故障应急处理流程、升级回滚方案。开发规范明确在代码规范中明确写入多活相关约束如“禁止跨单元事务”、“必须使用分布式ID生成器”、“缓存Key需包含单元信息”等。定期培训让所有相关技术人员理解多活的基本原理和本公司的实现方案。异地多活不是一项单纯的技术选型而是一个持续迭代的体系化工程。它追求的不是理论的完美而是在成本、复杂度、可用性之间找到最佳平衡。从最简单的“两地读写分离”开始逐步向“单元化双活”演进每一步都要稳扎稳打用监控和数据驱动决策。当你看到系统在某个机房故障后流量在几十秒内自动切换用户几乎无感知时你就会觉得这一切的复杂都是值得的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进