摘要:你有没有过这样的经历?负责的项目用户量涨到百万级后,老数据库的 “历史遗留问题” 突然爆发 —— 就像我们团队前段时间遇到的:用户表和技师表混存,单表数据超 400 万条,每次查询都要等 3 秒以上,高峰期甚至直接触发数据库熔断。更头疼的是,业务不能停,老板还
你有没有过这样的经历?负责的项目用户量涨到百万级后,老数据库的 “历史遗留问题” 突然爆发 —— 就像我们团队前段时间遇到的:用户表和技师表混存,单表数据超 400 万条,每次查询都要等 3 秒以上,高峰期甚至直接触发数据库熔断。更头疼的是,业务不能停,老板还催着 “一周内解决”,当时真的有种 “被逼到墙角” 的感觉。
如果你也正在面对 “高数据量 + 零停机” 的数据迁移难题,别慌!今天把我们从踩坑到落地的完整方案分享给你,从方案设计到代码实现,每一步都附详细说明,看完就能直接用在项目里。
可能有朋友会问:“数据查询慢,加个索引不就行了?” 一开始我们也是这么想的,但实际操作后发现根本行不通 ——
老表的结构是 5 年前设计的,用户基本信息、订单关联数据、技师服务记录全堆在一张表里,字段多达 87 个。之前加过 3 个联合索引,结果写入性能暴跌:高峰期每秒 200 + 的下单请求,有 30% 会超时。更危险的是,随着用户量每月 15% 的增长,单表已经快触及 MySQL 的性能上限,再拖可能会出现数据丢失风险。
这时候我们才确定:必须做分表迁移,而且要 “零停机”—— 总不能让用户半夜起来发现 APP 用不了吧?
我们最终确定的方案是 “双写 + 灰度切换”,简单说就是先让新老表同时写入数据,再逐步把读请求切到新表,最后下线老表。整个过程分 5 步,每一步都有明确的校验标准,避免出问题。
老表的核心问题是 “结构混乱 + 数据量太大”,所以新表设计要解决这两个问题:
分表策略:按用户 ID 哈希分表,分成 8 张表(user_info_0 到 user_info_7),每张表数据量控制在 50 万以内,MySQL 性能最佳;字段精简:把原来的 87 个字段拆成 3 张表:用户基本信息表(28 个字段)、用户订单关联表(12 个字段)、技师服务记录表(15 个字段),减少关联查询;冗余字段:把高频查询的 “用户等级”“会员到期时间” 冗余到订单关联表,避免跨表查询,这一步让后续查询速度提升了 60%。这里提醒大家:新表设计一定要和业务同学对齐,比如我们一开始漏了 “技师评分” 这个高频字段,后来补字段又花了 2 天,耽误了进度。
双写就是让每次数据写入(新增 / 修改 / 删除)同时操作新老表,这里的关键是 “加开关 + 异常重试”,避免新表出问题影响老业务。
我们在配置中心加了一个开关(double_write_switch),默认关闭,打开后才会执行双写逻辑。核心代码如下(Java 示例):
// 核心双写逻辑:先写老表,再写新表,老表成功才算整体成功public boolean saveUser(UserDTO userDTO) { // 1. 先写老表(保证老业务不受影响) boolean oldTableSuccess = oldUserMapper.insert(userDTO); if (!oldTableSuccess) { log.error("老表写入失败,用户ID:{}", userDTO.getUserId); return false; } // 2. 判断双写开关是否打开,打开则写新表 if (ConfigCenter.getBoolean("double_write_switch", false)) { try { // 转换老表DTO到新表PO UserInfoPO userInfoPO = UserConvert.toNewPO(userDTO); // 计算分表索引:用户ID哈希取模8 int tableIndex = Math.abs(userDTO.getUserId.hashCode) % 8; // 写对应分表 boolean newTableSuccess = newUserMapper.insert(tableIndex, userInfoPO); if (!newTableSuccess) { // 新表写入失败,加入重试队列(用RabbitMQ实现) retryQueue.send("new_user_table_retry", userInfoPO, 3); // 最多重试3次 log.warn("新表写入失败,已加入重试队列,用户ID:{}", userDTO.getUserId); } } catch (Exception e) { log.error("新表写入异常,用户ID:{}", userDTO.getUserId, e); // 异常不影响老表,避免阻断业务 } } return true;}这里有个细节:新表写入失败不会影响老表,而是加入重试队列,后续有专门的定时任务处理重试,我们当时设置的重试间隔是 5 分钟,确保最终数据一致。
新表设计好、双写逻辑上线后,就需要把老表的历史数据迁移到新表。我们用的是 “分批次迁移 + 实时校验”,避免一次性迁移压力太大。
迁移工具:用 Python 写了迁移脚本,每次迁移 1 万条数据,间隔 10 秒,避免占用过多数据库资源;迁移顺序:先迁移 3 个月前的历史数据(读写量小),再迁移近 3 个月的数据,最后迁移近 7 天的数据(读写量最大,放在凌晨 2 点执行);数据校验:每次迁移完一批,就对比新老表的 “数据量 + 关键字段”,比如用户数、订单数是否一致,关键字段(如余额、会员等级)是否有差异。我们当时迁移 400 万数据花了 3 天,中间发现有 128 条数据因为老表字段为空导致迁移失败,后来手动补全后才通过校验。这里建议大家:迁移前一定要做数据清洗,比如空值、特殊字符处理,不然会很麻烦。
数据迁移完成后,就可以把读请求逐步切到新表了。我们用的是 “按比例灰度”,从 10% 开始,没问题再升到 30%、50%、80%,最后 100%,整个过程花了 4 天。
切换的关键是 “实时监控”,我们在 APM 工具(如 SkyWalking)里加了两个监控指标:
新表查询成功率:必须保持 99.9% 以上,低于这个值就回滚;新表查询耗时:要求平均耗时低于 300ms,高于 500ms 就暂停切换,排查原因。比如切换到 30% 时,我们发现新表的 “用户等级查询” 耗时突然升到 800ms,查了才知道是忘了给 “user_level” 字段加索引,加完索引后耗时降到 120ms,才继续切换。
什么时候能下线老表?我们定了 3 个条件:
读请求切到新表后稳定运行 72 小时,没有任何异常;新表的写入和查询性能都达标(写入 TPS≥300,查询耗时≤300ms);业务同学确认所有依赖老表的接口都已迁移到新表,没有遗漏。最后我们在一个周末凌晨 3 点下线了老表,整个迁移过程零停机,用户完全没感知 —— 这也是我们最自豪的一点。
迁移完成后,我们从 “数据一致性” 和 “业务性能” 两个维度做了验证,结果比预期还好:
用 Python 写了校验脚本,对比新老表的所有数据:
总数据量:402.3 万条,新老表完全一致;关键字段校验:随机抽取 1 万条数据,对比 “余额”“会员到期时间” 等核心字段,准确率 100%;异常数据处理:老表中的 237 条重复数据,迁移时按 “最新时间” 合并,符合业务规则。查询速度:原来的 “用户订单列表查询” 从 3.2 秒降到 0.4 秒,提升 87.5%;写入性能:高峰期写入 TPS 从 200 + 提升到 500+,超时率从 30% 降到 0.5% 以下;服务器负载:数据库服务器 CPU 使用率从 75% 降到 35%,内存占用减少 40%。更意外的是,迁移完成后,用户投诉量下降了 22%—— 原来很多用户之前吐槽 “APP 卡顿”,现在问题解决了,体验自然好了。
这次迁移我们前后花了 15 天,踩了不少坑,总结出 5 个关键点,如果你也要做类似迁移,一定要注意:
我们一开始只加了一个总开关,后来发现想单独测试 “新表查询” 很麻烦,又补了 “read_switch”“write_switch” 两个开关,建议一开始就按 “读 + 写 + 迁移” 三个维度加开关,灵活度更高。
迁移过程中一定要实时校验,我们第一天迁移了 50 万数据,没及时校验,后来发现有 1 万条数据因为 “用户 ID 为空” 没迁移成功,又重新迁了一遍,多花了半天时间。
新表的索引别自己拍脑袋加,我们一开始给新表加了 5 个索引,DBA 看了说 “太多了,写入会慢”,最后减到 3 个,性能反而更好。
我们一开始想 2 天完成灰度切换,结果切换到 50% 时,发现有个老接口还在查老表,赶紧回滚,后来花了 4 天慢慢切,反而更稳。记住:技术方案不怕慢,就怕出问题。
迁移完成后,我们把新表结构、分表策略、双写逻辑都写成了文档,还录了个 10 分钟的讲解视频,后来新同事接手相关业务时,很快就懂了,省了不少沟通成本。
这次 400 万用户数据零停机迁移,虽然过程有点曲折,但最终结果还是不错的。不知道你有没有做过类似的数据迁移?遇到过哪些坑?比如有没有因为分表策略没选好,导致后续扩展麻烦?或者有没有更好的零停机方案?欢迎在评论区分享你的经验,咱们一起交流进步!
来源:从程序员到架构师
