MySQL读写分离和分库分表

290 阅读22分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

1. 单库单表存在的问题

高并发,和数据量大的问题

当项目中用户量开始大量增加,业务也越来越繁杂,一张表的字段可能有几十个甚至上百个,而且一张表存储的数据还很多,高达几千万数据,并且这样的表还挺多,于是一个数据库的压力就太大了,一张表的压力也比较大。试想一下,在一张几千万数据的表中查询数据,压力本来就大,如果这张表还需要关联查询,那时间等等各个方面的压力就更大了。

1、单太大:数据库里面的表太多,所在服务器磁盘空间装不下,IO次数多CPU忙不过来,自然就会造成读写缓慢,甚至是宕机

2、单太大:一张表的字段太多,数据太多,会导致索引树十分庞大,查询缓慢

2. 主从复制与读写分离

单库单表下越来越不满足需求,先考虑进行读写分离。我们将数据库的写操作和读操作进行分离, 使用多个从库副本(Slaver)负责读,使用主库(Master)负责写以及实时性要求比较高的读操作, 从库从主库同步更新数据,保持数据一致。

读写分离能提高性能的原因在于:

  • 主从服务器负责各自的读和写,极大程度缓解了锁的争用
  • 从服务器可以使用 MyISAM(MyISAM是非聚集索引),提升查询性能以及节约系统开销
  • 增加冗余,提高可用性

读写分离常用代理方式来实现,代理服务器接收应用层传来的读写请求,然后决定转发到哪个服务器。

这在一定程度上可以解决问题,但是用户超级多的时候,比如几个亿用户,此时写操作会越来越多,一个主库(Master)不能满足要求了,那就把主库拆分,这时候为了保证数据的一致性就要开始进行同步,此时会带来一系列问题:

(1)写操作拓展起来比较困难,因为要保证多个主库的数据一致性

(2)复制延时:意思是同步带来的时间消耗

(3)锁表率上升:读写分离,命中率少,锁表的概率提升

(4)表变大,缓存率下降:此时缓存率一旦下降,带来的就是时间上的消耗

注意,此时主从复制还是单库单表,只不过复制了很多份并进行同步。

主从复制架构随着用户量的增加、访问量的增加、数据量的增加依然会带来大量的问题,那就要考虑换一种解决思路也就是分库分表

3. 分库分表

不管是分库还是分表,都有两种切分方式:水平切分垂直切分

3.1 分库

(1)垂直分库

一个数据库的表太多。此时就会按照一定业务逻辑进行垂直切,比如用户相关的表放在一个数据库里,订单相关的表放在一个数据库里。注意此时不同的数据库应该存放在不同的服务器上,此时磁盘空间、内存、TPS等等都会得到解决。

结果

  • 每个库的结构都不同
  • 每个库的数据也不同,没有交集
  • 所有库的并集是全量数据

场景:系统绝对并发量上来了,并且可以抽象出单独的业务模块的情况下

分析: 随着业务的发展,一些公用的配置表、字典表等越来越多,这时可以将这些表拆到单独的库中,甚至可以服务化

(2)水平分库

水平分库理论上切分起来是比较麻烦的,它是指将单张表的数据切分到多个服务器上去,每个服务器具有相应的库与表,只是表中数据集合不同。 水平分库分表能够有效的缓解单机和单库的性能瓶颈和压力,突破IO、连接数、硬件资源等的瓶颈。

结果

  • 每个库的结构相同
  • 每个库中的数据不同,没有交集
  • 所有库的数据并集是全量数据

场景:系统绝对并发量上来了,分表难以根本上解决问题,并且还没有明显的业务归属来垂直分库的情况下

分析:库多了,IO 和 CPU 的压力自然可以成倍缓解

3.2 分表

(1)垂直分表

表中的字段较多,一般将不常用的、 数据较大、长度较长的拆分到“扩展表“。一般情况下表的字段可能有几百列,此时是按照字段进行数竖直切。注意垂直分是列多的情况。

结果

  • 每个表的结构不同
  • 每个表的数据也不同,一般来说,每个表的字段至少有一列交集,一般是主键,用于关联数据。
  • 所有表的并集是全量数据。

场景:系统绝对并发量并没有上来,表的记录并不多,但是字段多,并且热点数据和非热点数据在一起,单行数据所需的存储空间较大,以至于数据库缓存的数据行减少,查询时回去读磁盘数据产生大量随机读 IO,产生 IO 瓶颈。

分析:可以用列表页和详情页来帮助理解。垂直分表的拆分原则是将热点数据(可能经常会查询的数据)放在一起作为主表,非热点数据放在一起作为扩展表,这样更多的热点数据就能被缓存下来,进而减少了随机读 IO。

垂直拆分之后,要想获取全部数据就需要关联两个表来取数据,但不能用 Join,因为 Join 不仅会增加 CPU 负担并且会将两个表耦合在一起(必须在一个数据库实例上),关联数据应该在 Service 层进行,分别获取主表和扩展表的数据,然后用关联字段关联得到全部数据。

(2)水平分表

因为单表的数据量太大,按照某种规则(range,hash取模等),切分到多张表里面去。 但是这些表还是在同一个库中,所以库级别的数据库操作还是有IO瓶颈。这种情况是不建议使用的,因为数据量是逐渐增加的,当数据量增加到一定的程度还需要再进行切分,比较麻烦。

结果

  • 每个表的结构相同
  • 每个表的数据不同,没有交集,所有表的并集是全量数据

场景:系统绝对并发量没有上来,只是单表的数据量太多,影响了 SQL 效率,加重了 CPU 负担,以至于成为瓶颈,可以考虑水平分表

分析:单表的数据量少了,单次执行 SQL 执行效率高了,自然减轻了 CPU 的负担

3.3 一张图解释分库分表

image.png

4. 分库分表之后的问题

4.1 关联查询困难

分库分表后表之间的关联操作将受到限制,两个相关联的表可能会分布在不同的数据库,不同的服务器中,结果原本一次查询能够完成的业务,可能需要多次查询才能完成。

但有一些解决的一些方法:

(1)全局表

全局表,也可看做“数据字典表”,就是系统中所有模块都可能依赖的一些表,为了避免库 Join 查询,可以将这类表在每个数据库中都保存一份。这些数据通常很少修改,所以不必担心一致性的问题。

(2)字段冗余

一种典型的反范式设计,利用空间换时间,为了性能而避免 Join 查询。

例如,订单表在保存 userId 的时候,也将 userName 也冗余的保存一份,这样查询订单详情顺表就可以查到用户名 userName,就不用查询买家 user 表了。

但这种方法适用场景也有限,比较适用依赖字段比较少的情况,而冗余字段的一致性也较难保证。

(3)数据组装

在系统 Service 业务层面,分两次查询,第一次查询的结果集找出关联的数据 id,然后根据 id 发起器二次请求得到关联数据,最后将获得的结果进行字段组装。这是比较常用的方法,但效率很低。

(4)搜索引擎Elasticsearch

关联的表有可能不在同一数据库中,所以基本不可能进行联合查询,需要借助搜索引擎解决,通过大数据技术统一聚合和处理关系型数据库的数据,然后对外提供查询操作但如果实时性要求很高,就需要实现实时搜索。

4.2 事务

分库分表后,就需要支持分布式事务了。数据库本身为我们提供了事务管理功能,但是分库分表之后就不适用了。如果我们自己编程协调事务,代码方面就又开始了麻烦。

(1)分布式事务

当更新内容同时存在于不同库找那个,不可避免会带来跨库事务问题。跨分片事务也是分布式事务,没有简单的方案,一般可使用“XA 协议”和“两阶段提交”处理。

分布式事务能最大限度保证了数据库操作的原子性。但在提交事务时需要协调多个节点,推后了提交事务的时间点,延长了事务的执行时间,导致事务在访问共享资源时发生冲突或死锁的概率增高。

随着数据库节点的增多,这种趋势会越来越严重,从而成为系统在数据库层面上水平扩展的枷锁。

(2)最终一致性

对于那些性能要求很高,但对一致性要求不高的系统,往往不苛求系统的实时一致性,只要在允许的时间段内达到最终一致性即可,可采用事务补偿的方式。

与事务在执行中发生错误立刻回滚的方式不同,事务补偿是一种事后检查补救的措施,一些常见的实现方法有:对数据进行对账检查,基于日志进行对比,定期同标准数据来源进行同步等。

4.3 数据迁移、扩容问题

当业务高速发展、面临性能和存储瓶颈时,才会考虑分片设计,此时就不可避免的需要考虑历史数据的迁移问题。

通用的扩容方法包括如下5个步骤:

  • 按照新旧分片规则,对新旧数据库进行双写。
  • 将双写前按照旧分片规则写入的历史数据,根据新分片规则迁移写入新的数据库。
  • 将按照旧的分片规则查询改为按照新的分片规则查询。
  • 将双写数据库逻辑从代码中下线,只按照新的分片规则写入数据。
  • 删除按照旧分片规则写入的历史数据。

在第2步迁移历史数据时,由于数据量很大,通常会导致不一致,因此,先清洗旧的数据,洗完后再迁移到新规则的新数据库下,再做全量对比,对比后评估在迁移的过程中是否有数据的更新,如果有的话就再清洗、迁移,最后以对比没有差距为准。

如果是金融交易数据,则最好将动静数据分离,随着时间的流逝,某个时间点之前的数据是不会被更新的,我们就可以拉长双写的时间窗口,这样在足够长的时间流逝后,只需迁移那些不再被更新的历史数据即可,就不会在迁移的过程中由于历史数据被更新而导致代理不一致。

在数据量巨大时,如果数据迁移后没法进行全量对比,就需要进行抽样对比,在进行抽样对比时要根据业务的特点选取一些具有某类特征性的数据进行对比。

在迁移的过程中,数据的更新会导致不一致,可以在线上记录迁移过程中的更新操作的日志,迁移后根据更新日志与历史数据共同决定数据的最新状态,来达到迁移数据的最终一致性。

4.4 跨节点分页、排序、函数问题

跨节点多库进行查询时,会出现 limit 分页、order by 排序等问题。

分页需要按照指定字段进行排序,当排序字段就是分页字段时,通过分片规则就比较容易定位到指定的分片;当排序字段非分片字段时,就变得比较复杂。

需要先在不同的分片节点中将数据进行排序并返回,然后将不同分片返回的结果集进行汇总和再次排序。

最终返回给用户如下图:

image.png

上图只是取第一页的数据,对性能影响还不是很大。但是如果取得页数很大,情况就变得复杂的多。

因为各分片节点中的数据可能是随机的,为了排序的准确性,需要将所有节点的前N页数据都排序好做合并,最后再进行整体排序,这样的操作很耗费 CPU 和内存资源,所以页数越大,系统性能就会越差。

在使用 Max、Min、Sum、Count 之类的函数进行计算的时候,也需要先在每个分片上执行相应的函数,然后将各个分片的结果集进行汇总再次计算。

4.5 全局主键避重问题

在分库分表环境中,由于表中数据同时存在不同数据库中,主键值平时使用的自增长将无用武之地,某个分区数据库自增 ID 无法保证全局唯一。

因此需要单独设计全局主键,避免跨库主键重复问题。这里有一些策略:

(1)UUID

UUID 标准形式是 32 个 16 进制数字,分为 5 段,形式是 8-4-4-4-12 的 32 个字符。

UUID 是最简单的方案,本地生成,性能高,没有网络耗时,但是缺点明显,占用存储空间多。

另外作为主键建立索引和基于索引进行查询都存在性能问题,尤其是 InnoDb 引擎下,UUID 的无序性会导致索引位置频繁变动,导致分页。

(2)结合数据库维护主键 ID 表

可以通过设置数据库 sequence 或者表的自增字段步长来进行水平伸缩

比如说,现在有 8 个服务节点,每个服务节点使用一个 sequence 功能来产生 ID,每个 sequence 的起始 ID 不同,并且依次递增,步长都是 8。

适合的场景:在用户防止产生的 ID 重复时,这种方案实现起来比较简单,也能达到性能目标。但是服务节点固定,步长也固定

缺点:系统添加机器,水平扩展较复杂;每次获取 ID 都要读取一次 DB,DB 的压力还是很大,只能通过堆机器来提升性能。

(3)Snowflake分布式自增 ID 算法

Twitter 的 Snowflake算法解决了分布式系统生成全局 ID 的需求,雪花算法以划分命名空间的方式将 64-bit位分割成多个部分,每个部分代表不同的含义。而 Java中64bit的整数是Long类型,所以在 Java 中 Snowflake算法生成的 ID 就是 long 来存储的。

image.png

第1位占用1bit,其值始终是0,可看做是符号位不使用。

第2位开始的41位是时间戳,41-bit位可表示2^41个数,每个数代表毫秒,那么雪花算法可用的时间年限是(1L<<41)/(1000L360024*365)=69 年的时间。

中间的10-bit位可表示机器数,5 位 datacenterId,5 位 workerId。10 位长度最多支持部署2^10 = 1024台机器,但是一般情况下不会部署这么台机器。如果我们对IDC(互联网数据中心)有需求,还可以将 10-bit 分 5-bit 给 IDC,分5-bit给工作机器。这样就可以表示32个IDC,每个IDC下可以有32台机器,具体的划分可以根据自身需求定义。

最后12-bit位是自增序列,毫秒内计数,12 位的计数顺序号支持每个节点每毫秒产生 4096 个 ID 序列。

雪花算法提供了一个很好的设计思想,雪花算法生成的ID是趋势递增,不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的,而且可以根据自身业务特性分配bit位,非常灵活

缺点:雪花算法强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。如果恰巧回退前生成过一些ID,而时间回退后,生成的ID就有可能重复。官方对于此并没有给出解决方案,而是简单的抛错处理,这样会造成在时间被追回之前的这段时间服务不可用。 image.png

(4)使用Redis实现

Redis实现分布式唯一ID主要是通过提供像 INCR 和 INCRBY 这样的自增原子命令,由于Redis自身的单线程的特点所以能保证生成的 ID 肯定是唯一有序的。

但是单机存在性能瓶颈,无法满足高并发的业务需求,所以可以采用集群的方式来实现。集群的方式又会涉及到和数据库集群同样的问题,所以也需要设置分段和步长来实现。

为了避免长期自增后数字过大可以通过与当前时间戳组合起来使用,另外为了保证并发和业务多线程的问题可以采用 Redis + Lua的方式进行编码,保证安全。

Redis 实现分布式全局唯一ID,它的性能比较高,生成的数据是有序的,对排序业务有利,但是同样它依赖于Redis ,需要系统引进Redis 组件,增加了系统的配置复杂性。

当然现在Redis的使用性很普遍,所以如果其他业务已经引进了Redis集群,则可以资源利用考虑使用Redis来实现。

5. 大众点评订单系统分库分表实践

原大众点评的订单单表早就已经突破两百G,由于查询维度较多,即使加了两个从库,优化索引,仍然存在很多查询不理想的情况。2015年大量抢购活动的开展,使数据库达到瓶颈,应用只能通过限速、异步队列等对其进行保护;业务需求层出不穷,原有的订单模型很难满足业务需求,但是基于原订单表的DDL又非常吃力,无法达到业务要求。随着这些问题越来越突出,订单数据库的切分就愈发急迫了。

美团技术团队切分的目标是未来十年内不需要担心订单容量的问题

5.1 订单库进行垂直切分

将原有的订单库分为基础订单库、订单流程库等

image.png

垂直切分缓解了原来单集群的压力,但是在抢购时依然捉襟见肘。原有的订单模型已经无法满足业务需求,重新设计了一套新的统一订单模型,为同时满足C端用户、B端商户、客服、运营等的需求,分别通过用户ID和商户ID进行切分,并通过PUMA(美团内部开发的MySQL binlog实时解析服务)同步到一个运营库。

image.png

5.2 切分策略选择Hash切分

数据水平切分后希望是一劳永逸或者是易于水平扩展的,所以采用mod 2^n这种一致性Hash

以统一订单库为例,美团分库分表的方案是32*32的,即通过UserId后四位mod 32分到32个库中,同时再将UserId后四位Div 32 Mod 32将每个库分为32个表,共计分为1024张表。线上部署情况为8个集群(主从),每个集群4个库。

当数据库性能达到瓶颈,按照现有规则不变,可以直接扩展到32个数据库集群,如果32个集群也无法满足需求,那么将分库分表规则调整为(322^n) (32⁄2^n),可以达到最多1024个集群。

当单表容量达到瓶颈(或者1024已经无法满足),假如单表都已突破200G,200×1024=200T(按照现有的订单模型算了算,大概一万千亿订单),2×(32×2^n),这时分库规则不变,单库里的表再进行裂变,当然,在目前订单这种规则下(用userId后四位 mod)还是有极限的,因为只有四位,所以最多拆8192个表。

5.3 唯一ID方案

为了减少运营成本并减少额外的风险我们排除了所有需要独立集群的方案,采用了带有业务属性的方案: > 时间戳+用户标识码+随机数

有下面几个好处:

  • 方便、成本低。
  • 基本无重复的可能。
  • 自带分库规则,这里的用户标识码即为用户ID的后四位,在查询的场景下,只需要订单号就可以匹配到相应的库表而无需用户ID,只取四位是希望订单号尽可能的短一些,并且评估下来四位已经足够。
  • 可排序,因为时间戳在最前面。

当然也有一些缺点,比如长度稍长,性能要比int/bigint的稍差等。

5.4 数据迁移

数据库拆分一般是业务发展到一定规模后的优化和重构,为了支持业务快速上线,很难一开始就分库分表,垂直拆分还好办,改改数据源就搞定了,一旦开始水平拆分,数据清洗就是个大问题,为此,美团经历了以下几个阶段。

第一阶段

image.png

  • 数据库双写(事务成功以老模型为准),查询走老模型。
  • 每日job数据对账(通过DW),并将差异补平。
  • 通过job导历史数据。

第二阶段

image.png

  • 历史数据导入完毕并且数据对账无误。
  • 依然是数据库双写,但是事务成功与否以新模型为准,在线查询切新模型。
  • 每日job数据对账,将差异补平。

第三阶段

image.png

  • 老模型不再同步写入,仅当订单有终态时才会异步补上。
  • 此阶段只有离线数据依然依赖老的模型,并且下游的依赖非常多,待DW改造完就可以完全废除老模型了。

5.5 其他问题的解决方案

  • 事务支持:将整个订单领域聚合体切分,维度一致,所以对聚合体的事务是支持的。
  • 复杂查询:垂直切分后,就跟join说拜拜了;水平切分后,查询的条件一定要在切分的维度内,比如查询具体某个用户下的各位订单等;禁止不带切分的维度的查询,即使中间件可以支持这种查询,可以在内存中组装,但是这种需求往往不应该在在线库查询,或者可以通过其他方法转换到切分的维度来实现。

6. 什么时候考虑分库分表

1、“能不分就不分”

并不是所有表都需要切分,主要还是看数据的增长速度。切分后在某种程度上提升了业务的复杂程度。不到万不得已不要轻易使用分库分表这个“大招”,避免“过度设计”和“过早优化”。

分库分表之前,先尽力做力所能及的优化:升级硬件、升级网络、读写分离、索引优化等。当数据量达到单表瓶颈后,在考虑分库分表。

2、数据量过大,正常运维影响业务访问

这里的运维是指:

  • 对数据库备份,如果单表太大,备份时需要大量的磁盘 IO 和网络 IO
  • 对一个很大的表做 DDL,MySQL会锁住整个表,这个时间会很长,这段时间业务不能访问此表,影响很大
  • 大表经常访问和更新,就更有可能出现锁等待

3、随着业务发展,需要对某些字段垂直拆分

有些不经常访问或者更新频率低的字段应该从大表中分离出去

4、数据量快速增长

随着业务的快速发展,单表中的数据量会持续增长,当性能接近瓶颈时,就需要考虑水平切分,做分库分表