公司动态

数据迁移项目的踩坑复盘:亿级数据从 MySQL 到 TiDB 的平滑迁移

📅 2026/7/22 10:11:32
数据迁移项目的踩坑复盘:亿级数据从 MySQL 到 TiDB 的平滑迁移
数据迁移项目的踩坑复盘亿级数据从 MySQL 到 TiDB 的平滑迁移一、迁移背景与方案选择我们核心业务的订单表在过去三年中从 300 万行增长到 1.8 亿行MySQL 的分库分表方案逐渐力不从心。跨分片查询需要应用层聚合运维复杂度随着分片数量线性增长。同时业务需要实时数据分析能力而 MySQL 的 OLAP 查询在亿级数据量下经常超时。经过三个月的技术选型和 POC 验证我们决定将订单相关数据迁移到 TiDB。选择 TiDB 的核心考量有三点兼容 MySQL 协议和生态迁移改造成本最低原生分布式架构支持弹性扩缩容未来 3~5 年的增长无需担心HTAP 能力可以在一套集群中同时支撑 OLTP 和 OLAP 场景无需额外构建数据仓库。二、迁移工具链的选型与实践我们选择的工具链是 Dumpling全量导出 TiDB Lightning高速导入 TiCDC增量同步。这套组合是 TiDB 官方推荐的迁移方案但实际使用中仍有不少细节需要处理。Dumpling 导出环节配置了 16 个并发线程和 64MB 的文件大小分片。1.8 亿行数据导出为约 4200 个 SQL 文件总耗时 2.3 小时。需要注意的关键点是必须在业务低峰期执行并开启一致性快照--snapshot参数指定时间戳确保导出的数据是某个时间点的一致性视图。Lightning 导入环节选择 Local 模式以获取最大吞吐。16 个 TiKV 节点的集群配置下Lightning 的导入速度达到 38000 rows/s全量导入耗时约 80 分钟。这里踩过一个坑Lightning 导入期间如果 TiKV 的 compaction 跟不上写入会急剧下降。解决方法是在导入前手动触发一次全量 compaction并将region-split-size调整为 256MB。TiCDC 增量同步是最关键的环节。Lightning 完成全量导入后TiCDC 开始从 Dumpling 的快照时间点捕获 MySQL binlog 增量。我们的 binlog 格式设置为 ROW 模式确保 TiCDC 能正确解析每行变更。/** * 数据一致性校验服务——迁移后的行级比对 */ Service public class DataConsistencyChecker { private static final int BATCH_SIZE 50000; private static final int COMPARE_THREADS 8; Resource private JdbcTemplate mysqlTemplate; Resource private JdbcTemplate tidbTemplate; /** * 基于主键分片的行级数据比对 */ public ConsistencyReport checkTable(String tableName, long minId, long maxId) { ExecutorService executor Executors.newFixedThreadPool(COMPARE_THREADS); ListCompletableFutureSegmentResult futures new ArrayList(); long segmentSize (maxId - minId) / COMPARE_THREADS; for (int i 0; i COMPARE_THREADS; i) { long startId minId i * segmentSize; long endId (i COMPARE_THREADS - 1) ? maxId : startId segmentSize; futures.add(CompletableFuture.supplyAsync( () - compareSegment(tableName, startId, endId), executor)); } ConsistencyReport report new ConsistencyReport(); for (CompletableFutureSegmentResult future : futures) { try { SegmentResult result future.get(30, TimeUnit.MINUTES); report.merge(result); } catch (TimeoutException e) { log.error(比对超时tableName{}, tableName); report.markIncomplete(); } catch (Exception e) { log.error(比对异常tableName{}, tableName, e); report.markIncomplete(); } } executor.shutdown(); log.info(表{}比对完成: 一致{}, 差异{}, 缺失{}, tableName, report.getMatchCount(), report.getDiffCount(), report.getMissingCount()); return report; } private SegmentResult compareSegment(String tableName, long startId, long endId) { SegmentResult result new SegmentResult(); long lastId startId; while (lastId endId) { long batchEnd Math.min(lastId BATCH_SIZE, endId); // 从MySQL读取一个批次的数据 MapLong, String mysqlData loadBatch(mysqlTemplate, tableName, lastId, batchEnd); // 从TiDB读取同一批次的数据 MapLong, String tidbData loadBatch(tidbTemplate, tableName, lastId, batchEnd); // 逐行比对 for (Map.EntryLong, String entry : mysqlData.entrySet()) { Long id entry.getKey(); String mysqlValue entry.getValue(); String tidbValue tidbData.get(id); if (tidbValue null) { result.addMissing(id); } else if (!Objects.equals(mysqlValue, tidbValue)) { result.addDiff(id, mysqlValue, tidbValue); } else { result.addMatch(id); } } lastId batchEnd; } return result; } private MapLong, String loadBatch(JdbcTemplate template, String tableName, long startId, long endId) { String sql String.format( SELECT id, MD5(CONCAT_WS(|, %s)) AS row_hash FROM %s WHERE id ? AND id ?, getColumnList(tableName), tableName); MapLong, String result new LinkedHashMap(); template.query(sql, rs - { result.put(rs.getLong(id), rs.getString(row_hash)); }, startId, endId); return result; } }三、灰度切流与回滚预案切换阶段的设计原则是任何环节都必须可回滚。我们的切流方案分为四个阶段阶段一双写验证持续 3 天。应用层同时写入 MySQL 和 TiDB读操作仍走 MySQL。通过数据一致性校验工具对比两边的数据发现差异后立即修复。这一阶段发现了两个问题一是 TiDB 的事务隔离级别与 MySQL 的差异导致部分并发写入产生了轻微的顺序差异二是个别表的自增 ID 在 TiDB 上分配策略不同需要调整为 AUTO_RANDOM。阶段二10% 灰度读持续 1 天。随机选取 10% 的用户将查询路由到 TiDB监控延迟和错误率。如果出现异常如 P99 延迟超过 200ms 或错误率超过 0.1%自动切回 MySQL。阶段三逐步放量持续 2 天。按 10% → 50% → 100% 的节奏扩大读 TiDB 的比例每次放量后观察监控至少 2 小时。阶段四MySQL 停写下线。确认 TiDB 稳定运行 72 小时后停止对 MySQL 的写入完成最终的数据一致性校验正式下线 MySQL 源库。/** * 流量路由控制——支持动态切换MySQL/TiDB数据源 */ Component public class MigrationRouter { Resource Qualifier(mysqlDataSource) private DataSource mysqlDataSource; Resource Qualifier(tidbDataSource) private DataSource tidbDataSource; /** * 根据用户ID哈希决定读流量走向 */ public DataSource routeRead(Long userId) { MigrationConfig config getMigrationConfig(); if (config.isReadAllTiDB()) { return tidbDataSource; // 全量切换到TiDB } // 按用户ID取模实现灰度比例 int hash Math.abs(userId.hashCode()) % 100; if (hash config.getReadPercent()) { return tidbDataSource; } return mysqlDataSource; } /** * 写操作双写阶段同时写两端TiDB写入失败不影响主流程 */ public void dualWrite(Long userId, Runnable writeOperation) { // 主库写入当前仍为MySQL writeOperation.run(); // 副库异步写入TiDB异常不影响主流程 CompletableFuture.runAsync(() - { try { DataSourceHolder.set(tidbDataSource); writeOperation.run(); } catch (Exception e) { log.error(TiDB双写失败userId{}, userId, e); // 记录双写异常用于后续修复 dualWriteFailureRepository.record(userId, e.getMessage()); } finally { DataSourceHolder.clear(); } }); } }四、性能对比与踩坑记录迁移完成后我们对比了 MySQL 和 TiDB 在相同数据规模下的性能表现。在 TP 场景点查、小范围查询中TiDB 的 P99 延迟略高于 MySQL约高 15%~20%这是分布式数据库的固有开销但仍在业务可接受范围内 10ms。在 AP 场景聚合查询、跨月报表中TiDB 的性能提升显著月度营收报表的查询时间从 47 秒降至 2.3 秒。踩坑记录中最值得分享的几条TiDB 不支持存储过程和触发器迁移前需要将业务逻辑中的应用层代码替代AUTO_INCREMENT在 TiDB 中并非全局单调递增需要业务层不依赖 ID 的顺序语义大事务 100MB在 TiDB 中的性能远不如小批量提交建议事务大小控制在 10MB 以内。五、迁移复盘与经验提炼这次迁移从方案设计到最终下线 MySQL 源库历时 4 个月。核心经验归纳为三点第一充分验证是降低风险的关键双写验证阶段发现的 3 个兼容性问题如果进入生产后果严重第二渐进式切换比大爆炸式切换安全百倍灰度机制和自动回滚是底线保障第三迁移不只是数据搬家而是代码重构的契机存储过程到应用代码的迁移、自增 ID 到雪花 ID 的替换本质上是技术债清理。作者李然程序员鸭梨Java 架构师专注数据架构与企业级系统迁移实践。