分库分表

分库分表是企业里面针对常见的针对高并发、数据量大的场景下的一种技术优化方案

分库,分表,分库分表

所谓”分库分表”,根本就不是一件事儿,而是三件事儿,他们要解决的问题也都不一样。

这三个事儿分别是”只分库不分表”、”只分表不分库”、以及”既分库又分表”。

什么时候分库?

分库主要解决的是并发量大的问题。因为并发量一旦上来了,数据库就会成为瓶颈。因为数据库的连接数是有限的,虽然可以调整,但是也不是无限调整的。

当数据库的读或者写的QPS过高,导致数据库连接数不足了的时候,就需要考虑分库了,通过增加数据库实例的方式来提供更多的可用数据库链接,从而提升系统的并发度。

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

image-20240911104845422

有的时候可能会需要把历史订单挪到历史库里面去。这也是分库的一种具体做法

什么时候分表?

分库主要解决的是并发量大的问题,那分表其实主要解决的是数据量大的问题。

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

image-20240911104935731

通过将数据拆分到多张表中,来减少单表的数据量,从而提升查询速度。

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

什么时候既分库又分表?

那么什么时候分库又分表呢,那就是既需要解决并发量大的问题,又需要解决数据量大的问题时候。通常情况下,高并发和数据量大的问题都是同时发生的,所以,我们会经常遇到分库分表需要同时进行的情况。

所以,当你的数据库链接也不够了,并且单表数据量也很大导致查询比较慢的时候,就需要做既分库又分表了。

横向拆分和纵向拆分

谈及到分库分表,那就要涉及到该如何做拆分的问题。

通常在做拆分的时候有两种分法,分别是横向拆分(水平拆分)和纵向拆分(垂直拆分)。假如我们有一张表,如果把这张表中某一条记录的多个字段,拆分到多张表中,这种就是纵向拆分。那如果把一张表中的不同的记录分别放到不同的表中,这种就是横向拆分。

横向拆分的结果是数据库表中的数据会分散到多张分表中,使得每一个单表中的数据的条数都有所下降。比如我们可以把不同的用户的订单分表拆分放到不同的表中。

image-20240911105132950

纵向拆分的结果是数据库表中的数据的字段数会变少,使得每一个单表中的数据的存储有所下降。比如我可以把商品详情信息、价格信息、库存信息等等分别拆分到不同的表中。

image-20240911105152251

针对不同的业务做拆分成多个数据库的这种情况,其实也是纵向拆分的一种。

分表字段的选择

分库分表字段的选择非常关键,对于数据库来说,分库分表的目的在于提高查询和写入的效率。

可以举一个电商平台对于用户订单存储的场景。

一般来说,电商平台的用户订单满足 大数据、高并发等特点。一般的用户订单都会有 买家ID、买家ID、订单号、订单时间、地区等待字段。

分库分表的时候为了查询的方便,一般会选择区分度较高的字段(如果选择区分度低的字段,那可能导致很多数据最终还是到了一张表,一个数据库里面,分库分表就没有什么意义)。对于订单来说,备选项有 买家ID、买家ID、订单号。

买家ID和卖家ID

如果选择买家ID,也就是使用买家ID对于数据进行分区,通常来说,一个表里面只会存在一类的买家数据,也就是同一个买家的数据只会存储在一个表中,而不会出现在其他的表中,这样,对于特定的买家来说,如果需要查询对应的订单,就只需要在对应的一张表里面去查询即可。

但是,对于卖家来说,如果希望查询一个卖家对于的所有订单,那就只能去所有表中进行一次扫描,然后查出相应的数据,毫无疑问是非常困难的。

同理,如果选择使用卖家ID来作为分表字段,那么对于卖家进行查询效率就会非常高,但是对于买家来说,就会很慢。

有什么好的解决办法吗?

  • 可以采取空间换时间的方法

    Mysql有一个binlog日志,既然无论是采取买家ID还是卖家ID都无法满足现有的功能需求,那么可以在独立的做一套基于另一个ID分表,在插入数据的时候,通过Binlog进行同步,然后根据需求的不同,去不同的数据库查询,这样就可以满足买家和卖家的需求了。

    同时,另一套库只需要读,而不需要写(写是由binlog进行同步),那么可以选择HBASE、PolarDB、Lindorm等高性能的读数据库。

分表算法

分表算法的关键在于:对于同一个分表字段,经过分表算法后,得到结果一定得是一致的。

通用的分表算法有以下几种:

  • 直接取模

    在分库分表时,我们是事先可以知道要分成多少个库和多少张表的,所以,比较简单的就是取模的方式。

    比如我们要分成128张表的话,就用一个整数来对128取模就行了,得到的结果如果是0002,那么就把数据放到order_0002这张表中。

  • Hash取模

    那如果分表字段不是数字类型,而是字符串类型怎么办呢?有一个办法就是哈希取模,就是先对这个分表字段取Hash,然后在再取模。

    但是需要注意的是,Java中的hash方法得到的结果有可能是负数,需要考虑这种负数的情况。

  • 一致性Hash

    前面两种取模方式都比较不错,可以使我们的数据比较均匀的分布到多张分表中。但是还是存在一个缺点。

    那就是如果需要扩容二次分表,表的总数量发生变化时,就需要重新计算hash值,就需要涉及到数据迁移了。

    如果是前两种方法,数据迁移就需要全量数据进行重新计算,对于海量的数据来说,是非常慢的。

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

    image-20240913170420392

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

全局ID的生成

涉及到分库分表,就会引申出分布式系统中唯一主键ID的生成问题,因为在单表中我们可以用数据库主键来做唯一ID,但是如果做了分库分表,多张单表中的自增主键就一定会发生冲突。那就不具备全局唯一性了。

生成全局ID有以下几种方式:

  • UUID

    UUID由128位(16字节)组成,通常用32个16进制数字表示

    UUID保证一致性是通过其对应的生成算法

    • 时间戳、计数器、MAC地址:对于v1版本,时间戳结合MAC地址和计数器确保每台机器在同一时刻生成不同的UUID。
    • 伪随机数生成器:对于v4版本,使用强伪随机数生成算法,确保大规模生成UUID时发生碰撞的概率极低。
    • 哈希算法:对于v3和v5,使用命名空间与哈希算法的结合,确保输入相同的情况下生成一致的UUID。

    但是不推荐使用UUID作为全局ID,UUID太长了,其次字符串的查询效率也比较慢,而且没有业务含义,根本看不懂。

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

    通过对每个单表生成的ID范围做限制,利用数据库的自增ID来实现

    比如

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

    这样就能避免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

分库分表的工具

目前市面上比较不错的分库分表的开源框架主要有三个,分别是sharding-jdbc、TDDL和Mycat

Sharding-JDBC

现在叫ShardingSphere(Sharding-JDBC、Sharding-Proxy和Sharding-Sidecar这3款相互独立的产品组成)。它定位为轻量级Java框架,在Java的JDBC层提供的额外服务。它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。

开原地址:https://shardingsphere.apache.org

TDDL

TDDL 是淘宝开源的一个用于访问数据库的中间件, 它集成了分库分表, 读写分离,权重调配,动态数据源配置等功能。封装 jdbc 的 DataSource给用户提供统一的基于客户端的使用。

开源地址:https://github.com/alibaba/tb_tddl

Mycat

Mycat是一款分布式关系型数据库中间件。它支持分布式SQL查询,兼容MySQL通信协议,以Java生态支持多种后端数据库,通过数据分片提高数据查询处理能力。

开源地址:https://github.com/MyCATApache/Mycat2

分库分表带来的问题

做了分库分表之后,所有的读和写操作,都需要带着分表字段,这样才能知道具体去哪个库、哪张表中去查询数据。如果不带的话,就得支持全表扫描。

单表的时候全表扫描比较容易,但是做了分库分表之后,就没办法做扫表的操作了,如果要扫表的话就要把所有的物理表都要扫一遍。

还有,一旦我们要从多个数据库中查询或者写入数据,就有很多事情都不能做了,比如跨库事务就是不支持的。

分库分表之后就会带来因为不支持事务而导致的数据一致性的问题。

做了分库分表之后,以前单表中很方便的分页查询、排序等等操作就都失效了。因为我们不能跨多表进行分页、排序。

分库分表虽然能解决一些大数据量、高并发的问题,但是同时也会带来一些新的问题。所以,在做数据库优化的时候,还是建议大家优先选择其他的优化方式,最后再考虑分库分表。