不停机分库分表迁移

阿飞Javaer,转载请注明原创出处,谢谢!

需求说明

类似订单表这种规模上亿,未来甚至上十亿百亿的海量数据表,在项目初期为了快速上线,一般只是单表设计,不需要考虑分库分表。随着业务的发展,单表容量超过千万甚至达到亿级别以上,这时候就需要考虑分库分表这个问题了,而不停机分库分表迁移,这应该是分库分表最基本的需求,毕竟互联网项目不可能挂个广告牌"今晚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表,将这里的数据复制到分库分表后的目标表中,由于主动迁移被动迁移会一起运行,所以需要处理主动迁移和被动迁移碰撞的问题,笔者的主动迁移伪代码如下:

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),因为迁移过程中由于双写,导致max(id)会有变化,所以需要再次确认maxId的值判断是否遍历完成
                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);
}

这里有几点需要注意:

  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为例,其伪代码如下:

// 被动迁移方法是公用逻辑,所以与`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);

    // 单库单表的插入逻辑。是否需要这段旧业务代码,取决于方案的严谨性:如果需要方案可以回滚,那么这段代码需要保留;
    insertSql(installedApp);
}

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

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

3.方案完善1

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

4.方案完善2

另外,如addInstalledApp(InstalledApp)示例实现一个很大的缺点就是迁移代码和业务代码强耦合了,并且这些业务接口由于双写会导致耗时有所增长,这个可以通过订阅installed_app表的binlog(参考alibaba canal)来进一步优化,示例代码如下:

// 与`installed_app`表相关的业务--新增用户已安装的APP--这段旧业务代码保持不变
public void addInstalledApp(InstalledApp installedApp) throws Exception{
    insertSql(installedApp);
}

binlog消费:

// 当执行了新增SQL(insertSql(installedApp))后,会产生binlog日志,insert类型(canal可通过EventType判断)的binlog日志消费端的逻辑如下所示--即将被动迁移逻辑挪到binlog消费端处理即可:
public void insertBinlogConsumer(InstalledApp installedApp){
    // 先尝试被动迁移
    migratePassive(installedApp.getUserId());

    // 将用户已安装app信息(installedApp)插入到分库分表后的目标表中
    shardingInsertSql(installedApp);
}

说明:新增,修改,删除操作都会产生binlog日志,这些类型的接口都可以通过这种方式进行优化;而查询类的接口,也不产生binlog日志,也不会对数据有任何影响,所以不需要做任何改变,因为原installed_app表的数据一直是全量的数据;

5.遗留工作

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

回顾总结

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

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

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 197,368评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,941评论 2 374
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 144,369评论 0 326
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,848评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,719评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,505评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,904评论 3 388
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,528评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,819评论 1 293
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,848评论 2 314
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,652评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,468评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,912评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,095评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,389评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,906评论 2 343
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,120评论 2 339

推荐阅读更多精彩内容