业务场景

有一个系统的主要功能是这样的:它会对接客户的邮件服务器,自动收取发到几个特定客服邮箱的邮件,每收到一封客服邮件,就自动生成一个工单。之后系统就会根据一些规则将工单分派给不同的客服专员处理。

这家媒体集团客户两年多产生了近2000万的工单,工单的操作记录近1亿。平时客服在工单页面操作时,打开或者刷新工单列表需要10秒钟左右。要求进行优化:
当时的数据情况如下:
1)工单表已经达到3000万条数据。
2)工单表的处理记录表达到1.5亿条数据。
3)工单表每日以10万的数据量在增长。

在客户提出需求之前,项目组已经通过优化表结构、业务代码、索引、SQL语句等办法来提高系统响应速度,系统最终支撑起了3000万数据的表查询。这次只能尝试其他方案。

冷热分离一期实现思路:冷热数据都用MySQL

MySQL数据库的冷热数据分离方案 冷热数据分层_mysql

当决定用冷热分离之后,项目组就开始考虑使用一个性价比最高的冷热分离方案。因为资源有限、工期又短,冷热分离一期有一个主导原则,即热数据跟冷数据使用一样的存储(MySQL)和数据结构,这样工作量最少,等到以后有时间再做冷热分离二期。

需要考虑的问题
1)如何判断一个数据是冷数据还是热数据?
2)如何触发冷热数据分离?
3)如何实现冷热数据分离?
4)如何使用冷热数据?
5)历史数据如何迁移?

MySQL数据库的冷热数据分离方案 冷热数据分层_mysql_02

1.1 如何判断一个数据到底是冷数据还是热数据

关于判断冷热数据的逻辑,这里还有两个要点必须说明。
1)如果一个数据被标识为冷数据,业务代码不会再对它进行写操作。
2)不会同时存在读取冷、热数据的需求。

回到本章项目场景,这里就把lastProcessTime大于1个月,并且status为“关闭”的工单数据标识为冷数据。

1.2 如何触发冷热数据分离

一般来说,冷热数据分离的触发逻辑分为3种。

1)直接修改业务代码,使得每次修改数据时触发冷热分离(比如每次更新订单的状态时,就去触发这个逻辑),如图所示。这个逻辑在该业务场景中就表现为:工单表每做一次变更(其实就是客服对工单做处理操作),就要对变更后的工单数据触发一次冷热数据的分离。

建议在业务代码比较简单,并且不按照时间区分冷热数据时使用。

MySQL数据库的冷热数据分离方案 冷热数据分层_java_03

2)如果不想修改原来的业务代码,可以通过监听数据库变更日志binlog的方式来触发。具体方法就是另外创建一个服务,这个服务专门用来监控数据库的binlog,一旦发现ticket表有变动,就将变动的工单数据发送到一个队列,这个队列的订阅者将会取出变动的工单,触发冷热分离逻辑,如图所示。

建议在业务代码比较复杂,不能随意变更,并且不按时间区分冷热数据时使用。

MySQL数据库的冷热数据分离方案 冷热数据分层_数据_04

3)通过定时扫描数据库的方式来触发。这个方式就是通过quartz配置一个本地定时任务,或者通过类似于xxl-job的分布式调度平台配置一个定时任务。这个定时任务每隔一段时间就扫描一次热数据库里面的工单表,找出符合冷数据标准的工单数据,进行冷热分离,如图所示。

建议在按照时间区分冷热数据时使用。

MySQL数据库的冷热数据分离方案 冷热数据分层_mysql_05


3种触发逻辑的优缺点

MySQL数据库的冷热数据分离方案 冷热数据分层_数据库_06

1.3 如何分离冷热数据

分离冷热数据的基本逻辑如图1-5所示,细节如下。

MySQL数据库的冷热数据分离方案 冷热数据分层_java_07

1)判断数据是冷是热。
2)将要分离的数据插入冷数据库中。
3)从热数据库中删除分离的数据。

这个逻辑看起来简单,而实际做方案时,以下3点都要考虑在内。

1.一致性:同时修改多个数据库,如何保证数据的一致性?
这里提到的一致性要求是指如何保证任何一步出错后数据最终还是一致的。任何一个程序都要考虑在运行过程中突然出错中断时,应该怎么办。业务逻辑如下。
1)找出符合冷数据的工单。
2)将这些工单添加到冷数据库。
3)将这些工单从热数据库中删除。

举几个例子。
例1:假设执行到步骤2)的时候失败了,那么,要确保这些工单数据最终还是会被移到冷数据库。
例2:假设执行到步骤3)的时候失败了,那么,要确保这些工单数据最终还是会从热数据库中删除。这称为“最终一致性”,即最终数据和业务实际情况是一致的。

这里的解决方案为,保证每一步都可以重试且操作都有幂等性,具体逻辑分为4步。

1)在热数据库中给需要迁移的数据加标识:ColdFlag=WaittingForMove(实际处理中标识字段的值用数字就可以,这里是为了方便理解),从而将冷热数据标识的计算结果进行持久化,后面可以使用。

使用循环update,每次检索5000条数据进行更新,避免一下update太多数据给db打挂

2)找出所有待迁移的数据(ColdFlag=WaittingForMove)。这一步是为了确保前面有些线程因为部分原因运行失败,出现有些待迁移的数据没有迁移的情况时,可以通过这个标识找到这些遗留在热数据库中的工单数据。也就是上述例1中的情况。

3)在冷数据库中保存一份数据,但在保存逻辑中需要加个判断来保证幂等性(关于幂等性,后续还有详细的介绍),通俗来说就是假如保存的数据在冷数据库已经存在了,也要确保这个逻辑可以继续进行。这样可以防止上述例2中的情况,因为可能会出现有一些工单其实已经保存到冷数据库中了,但是在将它们从热数据库删除时的逻辑出错了,它们仍然保留在热数据库中,等下次冷热分离的时候,又要将这些工单重复插入冷数据库中。这里面就要通过幂等性来确保冷数据库中没有重复数据。

4)从热数据库中删除对应的数据。

2.数据量:假设数据量大,一次处理不完,该怎么办?是否需要使用批量处理?
前面讲了3种冷热分离的触发逻辑,前2种基本不会出现数据量大的问题,因为每次只需要操作那一瞬间变更的数据,但如果采用定时扫描的逻辑就需要考虑数据量这个问题了。

回到业务场景中,假设每天做一次冷热分离,根据前面的估算,每天有10万的工单数据和几十万的工单历史记录数据要迁移,但是程序不可能一次性插入几十万条记录,这时就要考虑批量处理了。这个实现逻辑也很简单,在迁移数据的地方加个批量处理逻辑就可以了。

假设每次可以迁移1000条数据。
1)在热数据库中给需要的数据添加标识:ColdFlag=WaittingForMove。这个过程使用Update语句就可以完成,每次更新大概10万条记录。
2)找出前1000条待迁移的数据(ColdFlag=WaittingForMove)。
3)在冷数据库中保存一份数据。
4)从热数据库中删除对应的数据。
5)循环执行2)~4)。

3.并发性:假设数据量大到要分到多个地方并行处理,该怎么办?
假设已经有3000万的数据,第一次运行冷热分离的逻辑时,这些数据如果通过单线程来迁移,一个晚上可能无法完成,会影响第二天的客服工作,所以要考虑并发,采用多个线程来迁移。

(1)如何启动多线程?
本项目采用的是定时器触发逻辑,性价比最高的方式是设置多个定时器,并让每个定时器之间的间隔短一些,然后每次定时启动一个线程后开始迁移数据。

还有一个比较合适的方式是自建一个线程池,然后定时触发后面的操作:先计算待迁移的热数据数量,再计算要同时启动的线程数,如果大于线程池的数量就取线程池的线程数,假设这个要启动的线程数量为N,最后循环N次启动线程池的线程来迁移数据。

本项目使用了第二种方式,设置一个size为10的线程池,每次迁移500条记录,如果标识出的待迁移记录超过5000条,那么最多启动10个线程。

考虑了如何启动多线程的问题,接下来就是考虑锁了。

因为是多线程并发迁移数据,所以要确保每个线程迁移的数据都是独立分开的,不能出现多个线程迁移同一条记录的情况。其实这就是锁的一个场景。

1)获取锁的原子性:当一个线程发现某个待处理的数据没有加锁时就给它加锁,这两步操作必须是原子性的,即要么一起成功,要么一起失败。实现这个逻辑时是要防止以下这种情况:“我是当前正在运行的线程,我发现一条工单没有锁,结果在要给它加锁的瞬间,它已经被别人加锁了。”

可采用的解决方案是在表中加上LockThread字段,用来判断加锁的线程,每个线程只能处理被自己加锁成功的数据。然后使用一条Update…Where…语句,Where条件用来描述待迁移的未加锁或锁超时的数据,Update操作是使LockThread=当前线程ID,它利用MySQL的更新锁机制来实现原子性。

LockThread可以直接放在业务表中,也可以放在一个扩展表中。放在业务表中会对原来的表结构有一些侵入,放在扩展表中会增加一张表。最终,项目组选择将其放在业务表中,因为这种情况下编写的Update语句相对更简单,能缩短工期。

2)获取锁必须与处理开始保证一致性:当前线程开始处理这条数据时,需要再次检查操作的数据是否由当前线程锁定成功,实际操作为再次查询一下LockThread=当前线程ID的数据,再处理查询出来的数据。为什么要多此一举?因为当前面的Update…Where…语句执行完以后,程序并不知道哪些数据被Update语句更新了,也就是说被当前线程加锁了,所以还需要通过另一条SQL语句来查出这些被当前线程加锁成功的数据。这样就确保了当前线程处理的数据确实是被当前线程成功锁定的数据。

3)释放锁必须与处理完成保证一致性:当前线程处理完数据后,必须保证锁被释放。线程正常处理完后,数据不在热数据库,而是直接到了冷数据库,后续的线程不会再去迁移它,所以也就没有锁有没有及时释放的顾虑了。

若某线程失败退出,但锁没释放,该怎么办(锁超时)?

如果锁定某数据的线程异常退出了且来不及释放锁,导致其他线程无法处理这个数据,此时该怎么办?解决方案为给锁设置一个合理的超时时间,如果锁超时了还未释放,其他线程可正常处理该数据。所以添加一个新的字段LockTime,在更新数据的LockThread时,也将Lock Time更新为当前时间。加锁的SQL语句则变成类似这样:Update Set LockThread=当前线程ID,LockTime=当前时间…Where LockThread为空Or LockTime<N秒这样的话,即使加锁的线程出现异常,后续的线程也可以去处理它,保证数据没有遗漏。

设置超时时间时,还应考虑如果正在处理的线程并未退出、还在处理数据而导致了超时,又该怎么办。这就是幂等性问题了。
那么如何实现幂等操作?使用MySQL的Insert…On Duplicate Key Update语句即可。使用这样的操作后,当前线程的处理就不会破坏数据的一致性。


1.4 如何使用冷热数据

在判断是冷数据还是热数据时,必须确保用户没有同时读取冷热数据的需求

在冷热分离一期的时候冷库工单表仍然有3000多万的工单数据,工单处理记录表仍然有数亿的数据。这个查询不可能不慢。一期主要目的是为了方便客服操作热数据

MySQL数据库的冷热数据分离方案 冷热数据分层_java_08

回到真实场景,在工单列表页面的搜索区域增加一个checkBox:查询归档。这个checkBox默认不勾选,这种情况下客服每次查询的都是非归档的工单,也就是未关闭或者关闭未超过1个月的工单。如果客服要查询归档工单,则勾选这个checkBox,这种情况下,客服只能查询归档的工单,查询速度还是很慢。

1.5 历史数据如何迁移

因为前面的分离逻辑在考虑失败重试的场景时刚好覆盖了这个问题,所以其解决方案很简单,只需要批量给所有符合冷数据条件的历史数据加上标识ColdFlag=WaittingForMove,程序就会自动迁移了

总结

MySQL数据库的冷热数据分离方案 冷热数据分层_java_09