京东二面: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,保障迁移过程可追踪、可回溯、不出错。流程解读:从用户通知开始,到最终链路验证结束,全流程闭环管理。每个节点都必须确认无误才能进入下一步,杜绝“跳步”带来的风险。
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 + 对账服务)定期校验新旧库关键表的数据差异。
核心流程图
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_2、order_db_3,与原有order_db_0、order_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=10086(10086%2=0→ 主写order_db_0),同时异步写入10086%4=2→order_db_2; - 新订单
uid=10087(10087%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字段 → 枚举值少,分布极不均匀
正确选择原则(口诀记牢):
高频查、分布匀、别为空、尽量小
推荐字段:uid、order_id(雪花ID)、shop_id 避免字段:status、type、is_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)
所有请求正常流转,系统稳定运行。
步骤 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 实时双向同步。
核心目的:确保新老库之间的数据最终一致。无论是老库写入的新订单,还是新库收到的回滚消息,都能实时同步过去,避免切换时出现“查不到记录”的异常。
类比:搬家前先把新家装潢成和旧家一模一样,并且装个“镜像系统”,两边新增的东西自动复制,做到无缝衔接。
此时架构如下图所示:
步骤 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 的归属,“边”是路由规则。我们不是靠重启应用来换路径,而是靠改变网络拓扑本身来引导流量走向。
图解说明:此时,虚拟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)推荐字段:uid、order_no、user_id —— 几乎全局均匀、高频查询绝对避坑:gender(男女比例失衡)、province(北上广深集中)、status(大部分是“待支付”)2 用哈希,别用范围分片hash(uid) % N —— 类似洗牌,把数据随机打散到各库按时间分库(如每月一个库)—— 新库永远爆满,老库存档没人碰3 持续监控 + 快速响应上 Grafana 看板,盯紧每个库的:
- 数据条数差异(超过 20% 就预警)
- QPS/TPS 分布
- 主从延迟
发现某个库数据量是平均值的 1.8 倍?立刻排查是否分片逻辑出 bug!
转自
https://mp.weixin.qq.com/s/KrbsOXfjC1n7EUXXtFWz0A

浙公网安备 33010602011771号