如何做到不停机分库分表迁移?

运维 数据库运维
随着业务的发展,单表容量超过千万甚至达到亿级别以上,这时候就需要考虑分库分表这个问题了,而不停机分库分表迁移,这应该是分库分表最基本的需求,毕竟互联网项目不可能挂个广告牌"今晚10:00~次日10:00系统停机维护",这得多low呀,以后跳槽面试,你跟面试官说这个迁移方案,面试官怎么想呀?

[[222412]]

需求说明

类似订单表,用户表这种未来规模上亿甚至上十亿百亿的海量数据表,在项目初期为了快速上线,一般只是单表设计,不需要考虑分库分表。随着业务的发展,单表容量超过千万甚至达到亿级别以上,这时候就需要考虑分库分表这个问题了,而不停机分库分表迁移,这应该是分库分表最基本的需求,毕竟互联网项目不可能挂个广告牌"今晚10:00~次日10:00系统停机维护",这得多low呀,以后跳槽面试,你跟面试官说这个迁移方案,面试官怎么想呀?

借鉴codis

笔者正好曾经碰到过这个问题,并借鉴了codis一些思想实现了不停机分库分表迁移方案;codis不是这篇文章的重点,这里只提及借鉴codis的地方--rebalance:

当迁移过程中发生数据访问时,Proxy会发送“SLOTSMGRTTAGSLOT”迁移命令给Redis,强制将客户端要访问的Key立刻迁移,然后再处理客户端的请求。( SLOTSMGRTTAGSLOT 是codis基于redis定制的)

分库分表

明白这个方案后,了解不停机分库分表迁移就比较容易了,接下来详细介绍笔者当初对installed_app表的实施方案;即用户已安装的APP信息表;

1. 确定sharding column

确定sharding column绝对是分库分表最最最重要的环节,没有之一。sharding column直接决定整个分库分表方案最终是否能成功落地;一个合适的sharding column的选取,基本上能让与这个表相关的绝大部分流量接口都能通过这个sharding column访问分库分表后的单表,而不需要跨库跨表,最常见的sharding column就是user_id,笔记这里选取的也是user_id;

2. 分库分表方案

根据自身的业务选取最合适的sharding column后,就要确定分库分表方案了。笔者采用主动迁移与被动迁移相结合的方案:

  1. 主动迁移就是一个独立程序,遍历需要分库分表的installed_app表,将数据迁移到分库分表后的目标表中。
  2. 被动迁移就是与installed_app表相关的业务代码自身将数据迁移到分库分表后对应的表中。

接下来详细介绍这两个方案;

2.1 主动迁移

主动迁移就是一个独立的外挂迁移程序,其作用是遍历需要分库分表的installed_app表,将这里的数据复制到分库分表后的目标表中,由于主动迁移和被动迁移会一起运行,所以需要处理主动迁移和被动迁移碰撞的问题,笔者的主动迁移伪代码如下:

 

  1. public void migrate(){  
  2.     // 查询出当前表的***ID, 用于判断是否迁移完成  
  3.     long maxId = execute("select max(id) from installed_app");  
  4.     long tempMinId = 0L;  
  5.     long stepSize = 1000;  
  6.     long tempMaxId = 0L;  
  7.     do{  
  8.         try {  
  9.             tempMaxId = tempMinId + stepSize;  
  10.             // 根据InnoDB索引特性, where id>=? and id<?这种SQL性能***  
  11.             String scanSql = "select * from installed_app where id>=#{tempMinId} and id<#{tempMaxId}" 
  12.             List<InstalledApp> installedApps = executeSql(scanSql);  
  13.             Iterator<InstalledApp> iterator = installedApps.iterator();  
  14.             while (iterator.hasNext()) {  
  15.                 InstalledApp installedApp = iterator.next();  
  16.                 // help GC  
  17.                 iterator.remove();  
  18.                 long userId = installedApp.getUserId();  
  19.                 String status = executeRedis("get MigrateStatus:${userId}");  
  20.                 if ("COMPLETED".equals(status)) {  
  21.                     // migration finish, nothing to do  
  22.                     continue 
  23.                 }  
  24.                 if ("MIGRATING".equals(status)) {  
  25.                     // "被动迁移" migrating, nothing to do  
  26.                     continue 
  27.                 }  
  28.                 // 迁移前先获取锁: set MigrateStatus:18 MIGRATING ex 3600 nx  
  29.                 String result = executeRedis("set MigrateStatus:${userId} MIGRATING ex 86400 nx");  
  30.                 if ("OK".equals(result)) {  
  31.                     // 成功获取锁后, 先将这个用户所有已安装的app查询出来[即迁移过程以用户ID维度进行迁移]  
  32.                     String sql = "select * from installed_app where user_id=#{user_id}" 
  33.                     List<InstalledApp> userInstalledApps = executeSql(sql);  
  34.                     // 将这个用户所有已安装的app迁移到分库分表后的表中(有user_id就能得到分库分表后的具体的表)  
  35.                     shardingInsertSql(userInstalledApps);  
  36.                     // 迁移完成后, 修改缓存状态  
  37.                     executeRedis("setex MigrateStatus:${userId} 864000 COMPLETED");  
  38.                 } else {  
  39.                     // 如果没有获取到锁, 说明被动迁移已经拿到了锁, 那么迁移交给被动迁移即可[这种概率很低]  
  40.                     // 也可以加强这里的逻辑, "被动迁移"过程不可能持续很长时间, 可以尝试循环几次获取状态判断是否迁移完  
  41.                     logger.info("Migration conflict. userId = {}", userId);  
  42.                 }  
  43.             }  
  44.             if (tempMaxId >= maxId) {  
  45.                 // 更新max(id),最终确认是否遍历完成  
  46.                 maxId = execute("select max(id) from installed_app");  
  47.             }  
  48.             logger.info("Migration process id = {}", tempMaxId);  
  49.         }catch (Throwable e){  
  50.             // 如果执行过程中有任何异常(这种异常只可能是redis和mysql抛出来的), 那么退出, 修复问题后再迁移  
  51.             // 并且将tempMinId的值置为logger.info("Migration process id="+tempMaxId);日志***一次记录的id, 防止重复迁移 
  52.             System.exit(0);  
  53.         }  
  54.         tempMinId += stepSize;  
  55.     }while (tempMaxId < maxId);  

 

这里有几点需要注意:

  1. ***步查询出max(id)是为了尽量减少max(id)的查询次数,假如***次查询max(id)为10000000,那么直到遍历的id到10000000以前,都不需要再次查询max(id);
  2. 根据id>=? and id<?遍历,而不要根据id>=? limit n或者limit m, n进行遍历,因为limit性能一般,且会随着遍历越往后,性能越差。而id>=? and id<?这种遍历方式即使会有一些踩空,也没有任何影响,且整个性能曲线非常平顺,不会有任何抖动;迁移程序毕竟是辅助程序,不能对业务程序有过多的影响;
  3. 根据id区间范围查询出来的List<InstalledApp>要转换为Iterator<InstalledApp>,每迭代处理完一个userId,要remove掉,否则可能导致GC异常,甚至OOM;

2.2 被动迁移

被动迁移就是在正常与installed_app表相关的业务逻辑前插入了迁移逻辑,以新增用户已安装APP为例,其伪代码如下:

 

  1. // 被动迁移方法是公用逻辑,所以与`installed_app`表相关的业务逻辑前都需要调用这个方法;  
  2. public void migratePassive(long userId)throws Exception{  
  3.     String status = executeRedis("get MigrateStatus:${userId}");  
  4.     if ("COMPLETED".equals(status)) {  
  5.         // 该用户数据已经迁移完成, nothing to do  
  6.         logger.info("user's installed app migration completed. user_id = {}", userId);  
  7.     }else if ("MIGRATING".equals(status)) {  
  8.         // "被动迁移" migrating, 等待直到迁移完成; 为了防止死循环, 可以增加***等待时间逻辑  
  9.         do{  
  10.             Thread.sleep(10);  
  11.             status = executeRedis("get MigrateStatus:${userId}");  
  12.         }while ("COMPLETED".equals(status)); 
  13.     }else {  
  14.         // 准备迁移  
  15.         String result = executeRedis("set MigrateStatus:${userId} MIGRATING ex 86400 nx");  
  16.         if ("OK".equals(result)) {  
  17.             // 成功获取锁后, 先将这个用户所有已安装的app查询出来[即迁移过程以用户ID维度进行迁移]  
  18.             String sql = "select * from installed_app where user_id=#{user_id}" 
  19.             List<InstalledApp> userInstalledApps = executeSql(sql);  
  20.             // 将这个用户所有已安装的app迁移到分库分表后的表中(有user_id就能得到分库分表后的具体的表)  
  21.             shardingInsertSql(userInstalledApps);  
  22.             // 迁移完成后, 修改缓存状态  
  23.             executeRedis("setex MigrateStatus:${userId} 864000 COMPLETED");  
  24.         }else {  
  25.             // 如果没有获取到锁, 应该是其他地方先获取到了锁并正在迁移, 可以尝试等待, 直到迁移完成  
  26.         }  
  27.     }  
  28.  
  29. // 与`installed_app`表相关的业务--新增用户已安装的APP  
  30. public void addInstalledApp(InstalledApp installedApp) throws Exception{  
  31.     // 先尝试被动迁移  
  32.     migratePassive(installedApp.getUserId());  
  33.     // 将用户已安装app信息(installedApp)插入到分库分表后的目标表中  
  34.     shardingInsertSql(installedApp);  

 

无论是CRUD中哪种操作,先根据缓存中MigrateStatus:${userId}的值进行判断:

  1. 如果值为COMPLETED,表示已经迁移完成,那么将请求转移到分库分表后的表中进行处理即可;
  2. 如果值为MIGRATING,表示正在迁移中,可以循环等待直到值为COMPLETED即迁移完成后,再将请求转移到分库分表后的表中进行处理处理;
  3. 否则值为空,那么尝试获取锁再进行数据迁移。迁移完成后,将缓存值更新为COMPLETED,***再将请求转移到分库分表后的表中进行处理处理;

3.方案完善

当所有数据迁移完成后,CRUD操作还是会先根据缓存中MigrateStatus:${userId}的值进行判断,数据迁移完成后这一步已经是多余的。可以加个总开关,当所有数据迁移完成后,将这个开关的值通过类似TOPIC的方式发送,所有服务接收到TOPIC后将开关local cache化。那么接下来服务的CRUD都不需要先根据缓存中MigrateStatus:${userId}的值进行判断;

4.遗留工作

迁移完成后,将主动迁移程序下线,并将被动迁移程序中对migratePassive()的调用全部去掉,并可以集成一些第三方分库分表中间件,例如sharding-jdbc,可以参考sharding-jdbc集成实战

回顾总结

回顾这个方案,***的缺点就是如果碰到sharding column(例如userId)的总记录数比较多,且主动迁移正在进行中,被动迁移与主动迁移碰撞,那么被动迁移可能需要等待较长时间。

不过根据DB性能,一般批量插入1000条数据都是10ms级别,并且同一sharding column的记录分库分表后只属于一张表,不涉及跨表。所以,只要在迁移前先通过sql统计待迁移表中没有这类异常sharding column即可放心迁移;

笔者当初迁移installed_app表时,用户最多也只拥有不超过200个APP,所以不需要过多考虑碰撞带来的性能问题;没有***的方案,但是有适合自己的方案;

 

如果有那种上万条记录的sharding column,可以把这些sharding column先缓存起来,迁移程序在夜间上线,优先迁移这些缓存的sharding column的数据,就可以尽可能的降低迁移程序对这些用户的体验。当然你也可以使用你想出来的更好的方案。 

责任编辑:庞桂玉 来源: 数据库开发
相关推荐

2020-05-06 13:47:42

ZooKeeperKubernetes迁移

2019-04-25 10:40:02

分库分表MySQL数据库

2018-01-12 15:17:40

数据库水平分库数据迁移

2024-10-25 10:00:00

云服务计算

2021-03-01 10:10:39

数据迁移扩容

2021-06-26 08:09:21

MySQL不停机不锁表

2019-01-02 16:40:13

MongoDBPostgres数据库

2020-07-28 09:04:09

NewSQL分库分表

2020-04-13 15:45:46

MySQL数据库备份

2011-11-09 15:49:52

API

2020-07-30 17:59:34

分库分表SQL数据库

2022-02-23 08:55:06

数据迁移分库分表数据库

2024-06-06 11:26:03

2022-07-11 08:16:47

NewSQL关系数据库系统

2022-10-08 09:33:00

平台中间件

2019-11-12 09:54:20

分库分表数据

2009-11-20 11:37:11

Oracle完全卸载

2023-08-18 12:17:03

Linode实时迁移云计算

2021-08-31 20:21:11

VitessMySQL分库

2023-08-11 08:59:49

分库分表数据数据库
点赞
收藏

51CTO技术栈公众号