分库分表

什么是分库?分表? #

**分库主要解决的是并发量大的问题。**因为数据库的连接数是有限的,虽然可以调整,但是也不是无限调整的。

比较典型的分库的场景就是我们在做微服务拆分的时候,就会按照业务边界,把各个业务的数据从一个单一的数据库中拆分开,分别把订单、物流、商品、会员等数据,分别放到单独的数据库中。

**分表主要解决的是数据量大的问题。**假如你的单表数据量非常大,因为并发不高,数据量连接可能还够,但是存储和查询的性能遇到了瓶颈了,你做了很多优化之后还是无法提升效率的时候,就需要考虑做分表了。

一般我们认为,单表行数超过 500 万行或者单表容量超过 2GB之后,才需要考虑做分库分表了,小于这个数据量,遇到性能问题先建议大家通过其他优化来解决。

分区和分表的区别? #

数据库中数据量过多,表太大的时候,不仅可以做分库分表,还可以做分区,分区和分表类似,都是按一定规则将一张大表进行分解。

主要是分区和分表后数据的数据存储方式有变化。

在Innodb中(8.0之前),表存储主要依赖两个文件,分别是.frm文件和.ibd文件。.frm文件用于存储表结构定义信息,而.ibd文件则用于存储表数据。

users_p1.ibd
users_p2.ibd
users_p3.ibd
users_p4.ibd
users.frm

在做了分区后,表面是还是只有一张表,只不过数据保存在不同的位置上了(同一个.frm文件),在做数据读取的时候操作的表名还是users表,数据库会自己去组织各个分区的数据。

users_1.ibd
users_1.frm
users_2.ibd
users_2.frm
users_3.ibd
users_3.frm
users_4.ibd

而在做了分表之后,不管是表面上,还是实际上,都已经是不同的表了(多个.frm文件),数据库操作的时候,需要去指定具体的表名。

一般来说,数据量变大时,我们应该先考虑分区,分区搞不定再考虑分表。

MySQL 数据库支持的分区类型为水平分区。

常见的表分区实践中,可以按照以下一些原则进行分区:

  • 按照系统负载,将数据分到不同的区域中;
  • 按照应用程序查询模式,将数据库分为不同的分区;
  • 按照月份或者年份分区;
  • 通过实践哈希法可以将记录放置到不同的分区中;
  • 基于范围查询,使用分段来将记录放置到不同的分区中,以便提高查询效率。

分表字段如何选择? #

根据业务慎重选择

通常,如果有特殊的诉求,比如按照月度汇总、地区汇总等以外,我们通常建议大家按照买家Id进行分表。因为这样可以避免一个关键的问题那就是——数据倾斜(热点数据)。

买家还是卖家 #

首先,我们先说为什么不按照卖家分表?

因为我们知道,电商网站上面是有很多买家和卖家的,但是,一个大的卖家可能会产生很多订单,比如像苏宁易购、当当等这种店铺,他每天在天猫产生的订单量就非常的大。如果按照卖家Id分表的话,那同一个卖家的很多订单都会分到同一张表。

那就会使得有一些表的数据量非常的大,但是有些表的数据量又很小,这就是发生了数据倾斜。这个卖家的数据就变成了热点数据,随着时间的增长,就会使得这个卖家的所有操作都变得异常缓慢。

但是,买家ID做分表字段就不会出现这类问题,因为不太容易出现一个买家能把数据买倾斜了。

但是需要注意的是,我们说按照买家Id做分表,保证的是同一个买家的所有订单都在同一张表 ,并不是要给每个买家都单独分配一张表。

我们在做分表路由的时候,是可以设定一定的规则的,比如我们想要分1024张表,那么我们可以用买家ID或者买家ID的hashcode对1024取模,结果是0000-1023,那么就存储到对应的编号的分表中就行了。

卖家查询怎么办? #

首先,业务问题我们要建立在业务背景下讨论。电商网站订单查询有几种场景?

1、买家查自己的订单

2、卖家查自己的订单

3、平台的小二查用户的订单。

首先,我们用买家ID做了分表,那么买家来查询的时候,是一定可以把买家ID带过来的,我们直接去对应的表里面查询就行了。

那如果是卖家查呢?卖家查询的话,同样可以带卖家id过来,那么,我们可以有一个基于binlog、flink等准实时的同步一张卖家维度的分表,这张表只用来查询,来解决卖家查询的问题。

数据同步

本质上就是用空间换时间的做法。

不知道大家看到这里会不会有这样的疑问:同步一张卖家表,这不又带来了大卖家的热点问题了吗?

首先,我们说同步一张卖家维度的表来,但是其实所有的写操作还是要写到买家表的,只不过需要准实时同步的方案同步到卖家表中。也就是说,我们的这个卖家表理论上是没有业务的写操作,只有读操作的。

所以,这个卖家库只需要有高性能的读就行了,那这样的话就可以有很多选择了,比如可以部署到一些配置不用那么高的机器、或者其实可以干脆就不用MYSQL,而是采用HBASE、PolarDB、Lindorm等数据库就可以了。这些数据库都是可以海量数据,并提供高性能查询的。

还有呢就是,大卖家一般都是可以识别的,提前针对大卖家,把他的订单,再按照一定的规则拆分到多张表中。因为只有读,没有写操作,所以拆分多张表也不用考虑事务的问题。

订单查询怎么办? #

上面说的都是有买卖家ID的情况,那没有买卖家ID呢?用订单号直接查怎么办呢?

这种问题的解决方案是,在生成订单号的时候,我们一般会把分表结果编码到订单号中去,因为订单生成的时候是一定可以知道买家ID的,那么我们就把买家ID的路由结果比如1023,作为一段固定的值放到订单号中就行了。这就是所谓的"基因法"

这样按照订单号查询的时候,解析出这段数字,直接去对应分表查询就好了。

至于还有人问其他的查询,没有买卖家ID,也没订单号的,那其实就属于是低频查询或者非核心功能查询了,那就可以用ES等搜索引擎的方案来解决了。就不赘述了。

分表算法有哪些? #

不管什么算法,需要确保一个前提,那就是同一个分表字段,经过这个算法处理后,得到的结果一定是一致的,不可变的。

直接取模 #

比如要分为35张表,用订单号对35取模。

按照关键字 #

在分表时可以对以关键字进行拆分,比如时间,某年某月放入一个表中,也可以按地区。

Hash取模 #

如果分表字段不是数字类型,是字符串类型怎么办?

先对这个字符串先做哈希,再取模

一致性哈希 #

如果需要扩容,二次分表,就需要重新计算哈希,就需要涉及到数据迁移。

为了解决扩容问题,采用一致性哈希的方式来分表。

一致性哈希可以按照常用的hash算法来将对应的key哈希到一个具有2^32次方个节点的空间中,形成一个顺时针首尾相接的闭合的环形。所以当添加一台新的数据库服务器时,只有增加服务器的位置和逆时针方向第一台服务器之间的键会受影响。

分表后全局ID如何生成? #

UUID #

太长,字符串查询效率比较慢,没有业务含义,看不懂

基于某个单表做自增主键 #

都从同一个表获取主键,单表会成为性能瓶颈,挂掉就废了。

基于多个单表+步长做自增主键 #

如何保证多张表里面生成的Id不重复呢?如果我们能实现以下的生成方式就行了:

实例1生成的ID从1000开始,到1999结束。 实例2生成的ID从2000开始,到2999结束。 实例3生成的ID从3000开始,到3999结束。 实例4生成的ID从4000开始,到4999结束。

这样就能避免ID重复了,那如果第一个实例的ID已经用到1999了怎么办?那就生成一个新的起始值:

实例1生成的ID从5000开始,到5999结束。 实例2生成的ID从6000开始,到6999结束。 实例3生成的ID从7000开始,到7999结束。 实例4生成的ID从8000开始,到8999结束。

我们把步长设置为1000,确保每一个单表中的主键起始值都不一样,并且比当前的最大值相差1000就行了。

雪花算法 #

雪花算法也是比较常用的一种分布式ID的生成方式,它具有全局唯一、递增、高可用的特点。

雪花算法生成的主键主要由 4 部分组成,1bit符号位、41bit时间戳位、10bit工作进程位以及 12bit 序列号位。

时间戳占用41bit,精确到毫秒,总共可以容纳约69年的时间。

工作进程位占用10bit,其中高位5bit是数据中心ID,低位5bit是工作节点ID,做多可以容纳1024个节点。

序列号占用12bit,每个节点每毫秒0开始不断累加,最多可以累加到4095,一共可以产生4096个ID。

所以,一个雪花算法可以在同一毫秒内最多可以生成1024 X 4096 = 4194304个唯一的ID

分库分表后会带来哪些问题? #

  • 所有读和写操作,都需要待着分表字段,这样才能知道具体去那个库,那个表中去查询数据。

  • 单表全表扫描较为容易

  • 不支持跨库事务,导致数据一致性问题

  • 分也查询、排序等操作实效

  • 二次分表的问题,一致性ID的问题等等

分库分表后如何进行分页查询? #

  • 拿出来放到内存中,进行分页和排序(不靠谱)

  • 分片查询+二次查询法(推荐)

    • 先在各分片查询主键ID和排序字段

    • 内存中合并并排序获取目标页的ID范围

    • 用ID精确查询完整数据

  • shardingkey查询

    一般来说,买家的订单查询是最高频的,对于买家来说,查询的时候天然就是可以带买家ID的,所以就可以路由到单个库中进行分页以及排序了。

  • 非shardingkey的关键查询

    那么,电商网站上不仅有买家,还有卖家,他们的查询也很高频,该怎么做呢?

    一般来说,业务上都会采用空间换时间的方案,同步出一张按照卖家维度做分表的表来,同步的过程中一般是使用canal基于bin log 做自动同步。虽然这种情况下可能存在秒级的延迟,但是一般业务上来说都是可以接受的。

    也就是说,当一条订单创建出来之后,会在买家表创建一条记录,以买家ID作为分表字段,同时,也会在卖家表创建一条记录出来,用卖家ID进行分表。

    并且这张卖家表不会做任何写操作,只提供查询服务,完全可以用一些机器配置没有那么高的数据库实例。

  • 非shardingkey的复杂查询

    一般来说,大型互联网公司用的比较多的方案就是使用分布式数据仓库来实现,也就是说我们会把这些数据同步到像TiDB、PolarDB、AnalyticDB等这些数据库中,或者同步到ES中,然后在这些数据库中做数据的聚合查询。

    有人说,都用了MySQL了,还要用这些数据库,那方案也太重了,搞的这么复杂干什么?

    那话又说回来了,分库分表,真不建议小公司、小团队用,这玩意本身就是意味着成本高的,又想要简单,又想要高效,又想要没问题?

分库分表后,表还不够怎么办? #

  • 首先在第一次分库分表时,就应该考虑未来的数据存储量,尽量避免这种情况。
  • 数据归档:就是定期清理一些不再需要,或者不再活跃的数据,把他们进行数据归档到辅助存储中(如历史表,离线数据仓等),这样就能减少数据的数量,能大大提升效率。
  • 只能继续分表,涉及到分表算法的更新,数据的迁移等。其中最关键的就是如何无损、无缝的做数据迁移。
  • 最开始就用一致性哈希进行分表路由,在做二次分表的时候,数据迁移的成本会低很多。

什么是数据倾斜,会带来哪些问题,如何解决? #

数据倾斜是指在分布式计算或数据库环境中,数据分布不均匀的现象。在理想的分布式系统中,数据和计算负载应该均匀分布在所有节点上。然而,由于各种原因,某些节点可能承载比其他节点更多的数据或计算负载,这就是数据倾斜。

数据倾斜主要会带来以下几个问题:

  • 性能瓶颈:数据倾斜可能导致某些节点的负载明显高于其他节点,从而成为整个系统的性能瓶颈。

    比如在Redis中,出现的热key问题,其实也是数据倾斜的一种具体情况,那么就会导致这个节点的负载非常高。

  • 资源利用不均:导致资源利用不均衡,一部分节点可能过载,而其他节点则闲置。

  • 查询效率低下:在数据库中,数据倾斜可能导致查询效率低下,特别是在执行JOIN操作或聚合查询时。

    比如数据库分表后数据倾斜,就会使得分表的效果并不明显,单表的数据量还是可能会很大,导致查询速度变慢。

  • 影响其他业务:某些数据倾斜会导致查询比较慢,这样不仅使这个业务有影响,和他在同一张表中的其他业务的数据也会有影响。

如何解决 #

  • 拆分

    比如在Redis中,为了解决热key的问题,可以采用Cluster模式,把一个热key拆分到多个实例上存储。

    还有就是在分库分表场景,那就是做一下二次分表。

  • 隔离

    所谓物理隔离,其实就是把这个严重倾斜的商户的数据,单独独立出来放到一个单独的数据库中。

    这样既可以降低对其他用户的影响,又可以单独给这个数据库增加配置,提升可用性。

为什么要全局分布式ID,每张表自增不能吗? #

  • ID会重复,没办法用唯一ID定位一个数据
  • 无法支持二次分表,分表还是会造成ID重复

分库分表的取模算法策略如何避免数据倾斜? #

  • 选择合适分片键

    分片键应该是高度散列的,并且在业务操作中经常被用作查询条件。

    例如,用户ID通常是一个好的分片键,因为它通常能保证数据分布的均匀性。但是当做分表的时候,买家ID肯定比卖家ID分片会出现倾斜的概率要低得多。毕竟一个买家的订单肯定不会有卖家的订单多。

  • 复合分片

    复合分片策略结合了多个分片键和/或多种分片方法。例如,可以先基于第一个分片键进行粗略分片,然后在每个粗略分片内部再根据第二个分片键进行细分。这种方法可以进一步细化数据分布,减少倾斜的风险。

    例如说,对于支付单的表,按照付款方分片之后,发现有的付款方式企业账号,他的数据量很大,就会出现数据倾斜。

    那么就可以针对付款方类型是企业的,再把"付款方ID+下单日期"组合在一起做一次分片。当然,这也会带来一个问题,就是下次查询的时候需要带上下单日期才行。但是我们前面的几篇分库分表的文章中讲过,我们可以通过基因法把分表结果组装到支付单号中,后续查询基于支付单号。

  • 虚拟分片

    虚拟分片意味着实际的物理分片数量远少于虚拟分片的数量。首先,基于一个较大的取模基数进行分片,将数据均匀分布到多个虚拟分片中,然后再将这些虚拟分片映射到实际的物理分片上。这种方法可以在一定程度上缓解数据倾斜的问题,因为即使某些键值分布不均,通过虚拟分片和映射也能实现较均匀的数据分布。

分库分表后怎么进行join操作? #

在我们做了分库分表后,数据会散落在不同的数据库中,这时候需要进行跨库或跨表的JOIN操作时,比较麻烦。

应用层join #

在应用代码中单独查询各个表,然后在应用层将结果进行合并。这意味着所有的必要数据被加载到应用服务器的内存中,然后执行类似join的操作。

优点:灵活,可以跨不同的数据库和表实现。不依赖数据库特性,适用于任何数据库系统。

缺点:数据量大时对服务器内存处理能力要求高,网络开销大等

使用数据库中间件 #

在分库分表后,我们也可以使用如MyCat、Shardingsphere等数据库中间件来支持分库分表环境下的 JOIN 操作,比如使用shardingsphere的联邦查询功能(这个功能还在完善中,并不是特别建议在生产环境中用)。这些中间件可以理解为一个数据库代理,对应用透明地处理数据分片和查询路由。

这个方案的优点是对应用透明,应用不需要关心数据如何分片。可以较为高效地执行查询优化和数据汇总。缺点就是引入额外的系统复杂性和维护成本。性能和支持的SQL特性可能受限于中间件的能力。

数据冗余 #

调整分库分表策略,尽量减少需要执行join操作的场景,通过合理的数据冗余和预聚合来避免跨库查询。

这个方案可以减少复杂查询,提升系统性能,缺点是存在一致性问题和增加空间。

搜索引擎 #

使用Elasticsearch等搜索引擎,也是可以解决跨库JOIN的问题的,尤其是在处理大数据和复杂搜索场景时。

我们可以基于前面的宽表的思想,把orders表和users中我们关心的所有字段做成一个文档,如类似以下形式:

{
  "userId": "123",
  "userName": "Hollis",
  "orders": [
    {
      "orderId": "a1",
      "orderDate": "2021-01-01",
      "amount": 100
    },
    {
      "orderId": "b2",
      "orderDate": "2021-02-01",
      "amount": 150
    }
  ]
}

然后再基于canal等工具,把orders表及users表的变更同步到ES中,这样我们就可以基于ES直接做查询了。

分库分表的数量为什么一般选择2的幂? #

分表的数量一般取 8、16、32、512、、、

为了将取模操作改为更高效的位运算。

因为,hash % 8 相当于 hash & (8-1),所以对于分8个表,可以使用哈希值与7进行按位与操作,这不仅简化了计算,还提升了性能。

所以,使用2的幂作为分表数量的第一个好处,就是可以将取模算法优化成位运算算法。

除了这个好处以外,我们通常分表都是伴随着分库的,比如我们分16个库128张表, 这样如果我们的分表数量和分库数量都是2的幂,那么就可以实现均匀分布。128张表就可以均匀的分到16个库中,每个库中有8张表。

所以,使用2的幂作为分表数量的第二个好处,就是可以使得多张分表在多个库中均匀分配。

当遇到二次分表问题时

假设我们最开始将订单表分成了4张表,分别是 order_00、order_01、order_02、order_03,这时候假设我们的分表算法是根据 userId取模。即 userId % 4

order_00:userId % 4 == 0

order_01:userId % 4 == 1

order_02:userId % 4 == 2

order_03:userId % 4 == 3

当数据量增长,需要将表扩展到8个时,需要将算法改为userId % 8 :

新的哈希值计算:

order_00:userId % 8 == 0

order_01:userId % 8 == 1

order_02:userId % 8 == 2

order_03:userId % 8 == 3

order_04:userId % 8 == 4

order_05:userId % 8 == 5

order_06:userId % 8 == 6

order_07:userId % 8 == 7

而在做储量数据迁移的时候,我们只需要重新分配那些原来在表0、1、2、3中,且哈希值满足 userId % 8 >= 4 的数据进行迁移就好了,userId % 8 的结果在0-3范围内的数据其实是不用动的。

也就是说,从4张表扩容到8张表其实只需要迁移一半的数据。而如果不是2的幂,比如说从5张表扩容成9张表,那每个原数据都需要重新计算哈希值并重新分配到新的表中。因为扩展后的表数量不是2的幂次,大多数数据都会被重新分配到不同的表中。

所以,使用2的幂作为分表数量的第三个好处,也是**最重要的一个好处,那就是在做数据迁移时只需重新计算和迁移一半数据。**这种方法不仅降低了系统扩展的复杂性,还减少了扩展过程中对系统性能的影响。

GORM的sharing #

分库分表后怎么设计可以降低数据迁移的难度? #

  • 分库分表的数量选择2的幂等
  • 一致性哈希

在分库分表时,如果遇到了对商品名称对模糊查询,要怎么处理? #

上搜索引擎,比如Elasticsearch

且不说分库分表,就是数据量超过几千万的这种模糊查询的话,数据库你用like去查询也都扛不住,因为%hollis%没办法走索引,性能也很差。

方案就是把数据同步到ES中一份,然后基于ES针对商品名称构建倒排索引,这样在分词后就可以做全文索引提升查询效率了。

在分库分表中,如何预估需要多少个库?多少张表? #

分成多少,一般是一般是结合订单存量数据,以及增量数据的情况来看要分多少张表。你要知道你存量已经有多少数据量了,以及一年大概增长多少,还要知道业务上希望你的数据保留多久。

分表数量= (订单存量总数 + 预计年增长量 * 保留年限)/2000万 => 向上取最接近的2的幂

以上公式中的2000万,是Innodb 理论上的单表极限(这里的极限指的是性能不变差的极限)。

假设存量数据已经有2000万了,预计每年增长500万,我们需要保留10年,那么就得出:

(2000 + 500 * 10) / 2000 = 3.5 => 4

这里为什么计算出的结果要向上取最接近2的幂呢?

因为这样可以更容易基于分表数量计算分库的数量,以及后期数据迁移的时候更加方便。

分库数量 = 分表数量 / 8

一个库8个表不是特定的,只是预估值。

订单号用了基因法之后,二次分表怎么办? #

上面提到过,为了让订单号也可以实现快速查询,我们采用基因法来编码订单号,也就是把分表结果放到订单号上面去,这样基于订单号就能直接精准找到具体的单表做查询了。

那么,有个关键问题,那就是原来的表如果是分成了128张,但是后面不够了,要重新分表成256,怎么办?老的订单没办法迁移了,因为订单号肯定是不能变的,而一旦数据迁移,就没办法查询到之前的订单了。那么可行的方案有哪些呢?

  • 把分表字段编码到订单号上

    所谓基因法,其实只要订单上有分表相关的基因就行了,但是不要求说一定要是基于分表结果做基因。也可以直接用分表字段做基因

    比如,原来我们是这样的,分成128张表,用buyer_id做分表字段,取模作为分表算法。

    数据插入的时候

    1、分表结果 = buyer_id % 128 ,如 23

    2、分表结果编码到订单号中,如xxxx0023

    那么在查询的时候:

    1、基于订单号xxxx0023,计算出他的物理表是0023这张表,那么就直接去这张表查询即可。

    但是,如果我们换一个方案,数据插入的时候是这样的:

    1、buyer_id编码到订单号中,如xxxx32132145

    查询的时候:

    1、从订单号xxxx32132145中解析出用户id:32132145

    2、基于32132145用户id,计算出具体的物理表

    3、直接去具体的物理表做查询

    如果是这样的话,你想想,我们是不是怎么扩容都可以。只要在扩容后做数据迁移就行了,因为我并没有在订单号上记录具体的分表信息,而是记录的用户id的信息,只要库容之后,我把分表算法同步改了就行,即之前是对128取模,现在是对256取模了。

    **优点:**向下兼容

    **缺点:**用户ID可能过长,可以通过固定保留后几位实现,比如一般保留后4位;需要做数据迁移。

  • 新老共存

    原来的订单按照旧基因法分到老的128张表中。扩容后,新订单写入新的256分表中。这样查询的时候,根据订单ID判断是老订单还是新订单,然后走不同的表。

    比如老订单20位,新订单21位。

    优点:

    • 不需要迁移旧数据
    • 风险较小

    缺点:

    • 路由逻辑更复杂
    • 跨分表聚合查询时需要查多个分区
  • 其他方案

    中间路由表,不可取,不建议