切库不停机:一套分库分表灰度方案的设计与踩坑记录
这套方案的核心思路:在原库和新分片集群之间加一层动态路由,通过配置中心实时切换数据源,让业务流量逐步从旧库流向新库。 我们在订单系统上跑了两个月,切了 4 个核心表,没有一次停机窗口。
问题的起点:为什么不能停机
我们的订单系统从 2021 年上线就是单库单表,到 2023 年 6 月,订单主表已经膨胀到 2.1 亿行,单表大小 87GB。高峰期 MySQL 的 CPU 使用率经常飙到 85%,慢查询一天超过 3000 条。分库分表势在必行,但业务方给的条件很苛刻:全年只有春节那 3 天允许凌晨停机,其他时间必须在线切换。
这意味着传统的“停机—导数据—切库—验证—开服”路线走不通。我们只能做在线灰度切流,让旧库和新分片集群同时存活一段时间,按某种粒度逐步把流量切过去。
这个粒度的选择是第一个关键决策。我们评估了三种:用户维度、租户维度、业务单据维度。订单表查询场景主要是买家看自己订单和商家看店铺订单,用户 ID 和商家 ID 天然适合做分片键,所以最终决定按用户 ID 取模来切流——先切 1% 的用户流量到新库,观察几天,再逐步放量到 10%、30%、50%,直到 100%。
方案设计:三层架构
整个灰度系统分三层:路由决策层、配置管理层、数据同步层。
路由决策层的核心是一个切面拦截器,在 DAO 层之前拦截所有 SQL,根据当前请求的 userId 计算出它属于哪个灰度桶,再决定走旧数据源还是新数据源。这里的关键是“计算灰度桶”的逻辑必须与分片键算法完全一致,否则会出现一个用户一部分操作落在旧库、一部分落在新库的诡异问题。
public class GrayRouteInterceptor implements MethodInterceptor {
private GrayConfigService grayConfigService;
private DataSourceManager dataSourceManager;
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
// 从方法参数中提取 userId
Long userId = extractUserId(invocation.getArguments());
if (userId == null) {
// 没有 userId 的查询走旧库
return invocation.proceed();
}
// 计算灰度桶
int bucket = (int) (userId % 100);
GrayRouteConfig config = grayConfigService.getCurrentConfig();
if (config.isInGrayBucket(bucket)) {
// 切换到新分片数据源
String shardKey = ShardStrategy.calculate(userId, config.getShardCount());
DataSource ds = dataSourceManager.getNewDataSource(shardKey);
DynamicDataSourceHolder.set(ds);
}
try {
return invocation.proceed();
} finally {
DynamicDataSourceHolder.clear();
}
}
}
配置管理层用的是 Apollo,存储一个简单的 JSON 配置:
{
"tableName": "orders",
"grayBuckets": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
"shardCount": 32,
"shardKeyField": "user_id",
"switchTime": "2024-01-15T10:30:00Z"
}
grayBuckets 是个数组,存的是 userId 对 100 取模后的哪些桶已经切到新库。最开始是空数组,所有流量走旧库。当我们把 [0, 1] 加进去,userId % 100 落在 0 和 1 这两个桶的用户就会走新分片集群。这个配置直接通过 Apollo 的实时推送生效,不需要重启应用,切换延迟在 2 秒以内。
数据同步层是整套方案里最复杂、也最容易出问题的一环。我们用了 Canal 做增量同步,监听旧库的 binlog,解析后写入新分片集群。全量数据则用 DataX 做了一次离线同步,在凌晨低峰期跑的,耗时 4 小时 37 分,同步了 2.1 亿行数据。全量同步完成的时间点记作 T0,Canal 从 T0 之前的 binlog 位点开始回放,保证不丢数据。
踩过的坑
第一个坑:全量同步时的新库写入与增量回放冲突。 DataX 往新库写数据时,Canal 也在同时回放 binlog,导致新库里出现主键冲突。原因是 DataX 的 insert 和 Canal 回放的 insert 操作的是同一条数据。解决办法是让 DataX 用 INSERT IGNORE 写入,并在全量同步期间暂停 Canal 的 INSERT 操作回放,只回放 UPDATE 和 DELETE。等全量同步完成,再打开 INSERT 回放。这个控制逻辑我们写在了 Canal 的 adapter 层,通过读取 Apollo 的一个开关 full.sync.in.progress 来决定是否跳过 INSERT 事件。
第二个坑:切流瞬间的“双写”问题。 当一个用户从旧库切到新库的那一瞬间,如果它的请求刚好跨了切换边界,可能出现前一个事务写旧库、后一个事务写新库的情况。比如用户下单时,订单创建走旧库,但库存扣减因为路由切换走了新库,两边的数据不一致。我们的解法不是在应用层做分布式事务——那太重了——而是将切换粒度从“单个用户”改为“用户取模桶”,并且保证同一个桶内的所有用户要么全在旧库、要么全在新库。 这样至少桶内用户的数据是一致的。跨桶的场景(比如商家查看不同用户的订单)通过 ES 搜索聚合来解决,ES 这里同步了两份数据,一份来自旧库、一份来自新库。
第三个坑:Canal 同步延迟导致的“查不到数据”。 用户刚在旧库下了一单,紧接着刷新订单列表,此时路由已经切到新库,但 Canal 还没把这条订单同步过来,用户看到订单“丢了”。这个问题本质上是因为切换是瞬间的,但同步是异步的。我们的做法是:在切流时,先把某个桶标记为“预灰度”状态,这个状态下读操作仍然走旧库,但写操作同时写旧库和新库(双写)。等 Canal 延迟降到 0 并稳定 10 分钟后,再把读操作也切到新库。这个双写阶段通常持续 30 分钟,足以覆盖 Canal 的同步延迟。
public enum GrayStatus {
NORMAL, // 正常走旧库
PRE_GRAY, // 双写阶段:写新旧两库,读旧库
GRAY, // 完全切到新库
ROLLBACK // 回滚中
}
第四个坑:分片键的选择变更。 最开始我们按 userId 分片,灰度切流也是按 userId 取模。但切到 30% 流量时,DBA 发现新集群的负载分布很不均匀——因为那 30% 的用户里刚好有几个大商户,订单量远超普通用户。我们紧急调整了分片策略,从单纯按 userId 哈希改为按 userId 和 tenantId 联合分片。这个改动导致灰度路由的逻辑也要同步修改,但老数据的分片键已经定了,改不了。最后的妥协方案是:新分片策略只对切流之后的新数据生效,老数据保持原来的分片键,在路由层加了一个判断——如果查询条件带时间范围,并且时间在切换之后,走新分片逻辑;否则走老分片逻辑。这个补丁代码到现在还没删干净。
回滚机制
灰度切流最大的好处是随时可以回滚。我们把 Apollo 配置里的 grayBuckets 数组减少几个桶,应用层就会把这些桶的流量切回旧库。但这里有个前提:新库的数据必须反向同步回旧库,否则回滚后用户会看到数据丢失。
我们做了一层反向 Canal 同步,从新分片集群的 binlog 同步回旧库。这个反向同步通道在灰度期间一直是开启的,确保新旧两库的数据实时双向同步。回滚时只需要改配置,不需要做额外的数据补偿。实际演练中,从决定回滚到全部流量回到旧库,耗时 37 秒。
最终效果
整个灰度切换从第一个桶切出去到最后一个桶切完,历时 22 天。期间经历了 4 次小规模回滚(都是因为新库查询性能不达预期,调整索引后重新切),没有影响线上业务。切完后旧库的 QPS 从峰值 18000 降到了 0,MySQL 的 CPU 使用率从 85% 降到了 12%。
常见问题
为什么不直接用 ShardingSphere 的读写分离加数据分片,而要自己写路由?
ShardingSphere 5.3 确实支持分片和读写分离,但它的数据源切换是基于配置文件的静态路由,没法做到按用户维度实时灰度。我们需要的核心能力是“同一个表同时存在两个数据源集群,按业务规则动态切换”,这个在现有开源方案里没有直接支持。我们评估过用 ShardingSphere 的 HintManager 强制路由,但需要在每个 SQL 调用的地方手动传分片键,侵入性太强。
双写阶段如果新库写入失败怎么办,会不会丢数据?
我们做了两层容错。第一层是应用层的 try-catch,新库写入失败只打日志和发告警,不阻塞旧库的正常写入。第二层是 Canal 的补偿机制,因为旧库写入成功,binlog 里会有这条数据,Canal 会在后续回放时把这个写入重放到新库。所以即使双写阶段新库写入失败,最坏情况是数据延迟同步,不会丢失。
灰度期间数据一致性怎么验证?
我们写了一个定时任务,每 10 分钟从新旧两库各取 1000 条相同 userId 的数据做全字段比对。比对结果写入一个差异表,人工每天 Review。整个灰度期间发现了 47 条不一致数据,全是 Canal 同步时的字段类型转换问题(旧库的 datetime(0) 和新库的 datetime(3) 精度不同导致比对误报),没有真正的数据丢失。