本文依据《从程序员到架构师》阅读有感,记录书中案例并且结合作者工作经历进行分析。
当数据量过大,业务查询慢甚至导致数据库服务器CPU飙升,导致数据库宕机,影响用户体验。
场景:
1.客户两年多产生了近2000万的工单,工单的操作记录近1亿
2.工单表已经达到3000万条数据。
3.工单表的处理记录表达到1.5亿条数据。
4.工单表每日以10万的数据量在增长。
5.系统性能已经严重影响了客服的处理效率,需要放在第一优先级解决,客户给的期限是1周
解决方法:数据库分区
数据库分区有以下优点。
1)比起单个文件系统或硬盘,分区可以存储更多的数据。
2)在清理数据时,可以直接删除废弃数据所在的分区。同样,有新数据时,可以增加更多的分区来存储新数据。
3)可以大幅度地优化特定的查询,让这些查询语句只去扫描特定分区的数据。比如,原来有2000万的数据,设计10个分区,每个分区存200万的数据,那么可以优化查询语句,让它只去查询其中两个分区,即只需要扫描400万的数据。
业务中用到的主要查询语句:
1)客服查询无处理人的工单:“Where assignedUserID=?”。
2)客服获取分派给自己的工单:“Where status in(…)and assignedUserID=?”。
3)客服组长查看自己组的工单:“Where assignedUserGroupID=?”。
4)客服查询特定客户的工单:“Where consumerEmail=?”。
为了达到只扫描特定分区的效果,必须在Where语句里面加上一个包含分区字段的条件,但是上面这些主要语句并不包含相同的字段。
MySQL的分区还有个限制,即分区字段必须是唯一索引(主键也是唯一索引)的一部分。工单表是用ticketID当主键,也就是说接下来无论使用什么当分区字段,都必须把它加到主键当中,形成复合主键。
依据上述业务场景,数据库分区不满足条件。
进一步分析业务场景:
1)系统从邮件服务器同步到邮件以后,创建一个工单,createdTime就是工单创建的时间。
2)客服先去查询无处理人的工单,然后把工单分派给自己。
3)客服处理工单,每处理一次,系统自动增加一条处理记录。
4)客服处理完工单以后,将工单状态改为“关闭”。
一般工单被关闭以后,客服查询的概率就很低了。对于那些关闭超过一个月的工单,基本上一年都打开不了几次。
思路也很简单:
新建一个数据库,然后将1个月前已经完结的工单数据都移动到这个新的数据库。这个数据库就叫冷库,因为里面基本是冷数据(当然,叫作归档数据库也可以),之后极少被访问。当前的数据库保留正常处理的较新的工单数据,这是热库。
这样处理后,热库大概只会有300万条数据,性能无问题。关于冷库查询,后面会讨论。
什么是冷热分离?
冷热分离就是在处理数据时将数据库分成冷库和热库,冷库存放那些走到终态、不常使用的数据,热库存放还需要修改、经常使用的数据。
什么情况下使用冷热分离?
假设业务需求出现了以下情况,就可以考虑使用冷热分离的解决方案。1)数据走到终态后只有读没有写的需求,比如订单完结状态。2)用户能接受新旧数据分开查询,比如有些电商网站默认只让查询3个月内的订单,如果要查询3个月前的订单,还需要访问其他的页面。
冷热分离一期实现思路:冷热数据都用MySQL
在冷热分离一期的实际操作过程中,需要考虑以下问题。
1)如何判断一个数据是冷数据还是热数据?
2)如何触发冷热数据分离?
3)如何实现冷热数据分离?
4)如何使用冷热数据?
5)历史数据如何迁移?
如何判断一个数据到底是冷数据还是热数据?
关于判断冷热数据的逻辑,这里还有两个要点必须说明。1)如果一个数据被标识为冷数据,业务代码不会再对它进行写操作。2)不会同时存在读取冷、热数据的需求。回到本章项目场景,这里就把lastProcessTime大于1个月,并且status为“关闭”的工单数据标识为冷数据。
如何触发冷热数据分离?
三种:
1.工单表每做一次变更(其实就是客服对工单做处理操作),就要对变更后的工单数据触发一次冷热数据的分离。
2.如果不想修改原来的业务代码,可以通过监听数据库变更日志binlog的方式来触发。具体方法就是另外创建一个服务,这个服务专门用来监控数据库的binlog,一旦发现ticket表有变动,就将变动的工单数据发送到一个队列,这个队列的订阅者将会取出变动的工单,触发冷热分离逻辑
3. 通过定时扫描数据库的方式来触发。这个方式就是通过quartz配置一个本地定时任务,或者通过类似于xxl-job的分布式调度平台配置一个定时任务。这个定时任务每隔一段时间就扫描一次热数据库里面的工单表,找出符合冷数据标准的工单数据,进行冷热分离
1.修改写操作的业务代码建议在业务代码比较简单,并且不按照时间区分冷热数据时使用。场景示例:假设是根据订单的状态来区分冷热数据,订单的状态不会随着时间自动变化,必须有人去修改才会变化,并且很容易找出所有修改订单状态的业务代码,这种情况下可以用这种触发逻辑。
2.监听数据库变更日志建议在业务代码比较复杂,不能随意变更,并且不按时间区分冷热数据时使用。示例场景跟上一场景类似:假设是根据订单的状态来区分冷热数据,订单的状态不会随着时间自动变化,必须有人去修改才会变化。其不一样的地方在于,业务代码很复杂,特别是有些用了很多年的系统中,修改订单状态的代码分布在多个位置,甚至多个服务中,不可能都找到,并且因为难以评估影响面,所以修改起来风险很大。这种情况下就适合使用监听数据库变更日志的方式。
3.定时扫描数据库建议在按照时间区分冷热数据时使用。示例场景就是这个项目中的业务场景。这里的业务需求是已经关闭超过1个月的工单视为冷数据,这种场景下,工单变更的那一瞬间,即使工单已经关闭了,也不能将其视为冷数据,而必须再等待1个月。这样的情况非常适合使用定时扫描。
项目组就选用了定时扫描数据库的触发方式。
1.一致性:同时修改多个数据库,如何保证数据的一致性?
这里提到的一致性要求是指如何保证任何一步出错后数据最终还是一致的。任何一个程序都要考虑在运行过程中突然出错中断时,应该怎么办。业务逻辑如下。1)找出符合冷数据的工单。2)将这些工单添加到冷数据库。3)将这些工单从热数据库中删除。
例1:假设执行到步骤2)的时候失败了,那么,要确保这些工单数据最终还是会被移到冷数据库。例2:假设执行到步骤3)的时候失败了,那么,要确保这些工单数据最终还是会从热数据库中删除。这称为“最终一致性”,即最终数据和业务实际情况是一致的。
解决方案为,保证每一步都可以重试且操作都有幂等性,具体逻辑分为4步。
1)在热数据库中给需要迁移的数据加标识:ColdFlag=WaittingForMove(实际处理中标识字段的值用数字就可以,这里是为了方便理解),从而将冷热数据标识的计算结果进行持久化,后面可以使用。
2)找出所有待迁移的数据(ColdFlag=WaittingForMove)。这一步是为了确保前面有些线程因为部分原因运行失败,出现有些待迁移的数据没有迁移的情况时,可以通过这个标识找到这些遗留在热数据库中的工单数据。也就是上述例1中的情况。
3)在冷数据库中保存一份数据,但在保存逻辑中需要加个判断来保证幂等性(关于幂等性,后续还有详细的介绍),通俗来说就是假如保存的数据在冷数据库已经存在了,也要确保这个逻辑可以继续进行。这样可以防止上述例2中的情况,因为可能会出现有一些工单其实已经保存到冷数据库中了,但是在将它们从热数据库删除时的逻辑出错了,它们仍然保留在热数据库中,等下次冷热分离的时候,又要将这些工单重复插入冷数据库中。这里面就要通过幂等性来确保冷数据库中没有重复数据。
4)从热数据库中删除对应的数据。
2.数据量:假设数据量大,一次处理不完,该怎么办?是否需要使用批量处理?
假设每天做一次冷热分离,根据前面的估算,每天有10万的工单数据和几十万的工单历史记录数据要迁移,但是程序不可能一次性插入几十万条记录,这时就要考虑批量处理了。
假设每次可以迁移1000条数据。
1)在热数据库中给需要的数据添加标识:ColdFlag=WaittingForMove。这个过程使用Update语句就可以完成,每次更新大概10万条记录。
2)找出前1000条待迁移的数据(ColdFlag=WaittingForMove)。
3)在冷数据库中保存一份数据。
4)从热数据库中删除对应的数据。
5)循环执行2)~4)。
3.并发性:假设数据量大到要分到多个地方并行处理,该怎么办?
在定时迁移冷热数据的场景里(比如每天),假设每天处理的数据量大到连单线程批量处理都应对不了,该怎么办?这时可以使用多个线程进行并发处理。回到场景中,假设已经有3000万的数据,第一次运行冷热分离的逻辑时,这些数据如果通过单线程来迁移,一个晚上可能无法完成,会影响第二天的客服工作,所以要考虑并发,采用多个线程来迁移。Tips虽然大部分情况下多线程较快,但笔者在其他项目中也曾碰到过这种情况:单线程的batchsize达到一定数值时效率特别高,比任何batchsize的多线程还要快。因此,是否采用多线程要在测试环境中实际测试一下。当采用多线程同时迁移冷热数据时,需要考虑如下实现逻辑。
(1)如何启动多线程?
本项目采用的是定时器触发逻辑,性价比最高的方式是设置多个定时器,并让每个定时器之间的间隔短一些,然后每次定时启动一个线程后开始迁移数据。还有一个比较合适的方式是自建一个线程池,然后定时触发后面的操作:先计算待迁移的热数据数量,再计算要同时启动的线程数,如果大于线程池的数量就取线程池的线程数,假设这个要启动的线程数量为N,最后循环N次启动线程池的线程来迁移数据。本项目使用了第二种方式,设置一个size为10的线程池,每次迁移500条记录,如果标识出的待迁移记录超过5000条,那么最多启动10个线程。
(2)某线程宣布正在操作某个数据,其他线程不能操作它(锁)
因为是多线程并发迁移数据,所以要确保每个线程迁移的数据都是独立分开的,不能出现多个线程迁移同一条记录的情况。其实这就是锁的一个场景。
1)获取锁的原子性:当一个线程发现某个待处理的数据没有加锁时就给它加锁,这两步操作必须是原子性的,即要么一起成功,要么一起失败。实现这个逻辑时是要防止以下这种情况:“我是当前正在运行的线程,我发现一条工单没有锁,结果在要给它加锁的瞬间,它已经被别人加锁了。”可采用的解决方案是在表中加上LockThread字段,用来判断加锁的线程,每个线程只能处理被自己加锁成功的数据。然后使用一条Update…Where…语句,Where条件用来描述待迁移的未加锁或锁超时的数据,Update操作是使LockThread=当前线程ID,它利用MySQL的更新锁机制来实现原子性。
Tips:
LockThread可以直接放在业务表中,也可以放在一个扩展表中。放在业务表中会对原来的表结构有一些侵入,放在扩展表中会增加一张表。最终,项目组选择将其放在业务表中,因为这种情况下编写的Update语句相对更简单,能缩短工期。
2)获取锁必须与处理开始保证一致性:当前线程开始处理这条数据时,需要再次检查操作的数据是否由当前线程锁定成功,实际操作为再次查询一下LockThread=当前线程ID的数据,再处理查询出来的数据。为什么要多此一举?因为当前面的Update…Where…语句执行完以后,程序并不知道哪些数据被Update语句更新了,也就是说被当前线程加锁了,所以还需要通过另一条SQL语句来查出这些被当前线程加锁成功的数据。这样就确保了当前线程处理的数据确实是被当前线程成功锁定的数据。
3)释放锁必须与处理完成保证一致性:当前线程处理完数据后,必须保证锁被释放。线程正常处理完后,数据不在热数据库,而是直接到了冷数据库,后续的线程不会再去迁移它,所以也就没有锁有没有及时释放的顾虑了。
(3)若某线程失败退出,但锁没释放,该怎么办(锁超时)?
如果锁定某数据的线程异常退出了且来不及释放锁,导致其他线程无法处理这个数据,此时该怎么办?解决方案为给锁设置一个合理的超时时间,如果锁超时了还未释放,其他线程可正常处理该数据。所以添加一个新的字段LockTime,在更新数据的LockThread时,也将Lock Time更新为当前时间。加锁的SQL语句则变成类似这样:Update Set LockThread=当前线程ID,LockTime=当前时间…Where LockThread为空Or LockTime<N秒这样的话,即使加锁的线程出现异常,后续的线程也可以去处理它,保证数据没有遗漏。
对于这种场景,除了将超时的时间设置成处理数据的合理时间外,处理冷热数据的代码必须保证是幂等性的。在编程中,一个幂等操作的特点是多次执行某个操作与执行一次操作的影响相同。
这句话什么意思?就是当多个线程先后对同一条数据进行迁移处理时,要让迁移线程的每一步都去判断:这条数据的当前步骤是否已经执行过了?如果是的话,直接进入下一步,或者忽略它。总之,需要达到的效果就是,不管只是线程甲处理A1数据一次,还是线程甲、乙各处理A1一次,甚至多个线程分别处理A1,都要确保最终的数据是一样的。那么如何实现幂等操作?使用MySQL的Insert…On Duplicate Key Update语句即可。使用这样的操作后,当前线程的处理就不会破坏数据的一致性。
如何使用冷热数据
在功能设计的查询界面上,一般都会有一个选项用来选择需要查询冷数据还是热数据,如果界面上没有提供,则可以直接在业务代码里区分,如图1-8所示。
Tips:在判断是冷数据还是热数据时,必须确保用户没有同时读取冷热数据的需求。
回到真实场景,在工单列表页面的搜索区域增加一个checkBox:查询归档。这个checkBox默认不勾选,这种情况下客服每次查询的都是非归档的工单,也就是未关闭或者关闭未超过1个月的工单。如果客服要查询归档工单,则勾选这个checkBox,这种情况下,客服只能查询归档的工单,查询速度还是很慢。
历史数据如何迁移
一般而言,只要与持久化层有关的架构方案都需要考虑历史数据的迁移问题,即如何让旧架构的历史数据适用于新的架构。因为前面的分离逻辑在考虑失败重试的场景时刚好覆盖了这个问题,所以其解决方案很简单,只需要批量给所有符合冷数据条件的历史数据加上标识ColdFlag=WaittingForMove,程序就会自动迁移了。
整体方案
总结一下,实现思路分为5个部分:冷热数据判断逻辑、冷热数据的触发逻辑、冷热数据分离实现思路、冷热数据库使用、历史数据迁移。
冷热分离二期实现思路:冷数据存放到HBase
不得不说,冷热分离一期的解决方案确实能解决写操作慢和热数据慢的问题,但仍然存在诸多不足。
1)用户查询冷数据的速度依旧很慢,虽然查询冷数据的用户比例很低。
2)冷数据库偶尔会告警。这两点不足体现在用户侧是什么样呢?那就是一旦客服在工单查询表中勾选“查询归档”checkBox,页面就会一直转圈,而后台冷数据库的IO就会飙升。如果客服发现页面没反应,可能会多点几次“查询”按钮,那么有可能把后台服务器的请求线程占满,导致整个系统响应都很慢。归档的数据库里面,工单表仍然有3000多万的工单数据,工单处理记录表仍然有数亿的数据。这个查询不可能不慢。一期要做冷热分离的时候,项目组只有1周的时间(实际用了10天),但是之后有空闲,就可以好好考虑一下归档数据库的设计了。
二期的代码改造
二期和一期的主要区别就是冷数据库使用了HBase,主要的代码逻辑有一个变化,就是关于事务。一期的批量逻辑如下所示。
1)取出300条工单。
2)通过单事务包围的BATCHSQL语句插入冷数据库。
3)通过一个单事务包围的BATCHSQL语句从热数据库中删除数据。
二期因为HBase不支持类似的事务,所以批量逻辑如下。
1)取出50条工单,先处理第一个工单。
2)将当前工单的各个ColumnKey值插入HBase。
3)通过一个单事务包围的SQL语句删除热数据库中该工单对应的数据。
4)循环执行第2)步和第3)步,依次处理完成所有50个工单。
加锁、多线程等其他相关的逻辑并没有变化,从MySQL这个冷数据库将数据迁移到HBase的方案可以参考一期,这里就不再赘述了。以上就是冷热分离二期的改造方案。二期花费了3周左右才上线,之后查询归档工单的性能好了很多,特别是单个归档工单的打开操作响应快了不少。这个方案还解决了一个隐患,即MySQL这个冷数据库随着归档工单数据量的增加支撑不住的问题。
1.6 小结
这样,冷热分离这个方案就完成了。冷热分离方案只是刚好适用于这个场景,它其实有很多不足。后来笔者又反思了一下,如果不是因为做一期方案的时候只有1周的时间,那么是不是还会使用冷热分离的方案?会不会有更好的方案?这个方案有一些明显的不足,如果碰到下面的任何一个场景,这个方案就不适用了。1)工单没有“归档”这一特点,经常需要修改。2)所有的工单数据都需要支持复杂的查询,并且需要非常快的响应速度。3)需要实时地对工单数据进行各种统计。笔者后来做其他项目的时候就碰到了上面列举的场景,那么,这些情况下,又该用什么方案来解决?请看下一场景。
感言:
读了冷热数据这段,笔者想起了之前从事电商行业的时候,查看订单数据。当时,设计的也是冷热数据分离,以订单完成且单据为三个月之前的为冷数据。每天夜里凌晨会有定时任务去执行,将热数据中复核要求的转为冷数据。冷热分离和作者的类似,但是冷数据处理这块,我们仍然使用的mysql,只是进一步区分了,按年份分别有三个冷数据库。后期考虑到成本问题,对冷数据进行了删除,只保存最近两年的,后续使用了polardb数据库来保持冷数据。