本文是专题的第一篇文章,主要讲解优化数据存储,涉及到锁、批处理、重试机制以及数据一致性等问题。下面 我们就开始吧。
一、案例
有一个客服工单系统,会从邮件服务器中获取客服邮箱收到的邮件,并且将这些邮件自动生成工单并自动分配给相应的客服组,每次客服人员从工单列表中选取一个工单进行处理,每处理一次就会产生一个工单处理记录,直到工单被客服关闭为止。 该系统已经运行了一年,在这一年中一共产生了一千万个工单和五千万条工单处理记录。因为所有工单和处理记录都存储在一个数据库中,因此每次客服查看工单列表时会很慢,但是客服还能忍受。直到某天,公司决定将其他几种类型的客服邮件也都加入到客服工单系统中,就出现了很大的问题,工单数量急剧增长,导致工单列表打开速度越来越慢,甚至造成客服工单系统崩溃。 开发人员收到反馈后,分析如下:
- 工单数量每天的增长量是原来的3倍;
- 工单总量已经超过3000万;
- 工单处理记录已经过亿。
根据上面的分析,不难看出是因为数据量过大造成的,于是开发人员进行了最常见的操作:优化库结构、优化系统代码、加索引等操作。虽然系统不再出现崩溃的情况,但是工单列表的查询依然很慢。经过开发组内部讨论,制定了如下两个方案:
- 数据库分区
- 数据冷热分离
下面我们就来具体看一下这两种方案的思路和优缺点。
二、数据库分区
2.1 什么是分区
分区,并不是出现新的数据表,而是在不创建新表的情况下,将表中的数据按照一定规则分配存储到不同的位置(硬盘、系统甚至是服务器中)。
Tip:因为本专题讲解的是架构知识,因此在大部分情况下不会涉及到某项技术、某种语言的讲解。
数据库分区有如下四个优点:
- 分区可以存储更多的数据,因为表中数据都被存储在的不同的硬盘、系统或服务器中;
- 随着数据的增加,可以随时增加新的分区来存储数据;
- 在清理数据的时候,可以直接删除分区;
- 优化了查询速度,每次查询数据时我们不需要查询全部数据,只需要查询特定分区即可,例如数据表按照日期分区,每个年月是一个分区,那么当我们查询某年某月的数据时,只需要一个分区的数据即可(当然,这里说的查询可能不严谨,这里只是举例子而已,不必在意,关于查询的处理我将在后面的文章中介绍)。
那么该如何分区呢?下面我们就以客服工单系统为例来讲解一下。
2.2 针对客服工单系统的处理方案
在客服工单系统中,我们有一个工单表,主要的表结构由:工单编号、工单创建日期、工单状态(进行中、无人处理、已关闭)、客服最后操作时间、最后处理人以及最后处理人所在组组成。针对工单表的查询操作如下:
- 客服查询无人处理的工单;
- 客服查询自己接手的工单;
- 客服组长查询本组的工单;
- 客服查询某个客户的工单;
- 客服主管/组长查询最近一个月完结的工单。
为了实现在查询时只查询特定的分区,我们需要在查询条件中包含分区字段,但是就目前而言这四个查询操作并没有共有的字段。那么,我们就来创建这个分区字段,首先我们来分析一下哪些字段合适作为分区字段。
- 系统在邮件服务中获取到客服邮件后会创建工单;
- 客服需要查询无人处理的工单;
- 客服查询自己正在处理的工单;
- 客服主管/组长查询最近一个月工单完结的情况;
- 工单处理完毕后,客服关闭工单。
分析的这五个方面,出现了三个适合做分区的字段:工单创建时间、工单状态、客服最后操作时间。那么哪个或哪几个更适合做分区字段呢?根据上面的分析可知我们可以将工单状态和客服最后操作时间作为分区的字段,进行中、无人处理以及最近一个月内关闭的工单放在一个A分区中,超过一个月的已关闭的工单放在一个B分区中。经过这样的处理,工单列表的查询速度就有了质的提高,每次查询 SQL 语句只用去扫描A分区就可以了。 但是,如果要这样做的话要考虑如下几个点:
- 开发组是否具有数据库分库经验;
- 由于要在生产环境中分区,因此要考虑分区给生产环境带来的影响。
三、数据冷热分离
在学习数据冷热分离前我们先来看一下基本概念
3.1 基本概念
- 冷热数据: 所谓的冷数据指的是不常用的,状态基本不变的数据,热数据指的是经常使用,并且会对其进行操作的数据。
- 冷热库: 存放冷数据的数据库被称为冷库,存放热数据的数据库被称为热库。
- 冷热分离: 在处理数据时,将数据按照冷热分为冷库和热库,在我们的案例中工单表是热库。
3.2 冷热分离方案
冷热分离方案有两种,一种是冷热数据都使用同一种类型的数据库,另一种是将冷数据存储在NoSQL数据库中。下面们我来分别讲解一下。
3.2.1 方案一:同类型数库存储
一般来说这个方案可以解决大部分数据存储问题,并且冷热库使用的是相同的库结构,数据从热库迁移到冷库时不用进行数据转换,并且代码部分改动较小。和数据库分区一样,我们在实行这个方案前,需要考虑这几个问题:
- 如何判断数据冷热;
- 冷热数据分离如何触发;
- 冷热数据分离如何实现;
- 冷热数据如何使用。
下面就针对这4个方面进行讲解
3.2.1.1 如何判断数据冷热
常见的判断方法是,根据主表中的一个或几个字段来判断。比如在工单系统中,可以使用工单状态、客服最后操作时间来作为冷热数据的判断条件,将已经关闭的并且超过一个月的工单视为冷数据,其他的工单视为热数据。 在判断冷热数据中,我们应遵循以下原则:
- 数据一旦被迁移到冷库中,就代表业务代码只能对它进行查询操作;
- 冷热数据不能同时读取。
3.2.1.2 冷热数据分离如何触发
触发冷热数据分离的方式有三种:在修改操作的代码后面加上触发冷热分离的代码、监听数据库变更日志、定时扫描数据库。针对这三种方式来一一讲解。
- 在修改操作的代码后面加上触发冷热分离的代码 在每次修改了数据后,都会触发执行冷热分离的代码。这种方法比较简单,每次只需要判断以下是否变成了冷数据即可,虽然能保证数据实时性,但是无法按照日期时间来区分冷热数据,而且所有与数据修改相关的代码都要加上冷热分离代码。因此这种方式使用的较少,一般用在小型系统上。
- 监听数据库变更日志 这种方式需要创建一个新服务来监听数据库变更日志,一旦发现相关的表发生了变动就触发冷热分离逻辑。这种方式又分为两种子方式,一个是直接触发冷热分离逻辑,另一个是将表更的数据发送到队列里(可以是自定义的公共 List,也可以是MQ),订阅放从队列中获取到数据后执行冷热分离逻辑。这种方式的好处是与业务代码完全解耦,低延迟,但是缺点和方式一一样无法按照日期来区分冷热数据,并且会出现业务代码和冷热分离逻辑代码同时操作同一条数据的问题,也就是并发问题。
- 定时扫描数据库 这种方式也是新建一个服务,定时扫描数据库。一般我们会使用任务调度平台来实现,或者通过第三方开源的库/组件来实现,当然,如果你愿意也可以通过编写操作系统定时任务来实现。这种方式的优点是与业务代码分离,并且可以根据日期时间区分冷热数据,缺点是无法做到实时性。
根据上面三种方式的描述来看,工单系统适合使用定时扫描数据库的方式来实现冷热分离。
3.2.1.3 冷热数据分离如何实现
已经有了冷热数据分离的解决方案了,那么在这一小节里我们来看看如何实现冷热分离。 实现冷热分离的基本步骤如下:
- 判断数据冷热;
- 将冷数据插入冷库;
- 将冷数据从热库中删除。
要实现这三个基本步骤,我们需要考虑以下内容: 在前面三个步骤中,我们无法百分百的保证不会出问题,因此我们必须通过代码来保证数据的最终一致性。要实现最终一致性,我们可以在工单表中新加一个列 是否冷数据(是、否,默认:否)。首先冷热数据分离服务将找到的冷数据全都标记为是冷数据,接着服务将冷数据迁移到冷库中,迁移完成后就从热库中将对应的数据删掉。如果在迁移或者删除数据的时候出现了异常,那么我们就需要在迁移和删除数据的业务代码中加入重试机制(这里一般会用主流的重试库,比如.NET中的Polly,Java中的guava-retry等)。如果多次重试后依然不成功,那么代码可以停止冷热数据分离的执行并发出警告,或者跳过不成功的数据,继续执行后续数据的迁移。在删除不成功并且跳过的情况下,很有可能会出现在下次执行冷热数据分离的时候在冷库中插入重复数据的情况,那么我们就需要在插入前判断冷库中是否存在该条数据,也可以使用数据库的幂等操作来实现插入操作(比如MySQL数据库的 Insert …On Duplicate Key Update 语句)。 到这里,我们思考一个问题,工单系统数据量庞大,如果一次性将所有冷数据插入到冷库中的话是很慢的,有可能需要几十分钟甚至几个小时,那么解决这个问题的绑法有两种:一种是批处理,一种是多线程处理。
Tip:何为幂等?完全相同的请求/操作,多次执行的结果和执行一次的结果一样。
我们先来说说批处理的方法。例如我们的工单系统中的标注的冷数据有1000万条,那么我们可以按照如下的步骤进行处理冷热分离:
- 取出前1万条冷数据;
- 将这1万条冷数据存储到冷库中;
- 从热库中删除这1万条冷数据;
- 循环1到3,直至说有冷数据迁移完成。
我们再来说说对线程处理的方法。多线程处理的方法分两种,一种是设置多个不同的定时器,每个定时器会在估计的间隔时间里启动一个线程来处理数据。另一种是使用线程池,先统计出需要迁移的冷数据总数,再根据每个线程最大迁移数据量计算出需要多少个线程,如果所需线程数量超过线程池中线程的数量的话,那么就将线程池中的所有线程全部启动(并不是线程越多效率越高)。这两种方式的基本原理都一样,同样需要注意的问题也是一样的。 数据迁移时应该如何避免多个线程迁移同一条冷数据呢?我们可以使用锁。在工单表上增加一个 加锁线程ID 字段,用来标识当前数据正在被线程处理。线程每次在获取数据后,就需要对自己所获得的数据的加锁线程ID字段写入自己的线程ID。写入线程ID后并不能直接开始迁移数据了,而是在迁移数据前再查询一次自己锁定的数据,这是防止向加锁线程ID字段加写入数据前被其他线程提前写入了数据,从而导致多个线程处理同一条数据的问题。再次查询后我们就可以进行数据迁移了,但是要注意数据迁移所用的数据是再次查询后获得数据,而不是线程刚开始获得的数据。 到这里,又有一个问题,如果某个线程挂掉了,锁就有很大可能没有释放(位于工单表中的冷数据没被删除),该怎么处理?其实很简单,在工单表中增加锁定时间列来记录被锁定的时间,并设置当锁定时间超过N分钟后(例如5分钟,N的值需要在测试环境中进行多次测试后取平均值)就可以被其他线程重新锁定。 当然这又出现另一个问题,如果某个线程没有挂,但是处理数据的时间也确实超时了,其他线程只知道数据锁定超时了,该怎么办?我们可以使用上一小节所说的数据库的幂等操作来实现插入操作。
3.2.1.4 冷热数据如何使用
这个问题解决起来也很简单,我们可以将冷数据查询和热数据查询分成两种操作,默认只能查询热数据,当需要查询冷数据时向服务端传递一个标识来告知需要查询冷数据。
TIP:一定不要进行冷热数据的同时查询
3.2.2 方案二:NoSQL存储
前面讲了同类型数据库冷热存储,使用NoSQL存储的原理是一样的,只不过是把冷库从关系型数据库改为了 NoSQL,步骤和注意事项也是一样的。但是使用 NoSQL 存储冷库的优点是数据量不管多大,只要在 NoSQL的承受范围内,查询速度都要比关系型数据库作为冷库要快,因为我们的冷库数据还是很多的。目前市面上的大部分流行 NoSQL 都适合做冷库使用,在实际项目中需要根据开发组技术水平、项目需求和运维成本等方面来决定使用哪个 NoSQL 作为冷库。
四、总结
分区和冷热分离说完,这两种方案适合有明确的分区或标识冷热数据的字段才使用,这个方案也覆盖了大部分项目需求,但是还有一些项目需求并不适合这两种方案,后续文章我将继续讲解。