表数据量大优化方案设计
场景:
有一个订单功能,里面的主表有几千万数据量,加上关联表,数据量达到上亿。
我们尝试了优化表结构、业务代码、索引、SQL 语句等办法来提高响应速度,但查询速度还是很慢。
一、什么是冷热分离
最终,我们决定采用一个性价比高的解决方案,在处理数据时,我们将数据库分成了冷库和热库 2 个库,不常用数据放冷库,常用数据放热库。
这就是“冷热分离”。
二、什么情况下使用冷热分离?
数据走到终态后,只有读没有写的需求,比如订单完结状态;
用户能接受新旧数据分开查询,比如有些网站默认只让查询3个月内的订单,如果你要查询3个月前的订单,还需要访问另外的单独页面。
三、冷热分离实现思路
在实际操作过程中,冷热分离整体实现思路如下:
1、如何判断一个数据到底是冷数据还是热数据?
2、如何触发冷热数据分离?
3、如何实现冷热数据分离?
4、如何使用冷热数据?
接下来,我们针对以上4个问题进行详细的分析。
(一)如何判断一个数据到底是冷数据还是热数据?
一般而言,在判断一个数据到底是冷数据还是热数据时,我们主要采用主表里的 1 个或多个字段组合的方式作为区分标识。这个字段可以是时间维度,比如“下单时间”这个字段,我们可以把 3 个月前的订单数据当作冷数据,3 个月内的当作热数据。
这个字段也可以是状态维度,比如根据“订单状态”字段来区分,已完结的订单当作冷数据,未完结的订单当作热数据。
我们还可以采用组合字段的方式来区分,比如我们把下单时间 > 3 个月且状态为“已完结”的订单标识为冷数据,其他的当作热数据。
关于判断冷热数据的逻辑有 2 个注意要点必须说明:
- 如果一个数据被标识为冷数据,业务代码不会再对它进行写操作;
- 不会同时存在读冷/热数据的需求。
(二)如何触发冷热数据分离?
了解了冷热数据的判断逻辑后,我们就要开始考虑如何触发冷热数据分离了。一般来说,冷热数据分离的触发逻辑分3种。
1、直接修改业务代码,每次修改数据时触发冷热分离(比如每次更新了订单的状态,就去触发这个逻辑);
2、如果不想修改原来业务代码,可通过监听数据库变更日志binlog的方式来触发(数据库触发器也可);
3、通过定时扫描数据的方式来触发(数据库定时任务或通过程序定时任务来触发);
修改写操作的业务代码
优点
1、代码灵活可控。2、保证实时性
缺点
1、不能按照时间区分冷热,当数据变为冷数据,期间可能没有进行任何操作。
2、需要修改所有数据写操作的代码。
监听数据库变更日志
优点:1、与业务代码解耦。2、可以做到低延时。
缺点:1、无法按照时间区分冷热,当数据变为冷数据,期间没有进行任何操作。2、需要考虑数据并发操作的问题,即业务代码与冷热变更代码同时操作同一数据。
定时扫描数据库
优点:1、与业务代码解耦。2、可以覆盖根据时间区分冷热数据的场景。
缺点:1、不能做到实时性
根据内容对比,我们可以得出每种出发逻辑的建议场景。
修改写操作的业务代码:建议在业务代码比较简单,并且不按照时间区分冷热数据时使用。
监听数据库变更日志:建议在业务代码比较复杂,不能随意变更,并且不按照时间区分冷热数据时使用。
定时扫描数据库:建议在按照时间区分冷热数据时使用。
(三)如何分离冷热数据?
分离冷热数据的基本逻辑如下:
1、判断数据是冷是热;
2、将要分离的数据插入冷数据中;
3、再从热数据库中删除分离的数据。
这个逻辑看起来简单,而实际做方案时,以下三点我们都得考虑在内,这一点就不简单了。
(1)一致性:同时修改过个数据库,如何保证数据的一致性
这里提到的一致性要求,指我们如何保证任何一步出错后数据还是一致的,解决方案为只要保证每一步都可以重试且操作都有幂等性就行,具体逻辑分为四步。
在热数据库中,给要搬的数据加个标识: flag=1。(1代表冷数据,0代表热数据)
找出所有待搬的数据(flag=1):这步是为了确保前面有些线程因为部分原因失败,出现有些待搬的数据没有搬的情况。
在冷数据库中保存一份数据,但在保存逻辑中需加个判断以此保证幂等性(这里需要用事务包围起来),通俗点说就是假如我们保存的数据在冷数据库已经存在了,也要确保这个逻辑可以继续进行。
从热数据库中删除对应的数据。
(2)数据量大:假设数据量大,一次性处理不完,该怎么办?是否需要使用批量处理?
前面说的3种冷热分离的触发逻辑,前 2 种基本不会出现数据量大的问题,因为每次只需要操作那一瞬间变更的数据,但如果采用定时扫描的逻辑就需要考虑数据量这个问题了。
这个实现逻辑也很简单,在搬数据的地方我们加个批量逻辑就可以了。为方便理解,我们来看一个示例。
假设我们每次可以搬 50 条数据:
a. 在热数据库中给要搬的数据加个标识:flag=1;
b. 找出前 50 条待搬的数据(flag=1);
c. 在冷数据库中保存一份数据;
d. 从热数据库中删除对应的数据;
e. 循环执行 b。
(3)并发性:假设数据量大到要分到多个地方并行处理,该怎么办?
在定时搬运冷热数据的场景里(比如每天),假设每天处理的数据量大到连单线程批量处理都来不及,我们该怎么办?这时我们就可以开多个线程并发处理了。(虽然大部分情况下多线程较快,但我曾碰到过这种情况:当单线程 batch size 到一定数值时效率特别高,比多线程任何 batch size 都快。所以,需要留意:如果遇到多线程速度不快,我们就考虑控制单线程。)
当多线程同时搬运冷热数据,我们需要考虑如下实现逻辑。
第 1 步:如何启动多线程?
因为我们采用的是定时器触发逻辑,这种触发逻辑性价比最高的方式是设置多个定时器,并让每个定时器之间的间隔短一些,然后每次定时启动一个线程就开始搬运数据。
还有一个比较合适的方式是自建一个线程池,然后定时触发后面的操作:先计算待搬动的热数据的数量,再计算要同时启动的线程数,如果大于线程池的数量就取线程池的线程数,假设这个数量为 N,最后循环 N 次启动线程池的线程搬运冷热数据。
第 2 步:某线程宣布某个数据正在操作,其他线程不要动(锁)。
关于这个逻辑,我们需要考虑 3 个特性。
获取锁的原子性: 当一个线程发现某个待处理的数据没有加锁,然后给它加锁,这 2 步操作必须是原子性的,即要么一起成功,要么一起失败。实际操作为先在表中加上 LockThread 和 LockTime 两个字段,然后通过一条 SQL 语句找出待迁移的未加锁或锁超时的数据,再更新 LockThread=当前线程,LockTime=当前时间,最后利用 MySQL 的更新锁机制实现原子性。
获取锁必须与开始处理保证一致性: 当前线程开始处理这条数据时,需要再次检查下操作的数据是否由当前线程锁定成功,实际操作为再次查询一下 LockThread= 当前线程的数据,再处理查询出来的数据。
释放锁必须与处理完成保证一致性: 当前线程处理完数据后,必须保证锁释放出去。
第 3 步:某线程正常处理完后,数据不在热库,直接跑到了冷库,这是正常的逻辑,倒没有什么特别需要注意的点。
第 4 步:某线程失败退出了,结果锁没释放怎么办(锁超时)?
锁无法释放: 如果锁定这个数据的线程异常退出了且来不及释放锁,导致其他线程无法处理这个数据,此时该怎么办?解决方案为给锁设置一个超时时间,如果锁超时了还未释放,其他线程可正常处理该数据。
设置超时时间时,我们还应考虑如果正在处理的线程并未退出,因还在处理数据导致了超时,此时又该怎么办?解决方案为尽量给超时的时间设置成超过处理数据的合理时间,且处理冷热数据的代码里必须保证是幂等性的。
最后,我们还得考虑一个极端情况:如果当前线程还在处理数据,此时正在处理的数据的锁超时了,另外一个线程把正在处理的数据又进行了加锁,此时该怎么办?我们只需要在每一步加判断容错即可,因为搬运冷热数据的代码比较简单,通过这样的操作当前线程的处理就不会破坏数据的一致性。
(四)如何使用冷数据
在功能设计的查询界面上,一般都会有一个选项供我们选择需要查询冷数据还是热数据,如果界面上没有提供,我们可以直接在业务代码里区分。(说明:在判断是冷数据还是热数据时,我们必须确保用户不允许有同时读冷热数据的需求。)
历史数据如何迁移?
一般而言,只要跟持久化层有关的架构方案,我们都需要考虑历史数据的迁移问题,即如何让旧架构的历史数据适用于新的架构?
因为前面的分离逻辑在考虑失败重试的场景时,刚好覆盖了这个问题,所以这个问题的解决方案也很简单,我们只需要给所有的历史数据加上标识:flag=1 后,程序就会自动迁移了。
冷热分离解决方案的不足
冷热分离解决方案确实能解决写操作慢和热数据慢的问题,但不足之处在于:
不足一: 用户查询冷数据速度依旧很慢,如果查询冷数据的用户比例很低,比如只有 1%,那么这个方案就没问题。
不足二: 业务无法再修改冷数据,因为冷数据多到一定程度时,系统承受不住。
我们可以用另外一种解决方案——查询分离。
场景二
系统里有一个工单查询功能,工单表中存放了几千万条数据,且查询工单表数据时需要关联十几个子表,每个子表的数据也是超亿条。
面对如此庞大的数据量,加上工单表中有些数据是几年前的,但是这些数据涉及诉讼问题,需要继续保持更新,因此无法将这些旧数据封存到别的地方,也就没法通过前面的冷热分离方案来解决。
最终采用了查询分离的解决方案:将更新的数据放在一个数据库里,而查询的数据放在另外一个系统里。因为数据的更新都是单表更新,不需要关联也没有外键,所以更新速度立马得到提升,数据的查询则通过一个专门处理大数据量的查询引擎来解决,也快速地满足了实际的查询需求。
那么什么是查询分离?
每次写数据时保存一份数据到另外的存储系统里,用户查询数据时直接从另外的存储系统里获取数据。示意图如下:
何种场景下使用查询分离?
数据量大;
所有写数据的请求效率尚可;
查询数据的请求效率很低;
所有的数据任何时候都可能被修改;
业务希望优化查询数据的效率;
只有了解了查询分离的真正使用场景,才能在遇到实际问题时采取最正确的解决方案。
查询分离实现思路
查询分离解决方案的实现思路如下:
如何触发查询分离?
如何实现查询分离?
查询数据如何存储?
查询数据如何使用?
我们一点点来讨论。
(一)如何触发查询分离?
这个问题说明的是我们应该在什么时候保存一份数据到查询数据中,即什么时候触发查询分离这个动作。
一般来说,查询分离的触发逻辑分为3种。
(1)修改业务代码:在写入常规数据后,同步建立查询数据。
(2)修改业务代码:在写入常规数据后,异步建立查询数据。
(3)监控数据库日志:如有数据变更,更新查询数据。
3种触发逻辑的优缺点对比如下:
修改业务代码同步建立查询数据
优点 1、保证查询数据的实时性和一致性。2、业务逻辑灵活可控
缺点 1、侵入业务代码。2、减缓写操作速度。
修改业务代码异步建立查询数据
优点:1、不影响主流程。
缺点:1、查询数据更新前,用户可能会查询到过时的数据。
监控数据库日志
优点:1、不影响主流程。2、业务代码0侵入
缺点:1、查询数据更新前,用户可能会查询到过时的数据。2、架构复杂一些
什么叫业务灵活逻辑可控?
一般来说,写业务代码的人能从业务逻辑中快速判断出何种情况下更新查询数据,而监控数据库日志的人并不能将全部的数据库变更分支穷举,再把所有的可能性关联到对应的更新查询数据逻辑中,最终导致任何数据的变更都需要重新建立查询数据。
什么叫减缓写操作速度?
建立查询数据的一个动作能减缓多少写操作速度?答案:很多。举个例子:当你只是简单更新了订单的一个标识,本来查询数据时间只需要 2ms,而在查询数据时可能会涉及重建(比如使用 ES 查询数据时会涉及索引、分片、主从备份,其中每个动作又细分为很多子动作,这些内容后面文章会聊到),这时建立查询数据的过程可能就需要 1s 了,从 2ms 变成 1s,你说减缓幅度大不大?
查询数据更新前,用户可能查询到过时数据。 这里我们结合第 2 种触发逻辑来讲,比如某个操作正处于订单更新状态,状态更新时会通过异步更新查询数据,更新完后订单才从“待审核”状态变为“已审核”状态。假设查询数据的更新时间需要 1 秒,这 1 秒中如果用户正在查询订单状态,这时主数据虽然已变为“已审核”状态,但最终查询的结果还是显示“待审核”状态。
根据前面的对比表,总结每种触发逻辑的适用场景如下:
触发逻辑 适用场景
修改业务代码,同步建立查询数据 业务代码比较简单且对写操作响应速度要求不高
修改业务代码,异步建立查询数据 业务代码比较简单且对写操作响应
监控数据库日志 业务代码比较复杂,或者改动代价太大
虽然我们对业务的代码比较熟悉,但是在一个真实业务场景中,业务要求每次修改工单请求时响应速度快,我们最终就选择了修改业务代码异步建立查询数据这种触发逻辑。
(二)如何实现查询分离?
关于第 2 种触发方案:修改业务代码异步建立查询数据,最基本的实现方式是单独起一个线程建立查询数据,不过这种做法会出现如下情况:
写操作较多且线程太多,最终撑爆JVM。
建查询数据的线程出错了,如何自动重试。
多线程并发时,很多并发场景需要解决。
面对以上三种情况,我们该如何处理?此时使用MQ管理这些这些线程即可解决。
MQ的具体操作思路为每次主数据写操作请求处理时,都会发一个通知给MQ,MQ收到通知后唤醒一个线程更新查询数据,示意图如下:
了解了MQ的具体操作思路后,我们还应该考虑以下5大问题。
问题一:MQ如何选型?
不管我们选择哪个 MQ ,最终都能实现想要的功能,只不过是易用不易用、多写少写业务代码的问题,因此我们从易用性和代码工作量角度考量。
如果公司已使用 MQ,那选型问题也就不存在了。
问题二:MQ宕机了怎么办?
如果 MQ 宕机了,我们只需要保证主流程正常进行,且 MQ 恢复后数据正常处理即可,具体方案分为三大步骤。
每次写操作时,在主数据中加个标识:NeedUpdateQueryData=true,这样发到 MQ 的消息就很简单,只是一个简单的信号告知更新数据,并不包含更新的数据 id。
MQ 的消费者获取信号后,先批量查询待更新的主数据,然后批量更新查询数据,更新完后查询数据的主数据标识 NeedUpdateQueryData 就更新成 false 了。
当然还存在多个消费者同时搬运动作的情况,这就涉及并发性的问题,因此问题与上一篇聊的冷热分离中的并发性处理逻辑类似,这里就不细聊了(有兴趣的同学可以去看看)。
问题三:更新查询数据的线程失败了怎么办?
如果更新的线程失败了,NeedUpdateQueryData 的标识就不会更新,后面的消费者会再次将有 NeedUpdateQueryData 标识的数据拿出来处理。但如果一直失败,我们可以在主数据中多添加一个尝试搬运次数,比如每次尝试搬运时 +1,成功后就清零,以此监控那些尝试搬运次数过多的数据。
问题四:消息的幂等消费
在编程中,一个幂等操作的特点是多次执行某个操作均与执行一次操作的影响相同。
举个例子,比如主数据的订单 A 更新后,我们在查询数据中插入了 A,可是此时系统出问题了,系统误以为查询数据没更新,又把订单 A 插入更新了一次。
所谓幂等,就是不管更新查询数据的逻辑执行几次,结果都是我们想要的结果。因此,考虑消费端并发性的问题时,我们需要保证更新查询数据幂等。
问题五:消息的时序性问题
比如某个订单 A 更新了 1 次数据变成 A1,线程甲将 A1 的数据搬到查询数据中。不一会儿,后台订单 A 又更新了 1 次数据变成 A2,线程乙也启动工作,将 A2 的数据搬到查询数据中。
所谓的时序性就是如果线程甲启动比乙早,但搬运数据动作比线程乙还晚完成,就有可能出现查询数据最终变成过期的 A1。如下图(动作前面的序号代表实际动作的先后顺序):
此时解决方案为主数据每次更新时,都更新上次更新时间 last_update_time,然后每个线程更新查询数据后,检查当前订单 A 的 last_update_time 是否跟线程刚开始获得的时间一样,且 NeedUpdateQueryData 是否等于 false,如果都满足的话,我们就将 NeedUpdateQueryData 改为 true,然后再做一次搬运。
MQ 在这里的作用只是一个触发信号的工具,如果不用 MQ 好像也没啥问题啊,这你就大错特错了。
服务的解耦:这样主业务逻辑就不会依赖更新查询数据这个服务了。
控制更新查询数据服务的并发量:如果我们直接调用更新查询数据服务,因写操作速度快,更新查询数据速度慢,写操作一旦并发量高,会给更新查询数据服务造成超负荷压力。如果通过消息触发更新查询数据服务,我们就可以通过控制消息消费者的线程数来控制负载。
(三)查询数据如何存储?
我们应该使用什么技术存储查询数据呢?目前,市面上主要使用 Elasticsearch 实现大数据量的搜索查询,当然还可能会使用到MongoDB、HBase 这些技术,这就需要我们对各种技术的特性了如指掌,再进行技术选型。
关于技术选型这个问题,我觉得很多时候我们不能单单只考虑业务功能的需求,还需要考虑组织结构。团队最熟悉哪款中间件,花费的成本最小,优先考虑的就应该是这种。
(四)查询数据如何使用?
因 ES 自带 API,所以使用查询数据时,我们在查询业务代码中直接调用 ES 的 API 就行。
不过,这个办法会出现一个问题:数据查询更新完前,查询数据不一致怎么办?这里分享 2 种解决思路。
在查询数据更新到最新前,不允许用户查询。(我们没用过这种设计,但我确实见过市面上有这样的设计。
给用户提示:您目前查询到的数据可能是 1 秒前的数据,如果发现数据不准确,可以尝试刷新一下,这种提示用户一般比较容易接受。
整体方案
以上,我们已经把四个问题都讨论完了,我们再一起看看查询分离的整体方案,如下图所示:
历史数据迁移
新的架构方案上线后,旧的数据如何适用新的架构方案?这是实际业务中需要我们考虑的问题。
在这个方案里,我们只需要把所有的历史数据加上这个标识:NeedUpdateQueryData=true,程序就会自动处理了。
查询分离解决方案的不足
查询分离这个解决方案虽然能解决一些问题,但我们也要清醒地认识到它的不足。
不足一: 使用 Elasticsearch 存储查询数据时,注意事项是什么(此方案并未详细展开)?
不足二: 主数据量越来越大后,写操作还是慢,到时还是会出问题。
不足三: 主数据和查询数据不一致时,假设业务逻辑需要查询数据保持一致性呢?
未完待续