京东二面:1亿-100亿数据,如何 做MySQL 秒级扩容?或者 MySQL 秒级切流;数据库分库分表【转】

尼恩说在前面

年底,大厂机会越混越多。在45岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如阿里、滴滴、极兔、有赞、希音、百度、网易、美团的面试资格。前两天一个 小伙伴面  京东,遇到的一个核心的架构面试题:1亿级数据,如何的实现  秒级扩容? 如何的实现   扩容  秒切?这个问题没有回答好,面试挂了。 来求助尼恩。这里尼恩给大家做一下系统化、体系化的梳理,使得大家可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”。也一并把这个题目以及参考答案,收入咱们的 《尼恩Java面试宝典PDF》V173版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。

最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请关注本公众号【技术自由圈】获取,后台回复:领电子书

此文为上下两篇文章,  尼恩带大家继续,挺进 120分,让面试官 口水直流。本文的前面几个  核心面试题:阿里面试:每天新增100w订单,如何的分库分表?这份答案让我当场拿了offer阿里二面:10亿级分库分表,如何丝滑扩容、如何双写灰度?阿里P8方案+ 架构图,看完直接上offer!

一、先说背景:为什么存在 “ MySQL   秒级扩容“难题?

问题痛点:数据爆炸式增长,MySQL 单点扛不住了!

当业务从百万用户迈向亿级规模,数据库压力呈指数级飙升。数据不断膨胀, 磁盘撑满、内存不足、连接数耗尽、主从延迟炸裂……各种问题接踵而至。需要 用 水平拆分(Horizontal Sharding) 的方式,把一个 大数据库,拆成多个轻量、独立的小库小表,实现“化整为零,分摊压力”。通过分库分表策略(如按用户ID取模、按时间分片等),将数据和流量均匀分散到多个 MySQL 实例上。每个实例只负责自己那“一亩三分地”,整体吞吐量自然成倍提升。在扩容的过程中, 需要实现  不停服。所以,  亿级扩容,是一个技术难题, 而且,是一个“不得不做”的生死线 难题。每一个架构师 ,都必须掌握它。每一个架构师 , 面对的不 是故障报警,而是系统演进的主动权。

方案一:停服扩容(最原始的方案)

1. 业务场景

问题痛点:系统在初期数据量小、性能表现良好,但随着订单量激增(突破1亿条),单库架构的瓶颈彻底暴露——查询变慢、读写集中、响应延迟飙升,大促期间甚至直接卡死。此时常规优化手段如加索引、读写分离已失效,无法根治根本问题。核心方案:采用“停服扩容 + 手动迁移”的基础路径,通过一个短暂停机窗口,将原有分库数据按照新的分片规则整体迁移到更多数据库实例中,实现物理层面的负载分摊,快速打破性能天花板。

场景还原:用户多了,数据库扛不住了

查个订单要等3秒?高峰期频繁超时?加索引没用,读写分离也救不了场。团队人少、时间紧,没精力搞复杂分布式架构。怎么办?不如“关店搬家”:先停服务 → 把老数据按新规则整体搬进4个新库 → 改代码重启 → 开门迎客。虽然用户得等2小时,但技术实现极简、风险可控,特别适合初创项目或非核心系统“先活下来,再谋发展”。

本质思路:用一次计划内停机,换取架构级扩容能力,是资源受限下的务实选择。

2. 方案实现(Java+Shell 实战)

步骤 1:数据迁移脚本(Shell+MySQL 命令)

问题痛点:原系统只有2个分库(uid % 2),现在要扩到4个(uid % 4)。如果迁移时不精准匹配新路由规则,会导致数据错乱、丢失,甚至请求找不到记录。核心方案:使用 Shell 脚本结合 MySQL 的条件查询能力,从旧库提取数据并按 uid % 4 新规则“对号入座”,导入对应的新分库,确保每条数据归位正确。


#!/bin/bash
# 停服后执行数据迁移:从旧库(2个)迁移到新库(4个)
# 旧库配置
OLD_DB_0="jdbc:mysql://192.168.1.10:3306/order_db_0?useSSL=false"
OLD_DB_1="jdbc:mysql://192.168.1.11:3306/order_db_1?useSSL=false"
OLD_USER="root"
OLD_PWD="123456"
# 新库配置
NEW_DB_0="jdbc:mysql://192.168.1.20:3306/order_db_0?useSSL=false"
NEW_DB_1="jdbc:mysql://192.168.1.21:3306/order_db_1?useSSL=false"
NEW_DB_2="jdbc:mysql://192.168.1.20:3306/order_db_2?useSSL=false"
NEW_DB_3="jdbc:mysql://192.168.1.21:3306/order_db_3?useSSL=false"
NEW_USER="root"
NEW_PWD="12346"
# 迁移旧库0的数据到新库0(uid%4=0)和新库2(uid%4=2)
mysql -u$OLD_USER -p$OLD_PWD -h192.168.1.10 -e "SELECT * FROM order_info WHERE uid%2=0" | mysql -u$NEW_USER -p$NEW_PWD -h192.168.1.20 -Dorder_db_0
mysql -u$OLD_USER -p$OLD_PWD -h192.168.1.10 -e "SELECT * FROM order_info WHERE uid%4=2" | mysql -u$NEW_USER -p$NEW_PWD -h192.168.1.20 -Dorder_db_2
# 迁移旧库1的数据到新库1(uid%4=1)和新库3(uid%4=3)
mysql -u$OLD_USER -p$OLD_PWD -h192.168.1.11 -e "SELECT * FROM order_info WHERE uid%4=1" | mysql -u$NEW_USER -p$NEW_PWD -h192.168.1.21 -Dorder_db_1
mysql -u$OLD_USER -p$OLD_PWD -h192.168.1.11 -e "SELECT * FROM order_info WHERE uid%4=3" | mysql -u$NEW_USER -p$NEW_PWD -h192.168.1.21 -Dorder_db_3
echo "数据迁移完成"
关键逻辑说明:虽然旧库是按 uid%2 拆分的,但在迁移时必须重新计算 uid%4 来决定归属哪个新库。例如,原来在 order_db_0 中的数据,可能是 uid%4=0 或 uid%4=2,需分别导入 new_db_0 和 new_db_2

步骤 2:停服公告与流量拦截

问题痛点:停服期间若不处理外部请求,用户访问接口会看到500错误、空白页或崩溃提示,严重影响体验,可能引发大量投诉。核心方案:在应用层前置“维护开关”,通过配置中心动态控制返回内容,屏蔽真实异常,展示友好提示,提升可维护性。


// 接口添加维护标记,返回统一提示(Spring Boot示例)
@RestController
@RequestMapping("/order")
public class OrderController {
    @Value("${system.maintenance:false}")
    private boolean maintenance;
    @GetMapping("/detail")
    public Result<OrderDetail> getDetail(Long orderId) {
        if (maintenance) {
            return Result.fail("系统升级中,预计2小时后恢复,给您带来不便敬请谅解");
        }
        return Result.success(orderService.getDetail(orderId));
    }
}
上线前通过配置中心将 system.maintenance=true,所有请求都会被优雅拦截,避免暴露底层故障。

步骤 3:修改路由配置并重启服务

问题痛点:数据已经迁完,但如果代码中的分库路由逻辑仍为 uid % 2,那么新请求依然会走错库,导致“数据存在但查不到”的诡异问题。核心方案:更新分库路由策略为 uid % 4,并与新增的4个数据库一一映射,重启服务后生效,确保流量正确打到目标库。


// 扩容后路由规则改为 uid%4
@Configuration
public class DataSourceConfig {
    @Bean
    public DataSourceRouter dataSourceRouter() {
        Map<Integer, DataSource> dataSourceMap = new HashMap<>();
        dataSourceMap.put(0, createDataSource("order_db_0"));
        dataSourceMap.put(1, createDataSource("order_db_1"));
        dataSourceMap.put(2, createDataSource("order_db_2"));
        dataSourceMap.put(3, createDataSource("order_db_3"));
        return new DataSourceRouter(dataSourceMap, 4); // 分库数由2改为4
    }
}
重点在于:数据迁移完成 ≠ 系统可用,必须同步更新代码中的分片逻辑,否则就是“新瓶装旧酒”。

核心流程图解

问题痛点:过程涉及多个环节:通知、停服、迁移、校验、改配置、重启……步骤多、依赖强,稍有疏漏,就可能导致数据不一致或服务不可用。核心方案:绘制清晰的执行流程图,明确阶段划分和操作顺序,形成标准化 SOP,保障迁移过程可追踪、可回溯、不出错。Mermaid流程解读从用户通知开始,到最终链路验证结束,全流程闭环管理。每个节点都必须确认无误才能进入下一步,杜绝“跳步”带来的风险。

3. 关键补充:易混淆细节与误区

问题痛点:一次性迁移上亿条数据极易失败;迁移完成后不做校验,可能遗漏数据丢失、主键冲突等问题,上线即事故。核心方案:采用分批迁移降低失败概率,并通过自动化脚本比对关键指标(总数、金额、ID范围等),确保数据一致性。误区 1:全量导出一亿条数据再导入?危险操作!容易造成内存溢出、网络中断、事务超时等问题。正确做法:按主键区间分段迁移,例如每次处理10万条,逐步推进,支持断点续传。误区 2:数据搬完就万事大吉?大错特错!必须进行数据完整性校验。自动化核对项包括:

  • 新旧库总记录数是否一致?
  • 订单总金额是否有偏差?
  • 最大/最小 UID 是否覆盖完整?

推荐编写 Python 或 Shell 脚本自动比对,输出差异报告,作为上线前最后一道防线。

4. 优缺点 + 适用场景

问题痛点:想快速解决性能瓶颈,又担心停服影响用户体验;未来业务继续增长,不可能每次都靠“关店搬家”。核心方案:接受短期停机代价,换取最简单、最可控的实施路径,适用于资源有限、容忍短暂停机的早期系统。

类型 内容
优点 实现成本低,无需引入中间件;逻辑清晰,小团队也能独立完成
缺点 必须停服2-4小时;数据迁移耗时长;一旦出错难以快速回滚
适用场景 初创项目、非核心业务、内部管理系统、用户可接受计划内维护

一句话总结能不用就不用,但关键时刻能救命。它是技术债务积累后的“急救包”,不是长期方案。待业务稳定后,应尽快升级为支持在线扩缩容的高级架构(如一致性哈希、虚拟节点、ShardingSphere 等)。面试加分点提醒面试官常问:“你们当时为什么不停机也能扩容?”回答思路:先承认用了停服方案(体现诚实),再说明后续如何演进到无感扩容(体现成长思维),这才是高阶答案。

方案二:双写扩容(无停服,但一致性难保障)

问题痛点:在线教育平台的订单库 数据量暴涨到8000万+,老数据库扛不住了——查询变慢、写入延迟、主从延迟拉高。但系统7×24小时运行,用户随时在下单买课,一分钟都不能停传统“停机迁移”直接被否决。核心方案: 采用 “双写 + 渐进式切换” 的策略,在不停服务的前提下完成分库扩容。整个过程就像 边开车边换轮胎。高风险,但只要步骤清晰、有兜底机制,就能平稳落地。核心流程:数据库弹性扩容方案(双写→迁移→校验→切流)关键思路是四个阶段:(1) 准备双路通道:提前搭好新库集群,并配置双数据源路由;(2) 新增数据双写:所有新订单同时写入新旧两个库,保证增量一致;(3) 老数据后台搬运:通过定时任务把历史数据逐步同步到新库;(4) 读流量灰度切换 → 停写旧库:从小比例开始切读请求,稳定后关闭对旧库的写入。最终实现 用户无感、系统不断、数据完整 的平滑迁移。

步骤 1:搭建新分库集群并配置双写数据源

问题痛点:程序不知道往哪写?新旧库并存时,必须让代码能灵活选择连接目标,否则后续双写无法开展。核心方案:提前注册两套独立的数据源路由机制,分别管理旧库(2个分片)和新库(4个分片),为“双路并发”打好基础设施基础。


// 双写数据源配置
@Configuration
public class DualWriteDataSourceConfig {
    // 旧库数据源(2个分库)
    @Bean("oldDataSourceRouter")
    public DataSourceRouter oldDataSourceRouter() {
        Map<Integer, DataSource> dataSourceMap = new HashMap<>();
        dataSourceMap.put(0, createDataSource("old_order_db_0"));
        dataSourceMap.put(1, createDataSource("old_order_db_1"));
        return new DataSourceRouter(dataSourceMap, 2);
    }
    // 新库数据源(4个分库)
    @Bean("newDataSourceRouter")
    public DataSourceRouter newDataSourceRouter() {
        Map<Integer, DataSource> dataSourceMap = new HashMap<>();
        dataSourceMap.put(0, createDataSource("new_order_db_0"));
        dataSourceMap.put(1, createDataSource("new_order_db_1"));
        dataSourceMap.put(2, createDataSource("new_order_db_2"));
        dataSourceMap.put(3, createDataSource("new_order_db_3"));
        return new DataSourceRouter(dataSourceMap, 4);
    }
}

相当于提前铺设两条“供水管道”——一条通向老水厂(旧库),一条通向新建自来水厂(新库)。系统可以按需同时使用,互不影响。面试加分点:这里体现了 多数据源动态路由设计模式,常用于分库分表、读写分离、跨租户隔离等场景。

步骤 2:实现双写逻辑(Service 层)

问题痛点:如果只写一个库,另一个库就缺失最新数据——迁移完成后会出现“用户下了单却查不到”的严重问题。核心方案:在下单的核心业务流程中,开启事务,依次将同一笔订单按各自的分库规则写入旧库和新库,确保两边都有这份数据。


@Service
public class OrderService {
    @Autowired
    @Qualifier("oldDataSourceRouter")
    private DataSourceRouter oldDataSourceRouter;
    @Autowired
    @Qualifier("newDataSourceRouter")
    private DataSourceRouter newDataSourceRouter;
    @Autowired
    private OrderMapper oldOrderMapper;
    @Autowired
    private OrderMapper newOrderMapper;
    // 双写保存订单
    @Transactional(rollbackFor = Exception.class)
    public Long saveOrder(OrderDTO orderDTO) {
        OrderPO orderPO = convertToPO(orderDTO);
        Long orderId = IdGenerator.nextId();
        orderPO.setId(orderId);
        // 写入旧库(uid%2)
        DataSourceContextHolder.setDbIndex(orderDTO.getUid() % 2);
        oldOrderMapper.insert(orderPO);
        // 写入新库(uid%4)
        DataSourceContextHolder.setDbIndex(orderDTO.getUid() % 4);
        newOrderMapper.insert(orderPO);
        return orderId;
    }
}

执行顺序:先旧后新,失败则整体回滚。理想情况下,两边数据完全一致。️ 实战注意:虽然用了事务,但这是 跨数据源事务,JDBC 原生不支持分布式事务,所以这里的 @Transactional 只能保证单个数据源内的原子性。若第二步失败,第一步已提交,无法自动回滚——这就是所谓的“双写一致性难题”。应对策略:必须配合补偿机制(如记录日志 + 异步修复),不能依赖本地事务解决一致性。

步骤 3:历史数据同步(后台任务)

问题痛点:双写只能覆盖新订单,那8000万条老订单还在旧库里!如果不搬过来,新库就是“空壳”,根本无法接管读请求。核心方案:启动一个低峰期运行的定时任务,分批扫描旧库未同步的老数据,插入新库,并标记状态防止重复搬运。


@Component
@EnableScheduling
public class DataSyncTask {
    @Autowired
    private OrderMapper oldOrderMapper;
    @Autowired
    private OrderMapper newOrderMapper;
    @Scheduled(cron = "0 0 * * * ?")
    public void syncHistoryData() {
        int pageNum = 1;
        int pageSize = 10000;
        while (true) {
            DataSourceContextHolder.setDbIndex(0);
            PageInfo<OrderPO> oldPage = oldOrderMapper.selectUnSyncData(pageNum, pageSize);
            if (oldPage.getList().isEmpty()) break;
            for (OrderPO orderPO : oldPage.getList()) {
                int newDbIndex = orderPO.getUid() % 4;
                DataSourceContextHolder.setDbIndex(newDbIndex);
                OrderPO exist = newOrderMapper.selectById(orderPO.getId());
                if (exist == null) {
                    newOrderMapper.insert(orderPO);
                }
                // 更新旧库同步状态
                DataSourceContextHolder.setDbIndex(orderPO.getUid() % 2);
                oldOrderMapper.updateSyncStatus(orderPO.getId(), 1);
            }
            pageNum++;
        }
        System.out.println("历史数据同步完成");
    }
}

关键字段:sync_status 标记是否已同步,避免新订单被误当作老数据重复插入。类比理解:就像搬家公司的清单系统,每搬完一户就在本子上打勾,防止漏搬或重搬。性能优化建议:- 分页大小控制在 5000~10000,避免内存溢出;- 在凌晨低峰期执行,减少对线上影响;- 加索引:sync_status + create_time 联合索引提升查询效率。

步骤 4:切换读流量与停写旧库

数据完成迁移后,开始 切换读流量。问题痛点:不能一刀切把所有读请求切到新库!万一新库性能不足、SQL 慢、缓存未预热,瞬间就会被打垮,引发雪崩。核心方案:通过配置中心动态控制读取来源,采用 灰度发布策略,从1%流量开始试探,逐步放大至100%,确保平滑过渡。


@Service
public class OrderService {
    @Value("${read.data.source:old}") // old/new,默认读旧库
    private String readDataSource;
    public OrderDetail getDetail(Long orderId) {
        OrderPO orderPO;
        if ("new".equals(readDataSource)) {
            int uid = getUidByOrderId(orderId);
            DataSourceContextHolder.setDbIndex(uid % 4);
            orderPO = newOrderMapper.selectById(orderId);
        } else {
            int uid = getUidByOrderId(orderId);
            DataSourceContextHolder.setDbIndex(uid % 2);
            orderPO = oldOrderMapper.selectById(orderId);
        }
        return convertToDetail(orderPO);
    }
}

切换流程四步走:(1) 配置中心修改 read.data.source=new(2) 先放 1% 用户 流量走新库,观察监控指标(QPS、RT、错误率);(3) 稳定后逐步扩到 10% → 50% → 100%;(4) 最终确认无异常,关闭双写逻辑,停止向旧库写入,旧库进入归档只读状态。推荐节奏:每轮切换间隔至少 5~10分钟,留足时间发现问题。面试高频问法:“你怎么验证数据一致性?”答:可通过抽样比对工具(如 DataX、Canal + 对账服务)定期校验新旧库关键表的数据差异。

核心流程图

Mermaid

3. 关键补充:那些容易踩的坑

误区 1:以为双写一定能成功

问题痛点:网络抖动、数据库超时、主从延迟等问题可能导致 一边写成功、一边写失败,造成新旧库数据不一致。例如:订单写入旧库成功,但新库因连接池满而失败,此时事务虽回滚了新库操作,但旧库已提交——数据丢失风险!核心方案:引入 异步补偿机制 主动发现并修复不一致:

  • 记录双写日志(如发MQ消息);
  • 启动补偿任务定时扫描差异;
  • 发现缺失立即补写,并触发告警通知人工介入。
推荐架构升级路径:用 消息队列解耦双写,改为“写旧库 + 发消息 → 消费者写新库”,提高可用性。
误区 2:历史同步时不加过滤,导致重复搬

问题痛点:双写期间的新订单也会落在旧库中,若不分青红皂白全量扫描,这些“刚出生”的数据会被当成“老数据”再次搬入新库,造成 重复插入主键冲突 或脏数据。核心方案:在旧库添加 sync_status 字段,默认为0(未同步),同步完成后置为1。同步任务只查询 sync_status = 0 的记录,搬完即标记,形成闭环。

就像快递员送货前先查系统状态,避免同一包裹送两次。

提示:也可结合时间范围过滤,比如只同步创建时间早于“双写开启时间点”的数据,双重保险。

️ 易混淆点:读切换必须灰度!

问题痛点:一次性将全部读请求切到新库,可能因以下原因导致服务崩溃:

  • 新库未建合适索引;
  • 缓存冷启动,大量穿透 DB;
  • 数据库连接数突增,超过上限。

核心方案:通过配置中心实现 动态读源路由,支持按百分比灰度放量。

建议节奏:1% → 10% → 50% → 100%,每次切换后观察 5~10分钟,重点关注 RT、CPU、慢 SQL 日志。

进阶做法:结合 APM 工具(SkyWalking、CAT)做实时对比分析,确保两边响应一致。

4. 优缺点 & 适用场景

类型 说明
优点 全程无需停机,用户体验零中断。实现成本低,无需引入复杂中间件(如ShardingSphere)
缺点 双写存在一致性风险,需额外补偿机制兜底。旧库压力翻倍(既要处理正常读写,又要被扫历史数据。历史同步耗时长,千万级数据可能需要数小时甚至天级别
适用场景 不能停服的核心业务系统(如教育、电商、直播)。允许短暂不一致的非金融级应用。团队具备基本监控、告警和容灾能力

总结一句话:双写扩容的本质,是在 可用性与一致性之间做权衡。它不是最完美的方案,但在“不能停”的现实约束下,是最务实的选择。关键是:步骤拆解清晰 + 每一步都有监控 + 出问题能快速回滚

方案三:中间件扩容(ShardingSphere )

问题痛点:

数据量爆炸、请求洪峰来袭,单数据库扛不住。想扩容?但又不能停机、改代码、伤业务,怎么办?典型场景就是电商大促:订单表干到 1.2 亿条,QPS 冲上 10万+,主库 CPU 直接拉满。换更大机器?治标不治本还烧钱;手动拆库分表?要动 DAO 层、改 SQL 路由逻辑,上线风险极高,一不小心就炸服。

核心方案:

引入 Apache ShardingSphere —— 数据库的“智能流量调度器”。它像一个隐形的中间层,把数据按规则自动分片到多个库中,对外仍是一个逻辑库。扩容时核心流程:数据库弹性扩容方案(双写→迁移→校验→切流)全程 不停机、不改一行业务代码,真正实现“边跑服务边扩容”。

第一步:环境准备(新增分库 + 双写配置)

1. 新增物理分库

先部署 2 个新物理库order_db_2order_db_3,与原有order_db_0order_db_1保持相同表结构、索引、权限配置,确保新库 “可用且兼容”。

2. 配置 ShardingSphere 双写规则

通过配置中心注册 4 个分库,同时开启「双写模式」:新请求仍按旧规则(uid%2)路由到旧库,同时异步复制一份数据到新规则(uid%4)对应的新库,确保新旧库数据实时同步。




<dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
    <version>5.3.2</version>
</dependency>

spring:
  shardingsphere:
    datasource:
      # 注册4个分库(旧库+新库)
      names: order_db_0,order_db_1,order_db_2,order_db_3
      order_db_0:
        url: jdbc:mysql://192.168.1.10:3306/order_db_0
        username: root
        password: 123456
      order_db_1:
        url: jdbc:mysql://192.168.1.11:3306/order_db_1
        username: root
        password: 123456
      order_db_2:
        url: jdbc:mysql://192.168.1.20:3306/order_db_2
        username: root
        password: 123456
      order_db_3:
        url: jdbc:mysql://192.168.1.21:3306/order_db_3
        username: root
        password: 123456
    rules:
      sharding:
        tables:
          order_info:
            actual-data-nodes: order_db_${0..3}.order_info
            # 主路由规则:仍用旧规则(uid%2),确保新请求先写旧库
            database-strategy:
              standard:
                sharding-column: uid
                sharding-algorithm-name: main_db_inline
            # 双写规则:同步写入新规则(uid%4)对应的库
            duplicate-data-sources:
              strategy: STANDARD
              sharding-algorithm-name: duplicate_db_inline
        # 主路由算法(旧规则):新请求优先写入旧库
        sharding-algorithms:
          main_db_inline:
            type: INLINE
            props:
              algorithm-expression: order_db_${uid % 2}
          # 双写算法(新规则):同步写入目标新库
          duplicate_db_inline:
            type: INLINE
            props:
              algorithm-expression: order_db_${uid % 4}
    # 双写开关:开启异步双写(不阻塞主流程)
    props:
      sql-show: true
      duplicate-data-source.enabled: true
      duplicate-data-source.async: true # 异步双写,避免影响主流程性能

3. 双写效果验证

  • 新订单uid=1008610086%2=0 → 主写order_db_0),同时异步写入10086%4=2 → order_db_2
  • 新订单uid=1008710087%2=1 → 主写order_db_1),同时异步写入10087%4=3 → order_db_3
  • 业务层无感知,仍为标准 JDBC 操作,双写逻辑由 ShardingSphere 透明实现。

关键注意点

  • 双写采用「异步模式」,避免同步双写导致接口响应延迟;
  • 开启双写日志,记录双写失败的订单(如网络抖动导致新库写入失败),定时重试;
  • 新库需提前建好索引、优化配置(如连接池、缓存),避免双写时新库性能瓶颈。

第二步:异步迁移老数据(ShardingSphere-Migration 工具驱动)

双写开启后,新数据已同步到新库,但旧库中历史数据(双写前产生的订单)仍未同步到新库。需通过迁移工具,将旧库中 “新规则下应属于新库” 的数据,全量迁移到目标库。使用官方提供的 ShardingSphere-Migration 工具,在线迁移指定分片数据,支持断点续传与一致性校验。


# 下载迁移工具包
wget https://archive.apache.org/dist/shardingsphere/5.3.2/apache-shardingsphere-5.3.2-shardingsphere-migration-tool-bin.tar.gz
tar -zxvf apache-shardingsphere-5.3.2-shardingsphere-migration-tool-bin.tar.gz

# migration.yaml 配置示例:将 uid%4==2 的数据从 db0 搬到 db2
sourceDataSource:
  parameter:
    url: jdbc:mysql://192.168.1.10:3306/order_db_0
    username: root
    password: 123456
targetDataSource:
  parameter:
    url: jdbc:mysql://192.168.1.20:3306/order_db_2
    username: root
    password: 123456
rule:
  type: SHARDING
  parameter:
    sourceRule:
      tables:
        order_info:
          actualDataNodes: order_db_0.order_info
          databaseStrategy:
            standard:
              shardingColumn: uid
              shardingAlgorithmName: source_inline
      shardingAlgorithms:
        source_inline:
          type: INLINE
          props:
            algorithm-expression: order_db_0
    targetRule:
      tables:
        order_info:
          actualDataNodes: order_db_2.order_info
          databaseStrategy:
            standard:
              shardingColumn: uid
              shardingAlgorithmName: target_inline
      shardingAlgorithms:
        target_inline:
          type: INLINE
          props:
            algorithm-expression: order_db_2
    dataConsistencyCheckAlgorithm:
      type: MD5

# 启动迁移任务(每次处理10万条)
bin/migration.sh -f migration.yaml -t 10000

核心逻辑:按新规则(uid%4)筛选旧库中需迁移的数据,精准同步到新库,避免重复 / 遗漏。迁移过程特点:

  • 支持并发、限速控制,不影响线上性能
  • 自动对比源目数据 MD5,确保零误差
  • 可暂停、重试、回滚,安全可控

第三步:全量一致性校验(双写 + 迁移结果验证)

迁移完成后,需确保「4 个分库的数据完全符合新规则」,且「新旧库数据无差异」,避免切流后出现数据缺失或不一致。

1. 校验维度与工具

校验类型 校验逻辑 工具 / 命令
数量校验 每个库的订单数≈总订单数 / 4(允许 ±1% 误差,因双写期间新增数据) 统计 SQL:select count(*) from order_info;(对比 4 个库结果)
分片规则校验 每个库只包含对应分片键的数据(如 db0 只含 uid%4==0,db2 只含 uid%4==2) 抽样 SQL:select distinct uid%4 from order_info limit 100;(结果应唯一)
内容一致性校验 随机抽取 1000 + 个 uid,对比旧库和新库的订单详情(id、金额、状态等关键字段) ShardingSphere-Migration 校验命令:bin/migration.sh -f migration.yaml -c
双写一致性校验 抽取双写期间的订单,验证旧库和新库的记录完全一致 校验 SQL:select * from order_info where create_time between '双写开始时间' and '迁移结束时间' order by uid limit 1000;

2. 校验异常处理

  • 数量不匹配:排查迁移配置的 dataFilter 是否正确,重新执行增量迁移;
  • 内容不一致:通过迁移日志定位差异订单,手动补全后重新校验;
  • 双写失败:查看双写日志,重试失败的双写任务,确保无遗漏订单。

3. 校验通过标准

  • 4 个库的订单数分布均匀(误差≤1%);
  • 分片规则校验 100% 符合(无跨分片数据);
  • 内容一致性校验通过率 100%(无差异订单);
  • 双写期间无未重试成功的失败记录。

第四步:动态切换分片规则(让新请求分流)

问题来了:如何让新增的请求自动写入四个库,而不是继续挤在前两个?解决方案:当数据完全一致且校验通过后,通过配置中心热更新分片规则。将流量从 “旧规则 + 双写” 切换到 “新规则 + 双写”,全程不停机、不改业务代码。通过 Nacos / ZooKeeper 等配置中心,热更新分片算法为 uid % 4,无需重启服务,实时生效!


spring:
  shardingsphere:
    rules:
      sharding:
        sharding-algorithms:
          order_db_inline:
            type: INLINE
            props:
              algorithm-expression: order_db_${uid % 4}

效果立现:- 新订单根据 uid % 4 均匀分布到 4 个库- 老数据仍在原库,不影响历史查询- 所有变更对业务透明,无感知切换这就是 中间件级弹性扩容 的核心优势:改规则 ≠ 改代码

第五步:验证收尾 + 安全下线

问题来了:迁移完就想删旧库?Too young。万一有漏迁或访问异常呢?解决方案:执行“三查一观察”策略,保障万无一失。必做动作清单:(1) 查数量:确认 order_db_0 中仅保留 uid%4 == 0 的数据(2) 验一致:运行全量 MD5 校验,比对关键字段是否匹配(3) 看几天:监控慢查询、错误日志、流量分布(4) 备份一周:保留原库快照,作为最后兜底手段只有当一切稳定后,才可逐步归档旧库资源。

避坑指南

误区一:改完规则 = 数据已迁移?

真相是:分片规则只影响新请求的路由路径,老数据不会自动搬家如果你不做迁移,老用户的订单依然在旧库,跨库 JOIN 或查询可能出错。正确姿势:规则切换 + 数据迁移双管齐下,缺一不可。

就像换了手机号,通讯录没同步,朋友还是打不通你。

误区二:迁移完马上就能删库?

真相是:即使迁移成功,也要留出“冷静期”观察。某些延迟任务、定时脚本、冷查询仍可能访问旧路径。建议保留备份至少 7 天,并持续监控相关 SQL 日志。

️ 易踩雷点:分片键选得不好等于自掘坟墓

常见错误:

  • 用 create_time 当分片键 → 时间集中,热点严重
  • 用 status 字段 → 枚举值少,分布极不均匀

正确选择原则(口诀记牢):

高频查、分布匀、别为空、尽量小

推荐字段:uidorder_id(雪花ID)、shop_id 避免字段:statustypeis_deleted

总结一句话:

ShardingSphere 扩容的本质,就是“先改路由、再搬数据”八字真言。借助中间件能力,做到 规则可配、流量可导、数据可迁、服务不断这不仅是技术方案,更是大厂应对海量请求的标准化打法——稳字当头,步步为营。

方案四:秒级平滑扩容(双虚 IP + 路由切换,大厂终极方案)

问题痛点:大促流量如潮水般涌来,数据库压力拉满,必须立刻扩容。但现实很残酷:系统不能停服、数据不能丢、扩容还必须快可传统扩容方式太“重”:动不动就要重启应用、迁移数据、停机维护……一旦操作,订单卡住、支付失败、用户体验直接崩盘——这是生产事故的节奏。更致命的是,应用和数据库绑得太死,改个配置都得断连接、重新初始化连接池,服务抖动在所难免。核心方案:用“双虚 IP + 动态路由”实现 秒级平滑扩容,做到 不动应用、不动数据、只换路径,像高速公路不停工修路一样,把流量悄悄引流到新库。整个过程用户无感知,API 不报错,订单不堆积,真正实现“在线热插拔”数据库。

1. 架构设计思想:解耦访问路径与物理实例

关键洞察是:让应用不再直连数据库物理 IP,而是通过虚拟 IP(VIP)间接访问

  • 虚拟 IP 就像“门牌号”,应用只知道门牌号,不知道背后是谁;
  • 物理数据库才是“住户”,可以随时更换,只要门牌挂着就行;
  • 只要控制“哪个门牌挂在哪台机器上”,就能实现流量切换。

引入“双虚 IP”机制,为每个老库配两个 VIP —— 相当于一个房子挂两个收件名,为后续“移花接木”预留操作空间。

类比:搬家前先给新家挂上旧地址的别名,等两边数据同步好了,再把快递员的收件表一更新,自然就送到新家了,全程无需通知用户。

2. 实施步骤详解:五步完成零感知扩容

步骤 1:初始状态(2 分库架构,单虚 IP 接入)

当前有 2 个分库,每库一主一备,高可用保障:

分库 主库物理 IP 备库物理 IP 虚拟 IP(VIP) 作用
0 192.168.1.10 192.168.1.101 VIP0 = 192.168.1.200 分库 0 的唯一 “门牌号”
1 192.168.1.11 192.168.1.102 VIP1 = 192.168.1.201 分库 1 的唯一 “门牌号”

应用层使用取模路由规则:


@Bean
public RouterAlgorithm routerAlgorithm() {
    return new InlineRouterAlgorithm("uid % 2"); // 根据 uid 决定走哪个 VIP
}
  • uid%2==0 → 访问 VIP0 → 实际指向 192.168.1.10(分库0)
  • uid%2==1 → 访问 VIP1 → 实际指向 192.168.1.11(分库1)

所有请求正常流转,系统稳定运行。Mermaid

步骤 2:前置准备 —— 给老库加“第二个门牌号”(双虚 IP 配置)

为每个老库新增一个备用虚拟 IP,形成“一库两 VIP”结构:

  • 分库 0:原有 VIP0(192.168.1.200),新增 VIP00(192.168.1.202)→ 同时指向老库;
  • 分库 1:原有 VIP1(192.168.1.201),新增 VIP11(192.168.1.203)→ 同样指向老库。

执行命令绑定新 IP 到网卡:


# 为旧分库0新增VIP00
ifconfig eth0:0 192.168.1.202 netmask 255.255.255.0 up
route add -host 192.168.1.202 dev eth0:0

效果达成:同一个物理数据库现在能响应两个不同 IP 的请求,相当于有了两个入口。这为下一步“把门牌挪到新库”提供了操作窗口,不中断服务的前提下完成过渡准备

步骤 3:搭建新库集群 + 开启双向数据同步

新建两个全新的数据库集群(分库2 和 分库3),结构完全对齐:

新分库 主库IP 备库IP 虚拟IP(VIP)
2 192.168.1.20 192.168.1.122 VIP2 = 192.168.1.204
3 192.168.1.21 192.168.1.123 VIP3 = 192.168.1.205

并立即开启 双主复制(双向同步)

  • 旧分库0 ↔ 新分库2 实时双向同步;
  • 旧分库1 ↔ 新分库3 实时双向同步。

核心目的:确保新老库之间的数据最终一致。无论是老库写入的新订单,还是新库收到的回滚消息,都能实时同步过去,避免切换时出现“查不到记录”的异常。

类比:搬家前先把新家装潢成和旧家一模一样,并且装个“镜像系统”,两边新增的东西自动复制,做到无缝衔接。

此时架构如下图所示:Mermaid

步骤 4:动态升级路由规则(逻辑扩容,流量仍走老库)

通过配置中心推送新的路由策略,无需重启应用,实现秒级生效。将原来的 uid % 2 升级为 uid % 4,映射关系扩展为四个分片:


@Bean
public Map<Integer, String> dbVipMap() {
    Map<Integer, String> map = new HashMap<>();
    map.put(0, "192.168.1.200"); // VIP0 → 旧分库0
    map.put(1, "192.168.1.201"); // VIP1 → 旧分库1
    map.put(2, "192.168.1.202"); // VIP00 → 当前仍指旧分库0
    map.put(3, "192.168.1.203"); // VIP11 → 当前仍指旧分库1
    return map;
}
@Bean
public RouterAlgorithm routerAlgorithm() {
    return new InlineRouterAlgorithm("uid % 4"); // 新路由规则
}

reload 后,路由效果如下:

用户ID取模 虚拟IP 实际指向的物理库 说明
uid % 4 = 0 VIP0: 192.168.1.200 旧分库0 保持原有路由
uid % 4 = 1 VIP1: 192.168.1.201 旧分库1 保持原有路由
uid % 4 = 2 VIP00: 192.168.1.202 旧分库0(暂时) 新增路由,目前指向旧库
uid % 4 = 3 VIP11: 192.168.1.203 旧分库1(暂时) 新增路由,目前指向旧库

关键理解:虽然逻辑上已经是 4 分片,但所有流量仍然落在两个老库上比如 uid%4=2 的请求会走 VIP00,而 VIP00 还挂在老库0 上,所以本质上是“两个库扛四份逻辑”。但这一步意义重大:

  • 完成了逻辑扩容,为后续分流打下基础;
  • 应用已适配新规则,随时可切;
  • 全程无重启、无抖动,耗时 < 10 秒。

步骤 5:切换虚 IP 指向(真正把门牌挂到新库上)

确认新老库数据一致后,进入最关键的“移花接木”阶段:将原来指向老库的“备用门牌”(VIP00、VIP11),迁移到新库上。执行操作:


# 下线老库上的 VIP00
ifconfig eth0:0 down
# 在新分库2 上绑定该 VIP
ssh 192.168.1.20 "ifconfig eth0:0 192.168.1.202 netmask 255.255.255.0 up"
ssh 192.168.1.20 "route add -host 192.168.1.202 dev eth0:0"

此时:

  • VIP00(192.168.1.202)→ 实际指向新分库2;
  • VIP11(192.168.1.203)→ 同理指向新分库3。

由于之前开启了双主同步,数据完全一致,因此应用访问新 IP 时读写无任何异常。

切换前后对比:从“逻辑扩容”到“物理分流”

路由映射变化对比

用户ID取模 虚拟IP 步骤4(切换前) 步骤5(切换后) 状态变化
uid % 4 = 0 VIP0 旧分库0 旧分库0 🟡 保持不变
uid % 4 = 1 VIP1 旧分库1 旧分库1 🟡 保持不变
uid % 4 = 2 VIP00 旧分库0 新分库2 已切换
uid % 4 = 3 VIP11 旧分库1 新分库3 已切换

流量分布变化

步骤4(切换前):- 旧分库0:承担 50% 流量(uid%4=0 + uid%4=2)- 旧分库1:承担 50% 流量(uid%4=1 + uid%4=3)- 新分库2/3:仅做数据同步,无业务流量步骤5(切换后):
- 旧分库0:承担 25% 流量(仅uid%4=0)- 旧分库1:承担 25% 流量(仅uid%4=1)- 新分库2:承担 25% 流量(uid%4=2)- 新分库3:承担 25% 流量(uid%4=3)成果达成:数据库实例从 2 个扩展到 4 个,负载均摊,性能翻倍提升,全程业务无中断、无丢数据、无感知。关键能力总结:
-  秒级切换:路由更新 + VIP 迁移,整体 < 30 秒;-  零停机:应用无需重启,连接不断开;-  数据不丢:依赖双主同步保证一致性;-  可灰度、可回滚:可先切部分流量验证,出问题快速切回。这就是大厂应对大促的核心底气——不是靠堆机器,而是靠架构设计的艺术用“双虚 IP + 动态路由”这套组合拳,把数据库扩容变成一次优雅的“热插拔”操作,彻底告别半夜停机维护的时代。

步骤5:切换虚IP指向完成后的架构状态

问题痛点:当数据库面临扩容需求时,传统停机迁移方案已无法满足现代高可用系统的要求。一旦操作不当,轻则服务中断,重则数据错乱甚至丢失——尤其是在流量高峰期间,任何“手动切流”都像是在刀尖上跳舞。更深层的问题是:如何在不改代码、不断连接、不丢数据的前提下,把用户流量精准无误地从旧库平移到新库?这就是分库扩容中最关键的“临门一脚”。核心方案:通过 虚拟IP(VIP)的动态重绑定,实现流量的无缝转移。本质是在网络层做了一次“热插拔”——让新的数据库集群对外“冒充”原库的网络身份,从而欺骗上游路由,完成静默切换。LangGraph 风格类比:这就像 AI 流程中的“节点跳转”,只不过这里的“节点”是物理数据库,“状态”是 VIP 的归属,“边”是路由规则。我们不是靠重启应用来换路径,而是靠改变网络拓扑本身来引导流量走向。Mermaid

图解说明:此时,虚拟IP00 和 虚拟IP11 已从旧库剥离,重新绑定到新库。但应用仍按 uid%4 规则访问相同的 VIP 地址,完全不知后端已悄然换血。

技术实现细节(自动化脚本级控制):


# 1. 解绑旧分库0上的虚拟IP00(停止对外服务)
ssh root@192.168.1.10 "ifconfig eth0:0 down"
# 2. 绑定虚拟IP00到新分库2(赋予新库“身份”)
ssh root@192.168.1.20 "ifconfig eth0:0 192.168.1.202 netmask 255.255.255.0 up"
ssh root@192.168.1.20 "route add -host 192.168.1.202 dev eth0:0"
# 3. 解绑旧分库1上的虚拟IP11
ssh root@192.168.1.11 "ifconfig eth0:1 down"
# 4. 绑定虚拟IP11到新分库3
ssh root@192.168.1.21 "ifconfig eth0:0 192.168.1.203 netmask 255.255.255.0 up"
ssh root@192.168.1.21 "route add -host 192.168.1.203 dev eth0:0"

关键点:- 所有命令必须封装为原子化脚本,支持一键执行 + 一键回滚;- 切换前需校验双主同步延迟(如 Seconds_Behind_Master = 0);- 操作窗口选择低峰期,并配合监控告警联动。切换完成后的真实效果:流量自动分流:- uid%4=0,1 → 仍走旧库(虚拟IP0/1未动)- uid%4=2,3 → 实际写入新库(因VIP00/VIP11已漂移)业务无感知:- 应用层无需重启、无需发版- TCP长连接不受影响(IP不变,只是MAC变了)- 用户正在下单、支付等操作不会中断最终达成:数据写入位置变了,但用户根本不知道 —— 真正做到了“静默升级”。
至此,系统进入“混合运行”阶段:一半流量在老库,一半在新库,数据双写受控,为最终拆分清理打下坚实基础。

高频问题标准答案(直接套用)

问题 1:1 亿数据量的 MySQL,如何实现秒级平滑扩容?

问题痛点:当 MySQL 数据量冲上 1 亿+,单库早已不堪重负。查询慢如蜗牛、写入排队卡顿,TPS 直接腰斩更要命的是——系统不能停!业务 7×24 小时在线,订单、支付、用户行为源源不断。哪怕停机 5 分钟,轻则用户投诉刷屏,重则资损百万。核心方案:必须用 “双虚 IP + 动态路由” 架构,实现真正的 零感知、秒级扩容简单来说:在数据库前面加一层“智能网关”,通过两个虚拟 IP(VIP)做流量调度——

  • 一个指向旧库(原集群)
  • 一个指向新库(扩容后分片集群)

迁移期间,读写流量由路由层动态控制,比如先切 1% 流量到新架构,验证无误后逐步灰度,最终全量切换。整个过程应用无感,连接不中断,事务不丢失。就像高速公路不停工改道:

  • 先修辅路(新库)→ 设置分流指示牌(路由规则)→ 分批导流 → 最终主路封闭(旧库下线)。
  • 全程车辆照跑,没有堵车。

关键能力:

  • 零停机
  • 可回滚(出问题立刻切回)
  • 精细化灰度控制
  • 与分库分表无缝集成

这不是简单的数据迁移,而是一场 高可用架构级别的手术。普通手段治标,这套组合拳才真正治本。

问题 2:对比停服扩容、双写扩容、ShardingSphere 扩容、双虚 IP 扩容的适用场景?

问题痛点:扩容方案 选错一个,轻则服务抖动,重则数据错乱、资金异常、客诉暴雷核心方案:别迷信技术先进性,关键是 根据公司阶段和技术水位对症下药扩容不是炫技,而是稳字当头。要问清楚三个问题:(1) 能不能接受停机?(2) 是否已有中间件能力?(3) 团队有没有足够运维支撑力?答案不同,路径完全不同。一句话总结:小公司能停就停,中型靠中间件,大厂才玩“无缝切换”

方案 适合谁? 核心特点 风险提示
停服扩容 初创项目 / 内部系统 实现简单,脚本导数据就行
⏱️ 停机维护窗口搞定
用户不可用,只适合非核心业务
双写扩容 社交App / 内容平台 不停机
 新旧库同时写入
️ 容易丢数据、难回滚
️ 一致性难保证
ShardingSphere 扩容 电商 / 金融核心链路 不改代码
 支持动态扩缩容
 路由透明化
️ 运维复杂度上升
 需专人维护
双虚 IP 扩容 大促级系统(如双11) 秒级切换,用户无感
 精准灰度控制
架构成本高
🧠 仅建议技术强的大厂使用

问题 3:水平分库扩容时,如何避免数据倾斜?

问题痛点:分库之后发现:有的库 CPU 90%,有的才 20% —— 明明是“负载均衡”,怎么变成“贫富差距”了?根源在于:数据分布不均,请求全压在一个节点上。结果就是——其他库闲着吃灰,系统照样雪崩。这就像四个外卖骑手,三个喝茶打游戏,一个狂送 50 单,累到住院。核心方案:解决数据倾斜,关键就两点:选对分片键 + 用对打散算法不能随便拿个字段就分库,更不能按“看起来合理”的方式切数据。

正确姿势三步走:

1 选对分片键(Shard Key)推荐字段:uidorder_nouser_id —— 几乎全局均匀、高频查询绝对避坑:gender(男女比例失衡)、province(北上广深集中)、status(大部分是“待支付”)2  用哈希,别用范围分片hash(uid) % N —— 类似洗牌,把数据随机打散到各库按时间分库(如每月一个库)—— 新库永远爆满,老库存档没人碰3  持续监控 + 快速响应上 Grafana 看板,盯紧每个库的:

  • 数据条数差异(超过 20% 就预警)
  • QPS/TPS 分布
  • 主从延迟

发现某个库数据量是平均值的 1.8 倍?立刻排查是否分片逻辑出 bug!

转自

https://mp.weixin.qq.com/s/KrbsOXfjC1n7EUXXtFWz0A

posted @ 2025-11-28 10:53  paul_hch  阅读(12)  评论(0)    收藏  举报