# 写在前面 在我们的后端架构中,我们采用了**以游戏为租户单位的多租户数据库设计**。所有游戏共享同一套数据库 schema,每张表通过 `game_id` 字段进行逻辑隔离。这种设计在初期带来了显著的开发与维护效率,支持我们快速迭代、快速上线多个游戏项目。 然而,随着接入的游戏数量增加、业务数据持续增长,这种“共享单表”的设计逐渐暴露出一些架构层面的瓶颈与隐患。 - 某些高流量的游戏影响到了其他本应“风平浪静”的项目;一些核心表的索引开始变得笨重,甚至拖慢了查询性能; - 数据分析时需要频繁在庞大的表中筛选特定游戏数据,效率极低。 更不用说,MySQL 的单表上限正一步步逼近我们曾以为遥远的边界。 我们意识到,曾经高效的“共享单表”策略,已经成为了未来扩展的绊脚石。于是,在年初,我们启动了一项重要的系统演进计划:**按服务维度进行数据库分表改造**。 这不仅是一次技术重构,更是系统架构从“共享瓶颈”走向“弹性隔离”的转型。本篇将完整记录我们这次数据库分表的路径与实践细节——希望对你有所启发。 # 实施原则与总体策略 数据库分表,不仅是表结构的变化,更是一套**兼容旧有系统、可渐进式切换、可回滚**的工程设计。 在正式动手之前,我们就明确了三项核心原则: 1. **不影响线上服务运行,兼容已有功能** 2. **支持灰度切换,逐步从旧表过渡到新表** 3. **设计可回滚机制,出现问题可以随时切换回旧表** 基于这三条,我们制定了如下的整体方案: 1. 我们将整个数据库分表的工作拆分为多个阶段,每次只处理一个服务的分表任务,避免全系统改动带来的风险,从影响面最小的服务开始进行实施,每个服务独立执行迁移程序,单独验证,按需上线。 2. 为了兼容旧表与新表,我们在每个服务内部引入了 `db_mode` 配置项,**按游戏粒度控制读写行为**: - `write_mode`: - `old_table`:仅写旧表 - `dual_write`:写入旧表 + 新表(事务) - `new_table`:仅写新表 - `read_mode`: - `old_table`:仅从旧表读取 - `dark_read`:主用旧表读取,同时异步比对新表 - `new_table`:仅从新表读取 这套机制允许我们**按游戏逐个切换**,并且在 write_mode 修改为 new_table 前,都可以进行回滚。 3. 自动建表:创建游戏即创建对应表 我们在后台创建游戏时,会自动触发分表建表逻辑: 这样,我们无需人工干预即可支持新游戏分表,游戏接入的流程保持不变。 4. 数据访问层改造 我们不改动业务代码,只修改数据库访问层。 根据 db_mode 配置,进行不同表的数据库访问层操作。 5. 内嵌迁移程序 每个服务内置一个迁移命令行程序,支持: - dry-run 模式比对数据差异 - 支持幂等多次执行 - 支持按游戏、按表迁移 我们通过 Nomad Batch Job 管理迁移任务,通过 Jenkins Pipeline 提供可配置的执行入口。迁移日志也会自动上报到内部通知系统。 # 数据访问层的改造范式 在这次改造中,我们遵循了“**只改访问逻辑,不动业务代码**”的原则,把所有分表逻辑封装在 repository 层内部,使业务代码无感知地享受分表带来的性能收益。 1. 注入 db_mode:为每个游戏配置读写模式 我们通过配置文件将每个游戏的读写策略注入到服务中。 这保证了即便是同一个服务,不同的游戏也可以处于**不同的分表阶段**。 2. 读操作:Old / New / DarkRead 三分法 每个原始读方法,都会被拆成三个版本 Dark Read 的核心逻辑是: - 主流程从旧表读取并返回 - 启动一个 goroutine,从新表读取 - 对比业务字段是否一致,若不一致则记录日志(包括差异字段、原始数据、新数据) 这让我们能在不影响线上服务的前提下,实时监控新表的数据同步情况,确保切换 `read_mode=new_table` 之前,新旧表是“业务等价”的。 3. 写操作:Old / New / DualWrite 三分法 dual_write 模式必须通过事务包装,确保新旧表一致性 4. 混合逻辑场景的改造 部分 repository 方法可能同时涉及读写,例如 `CreateAndGet()`。 这类方法无法直接套用 read_mode / write_mode 的切换逻辑,需要将其内部 SQL 操作**全部拆解成单一职责方法**后,再按以上的标准方式进行重构。 这样做虽然有很多的工作量,但换来了**可控性、可测试性与安全性**,为灰度切换打下了基础。通过这种结构化的改造范式,我们让所有数据库访问逻辑都可以: - **在配置层切换行为** - **在不改动业务逻辑的前提下实现分表** - **在新旧表切换阶段保障数据一致性** # 迁移程序设计与执行策略 分表改造并非一次性切换,而是一场有节奏、有保障的“数据搬家”。为了确保迁移过程中**数据不丢失、不重复、不污染新表**,我们在每个服务中内置了一套专用的**数据迁移子程序**。 > 为什么内置在服务中呢? 是为了保证迁移程序中所用的 db schema 对象与程序一致,无需新增代码仓库,保持迁移程序与原 service image 一致即可。 这套程序采用命令行方式运行,按 `game_id` 和表级别控制,具备如下核心特性: #### :fa-hand-o-right:通过 CLI 参数控制执行行为 | 参数 | 说明 | | ---------- | ---------------------- | | `-game-id` | 必选,指定要迁移的游戏 | | `-table` | 必选,指定要迁移的表 | | `-dry-run` | 可选,是否启用试运行模式,默认 `true` | #### :fa-hand-o-right: 主键游标分页查询 - 从旧表中按主键升序分页查询,避免 OFFSET 带来的性能问题。 #### :fa-hand-o-right:记录处理逻辑 - 仅处理指定 `game_id` 的数据 - 若新表无记录 → 插入 → `Inserted++` - 若新表有记录: - 若数据内容不同 → 更新 → `Updated++` - 若业务字段等价 → 忽略 → `Equivalent++` **注意:** - 不对比自增 ID 和时间字段(`created_at`, `updated_at`) - 写操作忽略自增 ID,仅迁移业务字段 #### :fa-hand-o-right: 幂等执行支持 - 可重复运行任意多次 - 若数据已一致,不会重复插入 / 更新 - 避免执行中断后的数据污染风险 #### :fa-hand-o-right:错误处理与退出机制 1. 全局 recover,优雅退出 - 迁移程序入口包装 panic-recover - 异常发生时输出详细日志、当前进度 - 通过 Messenger 通知协作群 2. 中断信号监听 - 支持 `SIGINT` / `SIGTERM` 信号监听 - 在每一批处理前检测 context 是否已取消 - 收到信号后中止执行,输出中间状态并发送通知 #### :fa-hand-o-right:结构化统计信息 + 可追溯日志 每次执行完成后,程序会输出统计信息,并自动发送至内部通知群。 # 上线切换流程与回滚策略 数据库分表的目标,并不是“改造完上线就完事”,而是**从 old_table 模式平滑切换到 new_table 模式**,并确保在每一步都有明确的验证机制和可回退能力。 这是一场有节奏的灰度迁移流程,我们为每一步都设计了**验证手段、执行流程与回滚方案**。 ### 分阶段策略 我们将一次完整的分表切换流程拆解为以下 6 个阶段: 1. 阶段一:部署改造完成的程序,此时所有的游戏都读写 old table。 以下阶段按游戏进行: 2. 阶段二:部署 dual_write,开始写新旧双表 3. 阶段三:多次执行数据迁移,确保新表一致 4. 阶段四:read_mode 切换为 dark_read,开始观测新表稳定性 5. 阶段五:read_mode 切换为 new_table 6. 阶段六:write_mode 切换为 new_table ### 回滚策略 我们特别强调:**在任意切换阶段,如果发现异常,都可以回退到旧表访问模式。** 例如: |异常场景|回滚操作| |---|---| |dark_read 中发现大量差异|read_mode → old_table| |切换 new_table 后接口报错|read_mode → dark_read 或 old_table| |dual_write 写新表失败|write_mode → old_table| 所有行为都是**配置可控、逻辑封装良好、无须变更代码**。 ### 迁移状态判断与团队共识 我们曾讨论是否要在 Redis 或数据库中记录迁移状态(例如,某个游戏是否已完成迁移)。最终我们选择: > **不使用自动标记机制,而是依靠 MR review 和群通知日志来确认状态。** 具体判断依据: - `Inserted=0` 且 `Updated=0` 的绿色通知连续两次 - 数据访问层未输出 dark_read_diff 日志 - 多人 Review 确认可切换 # 结语 数据库分表是一场面向未来的系统演进,它不是一次性的技术“升级”,而是对系统架构能力的整体提升。 在这次分表实践中,我们学到: - 如何在不动业务逻辑的前提下,完成结构性重构。 - 如何利用配置驱动的方式,设计出灵活的迁移与回滚策略。 - 分表应尽早规划实施。若等到数据库压力逼近瓶颈再动手,改造将变得异常谨慎且复杂,工作量与风险也将同步增加。 这一次的分表实践,为我们构建了一个更具弹性的起点。如果你对这套分表实践的任何部分感兴趣,欢迎留言讨论~ 同步发表在 [GitHub](https://github.com/whrss9527/blog-backup/blob/main/mysql-sharding-migration.md "GitHub") [微信公众号](https://mp.weixin.qq.com/s/HK9sGUFAwVuPxKYvaLo0YA "微信公众号")