狂赞!海量数据迁移方案,免费送给你

一、背景

在创业初期,为了快速把项目搭建运行起来,往往不会过多地去考虑系统是否可以支持未来更大的数据吞吐量,所以往往不会分表或分库。可当项目真正运行了一年两年之后,会发现原来的单表已经存储不了更多的数据了,或者查询性能受到影响,此时就要考虑分库或分表了。

一般涉及到分库分表,数据迁移是必须要做的一个工作。那么接下来,笔者就以自己亲身实践过的一次数据迁移经验为依据,向大家介绍一下,当数据量过亿时,进行数据迁移,我们要做些什么,有哪些坑(限于保密协议,笔者会对表名及字段名做一定的脱敏,但不妨碍理解)。

假设这里有两个表,分别用于存储用户行为数据和行为状态(你可以想像成是下单行为,或者投稿行为等等),目前已分了100张表。可是随着用户数据的暴增,且这种分表策略(user_id % 100)难以扩展(如果想扩展为200张表,则需要将原来100张表所有数据重新散列到200张表中,工作量很大),于是现在采用了一致性哈希算法来重新分表。

以下是两张表的表结构:

表1:user_action_0

表2:user_action_status_0

其中第一张表user_id和data_id为一个唯一索引;第二张表中action_id与status为唯一索引。其中action_id即为第一个表的id。

新的分表策略采用一致性哈希算法,关于一致性哈希,网上有若干博文可以查阅,此处不展开来讲,如:

公众号:Java技术人五分钟彻底理解一致性哈希算法

我们的实现思路是,将分表shard字段(这里是user_id)作md5,结果是一个32位的16进制字符串,然后取其中4位,前3位转为数字,后一位转为数字,则前3位正好是163=4096种可能,后一位16种可能。4096种可能表现为环形哈希的4096个桶,而这些桶最多可以存放4096个库,初期我只有16个库即可,后一位的16种可能,作为一个库中的16个分表。这样下来,本次要将之前100张表的数据,洗到16*16=256个表中。

目前user_action_0-100表中有1.8亿条数据;user_action_status_0-100中有3.5亿条数据。共77G的数据。并且每天以百万的量增加。

二、思路&方案

了解上面的背景以后,来看看如何设计方案。

  1. 建库:创建新的数据库与分表

  2. 重构项目:将现在的项目改为新的分库分表策略

  3. 全量迁移:从旧库旧表中全量移植数据到新库新表

 

当移植数据时,user_action_n没有什么问题,只要从旧表取出数据,根据user_id按照新的策略,将数据插入到新库新表即可。但是user_action_status_n表中的action_id依赖于前表,在读取了状态数据以后,不能直接根据新策略插入到新表,因为其中的action_id还是旧的。

关于这个依赖问题,可以有以下的方案:

    1)两个表同时进行迁移,即插入action表,成功后获取id;读取以该条记录为id的status数据,查询并更新其中的action_id,将状态数据插入到新库新表中。
    2)两个表独立插入,action表插入时,维护一个中间表,保存新旧的action_id的映射关系,插入status表时,再从这个中间表获取。
    3)两个表独立插入,action表插入时,主键不再用自增,而是让主键加上一个偏移量,100张表的主键出现重复的可能,再插入status表时,更新其中的action_id时只需要一个很少的函数计算就能得到新的action_id。简单说下这个偏移量的计算,假如有100张表,其中最大的表大小为100万,则第一张表主键id+0迁移,第二张表主键id+100万,第三张表主键id+2*100万...以此类推。这样,将旧表中数据重新散列到新库以后,就不会出现主键的重复问题。

    4. 增量程序:背景中说过,数据并不是静止的,每天都在发生变化,那就要有一个增量程序在全量结束以后,把新增加的数据迁入新库,这个也可以有下面的方案:
    1)全量结束以后,从旧库中扫表,将大于某个id或时间点的数据移植过来,但这个有个缺点,100个表如何去扫?扫完user_action_0再去扫user_action_1的时候,0可能又增加了不少数据。
    2)建一个change_log表,旧库在线上写入时,同时往这个表中记录增删改查的行为(包括操作类型,操作表,主键id),如action表的insert、update。这样全量以后,只要从这个表出发,就可以简单而高效地全表扫描了。

    

    5、检查:数据迁移完成之后,测试是难免的,让QA找那么20来个用户数据进行验证,但人的力量终究太小了。因此需要一个检查程序
    1)读取旧库分表条目数加和,新库的分表条目数加和,看差别的大小;
    2)从旧库的每个表中取出1w条数据,与新库中的数据进行按字段比较

以上就是这次迁移的整理思路,下面理一下先后顺序:

1、建库建表,建库建表(包括change_log表)
2、将线上程序,在每次写入action、status表数据时,插change_log表,上线以后观察数据是否正确
3、编写全量移植程序、增量移植程序、检查程序
4、将线上程序,去掉写入change_log表,改为新的分库策略(这是最终上线版本)
5、在线下进行全量、增量的程序测试,尤其是全量,要能根据线下估算出线上的大概执行时间,能否全部执行完成(会不会在中间因为内存等问题歇菜),日志是否足够(日志非常重要)。
6、测试没问题后,在线上进行数据移植,最后上新程序。

 

三、代码设计

根据上面的思路,来设计代码,先看结构:

├── all        ———— 全量├── bean       ———— 存放用到的中间Java对象├── check      ———— 检查程序├── common     ———— 存放一些如SQL语句,工具类等├── dao        ———— 数据操作层└── incr       ———— 增量

由于数据量比较大,单线程边读边写性能将非常差,因此这里要用到多线程,使用线程池控制线程在合理的数量;另外迁移程序的边读边写非常适合用生产者-消费者模式来做,因此要用到阻塞队列来保存中间数据。

private final LinkedBlockingQueue<OldEntity> dataQueue = new LinkedBlockingQueue<OldEntity>(1000);

    如上用LinkedBlockingQueue数据结构作为数据池,读取数据使用put()方法将数据放入队列,当数据满1000时,读线程将被阻塞;写库线程从队列中通过task()方法拿出数据,当了队列为空时,写线程被阻塞。

    另外,为了避免在数据迁移完时,写线程无限等待下去,可以在读取完所有的数据以后,在队列中设置一定数量的“毒瘤”,如放置OldEntity时故意将id设置为null,这样在拿出数据时,检查下这个状态,如果拿到这样的数据,就退出循环体。

    在线下测试发现,读取数据是一个非常快的操作,而相对读取,写操作慢的不是一个档次,因此在设计线程组的时候,要记住一读多写,具体多少个写,要看你的线程数。多线程数的设计,以保证最小的线程切换,这块可以用java/bin目录下提供的jvisualvm来进行性能测试,如下图:

图中展示的就是在jvisualvm中看到线程使用情况,这里我有了四组线程(1读+n写为一组),每组有一个读线和4个写线程,可以看到,即使这样,读线程大部分时间仍然在等待(黄色),而写线程一直在繁忙地写库。另外执行期间也要观察内存状态,尤其要确保没有内存溢出情况发生,即old区不会涨:

如果发现old区域不断上涨,有可能在诸如没有关闭prepareStatement等引起的。

四、上线及问题

线程数不能太大:按最初的设想,是通过配置线程组和写线程数来提高程序的执行速度,但是忘了线的数据库还在支持线上业务,因此不能任性的使用太多线程,最终使用线下测试时1/4的线程数据,用38个小时完成了数据移植。

主从延迟:由于线上数据库采用的是一主二从,为了不影响线上的业务,从从库中读取数据写入到新库的主库中。但是由于新建的库与老库在一个实例下面,导致从库同步主库的数据异常缓慢(因为从读同步主库sql log以串行的方式写入数据),大概延迟了10000s,即近一天的数据,这样导致专门从从库读取数据的业务不能正常读取到实时的数据.

五、总结

最后总结一下,上亿数据的迁移,是如何做到7*24小时服务不中断的呢?关键点就在于第4点增量程序,这里隐含了一点是,增量程序必须比真实数据跑的更快,否则增量追不上正常入库数据就麻烦了。

另外有完善的数据检查程序也是非常必须的,否则不能保证数据完全迁移完成。

最后上线时,要注意,虽然我们的设计可以让迁移程序在更短的时间内跑完,但如果线上资源有限,没有多个实例或机器,这样源库和目标库之间就会相互影响,所以我还反而要控制写入速度。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章