数据库分表实践:如何优雅地切换到分表架构

写在前面

在我们的后端架构中,我们采用了以游戏为租户单位的多租户数据库设计。所有游戏共享同一套数据库 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 前,都可以进行回滚。

  1. 自动建表:创建游戏即创建对应表
    我们在后台创建游戏时,会自动触发分表建表逻辑:
    这样,我们无需人工干预即可支持新游戏分表,游戏接入的流程保持不变。

  2. 数据访问层改造
    我们不改动业务代码,只修改数据库访问层。
    根据 db_mode 配置,进行不同表的数据库访问层操作。

  3. 内嵌迁移程序
    每个服务内置一个迁移命令行程序,支持:

  • dry-run 模式比对数据差异

  • 支持幂等多次执行

  • 支持按游戏、按表迁移

    我们通过 Nomad Batch Job 管理迁移任务,通过 Jenkins Pipeline 提供可配置的执行入口。迁移日志也会自动上报到内部通知系统。

数据访问层的改造范式

在这次改造中,我们遵循了“只改访问逻辑,不动业务代码”的原则,把所有分表逻辑封装在 repository 层内部,使业务代码无感知地享受分表带来的性能收益。

  1. 注入 db_mode:为每个游戏配置读写模式
    我们通过配置文件将每个游戏的读写策略注入到服务中。
    这保证了即便是同一个服务,不同的游戏也可以处于不同的分表阶段。

  2. 读操作:Old / New / DarkRead 三分法
    每个原始读方法,都会被拆成三个版本
    Dark Read 的核心逻辑是:

  • 主流程从旧表读取并返回
  • 启动一个 goroutine,从新表读取
  • 对比业务字段是否一致,若不一致则记录日志(包括差异字段、原始数据、新数据)
    这让我们能在不影响线上服务的前提下,实时监控新表的数据同步情况,确保切换 read_mode=new_table 之前,新旧表是“业务等价”的。
  1. 写操作:Old / New / DualWrite 三分法
    dual_write 模式必须通过事务包装,确保新旧表一致性

  2. 混合逻辑场景的改造
    部分 repository 方法可能同时涉及读写,例如 CreateAndGet()。
    这类方法无法直接套用 read_mode / write_mode 的切换逻辑,需要将其内部 SQL 操作全部拆解成单一职责方法后,再按以上的标准方式进行重构。

这样做虽然有很多的工作量,但换来了可控性、可测试性与安全性,为灰度切换打下了基础。通过这种结构化的改造范式,我们让所有数据库访问逻辑都可以:

  • 在配置层切换行为
  • 在不改动业务逻辑的前提下实现分表
  • 在新旧表切换阶段保障数据一致性

迁移程序设计与执行策略

分表改造并非一次性切换,而是一场有节奏、有保障的“数据搬家”。为了确保迁移过程中数据不丢失、不重复、不污染新表,我们在每个服务中内置了一套专用的数据迁移子程序。

为什么内置在服务中呢? 是为了保证迁移程序中所用的 db schema 对象与程序一致,无需新增代码仓库,保持迁移程序与原 service image 一致即可。

这套程序采用命令行方式运行,按 game_id 和表级别控制,具备如下核心特性:

👉通过 CLI 参数控制执行行为

参数 说明
-game-id 必选,指定要迁移的游戏
-table 必选,指定要迁移的表
-dry-run 可选,是否启用试运行模式,默认 true

👉 主键游标分页查询

  • 从旧表中按主键升序分页查询,避免 OFFSET 带来的性能问题。

👉记录处理逻辑

  • 仅处理指定 game_id 的数据
  • 若新表无记录 → 插入 → Inserted++
  • 若新表有记录:
    • 若数据内容不同 → 更新 → Updated++
    • 若业务字段等价 → 忽略 → Equivalent++
      注意:
  • 不对比自增 ID 和时间字段(created_at, updated_at)
  • 写操作忽略自增 ID,仅迁移业务字段

👉 幂等执行支持

  • 可重复运行任意多次
  • 若数据已一致,不会重复插入 / 更新
  • 避免执行中断后的数据污染风险

👉错误处理与退出机制

  1. 全局 recover,优雅退出

    • 迁移程序入口包装 panic-recover
    • 异常发生时输出详细日志、当前进度
    • 通过 Messenger 通知协作群
  2. 中断信号监听

  • 支持 SIGINT / SIGTERM 信号监听
  • 在每一批处理前检测 context 是否已取消
  • 收到信号后中止执行,输出中间状态并发送通知

👉结构化统计信息 + 可追溯日志

每次执行完成后,程序会输出统计信息,并自动发送至内部通知群。

上线切换流程与回滚策略

数据库分表的目标,并不是“改造完上线就完事”,而是从 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
微信公众号

评论