1.1 如何扩展数据库
前面我们讲到了数据库扩展的一个大致流程,下面我们详细讲解每种扩展方案。
1.1.1 X轴扩展—主从复制集群
假设我们的service访问数据库的吞吐量在4500TPS,其中写为500TPS,这种典型的读多写少的场景,我们通常会采用读写分离,如图5-1所示,将所有的读请求分发到Slave上,Master只负责写,Master和Slave之间通过数据库自带同步机制复制数据。
图 5‑1 Master-Slave结构
在这种方案中,多个Slave服务器异步的复制Master的数据,复制步骤如下。
1. Master将更新记录到二进制日志(binary log)中。
2. Slave将Master的日志(binary log)拷贝到它的中继日志(relay log)。
3. Slave重做中继日志中的事件。
由于很多业务都符合读多写少的特点,使得这种扩展方式简单有效,可以很容易的缓解读负载。采用主从复制还可以将服务进行隔离,例如,终端用户访问Slave-01,系统内部统计工具访问Slave-02,当运营人员做系统内部统计的时候会使Slave-02压力骤增,这种隔离方式将起到保护Slave-01不受影响。
这个方案的问题在于当Slave增加到一定数量时,Slave对Master的负载以及网络带宽都会成为严重的问题。本身单库承受不了可能是因为磁盘IO达到上限,而同步数据同样需要消耗Master服务器的性能。
使用新版本有助于解决部分问题,如MySQL5.7在主从复制方面提供了几个比较实用的功能。
- 多源复制(多主一从)。
- 半同步复制改进。
- 基于组提交(LOGICAL_CLOCK)的并行复制。
1.1.2 Y轴扩展—分库、垂直分表
Master-Slave集群适合读多写少的场景,只能通过不断增加Slave的实例个数解决读的性能问题,但是毕竟只能通过Master写入,单节点的写入能力有限,况且,如果要满足写后读一致性,就需要让读也访问Master,当系统规模不断增大时,如果写成为了瓶颈点,就需要考虑Y轴的扩展了。首先要考虑的是分库,分库是指把原来一个数据库中的多张表根据数据量、访问量、关联程度分解到多个数据库中。通常分库操作是和微服务拆分同步进行的,可以根据微服务划分的原则进行划分,划分后每个服务独享一个数据库。分库的最大特点就是相对简单,尤其适合各业务之间的耦合度比较低,业务逻辑非常清晰的系统。
分库相对于Master-Slave集群付出的成本更高,需要处理分布式事务问题、关联查询问题。但是相对于下面的方案更简单一些。
垂直分表是分库的一种特殊形式。有的业务中,单表字段数非常多,一些电商中的用户表可能超过200个字段。虽然表内的字段确实是一对一的关系,但是实际上,并不是所有的字段都是常用的,通常这200个字段可能只有十几个字段是常用的。如果我们已经进行了分库,将用户表独立出来了,仍然存在性能问题,此时我们可以尝试进行垂直分表。也就是把单表的字段垂直拆分为多张表。初期可以放到一个数据库中,查询的时候更简单。如果仍然存在性能问题,可以分到不同的数据库,放到不同的物理机上。
1.1.3 Z轴扩展—分片(sharding)
如果采用分库、垂直分表还是不能解决问题,此时只能通过Z轴的扩展方式,进行分片了。什么时候开始考虑分片呢?由于采用分片会导致架构的复杂度大幅上升,所以如果能避免应该尽量避免。一般按照经验值,MySQL在单表十个字段以下,数据量达到1千万左右时,如果采用SATA磁盘,性能会遇到比较大的瓶颈。如果此时数据量还是大幅增长,就应该考虑分片了。
数据库分片的目标如下。
- 数据量尽可能分布均匀。因为数据量会对数据库造成压力,影响性能指标。在100条数据里搜索一条数据和在一亿条数据里搜索一条数据完全不一样。
- 访问量尽可能分布均匀。例如微博某大V,如果发布一条“介绍女朋友”的信息,可能会有几千万的的转发评论。最好不要存在某个点特别热,因为扩缩容通常是整体架构的行为,当然也可以通过缓存的方式,让热点数据尽量命中缓存,缓解热点问题。
- 一次访问尽可能落到一个分片。在分片的时候,按照哪个key进行切分可以决定一次请求会访问几个分片。例如,如果订单表按照订单ID进行切分,以买家维度进行查询时,势必造成要遍历所有的表,这样通过分片提升的性能就大打折扣。系统的扩展性受到挑战。
- 数据迁移量尽可能少。当需要扩容的时候,为了不中断服务,数据迁移的过程是比较复杂的,需要迁移的数据量越少,对系统整体的压力就会越小。
数据库分片对原有的架构破坏性很大,需要考虑的地方很多,因此分片的算法至关重要,以下我们就来了解一下几种常用的分片算法。
1. 区间法(Range-Based)
如图5-2所示,假设现在一共有2000万条记录,此时,我们可以按照ID的范围分成四张表,每张表独占一个数据库。当然也可以根据时间、地域、组织进行切分,例如一个月一张表、一个省一张表、一个租户一张表等等。电信级的应用很多是基于省份分片,这样做的另一个好处是隔离性。
图 5‑2 区间法
区间法的优势如下。
利于排序,这几种分区算法中,只有区间法可以配合分区算法更容易排序。
区间法的缺点如下。
- 容易导致热点问题。假设上图中500w-1000w压力较大,此时如何分裂?如何迁移数据?
- 需要额外的元数据记录。
适用场景如下。
- 历史数据严重低于最近的数据访问,历史数据可以归档。例如电商中订单的物流信息,可能保留三个月就可以了。
- 数据分布相对比较均匀的场景。
- 数据按照区域需要隔离的场景。
2. 轮流法(Round-Robin)
轮流法是根据关键字对分片总量求余以实现均匀分布。给定一个数据K,应该放到哪个分区?可以按照这个公式n= K mod N,N代表分片总数,K代表分片关键字,n就是我们要放的节点位置。如图5-3所示,如果把用户ID作为key,userID=1的数据对4求余等于1,应该放到第二个数据库。如果key是自增长的int或long,数据分布均匀,不容易出现热点问题。如果key是规律的字母或数字组成,则很容易出现问题,此时我们可以对key计算hash值缓解。公式变为n= Hash(K)mod N。
图 5‑3 轮流法
采用轮询法,当进行扩容的时候,最好是成倍扩容,迁移的数据量少。例如从两个节点扩容为四个节点,需要迁移一半的数据。我们举例说明一下,如果现在有两个库,采用轮询法分库,现在由两个库扩展为三个库,发生移动的数据量为三分之二。但是如果是从两个分片变为四个分片,只有一半的数据发生了移动,迁移的数据量更少,并且达成了扩容的效果。
轮询法的优势如下。
- 简单,开发运维人员看到Key的时候,很容易知道这条数据应该在哪个分片。
- 不需要维护元数据。
轮询法的缺点如下。
- 当进行扩缩容的时候,迁移的数据量较大。
- 不容易排序。
轮询法的适用场景如下。
- 不经常扩缩容的场景。
- 不需要排序或者可以用其他方式代替的场景。
3. 一致性哈希法(Consistent Hashing)
假设现在我们有一张用户表,水平切分为两张表。用户ID是自增长的。现在我们计算一下用户ID是1、2、3、4、5、6、7、8、9的数据应该如何分布?
按照轮流法进行MOD,结果应该如图5-4所示,DB-0储2/4/6/8,DB-1存储1/3/5/7/9。
图 5‑4 一致性哈希法
如果现在进行扩容,扩展到3个数据库。如图5-15所示,DB-0中的2要迁移到DB-2,4要迁移到DB-1,DB-1中的3要迁移到DB-0,DB-1中的5要迁移到DB-2。只剩下框内的数据没有发生移动。实际上,DB-2只是存储了3条数据,却移动了5条数据(所以,一般基于MOD算法的数据扩容,通常是基于倍数进行,原因就是为了减少数据迁移量)。
图 5‑5 数据迁移
那么,有没有办法只移动3条数据呢?
一致性哈希就是为了解决这个问题而生的。一致性哈希算法(Consistent Hashing)是在1997年由麻省理工学院提出的一种分布式哈希(DHT)实现算法,设计目标是为了解决因特网中的热点(Hot Spot)问题,一致性哈希相比其他算法可以减少数据的迁移量。一致性哈希的架构如图5-6所示。
图 5‑6 一致性哈希
首先。将key按照常用的hash算法对应到一个具有2^32次方个桶的空间中,即0~(2^32)-1的数字空间中。我们可以将这些数字头尾相连,想象成一个闭合的环形,如图5-7所示,2的32次方是42亿,这相当于有了42亿个节点,当然这些节点不必真的对应一个数据库,可以认为是一个虚拟节点
图 5‑7 哈希环(一)
现在假设将两个数据库的IP、HostName加上端口计算出一个hash code值,如果DB-0是1201(虚拟的),DB-1是13465456,将这两个节点分布在这个环上,那么所有的数据如果通过hash code后取模计算出的结果落在0-1201范围,就放到DB-0上,如果在1201-13465456或者大于13465456就放到DB-1上。如图5-8所示,一致性哈希是按照顺时针分布数据的。
图 5‑8 哈希环(二)
继续用上面的例子,将用户ID(1-9)也用同样的方法算出hashcode并对42亿取模将其存放到环形节点上。假设(1/4/6/7)落在了DB-0,(2/3/5/8/9)落在了DB-1,如果现在新增一个节点,假设按照IP、HostName加上端口计算出一个hash code值是23123,只有落在DB-1的数据(2/3/5/8/9)涉及到迁移,DB-0可以保持不变,可能3/8迁移到了新的节点。
我们简单介绍了一致性哈希大致原理,到这里大家应该大致明白一致性哈希为什么迁移的数据量比较小,因为一致性哈希最终是基于范围迁移的。相对于直接通过范围分片,一致性哈希做了一次哈希值计算,分散了热点。当然,这里存在一个比较大的问题,当节点比较少的时候,数据分布不均匀。会导致热点的存在,为了解决这个问题。又引入了虚拟节点(virtual node)的机制。
如图5-9所示,可以针对每个节点虚拟出N个节点,因为虚拟出的节点是按照hash code并对42亿取模结果放到哈希环上的,所以,不会像很多初学者认为的那样排好序的,结果是散落在环上的,也就是说DB-0对应的是DB-0-1和DB-0-2。他们在环上并不是一定会挨在一起的,当虚拟节点足够多的时候,是平均散落在环上的。
图 5‑9 哈希环(三)
假设现在DB-0发生故障,按照顺时针,DB-0-1的数据会落到DB-2-0上,DB-0-2的数据会落在DB-1-1上。
一致性哈希的优势如下。
- 扩缩容数据迁移量较少。
一致性哈希的缺点如下。
- 算法复杂,不容易调试。
- 不容易排序。
一致性哈希的适用场景如下。
- 不需要排序或者可以用其他方式代替的场景。
当采用水平切分的时候,可能会遇到很多问题,以下两点尤其需要注意。
1、如何避免重新均衡数据?因为重新均衡是昂贵的。需要重新均衡的原因是分片是静态的,但是数据是动态的,业务是动态的,也就是说,可能现在是均衡的,过了一段时间,变成不均衡的了。也有可能某段时间是均衡的,另外一段时间是不均衡的。如很多SNS类的网站,初始阶段,活跃用户可能集中在1千万以下的区间,过了几年,活跃用户可能会集中在1亿到2亿之间,这是一个动态变化的过程。所以,在分片的时候,要用发展的眼光看问题。
2、管理分片是一个复杂的问题。我们很难实现数据的强一致性,如果采用分布式事务,会使性能、扩展性受到很大影响。如何进行分页、排序等查询?以前一条SQL就搞定了,如果使用了分片,当进行分页、排序的时候,就会变的非常复杂了。如为了避免热点,根据哈希分了64张表,查询出按时间排序后的第10000条到100010条数据,这就需要去所有分片取数据,在内存中进行计算。当然,也可以建立另一维度的数据去解决,可以参考5.5.5章节。但是这增加了架构的复杂度,有可能还要为此引入其他的服务。因此,水平分表应该作为最后一个选择。
1.1.4 为什么要带拆分键
我们都知道当数据库进行水平分表的时候,需要通过拆分键路由进行查询。这是为什么呢?因为如果你不带拆分键,就要到所有的表去查询,数据库中间件不知道去哪查。虽然可以通过并行的方式查询所有表,但是这会导致数据库的压力提升。
如图5-10所示,如果带上拆分键uid,则很容易定位到DB2,如果不带拆分键uid,数据库中间件不知道去哪查,只能查询所有的数据库。
图 5‑10 是否带拆分键对比
1.1.5 分片后的关联查询问题
我们通过一个例子来了解这个问题。例如,一个库中包含两张表,一个用户表,一个订单表,如果查询“北京的订单金额大于100的数量”,在一个库中可以通过关联查询完成。如果用户和订单被划分到了不同的服务,再进行关联查询,就非常麻烦了。
方案一:建立多维度数据库
可以尝试建立另外一个综合数据库,相当于为了进行关联查询多冗余了一份数据。
如图5-11所示,电商中比较典型的例子。实施微服务架构后,商品、价格、库存垂直划分为多个独立的数据库。
图 5‑11 建立多维度数据库
更新时,通过消息中间件异步更新到综合数据库内。
查询时,直接从综合数据库查询。
虽然综合表有可能变得臃肿,但是综合表的查询一般是后台管理人员使用,查询频率较低。当然,综合表可以替换为Mongodb,因为Mongodb可以自动伸缩。运维的工作量要更简单。
一个类似的做法是在大数据平台建立综合数据查询系统,问题是有可能存在延迟。需要根据具体业务场景决定。
建立多维度数据库方案的优势是架构简单,问题主要包含如下两方面。
- 可能存在不一致的风险。
- 综合表有可能变的庞大无比,如果查询量比较大,可能会成为性能瓶颈。
方案二:建立外部搜索引擎
如图5-12所示,可以通过分布式的搜索引擎,建立倒排索引,进行全文检索。
图 5‑22 建立外部搜索引擎
全文检索目前有很多开源的方案,最为流行的莫过于apache solr和elasticsearch。很多互联网公司的全文检索解决方案都是用这两个框架或者基于这两个框架进行开发。他们的共同点是底层都采用了lucene。
这种方案目前应用比较广泛,在电商场景中比较常见。
方案三:通过分布式缓存
通过分布式缓存,冗余数据。如果存在一份数据相对比较小,占用空间并不是特别大,可以用这种方案存储结果性数据。特别适合于多对多的场景。这种方案常用在SNS类系统的综合查询。
1.1.6 分片扩容(re-sharding)
分片的时候,应该从长期考虑,避免频繁的进行重新分片(re-sharding),因为重新分片会导致大量的数据迁移。可以根据未来数据量的增长速度、架构调整的可能性进行规划,如果预留太多会导致成本增加,预留太少会导致频繁迁移,根据经验值,可以预留未来1-2年的空间,如果害怕资源浪费,可以把多个实例部署到一台服务器。
假设现在有两个分片,采用MOD的分片算法,如果存在(0-9)的用户ID,进行MOD后的分布情况是DB-0(0/2/4/6/8), DB-1(1/3/5/7/9)。按照前面我们的建议,最好是按照倍数扩容,此时迁移的比例最少,因为当数据量较大的时候,迁移会对系统压力造成很大的影响,应该尽量减少迁移。
以MySQL为例,应该选择流量较小的时段进行扩容。禁止在高峰期扩容。
方案一、停服扩容。
如图5-13所示,停服扩容步骤如下。
1. 选择好升级的时间段,评估升级所需时长,对外沟通,挂出公告。
2. 到时间后,所有流量在前端负载均衡处转发到停服公告页面,观察数据库状态,没有流量后先对数据库进行备份,然后开始升级。
3. 新建两个数据库,分别命名为DB-2、DB-3。可以通过一些开源的迁移工具,也可以自己开发一个迁移服务,或者利用存储过程进行迁移数据。
4. 迁移完成后,删除DB-0、DB-1冗余的数据。验证数据完整性、一致性。
5. 如果有数据库中间件,修改中间件分片策略;如果没有数据库中间件,修改service的分片策略。
6. 验证。如果没有问题流量切回。如果发现问题,再挂出公告利用前面的备份进行回滚。
图 5‑13 停服扩容
这种做法比较简单,容易操作。但是需要停止服务,要求一次性作对,否则回滚比较麻烦。这在产品初期,访问量不是特别高,技术实力较差时可以选择停服扩容。但是通常需要扩容的情况,一般数据量都比较大了。技术人员的水平、熟练程度、前期的准备工作对于这种方案的成败起到了决定性的作用,包括中断的时长也会受到以上因素影响。
方案二、基于数据库的0中断扩容。
首先需要说的是,0中断并非不能有中断,而是中断的时间足够短,可以忽略,不需要通知用户,或者对用户体验影响不大。以下案例假设存在数据库中间件,如果没有,则需要基于业务服务进行操作。
1. 选择好升级的时间段,先对数据库备份。
2. 通常为了容灾,提升读性能,此时应该已经有了Slave节点。如果没有Slave,如图5-14所示,需要先分别为两个数据库建立Slave实例DB-2、DB-3。
图 5‑14 建立Slave
3. 当Master和Slave之间延迟较小时,修改数据库中间件配置,停止写入,只能读取。如图5-15所示,将Slave提升为Master。
图 5‑15 将Slave提升为Master
4. 如图5-16所示,修改数据库中间件配置,分片规则改为四个库。
图 5‑16 修改数据库中间件配置
5. 修改数据库中间件配置,允许正常读写。注意,此时存在冗余数据,必须要求数据库中间件具备排重功能。
6. 删除冗余数据。
注意,从3到5,读取不受影响。写入是中断的,中断的时长取决于前期的准备工作和操作的熟练程度,一般可以控制到分钟级。如果希望中断时间更短,可以把主从同步修改为主主同步或者半同步复制,会缩短中断时间。但是,这需要提前做好准备。
另外,也可以把所有的写入数据暂时记录日志或者写入MQ,等到主从完全一致之后,先写入日志或者MQ的数据。
方案三、基于数据库中间件的0中断扩容。
还有一种方案和上一种方案类似,只不过是通过数据库中间件完成的。大致步骤如下。
1. 备份数据。
2. 建立另外两个数据库实例DB-2、DB-3,分别作为DB-0、DB-1的副本。可以通过迁移工具(基于状态机模式读取binlog)让数据尽量接近。
3. 修改数据库中间件配置,停止update和delete,只能insert和读取,insert的时候需要通过数据库中间件进行双写,将DB-0的数据同步写入到DB-2,将DB-1的数据同步写入到DB-3。
4. 比对数据,当DB-0和DB-2的数据完全一致,DB-1和DB-3的数据完全一致的时候,修改数据库中间件配置,开始update和delete。
5. 修改数据库中间件配置,修改分片规则,切换为4个库进行读写。
6. 删除冗余数据。如图5-17所示,扩容结束。
图 5‑17 基于数据库中间件的0中断扩容
这个方案中断的只有update和delete,如果业务场景这两个操作比较少,比较适合这个方案。
在实际的业务中,如果复杂度较高,会混合使用以上的分片算法,例如微信红包的规则为db_xx.t_y_dd,xx/y代表红包ID的hash值后三位,dd的代表天数,实际上混用了一致性哈希和区间法,通过这种混合的分片算法,既避免了区间法导致的热点数据的问题,又利于迁移数据,可以按照天为单位整张表进行迁移。
1.1.7 精选案例
案例一 活动平台数据表水平切分
如图5-18所示,假设现在有一个活动平台,管理员可以创建活动,为活动添加用户,针对一个活动给用户发送促销短信或邮件提醒。按照场景,正常的操作逻辑是,查询关注某活动的所有用户,并且发送消息。
图 5‑18 活动平台需求关系
这是一个典型的多对多,通常当用户数据量比较大的时候,会先选择分库,也就是把用户表独立到一个数据库,活动和活动关系表放到一个数据库。活动一般不会特别多,或者说活跃的活动不会特别多,但是活动用户关系表会非常大。
如果活动库变的非常大,此时该如何切分?一般不会选择直接把活动用户关系表独立到一个库,由于活动的数据量不是特别大,分库的效果不好,避免不了水平分表。
如果按照活动进行分片,当查询关注活动的所有用户时,可以在一个分片内查出所有数据,但是按照活动进行分片会产生热点数据,因为有的活动可能用户比较多,而有的活动用户比较少。
如果按照用户进行切分,虽然不会有热点数据问题。但是当查询关注活动的所有用户时,需要遍历所有分片。
实际上,关系数据只是两个id而已,数据条数可能比较多,但是占用的存储空间并不会特别大,可以优先考虑通过缓存缓解数据库的压力,不做分区。活动有明显的时效性,一旦活动结束,数据就可以归档到历史数据库了。因为关系数据一般不会更新,可以将缓存的过期时间设置的长一点。
案例二 SNS数据表水平切分
很多人用过微博或者微信,主要的表结构包括用户表、用户关系表(谁关注了谁)、消息表(发的微博、朋友圈),关系如图5-19所示。用户之间是有关系的,通常发布出去的内容按照用户的查询方式有多个维度。例如在微博中,首页通常是timeline[1],是自己看的比较多的页面,还有一个页面是profile[2],关注者会经常访问。当消息的量比较大时,需要进行水平分表,如何切分保证不去遍历所有的分片呢?
图 5‑19 SNS需求关系
如果按照发布消息的用户ID去切分数据,在查询profile的时候,可以在一个分片取到所有数据,但是,在查看timeline的时候,就面临着遍历所有的分片数据。
如以下表格所示,根据需求有两张表,分别是消息表和用户关注表,如果消息表被分为8张表,根据求余的算法,用户ID为1的消息会落入第一个分片,用户ID为2的消息会落入第二个分片,如果查询用户ID为1的profile页面,可以直接在第一个分片取到所有数据,但是在用户关注表中我们不难发现,用户ID为10001的用户关注了用户ID为1和2的两个用户,如果用户10001查询自己的timeline页面,需要遍历所有分片。而timeline才是读取量比较大的页面,这样性能会非常低。
表格 5‑1 消息表分片1
消息ID | 发布消息的用户ID | 消息内容 |
1000 | 1 | …… |
1001 | 1 | …… |
表格 5‑2 消息表分片2
消息ID | 发布消息的用户ID | 消息内容 |
1005 | 2 | …… |
1006 | 2 | …… |
表格 5‑3 用户关注表
用户ID | 关注的用户ID |
10001 | 1 |
10001 | 2 |
提升读性能的关键方案之一就是冗余,除了上面的消息内容表,可以再增加一张表,让timeline数据可以在一个分片内取到,一次来兼顾两个维度的查询性能,带来的问题是可能会比较浪费资源。这也是目前Twitter的方案,由于国内的SNS存在大量僵尸用户,一般都采用推拉结合的方式兼顾。
案例三 电商数据表水平切分
电商中以订单为典型,一个订单包含订单id,买家id,卖家id,三个比较重要的查询关键字。为了简化,我们先不考虑订单和子订单相关的内容。
那么,该如何选择水平切分键呢?
如果按照订单id切分,则按照卖家id和买家id查询会遍历所有的表;
如果按照买家id切分,则按照订单id和卖家id查询会遍历所有的表;
如果按照卖家id切分,则按照订单id和买家id查询会遍历所有的表。
现在我们先来分析一下业务场景。
- 80%以上的查询是通过订单id进行查询。
- 15%左右通过买家id查询已购买商品列表。
- 5%左右通过卖家id查询已卖出商品列表。
以上数据各个电商平台业务场景不一,比例会有浮动。
很明显,根据业务场景,如果只能选一个,我们最好是选择订单id进行切分。那么,如果按照卖家id和买家id查询如何解决?
一种方法,我们可以为卖家和买家分别存储一个维度的冗余数据,以供查询。但是这样就是三倍冗余,比较浪费。
另外一种方法是通过外置搜索引擎建立索引进行查询。相比上一种更好。因为用户买家id或者卖家id进行查询的时候,通常还有其他过滤条件。
还有一种比较讨巧的做法,就是让订单id和买家id建立联系。我们在水平切分表的时候,可以截取买家id的后几位,生成订单id时在末尾加上截取出来的买家id。如果在生成订单id的时候能够保持后几位和买家id一致,那订单id和买家id就相当于建立了联系。通过订单id和买家id查询都不用遍历所有表了。