冷热分离就是在处理的数据的时将数据分成冷库和热库,冷库存放的是已经走到最终状态的数据,同时也是不常使用的数据;热库存放的未走到最终状态的数据,还需要在进行变更的、经常使用的数据。
假设业务需求出现了以下情况,就可以考虑使用冷热分离的解决方案。
首先我们要解决如下问题:
接下来看看我们如何处理上面的问题。
一般而言,在判断一个数据到底是冷数据还是热数据时,主要采用主表里一个字段或多个字段的组合作为区分标识。
这个字段可以是时间维度,比如订单的下单时间,可以把3个月前的订单数据当作冷数据,3个月内的订单数据当作热数据。当然,这个字段也可以是状态维度,比如根据订单状态字段来区分,将已完结的订单当作冷数据,未完结的订单当作热数据。
还可以采用组合字段的方式来区分,比如把下单时间小于3个月且状态为已完结的订单标识为冷数据,其他的当作热数据。而在实际工作中,最终使用哪种字段来判断,还是需要根据实际业务来决定的。
注意:
- 如果一个数据被标识为冷数据,业务代码不会再对它进行写操作。
- 不会同时存在读取冷、热数据的需求。
直接修改业务代码,使得每次修改数据时触发冷热分离(比如每次更新订单的状态时,就去触发这个逻辑)。
修改写操作的业务代码建议在业务代码比较简单,并且不按照时间区分冷热数据时使用
场景示例:假设是根据订单的状态来区分冷热数据,订单的状态不会随着时间自动变化,必须有人去修改才会变化,并且很容易找出所有修改订单状态的业务代码,这种情况下可以用这种触发逻辑。
如果不想修改原来的业务代码,可以通过监听数据库变更日志binlog
的方式来触发。具体方法就是另外创建一个服务,这个服务专门用来监控数据库的binlog
,一旦发现ticket表有变动,就将变动的订单数据发送到一个队列,这个队列的订阅者将会取出变动的订单,触发冷热分离逻辑。
监听数据库变更日志建议在业务代码比较复杂,不能随意变更,并且不按时间区分冷热数据时使用。
假设是根据订单的状态来区分冷热数据,订单的状态不会随着时间自动变化,必须有人去修改才会变化。其不一样的地方在于,业务代码很复杂,特别是有些用了很多年的系统中,修改订单状态的代码分布在多个位置,甚至多个服务中,不可能都找到,并且因为难以评估影响面,所以修改起来风险很大。这种情况下就适合使用监听数据库变更日志的方式。
通过定时扫描数据库的方式来触发。这个方式就是通过quartz
配置一个本地定时任务,或者通过类似于xxl-job
的分布式调度平台配置一个定时任务。这个定时任务每隔一段时间就扫描一次热数据库里面的订单表,找出符合冷数据标准的订单数据,进行冷热分离。
定时扫描数据库建议在按照时间区分冷热数据时使用。
比如业务需求是已经关闭超过1个月的订单视为冷数据,这种场景下,订单变更的那一瞬间,即使订单已经关闭了,也不能将其视为冷数据,而必须再等待1个月。这样的情况非常适合使用定时扫描。
如何选择这几中方案呢?
当决定了冷热分离的触发方式后,就进入下一个决策点:如何分离冷热数据。整个方案最复杂的环节就是这里。
在讲解如何分离冷热数据之前,先来了解一下分离冷热数据的基本逻辑,只有掌握了基本原理,才能真正理解事物的本质。
这个逻辑看起来简单,而实际做方案时,以下3点都要考虑在内。
任何一个程序都要考虑在运行过程中突然出错中断时,应该怎么办。比如业务逻辑如下。
举几个例子:
例1:假设执行到步骤2的时候失败了,那么,要确保这些订单数据最终还是会被移到冷数据库。
例2:假设执行到步骤3的时候失败了,那么,要确保这些订单数据最终还是会从热数据库中删除。
这称为“最终一致性”,即最终数据和业务实际情况是一致的。这里的解决方案为,保证每一步都可以重试且操作都有幂等性,具体逻辑分为4步。
ColdFlag=WaittingForMove
(实际处理中标识字段的值用数字就可以,这里是为了方便理解),从而将冷热数据标识的计算结果进行持久化,后面可以使用。ColdFlag=WaittingForMove
)。这一步是为了确保前面有些线程因为部分原因运行失败,出现有些待迁移的数据没有迁移的情况时,可以通过这个标识找到这些遗留在热数据库中的订单数据。也就是上述例1中的情况。前面讲了3种冷热分离的触发逻辑,前2种基本不会出现数据量大的问题,因为每次只需要操作那一瞬间变更的数据,但如果采用定时扫描的逻辑就需要考虑数据量这个问题了。回到业务场景中,假设每天做一次冷热分离,根据前面的估算,每天有10万的订单数据和几十万的订单历史记录数据要迁移,但是程序不可能一次性插入几十万条记录,这时就要考虑批量处理了。这个实现逻辑也很简单,在迁移数据的地方加个批量处理逻辑就可以了。为方便理解,来看一个示例。
假设每次可以迁移1000条数据。
ColdFlag=WaittingForMove
。这个过程使用Update语句就可以完成,每次更新大概10万条记录。ColdFlag=WaittingForMove
)。在定时迁移冷热数据的场景里(比如每天),假设每天处理的数据量大到连单线程批量处理都应对不了,该怎么办?这时可以使用多个线程进行并发处理。回到场景中,假设已经有3000万的数据,第一次运行冷热分离的逻辑时,这些数据如果通过单线程来迁移,一个晚上可能无法完成,会影响第二天的客服工作,所以要考虑并发,采用多个线程来迁移。
虽然大部分情况下多线程较快,但笔者在其他项目中也曾碰到过这种情况:单线程的
batchsize
达到一定数值时效率特别高,比任何batchsize的多线程还要快。因此,是否采用多线程要在测试环境中实际测试一下。
当采用多线程同时迁移冷热数据时,需要考虑如下实现逻辑。
(1)如何启动多线程?本项目采用的是定时器触发逻辑,性价比最高的方式是设置多个定时器,并让每个定时器之间的间隔短一些,然后每次定时启动一个线程后开始迁移数据。还有一个比较合适的方式是自建一个线程池,然后定时触发后面的操作:先计算待迁移的热数据数量,再计算要同时启动的线程数,如果大于线程池的数量就取线程池的线程数,假设这个要启动的线程数量为N,最后循环N次启动线程池的线程来迁移数据。本项目使用了第二种方式,设置一个size为10的线程池,每次迁移500条记录,如果标识出的待迁移记录超过5000条,那么最多启动10个线程。考虑了如何启动多线程的问题,接下来就是考虑锁了。
(2)某线程宣布正在操作某个数据,其他线程不能操作它(锁)因为是多线程并发迁移数据,所以要确保每个线程迁移的数据都是独立分开的,不能出现多个线程迁移同一条记录的情况。其实这就是锁的一个场景。
1)获取锁的原子性:当一个线程发现某个待处理的数据没有加锁时就给它加锁,这两步操作必须是原子性的,即要么一起成功,要么一起失败。实现这个逻辑时是要防止以下这种情况:“我是当前正在运行的线程,我发现一条订单没有锁,结果在要给它加锁的瞬间,它已经被别人加锁了。”可采用的解决方案是在表中加上LockThread
字段,用来判断加锁的线程,每个线程只能处理被自己加锁成功的数据。然后使用一条Update…Where…
语句,Where条件用来描述待迁移的未加锁或锁超时的数据,Update
操作是使LockThread
=当前线程ID,它利用MySQL
的更新锁机制来实现原子性。
LockThread
可以直接放在业务表中,也可以放在一个扩展表中。放在业务表中会对原来的表结构有一些侵入,放在扩展表中会增加一张表。最终,项目组选择将其放在业务表中,因为这种情况下编写的Update语句相对更简单,能缩短工期。
2)获取锁必须与处理开始保证一致性:当前线程开始处理这条数据时,需要再次检查操作的数据是否由当前线程锁定成功,实际操作为再次查询一下LockThread
=当前线程ID的数据,再处理查询出来的数据。为什么要多此一举?因为当前面的Update…Where…
语句执行完以后,程序并不知道哪些数据被Update语句更新了,也就是说被当前线程加锁了,所以还需要通过另一条SQL语句来查出这些被当前线程加锁成功的数据。这样就确保了当前线程处理的数据确实是被当前线程成功锁定的数据。
3)释放锁必须与处理完成保证一致性:当前线程处理完数据后,必须保证锁被释放。线程正常处理完后,数据不在热数据库,而是直接到了冷数据库,后续的线程不会再去迁移它,所以也就没有锁有没有及时释放的顾虑了。
在功能设计的查询界面上,一般都会有一个选项用来选择需要查询冷数据还是热数据,或者是根据业务查询根据日期区分查询。如果界面上没有提供,则可以直接在业务代码里区分。
在判断是冷数据还是热数据时,必须确保用户没有同时读取冷热数据的需求。
一般而言,只要与持久化层有关的架构方案都需要考虑历史数据的迁移问题,即如何让旧架构的历史数据适用于新的架构。因为前面的分离逻辑在考虑失败重试的场景时刚好覆盖了这个问题,所以其解决方案很简单,只需要批量给所有符合冷数据条件的历史数据加上标识ColdFlag=WaittingForMove
,程序就会自动迁移了。还可以采用binglog方式同步。