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