前言
近三个月没更新文章, 可不是我偷懒, 而是工作太忙,实在没有时间.
时间回到三个月前, 领导突然找到我, 说要准备搞个“大工程”,一起讨论讨论.
什么大工程呢? 简而言之, 合库!
将几十个数据库合并成一个数据库!
我想很多技术人和我一样, 第一反应就是: 啥, 合库?
分库分表大多技术人或多或少都知道一些, 但是对于合库可能就没那么熟悉了.
不过, 仔细一想, 这样的需求还是存在的, 比如游戏常见的“合区服”, 其实技术层面可能就是通过合库合表实现的.
合库背景简介
和相关项目组的人员聊了一轮后, 得到了以下信息:
共35个城市库, 表结构一致, 表的主键为Mysql自增ID
共2个公共库,用来存放共有信息以及来自各个城市库产生的业务信息,以city_id区分.
城市库和公共库均有触发器/存储过程
各个城市发展业务进度不同, 所以城市库间的大小有了分化, 小城市的库特别小, 甚至部分表记录为空; 大城市的的库单表可达千万级别.
无论城市库还是公共库中的表, 均存在json或字符串的形式来记录ID的字段: json里面有ID值, 字符串字段有以空格,逗号分隔的ID值.
数据合并后, 应用程序也得调整数据库层面的代码.
看到这里, 你可能也和咸鱼会有一样的困惑:
大城市单表都上千万数据了, 单独一个库不是很正常的设计么?
为啥要冒着风险合并这么多库呢?
而且就算合并成功了, 接踵而至的性能问题等等, 最终不还是得通过分库分表来解决???
关于这些问题, 咸鱼当时也是想到了, 不过和项目人员以及业务人员沟通后, 方知“项目早期被过度设计的痛点”.
飞速发展的业务, 项目迭代速度很快, 而这种多个城市库的设计让迭代跟不上业务发展的脚步;
另外, 这种设计也对业务人员统计全国数据造成了一些困扰……
总之, 再三沟通确认后, 最终决定对项目城市库进行合并, 让设计回归最原始的样子.
合库方案设计
不难看出, 妨碍数据合并的问题如下:
- 虽然各个城市库表结构一致, 但是表的主键ID自增, 合并势必冲突
- 城市库进行分布式唯一改造后, 两个公共库也得对应进行调整
- 城市库及公共库使用了触发器/存储过程, 需要考虑影响
- 城市库和公共库中表均存在json和字符串类型的主/外键, 需要解析出来进行全局唯一性处理
- 35个城市库及2个公共库, 不用想, 一晚上肯定搞不完, 毕竟业务也得继续跑, 为了合并停服几天不现实!
- 涉及合库项目的所有应用程序也得相应调整数据层面的代码
- 最重要的一点, 出现问题如何回滚?
经过了几轮讨论, 初步形成了以下实施大纲:
ID改造: 进行ID全局唯一性改造, 涉及城市库和公共库的所有主/外键字段(包含json及字符串)
合库: 进行合库操作, 保留原库.
切库: 将涉及到的应用程序调整, 读写新库.
大致方案敲定, 接下来就是进行实施细节的一些商讨, 力求能最大程度的降低风险.
比如:
- 如何进行ID改造才能在保证全局唯一的基础上,最小的改动?
- json、字符类型的存储的ID如何处理?
- 触发器、存储过程如何处理?
- ID改造期间出现问题如何回滚数据?
- 合库操作如何进行? 保留原库的前提下, 如何原库的数据变动能持续同步至新库?
- 这么多城市, 需要分批进行么?
- 停服更新还是热更新?
- …………
经历了将近半个月的脑暴, 最终在以上大纲的基础上完善了细节规则的制定:
考虑到城市较多,数据量大, 为了减少风险, 对35个城市按业务发展进行了批次划分, 大致如下:
试点城市(选中了两个数据小的城市): A、B
第一批次: 华东区域城市
第二批次: 华北区域城市
第三批次: 中西区域城市
第四批次: 华南区域城市
PS: 分批的精髓就在于, 让一部分小城市试水, 发现问题可以及时“止损”.
合库方案实施步骤
几轮探讨下来, 要完成最终的合库, 大致有以下几个步骤, 每个步骤均按照以上的城市规划, 分批进行, 尤其是试点的两个城市, 需要尽可能的保证试点时间, 排查各种错误,方便后续几批城市的复制.
分批停服更新
避开业务使用时间, 屏蔽当次试点城市的所有应用服务的访问.
PS: 只屏蔽当次试点城市的流量进入, 不影响其他城市的访问!
实施步骤:
- 提前部署可以屏蔽相关城市访问的代码逻辑
- 提前给受影响城市的一线发停服通知
- 屏蔽相关应用服务 (防止ID改造时, 导致有流量进来)
- 关闭相关定时任务 (防止ID改造时, 定时任务导致的数据变动)
分批数据备份
每批次城市处理时, 均需要备份数据, 用于紧急回退.
实施步骤:
- 备份城市库: 全库备份本次受影响的城市库
- 备份公共库: 备份公共库中本次城市的所有记录
这里采用的是备份城市记录的方式, 通过新建一张表, 然后复制相应的记录进新表即可.
意外恢复时, 只需根据ID更新恢复即可.
分批ID改造:
ID改造, 最重要的就是新ID的规则, 既要保证全局唯一, 又要保证最小影响.
最终我们商讨后采用了以下方式生成
新ID = 城市库独立基数 + 原ID
新ID生成的规则说明
其实, 就ID的生成规则采用哪种形式, 我们内部也讨论了一波.
最后基于“对当前应用影响最小”的思想下, 团队摒弃了类似雪花算法这类的规则.
而最终采用了新ID = 城市库独立基数 + 原ID这种规则PS: 要是采用雪花算法规则生成新ID, 那么应用代码上就得调整有关新增的逻辑,同时还得修数据.
城市库独立基数的生成规则
基数的规则需要基于生产数据的分析, 比如35个城市库中最大的库是A, 最小的是B库:
A中最大的表数据已达100 00000
B中最大表数据只有10 0000此时, 基数应该在100 0000之上考虑, 考虑到我们分批进行ID改造工作, 还得考虑城市的增量以及分批合库、切库的影响, 所以在此基础上预留足够大的增量空间.
为什么要考虑城市的增量?
若如下设计:
A库ID规则 = 1000 0000 + 原ID
B库新ID = 101 0000 + 原ID因为我们是分批合库的, 是有时差的, 那么按照A库的增长速度, 很快就会出现101 0000以后的ID, 这必将导致合库时数据冲突.
为什么要考虑切库的增量?
若我们为每个城市预留100w的城市数据增量, 那么基数设置如下:
A库新ID = 1000 0000 + 原ID
B库新ID = 1100 0000 + 原ID
C库新ID = 1200 0000 + 原ID
K库为新库,用来合并所有城市库的数据.如果仅仅是ID的处理, 显然什么问题, 毕竟最大的A库最多的数据也才100W, 业务再这么发展, 也无法短时间内翻倍.
可一旦考虑到合库和切库, 那么预留的100W数据增量可能就不够用了.
A,B库分批先合进K,然后应用程序把来自A、B城市的数据切到K库, 那么来自A、B城市的数据都将再B的基数上继续增加.举例:
A: 1000 0010
B: 1100 0010
合进K库的某表后,对应表记录为
1000 0010
1100 0010
再进行切库, 把A,B城市的数据接入K库,若此时A有新数据进来,那么数据为1100 0011, 而不是1000 0011;这就表示, A、B城市共享了100W的数据增量, 那么就有可能数据迅速增加至1200 0000 , 那么就与C库冲突.
所以, 我们必须考虑切库的时间影响, 设计好数据增量来防止这种情况.
新ID的生成规则制定完后,下一步就是具体的实施了.
通过对生成库数据的排查, 我们大致对需要改造的ID字段做了如下区分:
普通数值ID字段: int型字段, 存储单个ID
特殊型ID字段: varchar或text类型字段, 存储多个以符号分隔的ID或直接存储json字符串
不难看出,普通数值ID字段最好处理, 可能一个update语句即可
update table set uid = uid + 基数
特殊型ID字段就不行了, 必须要程序去解析然后处理.
最后我们决定针对ID改造这件事专门写个服务来进行处理.
那么, 首先需要做的事就是必须要有一个明确的处理规则.
我们细致的排查城市库和公共库的表结构, 列出每张表包含主外键的字段, 区分数字类型 和 特殊类型(字符串和json), 同时特殊类型需要注明解析规则.
数字类型: 某个字段就用来存放某个主外键ID
特殊类型: 某个字段存放的是逗号分隔的ID串, 或包含一堆ID的JSON串
表格大致如下:
表名 | 字段名 | 字段类型(1主键/2外键/3/json对象/4json数组/5字符串) | 字段包含的外键 | 特殊类型解析说明 | 库类型(1城市库/2公库一/3公库二) |
---|---|---|---|---|---|
student | id | 1 | 1 | ||
student | class_id | 2 | 1 | ||
student | schoolObject | 3 | school_id | schoolObject.school_id需要改造 | 1 |
student | schoolObject | 3 | city_id | schoolObject.city_id需要改造 | 1 |
student | teacherIds | 5 | 逗号分隔的id外键 | 1 |
有了规则, 接下来无非是按部就班的coding工作.
实施步骤:
- 对城市库所有表加上city_id字段并赋值, 后续合库用来数据隔离
- 修改原表中存放ID的类型
确认原表的ID字段类型足够放下新ID! (
重要
)关于这点, 我们就踩了坑, 因为前期忽略了这个事.
原ID字段为数字型(int)
原库的表所有存放ID主外键的字段均采用了“int”型, 而我们生成的新ID是放不下的, 所以, 得先将表的字段类型调整置“bigint”.原ID字段为特殊型(varchar、text)
text可能无需处理, 不过varchar这类的字段就需要加长了! 在我们处理的表中有varchar(500)的字段存放逗号分隔的ID串, ID改造后, 直接放不下了.
- 屏蔽受影响的触发器
排查生产库时发现了城市库和公共库均有insert和update触发器, 会影响我们的ID改造工作.
针对城市库和公共库的触发器,我们必须要分开处理,要是一刀切的全部删除, 那么就会对非试点城市造成影响.ID改造前删除城市库触发器, 改造完成后恢复.
ID改造前修改公共库的触发器(增加除法条件,屏蔽当次试点城市), 改造完成后恢复.
- 城市库ID改造
- 公共库ID改造
- 恢复受影响的触发器
踩过的一些坑
- 所有表存放ID的字段是int型, 放不下新ID,需改为bigint
- 部分表存放ID的字段是varchar类型, ID修改后, 因长度不够放不下, 需要进行加长
- 部分表编码类型为utf8, 但是json串数据中居然有emoj表情包数据,
导致解析出来后无法更新回去, 需要修改表的编码模式为utf8mb4 - 当对表的主键ID进行ID改造时, AUTO_INCREMENT值居然失效了.
举例:
test表, 主键为id, int型, 自增值已经到10了.
首先修改类型:
alter table test CHANGE id id BIGINT (20) auto_increment;
然后修改ID
update test set id = id+1000
如果此时你新增记录, 你会发现, auto_increment并没有在1010后继续新增, 而是在10的基础上新增, 也就是新纪录的id为11;所以我们对auto_increment需要特殊处理下:
首先修改类型:
alter table test CHANGE id id BIGINT (20);
然后修改ID
update test set id = id+1000
组后加上auto_increment
alter table test CHANGE id id BIGINT (20) auto_increment;
- 空表的处理
虽说所有城市库的表结构都一致, 不过每个城市因业务发展程度不一样, 导致部分城市库的部分表记录为空. 这种空表如果不处理也将对后续的合库造成影响,所以必须将这些空表的AUTO_INCREMENT改造为城市基数.
步骤3. 合库: 进行合库操作, 保留原库
关于合库, 同样也是基于我们的大前提: 分批进行.
这里我们使用了阿里的DTS同步工具,关于这个工具简单说明下它的作用
- 将多个库实时同步到一个库, 前提是库中的表结构必须完全一样,且ID不能冲突
所以合库的前提便是城市库表结构一致,且ID全局唯一
- 多对一实时同步时, 只能单向同步,不能反向
DTS多对一同步时, 只支持单向的同步, 也就是只能是A、B、C的数据同步到SUM, 而不能反向.
这一点比较重要.
当你对几个试点城市切库时, 数据读写全在新库中, 这些数据不支持反向同步到老的城市库中的, 万一发现重大问题需要回滚时, 则需单独制定一个靠谱的数据回退方案.
- 多对一实时同步时, 新库中AUTO_INCREMENT最终以最大的自动设置
起初我们调研时对此有疑惑:
A的AUTO_INCREMENT = 1000;
B的AUTO_INCREMENT = 2000;
C的AUTO_INCREMENT = 3000;
那么, DTS同步A、B、C到SUM时, 最终SUM的AUTO_INCREMENT和同步顺序是否有关系???通过询问以及实验, 最终发现, 并无关系,
通过DTS多对一同步, SUM库总是以AUTO_INCREMENT最大的C为准.
- 同步是一种关联关系, 可以手动断开
使用DTS进行同步时, 建立这种同步关系也就是点个按钮的事.
在我们合库时, 期间经历了好几次业务迭代, 其中有涉及到表的修改变动,起先我们以为涉及表结构的调整需要停止DTS, 之后发现, DTS同步只受新库表结构的影响.
也就是A、B、C的表结构变动可以同步到SUM, 无需停止DTS; 若直接对SUM调整表结构, 那么DTS会报错.
注意点:
这里注意, 虽然城市库业务表结构是一致的, 不过因为一些原因, 各个城市库中均有一些备份表存在, 这导致了数量上差异, 我们在处理时, 评估了这批差异表, 发现没什么用后直接删除来保证城市库间的一致性.
步骤4. 切库: 将涉及到的应用程序调整, 读写新库.
关于切库, 这块, 一样是按批次试点切, 到一步时其实没什么大的风险了,无非是代码上的调整, 读写全在新库中.
唯一的风险点也就是前面提过的发现了重大问题, 需要回退增量数据到城市库.
小结
从合库计划的讨论,到实施, 再到完成, 经历了三个多月忙碌的时光, 不过直到最后, 还是感觉挺充实的, 毕竟, 这算一份难得的经历.
回顾下整个合库的经历, 其实最刺激的点还是在ID改造那一块, 熬个夜, 脚本一点, 然后就是紧张的等待上几个小时....
关于ID改造的脚本, 后续有时间再整理分享.