分库分表

系统正在持续不断地发展,注册的用户越来越多,数据库中存储的数据也越来越多。

会遇到的问题:

  • 这时即使你使用了索引,索引占用的空间也随着数据量的增长而增大,数据库就无法缓存全量的索引信息,那么就需要从磁盘上读取索引数据,就会影响到查询的性能了。
  • 数据量的增加也占据了磁盘的空间,数据库在备份和恢复的时间变长。
  • 不同模块的数据,比如用户数据和用户关系数据,全都存储在一个主库中,一旦主库发生故障,所有的模块都会受到影响。

这些问题都是数据库的写入请求量大造成的性能和可用性方面的问题,一般采取的措施就是对数据进行分片即分库分表

为什么要分库分表:

  • 如果数据库已经到了写瓶颈怎么办?要么优化写操作,要么分库。
    • 对于写瓶颈来说,分区表可以缓解问题,而读写分离几乎没有效果,比如频繁地增删改操作。
  • 如果数据库已经到了读瓶颈怎么办?要么优化读操作,要么加从库,要么分库或分表。
  • 对于硬件瓶颈来说,读写分离、分区表基本上也解决不了,比如写操作引发的网络带宽问题。

一般非必要不进行分库分表,大致要求如下:

  • 单表数据超过1000w
  • 单表数据文件大小超过20GB

方案分为:

  • 单库多表:有效缩小磁盘扫描范围
    • 单表数据量过大、锁竞争等跟表维度相关的资源引发的性能问题时选择
  • 多库单表:根据不同业务分库
    • 当单一数据库实例无法满足整体业务的读写需求时,可以考虑根据业务模块分库。
  • 多库多表:提供数据库并行处理能力
    • 当系统面临高并发、大数据量的情况,即使分库仍然无法满足性能需求时,可以选择分库分表。

好处:

  • 每个节点只保存部分的数据,这样可以有效地减少单个数据库节点和单个数据表中存储的数据量,在解决了数据存储瓶颈的同时也能有效地提升数据查询的性能。
  • 让单一写请求变为多个数据分片节点的请求,在一定程度上也会提升并发写入的性能。

数据库分库分表的方式有两种:一种是垂直拆分,另一种是水平拆分

垂直拆分

垂直拆分,顾名思义就是对数据库竖着拆分,也就是将数据库的表拆分到多个不同的数据库中。原则一般是按照业务类型来拆分,核心思想是专库专用,将业务耦合度比较高的表拆分到单独的库中。这样在数据层面还起到故障隔离的作用。

垂直拆分示例图:

image-20230422165257447

问题:

垂直拆分依然不能解决某一个业务模块的数据大量膨胀的问题,一旦你的系统遭遇某一个业务库的数据量暴增,就需要将数据拆分到多个数据库和数据表中,也就是对数据库和数据表做水平拆分了。

水平拆分

水平拆分就和垂直拆分的关注点不一样了,关注点在数据的特点,将单一数据表按照某一种规则拆分到多个数据库和多个数据表中。

常见拆分的规则:

  1. 按照某一个字段的哈希值做拆分,这种拆分规则比较适用于实体表;

    比如说用户表,内容表,我们一般按照这些实体表的 ID 字段来拆分,先对ID做一次hash对n取余得到是哪个库,再对m取余得到哪个表。

    缺点:存在范围查询跨库问题

  2. 另一种比较常用的是按照某一个字段的区间来拆分,比较常用的是时间字段。

    比如说内容表,按照创建时间进行分片,根据数据量来判断每天一个表或者每个月一个表。在查询时就可以根据创建时间先定位数据存储在哪个表里面,再按照查询条件来查询。

    缺点:存在明显的热点问题,数据倾斜,例如一般只会关注最近的数据。

    注意事项:需要提前创建好数据库表,不然没库表可写会发生故障。

容量预估

分库分表容量确定需要依据两点:你现在有多少数据、你将来有多少数据。所以如果讨论到了容量估算的问题,你就要直接指出核心:现有数据和增长趋势。

  • 存量数据:存量数据是最好处理的。基本上你只需要简单计算一下就可以得到。

    不过并不是所有存量数据都需要进行分库分表,部分不重要的、用不上的、历史悠久的数据,不如直接归档,或者放到大数据平台上。所以你真正需要计算的是那些还需要线上继续查询的数据的量。

  • 增长趋势:你需要考虑两点,一个是现有数据增长率(一阶导数),另一个是数据增长率的变化趋势(二阶导数)

    • 数据增长率:数据的增长趋势只需要根据公司的战略规划来就可以(按照业务可能的增长上限3-5年来评估,避免扩容)。比如说今年公司的目标是业务翻倍,那么就可以认为今年数据的增长率是 100%。

大厂在容量规划的时候都是按照 2 的幂来规划的,比如说 4×2×8 ,或者 8×4×32。而且在扩容的时候,也是按照 2 的幂来进行的。也就是说,基本上扩容都是选择容量翻倍。这其实是因为 2 的特性,在使用哈希取余来进行分库分表的时候,可以使用位运算来计算余数,非常高效。

分库分表引入的问题:

  • 分布式事务问题:

    需要引入XA、TCC、SAGA等分布式事务解决方案。

  • 查询的复杂度问题:

    原先只需要根据查询条件到从库中查询数据即可,现在则需要先确认数据在哪一个库表中,再到那个库表中查询数据。但是可以通过前面提到的数据库中间件来解决。

  • 分库分表键问题:

    无论是哈希拆分还是区间段的拆分,我们首先都需要选取一个数据库字段,这样之后所有的查询都需要带上这个字段,才能找到数据所在的库和表,否则就只能向所有的数据库和数据表发送查询命令。

    • 如果主键Id中可以解析出分表键的话,这种方案是最简单的。

    • 如果需要按照别的字段来查询,最合适的解决方案是建立一个该字段和 ID 的映射表。在查询的时候要先通过昵称查询到 ID,再通过 ID 查询完整的数据。

      一般来说,在设计中间表的时候应该包含尽可能少的列,而且这些列的值应该尽可能不变,会频繁更新的列就不要放了,而且大表存在表结构改变问题。

    • 二次分库分表:二次分库分表指复制出来一份数据,然后尝试再进行分库分表。所以你的系统里面就会有两份数据,分别按照不同的分库分表规则存储。缺陷就是数据一致性问题,以及数据复制一份需要很多存储空间。

    • 使用其他中间件:为了支持复杂多样的查询,可以尝试使用别的中间件,比如说 Elasticsearch。为了减轻 Elasticsearch 的压力,我们选择了只同步部分字段。一些非常庞大的字段,比如说 TEXT 或者 BLOB 本身我们是不会同步过去的。

    • 我们还有一些兜底措施,也就是如果一个查询确实没办法使用前面那些方案的时候,那就可以考虑使用广播。也就是说直接把所有的请求发送到所有的候选节点里面,然后收集到的数据就是查询的结果。

    数据同步问题:在引入中间表、二次分库分表和使用其他中间件三个解决方案里面,都面临一个同样的问题:你怎么进行数据同步?

    • 双写:就是在写入源数据表的时候,同时写到另外一个地方。这个可以通过改造 ORM 或者分库分表中间件来达成。
    • 利用 Canal:利用 Canal 之类的框架监听 binlog,然后异步地把数据库同步到其他地方。

    不管是双写,还是监控 binlog,都绕不开失败这个话题。那失败的时候怎么办呢?无非就是各种重试(Spring-Retry),在重试都失败之后进行异步修护,如果修护失败就人手工介入处理。

    亮点方案:

    在分库分表之后,为了充分满足不同情况下的查询需求,我们公司综合使用了三种方案:引入中间表、二次分库分表和 Elasticsearch。对于卖家查询来说,我们直接复制了一份数据,按照卖家 ID 分库分表。对于一些复杂的查询来说,就是利用 Elasticsearch。还有一些查询是通过建立中间表来满足,比如说商品 ID 和订单 ID 的映射关系。

    image-20231205111335092

    买家和卖家同时修改订单问题:

    • 所以类似的场景最好是采用分布式锁和双写方案。比如买家修改状态的时候,要先拿到分布式锁,然后同时修改买家库和卖家库。当然,要是覆盖数据也没关系,那么就还是可以继续采用 Canal 的同步方案
    • 也可以考虑只允许从买家库进去修改数据,也就是说,不允许直接修改卖家库的数据。举个例子,如果卖家想要修改某个订单的数据,那么他需要在卖家库查到订单的信息,但是在修改的时候要拿着订单信息去买家库修改。
  • 一些数据库的特性在实现时可能变得很困难:

    • 比如说多表的 JOIN 在单库时是可以通过一个 SQL 语句完成的,但是拆分到多个数据库之后就无法跨库执行 SQL 了。

      解决:可以在业务层去处理先查A库再查B库;使用MyCat、ShardingSphere实现两表跨库join

    • count()问题

      解决:将计数的数据单独存储在一张表中或者记录在 Redis 里面

    • 跨节点分页查询问题

      解决:

      • 业务端单节点各取n条,之后合并运算取top n;
      • 禁止跨页查询,通过上一次查询条件来进行where筛除
      • 或者就同步到es中在es中进行查询

      亮点方案:

      • 二次查询:二次查询的基本理念是先尝试获得某个数据的全局偏移量,然后再根据这个偏移量来计算剩下数据的偏移量

        假设你的分库分表总共有 N 个表,查询是 LIMIT X OFFSET Y,步骤如下:

        • 首先发送查询语句 LIMIT X OFFSET Y/N 到所有的表。
        • 找到所有返回结果中的最小值(升序),记为 min。
        • 执行第二次查询,关键是 BETWEEN min AND max。其中 max 是在第一次查询的数据中每个表各自的最大值。
        • 根据 min、第一次查询和第二次查询的值来确定 min 的全局偏移量。总的来说,min 在某个表里面的偏移量这样计算:如果第二次查询比第一次查询多了 K 条数据,那么偏移量就是 Y 除以 N 减去 K。然后把所有表的偏移量加在一起,得到的就是 min 的全局偏移量。
        • 根据 min 的全局偏移量,在第二次查询的结果里面向后补足到 Y,得到第一条数据的位置,再取 X 条。
      • 引入中间表:引入中间表的意思是额外存储一份数据,只用来排序。这个方案里面就是在中间表里加上排序相关的列

        缺点:

        • 这个方案有两个明显的缺陷,一个是 WHERE 也只能使用中间表上的列;另外一个是维护中间表也会引入数据一致性的问题。

          • 对于一致性问题:

            比较简单的做法就是业务保持双写,也就是写入目标表也写入中间表。不过这里我更加建议使用 Canal 之类的框架来监听 binlog,异步更新中间表。这样做的好处是业务完全没有感知,没有什么改造成本。更新的时候可以考虑引入重试机制,进一步降低失败的几率。

  • 唯一主键生成问题:

    当我们分库分表之后,同一个逻辑表的数据被分布到多个库中,这时如果使用数据库自增字段作为主键,那么只能保证在这个库中是唯一的,无法保证全局的唯一性。

    • uuid:

      • UUID 不是递增的
        • 因为这样可以迫使数据库的树朝着一个方向增长,而不会造成中间叶节点分裂,这样插入性能最好。随机会引起更加频繁地页分裂,在糟糕的情况下,这种分裂可能引起连锁反应,整棵树的树形结构都会受到影响。
        • 顺序读问题:自增主键还有一个好处,就是数据会有更大的概率按照主键的大小排序,两条主键相近的记录,在磁盘上位置也是相近的。
    • 设置步长递增:

      例如:经过分库分表之后我有十个表,那么我可以让每一个表按照步长来生成自增 ID。比如说第一个表就是生成 1、11、21、31 这种 ID,第二个表就是生成 2、12、22、32 这种 ID。

      ID 虽然并不一定是全局递增的,但是在一个表内部,它肯定是递增的。这个方案的性能基本取决于数据库性能,应用层面上也不需要关注。

    解决:

    • 基于 Snowflake 算法

      Snowflake 的核心思想是将 64bit 的二进制数字分成若干部分,每一部分都存储有特定含义的数据,比如说时间戳、机器 ID、序列号等等,最终生成全局唯一的有序 ID。

      image-20230424151618698

      有些时候还可以缩短序列号,加入一些业务ID字段;又或者进一步切割,机器 ID 前半部分表示机器,后半部分可以表示这个机器上用于产生 ID 的进程、线程或者协程。

      注意嵌入业务代码带来的问题:

      • 无法保证全局递增,但是性能比完全随机好

      • 产生一样 ID 的概率不是没有,而是极低。它要求同一个用户在同一时刻创建了两个订单,然后订单 ID 的随机数部分一模一样,这是一个很低的概率。

        解决方案其实也很简单,就是在插入数据的时候,如果返回了主键冲突错误,那么重新产生一个,再次尝试就可以了。

      实现方式:

      • 一种是嵌入到业务代码里,也就是分布在业务服务器中
      • 另外一个部署方式是作为独立的服务部署,这也就是我们常说的发号器服务。

      可以利用提前批量取并缓存TLB中来优化发号器。

      问题:

      • 依赖于时间戳,一旦系统时间不准,就有可能生成重复的 ID,时间回拨问题。

        发现系统时钟不准,就可以让发号器暂时拒绝发号,直到时钟准确为止

      • 如果请求发号器的 QPS 不高,比如说发号器每毫秒只发一个 ID,就会造成生成 ID 的末位永远是 1,那么在分库分表时如果使用 ID 作为分区键就会造成库表分配的不均匀。

        1. 时间戳不记录毫秒而是记录秒,这样在一个时间区间里可以多发出几个号。
        2. 生成的序列号的起始号可以做一下随机。
      • 并发很高,序列号不够用:

        1. 如果 12 比特不够,你就给更多比特,这部分比特可以从时间戳里面拿出来
        2. 如果还不够,那么就让业务方等待一下,到下一个时刻,自然又可以生成新的 ID 了,也就是时间戳变了,这也是一种变相的限流。

    其他唯一主键方案:

    • 百度开源的UidGenerator(仅支持单机部署)使用Snowflake算法,单机QPS可达600万。
    • 美团Leaf(分布式ID生成系统),QPS近5万。
    • 微信序列号生成器
  • 扩容问题:

    • 范围分表容易扩容,但存在尾部热点问题
    • Hash分表极难扩容,建议改为一致性Hash,但迁移难度较大
  • 访问查询问题:例如对于以uid为hash进行分库后,如果需要按照username来查询就会遇到不知道去哪个库的问题。

    • 基因法:会改变uid生成的策略,会对username进行md5加密取最后两位,uid的生成策略就是前62位随机生成+上面的两位组成(不一定是两位,看有几台数据库2的次方)。
      • 优点:性能极好,不用额外查询便可快捷分表查询。
      • 缺点:
        • 建议2(1)/4(2)/8(3)/16(4)数据库节点,提前规划不具备迁移条件
        • 主键生成器算法要求更高,如62+2全局唯一
        • 只能由1个非主键字段与之主键对应
    • 倒排索引:生成Redis倒排索引,key为需要查询的关键字:值,value为uid,服务器编号
      • 优点:最通用的分库分表检索方案,几乎适用于所有系统。
      • 缺点:
        • 控制不好数据量爆炸,对Redis内存需求量大(如果SSD性能好,也可使用InnoDB表替代)
        • 需要手动维护数据库与缓存间的一致性,存在软状态

小结

当然,虽然分库分表会对我们使用数据库带来一些不便,但是相比它所带来的扩展性和性能方面的提升,我们还是需要做的。

所以总结了下面分库分表的原则:

  1. 如果在性能上没有瓶颈点那么就尽量不做分库分表;

  2. 如果要做,就尽量一次到位,比如说 16 库,每个库 64 表就基本能够满足几年内你的业务的需求。

  3. 如果更加熟悉 NoSQL 数据库,那么也可以考虑使用这些 NoSQL 数据库替代传统的关系型数据库。