终于有人能把分库分表讲明白了

2,526 阅读24分钟

此文参考其他作者写的文章进行了汇总,参考文献在文末。

前言

首先,能不分库分表就不分库分表。分库分表会带来很多问题,比如分布式事务、全局的唯一性id、结果集的合并等等。

那什么是分库分表呢?

其实分库分表根本不是一件事,而是三件事。

那到底是哪三件事呢?

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

什么是分库

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

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

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

一阶段:单应用单数据库

在早期创业阶段想做一个商城系统,基本就是一个系统包含多个基础功能模块,最后打包成一个 war 包部署,这就是典型的单体架构应用。

image.png

商城项目使用单数据库

如上图,商城系统包括主页 Portal 模板、用户模块、订单模块、库存模块等,所有的模块都共有一个数据库,通常数据库中有非常多的表。

因为用户量不大,这样的架构在早期完全适用,开发者可以拿着 demo 到处找(骗)投资人。

一旦拿到投资人的钱,业务就要开始大规模推广,同时系统架构也要匹配业务的快速发展。

二阶段:多应用单数据库

在前期为了抢占市场,这一套系统不停地迭代更新,代码量越来越大,架构也变得越来越臃肿,现在随着系统访问压力逐渐增加,系统拆分就势在必行了。

为了保证业务平滑,系统架构重构也是分了几个阶段进行。

第一个阶段将商城系统单体架构按照功能模块拆分为子服务,比如:Portal 服务、用户服务、订单服务、库存服务等。

如上图,多个服务共享一个数据库,这样做的目的是底层数据库访问逻辑可以不用动,将影响降到最低。

三阶段:多应用多数据库

随着业务推广力度加大,数据库终于成为了瓶颈,这个时候多个服务共享一个数据库基本不可行了。我们需要将每个服务相关的表拆出来单独建立一个数据库,这其实就是“分库”了。

单数据库能够支撑的并发量是有限的,拆成多个库可以使服务间不用竞争,提升服务的性能。

如上图,从一个大的数据中分出多个小的数据库,每个服务都对应一个数据库,这就是系统发展到一定阶段必须要做的“分库”操作。

现在非常火的微服务架构也是一样的,如果只拆分应用不拆分数据库,不能解决根本问题,整个系统也很容易达到瓶颈。

什么是分表

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

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

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

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

什么的是既分库又分表

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

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

什么情况需要考虑分库分表呢

分库分表的根本原因是:数据库出现性能瓶颈。用大白话来说就是数据库快扛不住了。

数据库出现性能瓶颈,对外表现有几个方面:

  • 大量请求阻塞

    在高并发场景下,大量请求都需要操作数据库,导致连接数不够了,请求处于阻塞状态。

  • SQL 操作变慢

    如果数据库中存在一张上亿数据量的表,一条 SQL 没有命中索引会全表扫描,这个查询耗时会非常久。

  • 存储出现问题

    业务量剧增,单库数据量越来越大,给存储造成巨大压力。

从机器的角度看,性能瓶颈无非就是 CPU、内存、磁盘、网络这些,要解决性能瓶颈最简单粗暴的办法就是提升机器性能,但是通过这种方法成本和收益投入比往往又太高了,不划算,所以重点还是要从软件角度入手。

多少的数据量需要考虑分库分表呢?

在中国互联网技术圈有一句广为流传的说法:MySQL 单表数据量大于 2000 万行,性能会明显下降。

事实上,这个传闻据说最早起源于百度。

具体情况大概是这样的,当年的 DBA 测试 MySQL性能时发现,当单表的量在 2000 万行量级的时候,SQL 操作的性能急剧下降,因此,结论由此而来。

然后又据说百度的工程师流动到业界的其它公司,也带去了这个信息,所以,就在业界流传开这么一个说法。

再后来,阿里巴巴《Java 开发手册》提出单表行数超过 500 万行或者单表容量超过 2GB,才推荐进行分库分表。对此,有阿里的黄金铁律支撑,所以,很多人设计大数据存储时,多会以此为标准,进行分表操作。

我对于分库分表的观点是,需要结合实际需求,不宜过度设计,在项目一开始不采用分库与分表设计,而是随着业务的增长,在无法继续优化的情况下,再考虑分库与分表提高系统的性能。

对此,阿里巴巴《Java 开发手册》补充到:如果预计三年后的数据量根本达不到这个级别,请不要在创建表时就分库分表。

垂直拆分和水平拆分

就拿用户表(user)来说,表中有 7 个字段:id,name,age,sex,nickname,description,如果 nickname 和 description 不常用,我们可以将其拆分为另外一张表:用户详细信息表,这样就由一张用户表拆分为了用户基本信息表+用户详细信息表,两张表结构不一样相互独立。但是从这个角度来看垂直拆分并没有从根本上解决单表数据量过大的问题,因此我们还是需要做一次水平拆分。

可以按照 id 拆表,还可以按照时间维度去拆分,比如订单表,可以按每日、每月等进行拆分。

  • 每日表:只存储当天的数据。
  • 每月表:可以起一个定时任务将前一天的数据全部迁移到当月表。
  • 历史表:同样可以用定时任务把时间超过 30 天的数据迁移到 history 表。

总结一下水平拆分和垂直拆分的特点:

  • 垂直拆分:基于表或字段划分,表结构不同。
  • 水平拆分:基于数据划分,表结构相同,数据不同。

分表字段的选择

在分库分表的过程中,我们需要有一个字段用来进行分表,比如按照用户分表、按照时间分表、按照地区分表。这里面的用户、时间、地区就是所谓的分表字段。

那么,在选择这个分表字段的时候,一定要注意,要根据实际的业务情况来做慎重的选择。

比如说我们要对交易订单进行分表的时候,我们可以选择的信息有很多,比如买家Id、卖家Id、订单号、时间、地区等等,具体应该如何选择呢?

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

买家还是卖家?

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

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

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

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

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

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

卖家查询怎么办?

如果按照买家Id进行了分表,那卖家的查询怎么办,这不就意味着要跨表查询了吗?

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

  • 买家查自己的订单
  • 卖家查自己的订单
  • 平台的小二查用户的订单。

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

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

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

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

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

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

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

按照订单查询怎么办?

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

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

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

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

分表算法

选定了分表字段之后,如何基于这个分表字段来准确的把数据分表到某一张表中呢?

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

通常情况下,当我们对order表进行分表的时候,比如我们要分成128张表的话,那么得到的128表应该是:order_0000、order_0001、order_0002.....order_0126、order_0127

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

直接取模

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

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

比如表中有一万条数据,我们拆分为两张表,id 为奇数的:1,3,5,7……放在 user1 中, id 为偶数的:2,4,6,8……放在 user2 中,这样的拆分办法就是水平拆分了。

Hash取模

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

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

一致性Hash

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

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

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

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

全局ID的生成

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

那么,如何生成一个全局唯一的ID呢?有以下几种方式:

UUID

很多人对UUID都不陌生,它是可以做到全局唯一的,而且生成方式也简单,但是我们通常不推荐使用他做唯一ID,首先UUID太长了,其次字符串的查询效率也比较慢,而且没有业务含义,根本看不懂。

基于某个单表做自增主键

多张单表生成的自增主键会冲突,但是如果所有的表中的主键都从同一张表生成是不是就可以了。

所有的表在需要主键的时候,都到这张表中获取一个自增的ID。

这样做是可以做到唯一,也能实现自增,但是问题是这个单表就变成整个系统的瓶颈,而且也存在单点问题,一旦他挂了,那整个数据库就都无法写入了。

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

为了解决单个数据库做自曾主键的瓶颈及单点故障问题,我们可以引入多个表来一起生成就行了。

但是如何保证多张表里面生成的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

分库分表的工具

在选定了分表字段和分表算法之后,那么,如何把这些功能给实现出来,需要怎么做呢?

我们如何可以做到像处理单表一样处理分库分表的数据呢?这就需要用到一个分库分表的工具了。

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

Sharding-JDBC

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

开原地址:shardingsphere.apache.org

TDDL

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

开源地址:github.com/alibaba/tb_…

Mycat

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

开源地址:github.com/MyCATApache…

分库分表带来的复杂性

分库分表之后,会带来很多问题。

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

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

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

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

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

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

跨库关联查询

在单库未拆分表之前,我们可以很方便使用 join 操作关联多张表查询数据,但是经过分库分表后两张表可能都不在一个数据库中,如何使用 join 呢?

有几种方案可以解决:

  • 字段冗余:把需要关联的字段放入主表中,避免 join 操作;
  • 数据抽象:通过 ETL 等将数据汇合聚集,生成新的表;
  • 全局表:比如一些基础表可以在每个数据库中都放一份;
  • 应用层组装:将基础数据查出来,通过应用程序计算组装;
  • 冗余业务表。”A库的a表需要与B库的b表关联查询“场景,可以在A库中冗余存储B库的b表,通过数据同步机制保持与B库的b表数据一致,这样A库就可以直接关联冗余表了。
  • 绑定表。需要关联的数据放在一个节点上。比如按租户分库分表,不同租户之间不会有数据关联查询,同一个租户内的数据都在一个节点上,可以直接关联。 当出现了跨接待你关联的问题,一定要想一想业务逻辑是否合理,如果真的要跨节点关联,一般是通过rpc调用另外一个节点的数据,组装好再返回给前端或者第三方,对外无感知。

分布式事务

单数据库可以用本地事务搞定,使用多数据库就只能通过分布式事务解决了。

常用解决方案有:基于可靠消息(MQ)的解决方案、两阶段事务提交、柔性事务等。

排序、分页、函数计算问题

在使用 SQL 时 order by、limit 等关键字需要特殊处理,一般来说采用分片的思路:

先在每个分片上执行相应的函数,然后将各个分片的结果集进行汇总和再次计算,最终得到结果。

跨节点多库进行查询时,会出现 limit 分页,order by 排序的问题。比如有两个节点,节点 1 存的是奇数 id=1,3,5,7,9……;节点 2 存的是偶数 id=2,4,6,8,10……执行 select * from user_info order by id limit 0, 10,则需要在两个节点上各取出 10 条,然后合并数据,重新排序。

max、min、sum、count 之类的函数在进行计算的时候,也需要先在每个分片上执行相应的函数,然后将各个分片的结果集进行汇总和再次计算,最终将结果返回。

或者根据名称模糊分页查询,而名称字段单独存放在表,这个时候就需要先从表中查出所有符合模糊查询的记录,然后从主表中分页查询,当表非常大的时候很耗性能。

分布式 ID

如果使用 Mysql 数据库在单库单表可以使用 id 自增作为主键,分库分表了之后就不行了,会出现 id 重复。

常用的分布式 ID 解决方案有:

  • UUID
  • 基于数据库自增单独维护一张 ID表
  • 号段模式
  • Redis 缓存
  • 雪花算法(Snowflake)
  • 百度 uid-generator
  • 美团 Leaf
  • 滴滴 Tinyid

多数据源

分库分表之后可能会面临从多个数据库或多个子表中获取数据,一般的解决思路有:客户端适配和代理层适配。

业界常用的中间件有:

  • shardingsphere(前身 sharding-jdbc)
  • Mycat

数据库扩容、数据迁移问题

有的分库分表策略在数据库扩容的时候不方便。比如按id的奇偶分表,当某一天按奇偶分表还是太大,需要换再加一张表,则需要换一种分表策略,比如模3或者模5,则需要把之前的两张表数据读取出来重新分表;

有的表按月份分表,每个月增加一张表,则不需要迁移历史数据。

如果采用数值范围分片,只需要添加节点就可以进行扩容了,不需要对分片数据迁移。如果采用的是数值取模分片,则考虑后期的扩容问题就相对比较麻烦。

参考文献:

《再有人问你什么是分库分表,直接把这篇文章发给他》www.51cto.com/article/709…

《什么时候进行MySQL分库和分表?》www.cnblogs.com/wangyongwen…