存储优化 -- 查询分离
上一篇文章中我们讲解了利用数据库分区与冷热分离的方式来优化存储,虽然解决了查询速度慢的问题,但是在海量数据情况下依然会出现查询缓慢问题,并且部分系统中的冷热数据也是需要频繁或同时查询的。那么,这篇文章中我将带领大家来学习一下如何在设计系统架构时解决海量的数据存储与查询。
Tip:
目前任何一个与数据有关的系统,甚至互联网系统都有极大的可能出现海量的数据存储。
本文中将使用“更新”一词来表示对数据库的增、删、改操作
一、案例
我们有一个自动化舆情系统,简要的工作流程是这样的:每天数据采集服务会从公共社交媒体上采集数据并存入数据库,识别服务读取数据库中存储的数据按照一定的规则进行分析识别,并将分析识别的结果存入库中,客户端实时读取数据库的分析识别结果,将信息展示出来,而且该系统还要支持舆情追溯功能(例如某人在某公共社交平台发布了不良言论,可以通过该系统查找他以前发布的所有言论以及所有言论的识别结果)。根据上面对舆情系统的简要描述我们可知,该系统需要采集大量的数据来进行分析,并且需要对历史数据进行查询。因此该系统具备如下两个特征:
海量数据;
冷热数据同时查询。
那么根据这两个特征,我们就不能使用上一篇文章中所说的分区或冷热分离的方式来设计数据存储架构了。就目前来看比较适合的就是查询分离方案(目前指的是在这篇文章中,下一篇文章针对舆情系统还有更好的数据存储解决方案)。
二、简介
2.1 概念
每次向数据库中更新数据的同时,将数据也保存到其他存储系统中(其他存储系统可以是),当用户查询数据的时候直接从其他从出系统中查询出即可。这个更新的数据库被称为主存储,用来查询的数据库被称为查询存储。基本架构图如下:
Tip:查询分离和读写分离是有区别的,读写分离数据库类型是相同的,比如都是 MySql 库。读写分离是通过数据库的主从复制的方式来同步数据,通过让主数据库负责事务性的增删改,而从数据库负责非事务性的查询操作来提升数据库的并发负载能力。
2.2 适用场景
一般来说如果遇到如下问题就可以使用查询分离:
数据量大,单表数据达到千万;
查询速度慢,即使加了索引也是很慢;
存在复杂的表关联查询;
所有数据都有可能被修改和查询。
三、实现
查询分离的实现思一般分三个步骤:
如何触发;
如何实现
查询存储如何存储。
下面我们就来一一讲解一下。
3.1 如何触发
常见的查询分离触发方式有三种:
在向主存储更新数据后马上向查询存储更新同样的数据,并在查询存储数据更新完成后向用户返回结果。这种触发方式虽然保证了查询存储数据的实时性和一致性,并且业务逻辑也可控,但是对代码的侵入较大,只要是涉及更新主存储的代码,都要加入向查询存储更新数据的代码。而且这种方式还会减缓写操作的响应时间,因为我们要等待查询存储的数据跟新完成后才能返回响应结果。
在向主存储更新数据后异步更新查询存储,不等待查询存储数据更新完成,就向用户返回结果。这种方式不影响主流程,但是如果在查询存储更新前,用户进行了相关查询,就会返回过时的数据,而且这种方式对代码同样有侵入性。
监控主存储日志,如果发生变更,就去更新查询存储。这种方式不影响主流程,对业务代码零侵入,但是在查询存储更新前,用户可能会查到过时的数据,并且这种方式的架构比前两种方式复杂。
下表列出了这三种方式适合的场景:
针对上面三种触发方式的简单讲解和适合场景的描述可知,我们的舆情系统适用于方式二。
3.2 如何实现
我们已经在上一小节中确定使用第二种触发方式来设计我们的数据存储架构了。实现它的思路可以简述为:单独启动一个线程来对查询存储进行更新。但是,这种方式在实现时要考虑如下三个情况:
当出现大量写入操作时,更新查询存储的线程会很多,就会给舆情系统、查询存储、甚至服务器带来巨大的压力,那么这个时候我们就需要控制跟新查询存储的线程数量了;
如果在更新查询存储的过程中出现了更新失败的情况,我们应该引入重试机制,那么我们该怎么标识需要重试的数据;
多线程并发更新查询存储时该怎么处理?
针对上述三种情况,我们可以使用 MQ 来解决,思路也很简单:当向主存储更新数据时,都要向 MQ 发出一个通知,MQ 在收到通知后启动一个线程来更新查询存储。当然在使用 MQ 的时候也会遇到各种问题,最常见的问题就是 MQ 挂掉了。当 MQ 挂掉后会出现如下两种情况:
主存储跟新完后向 MQ 发送通知,但是 MQ 无法收到这个通知,因此数据也就不会更新到查询库里,那么就出现了数据丢失的问题;
MQ 消费者收到消息去更新查询存储后,告诉 MQ 消费完成,但是 MQ 这时挂了,已经收不到这个反馈了,但是在 MQ 重新启动后,这条消息又被投递了,这时就出现了重复投递的问题。
前述的这两种情况我们可以在主存储中增加一个 需要更新到查询存储 字段,每次在向主存储更新数据时就将这个字段设置为 true,扎样在向 MQ 发消息时只用发送一个简单的更新消息即可,不需要向 MQ 发送包含数据 ID 的消息。查询存储更新服务在获取到这个消息后,首先在主存储中查询 需要更新到查询存储字段为 true 的数据,然后将这些数据批量更新到查询存储中,更新完毕后再将这些数据的需要更新到查询存储字段改为 false 即可。我们也可以利用 MQ 来实现削峰,当更新的请求太多时通过 MQ 来控制执行同步查询库的线程数。数据更新到查询存储失败问题我们可以引入重试机制,并且只有在更新成功后才去修改需要更新到查询存储字段的值。当然,并不是每次重试机制都能将数据更新成功,因此我们可以设定一个重试次数阈值,当数据重试次数达到这个阈值时就需要及时通知相关人员进行人工干预了。
TIP:这里说的重试机制不仅仅是类似于以 Polly 为基础的重试机制,还包括后续的其他线程在更新查询存储时将失败的数据一并更新的方法。
关于并发问题的解决方法可以参考上一篇文章。这一小节还剩最后一个问题,时序性问题。例如小明先将 A 数据更新为 123,小红后将 A 数据更新为 321,但是更新查询存储时,小红的更新线程却先于小明的更新线程启动,这时就出现了小明的旧数据覆盖了小红的新数据。解决这个问题也很简单,在主存储中增加 最后更新时间 字段,线程更新完查询存储后,检查当前数据的最后更新时间 字段的值是否和线程刚启动时是一样的,并且需要更新到查询存储字段为 false,如果满足条件就将需要更新到查询存储字段改成 true,在做一次更新查询存储活动。
TIP:MQ 在这里不仅仅是起到了触发更新查询存储信号作用,还起到了服务解耦和控制更新查询存储并发量的作用。
3.3 查询存储如何存储
一般来说比较常见的存储方案是使用 Elasticsearch、MongoDB 或 Redis,这三种方案都时候复杂查询。但是,具体存储方案的选型应该根据开发组技术情况和项目情况来走。
TIP:这里要注意,不管使用哪种存储方案,都要记住的是使用异步更新查询存储的方式会出现用户查询到旧数据的问题,这是因为向查询存储更新数据,可能会涉及到索引重建、主从备份和分片等问题。解决这个问题的方法是在向主存储更新完数据后的 N 秒内,如果有查询相关数据的操作,那么我们可以给用户提示:”数据有可能是旧数据“。
四、总结
这一篇文章主要讲了查询分离的知识以及如何设计数据存储架构,下一篇我将优化本篇案例的解决方案。
评论