查看原文
其他

借鉴Codis实现的两种不停机分库分表迁移方案

2018-03-02 阿飞 DBAplus社群


需求说明


类似订单表、用户表这种未来规模上亿甚至上十亿、百亿的海量数据表,在项目初期为了快速上线,一般只是单表设计,不需要考虑分库分表。


随着业务的发展,单表容量超过千万甚至达到亿级别以上,这时候就需要考虑分库分表这个问题了,而不停机分库分表迁移,这应该是分库分表最基本的需求。毕竟互联网项目不可能挂个广告牌"今晚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后,就要确定分库分表方案了。笔者采用主动迁移与被动迁移相结合的方案:


  • 主动迁移就是一个独立程序,遍历需要分库分表的installed_app表,将数据迁移到分库分表后的目标表中。

  • 被动迁移就是与installed_app表相关的业务代码资深将数据迁移到分库分表后对应的表中。


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


主动迁移


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


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


这里有几点需要注意:


  • 第一步查询出max(id)是为了尽量减少max(id)的查询次数,加入第一次查询max(id)为10000000,那么直到遍历的id到10000000以前,都不需要再次查询max(id);

  • 根据id>=? and id<? 遍历,而不要根据id>=? limit n 或者 limit m,n进行遍历,因为limit性能一般,且会随着遍历越往后,性能越差。而 id>=? and id<? 这种遍历方式即使会有一些踩空,也没有任何影响,且整个性能曲线非常平顺,不会有任何抖动;迁移程序毕竟是辅助程序,不能对业务程序有过多的影响;

  • 根据id区间范围查询出来的 List< InstalledApp > 要转换为 Iterator <InstalledApp > ,每迭代处理完一个userld,要remove掉,否则可能导致GC异常,甚至OOM。


被动迁移


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


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


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


  • 如果值为COMPLETED,表示已经迁移完成,那么将请求转移到分库分表后的表中进行处理即可;

  • 如果值为MIGR ATING,表示正在迁移中,可以循环等待直到值为COMPLETED即迁移完成后,再将请求转移到分库分表后的表中进行处理;

  • 否则值为空,那么尝试获取锁再进行数据迁移。迁移完成后,将缓存值更新为COMPLETED,最后再将请求转移到分库分表后的表中进行处理。


方案完善


当所有数据迁移完成后,CRUD操作还是会先根据缓存中 MigrateStatus:${userId} 的值进行判断,数据迁移完成后这一步已经是多余的。


可以加个总开关,当所有数据迁移完成后,将这个开关的值通过类似TOPIC的方式发送,所有服务接收到TOPIC后将开关local cache化。


那么接下来服务的CRUD都不需要先根据缓存中 MigrateStatus:${userId} 的值进行判断。


遗留工作


迁移完成后,将主动迁移程序下线,并将被动迁移程序中对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的数据,就可以尽可能的降低迁移程序对这些用户的体验。当然,你也可以使用自己想出的更好的方案。


本文转自公众号匠心零度(jiangxinlingdu),作者阿飞,经平台授权转载。



近期热文

在“开源统治世界”的当下,谈企业开源战略与实操

从Elasticsearch集群及数据层架构,看分布式系统设计

DBA+工具:SQL自审自上线,摆脱人肉审核就在当下 

类比NBA队伍建设,趣谈如何打造一支运维明星团队

关联与下钻:快速定位MySQL性能瓶颈的制胜手段


最新活动

2018 Gdevops全球敏捷运维峰会(成都站)

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存