分库分表是什么,怎么做

本文大纲

  • 什么是分库分表?
  • 如何切分库和表?
  • 为什么要分库分表?
  • 切分策略
  • 分库分表产生的问题
  • 分库分表如何落地?

什么是分库分表

1.1 分库

分库是指在表数量不变的情况下对库进行切分。

举例:如下图,数据库 A 中存放了 user 和 order 两张表,将两张表切分到两个数据库中,user 表放到 database A,order 表放到 database B。

分库

1.2 分表

分表是指在库数量不变的情况下对表进行切分。

举例:如下图,数据库 A 中存放了 user 表,将 user 表切分成 user1 和 user2 两张表并放到 database A 中。

分表

1.3 分库分表

分库分表是指库和表都切分,数量都发生变化。

举例:如下图,数据库 A 中存放了 user 表,将 user 表切分成 user1、user2、user3、user4 四张表,user1 和 user2 放到 database A 中,user3 和 user4 放到 database B 中。

Alt text

二、如何切分库和表?

主流的切分方式有 3 种:水平切分、垂直切分和混合切分。

2.1 水平切分

水平切分包含水平分库和水平分表。

2.1.1 水平分表

水平分表指的表结构不变,将单表数据切分成多表。切分后的结果:

  • 每个表的结构一样;
  • 每个表的数据不一样;
  • 所有表的数据并集为全量数据;

切分抽象图如下:

Alt text

举例:如下图,order 表,按照 oder_id 的数据范围水平切分后变成了 order1 和 order2 表,两个表的结构一样,数据不同。

Alt text

2.1.2 水平分库

水平分库是指,将表水平切分后分到不同的数据库,使得每个库具有相同的表,表中的数据不相同,水平分库一般是伴随水平分表。

举例:如下图,order 表,水平切分后,分到 database A 和 database B 中,这样原来一个库就被拆分成 2 个库。

Alt text

2.2 垂直切分

垂直切分包含垂直分库和垂直分表。

2.2.1 垂直分表

垂直分表指将存在一张表中的字段切分到多张表。切分后的结果:

  • 每个表的结构不一样;
  • 每个表的数据不一样;
  • 所有表的字段并集是原表的字段;

切分抽象图如下:

Alt text

举例:如下图,order 表,根据字段垂直切分,切分后 order_base 表包含一部分字段的数据 和 order_info 表包含另一部分字段的数据。

Alt text

2.2.2 垂直分库

垂直分库指的是,将单个库中的表分到多个库,每个库包含的表不一样。

举例:如下图,database A 中的 order 表 和 user 表,垂直分库为 database A 包含 order 表,database B 包含 user 表。

Alt text

2.3 混合切分

混合切分其实就是水平切分和垂直切分的组合,切分抽象图如下:

Alt text

举例:如下图,order 表,按照 oder_id 数据范围做了水平切分,并且按照表字段做了垂直切分。

Alt text

说明:上面的举例只是为了更好的展示如何切分,并不包含真实业务内容。

三、为什么要分库分表?

3.1 单库出现性能瓶颈

单库出现性能瓶颈,通常有以下几种情况:

  • 数据库服务器磁盘空间不足,但是无法扩容,导致写数据异常;
  • 数据库服务器 CPU 压力过大,无法升配,导致读写性能较慢;
  • 数据库服务器内存不足,无法扩容,导致读写性能瓶颈;
  • 数据库服务器网络带宽不足,无法升配,导致读写性能瓶颈;
  • 数据库服务器连接数过多,无法升配,导致客户端连接等待/超时;

如下图,单库已经达到了性能瓶颈,因此需要扩容成 2 个数据库:

Alt text

3.2 单表出现性能瓶颈

单表出现性能瓶颈,通常是因为单表数据量过大,导致读写性能较慢。

如下图,order 表已经达到了性能瓶颈,因此需要切分成 2 张表:

Alt text

3.3 微服务化

因公司架构发展,技术团队需要对服务器进行微服务化,从而分库分表。如下图展示:

Alt text

四、切分策略

主流的切分策略有 3 种:Range 范围、hash 切分、映射表。

4.1 Range 范围

Range 范围是指按某个字段的数据区间来进行切分。

比如:user 表按照 user_id 的数据范围切分成多张表,每 1000 万条数据存放一张表,切分后的表可以放到同一个数据库,也可以放到不同的数据库,示例图如下:

Alt text

  • 优点

方便扩容,每次数据量达到 range 值就新加一张表,可以通过代码实现自动化扩容;

  • 缺点

存在写偏移,可能有热点问题;

举例

比如用户注册场景:user 表,因为新注册的用户数据都是写新表,通常来说新用户的活跃度高,所以读写流量全部集中在最新的 user 表,因此,新表可能存在热点问题。

4.2 hash 切分

通过对分表键 key 进行一定的运算(通常有取余、取模运算,比如:key % m,key / m,hash(key)/m 等等),通过运算结果来决定路由的库和表。目前大多数互联网公司主要采用该方法。

  • 优点

数据分片比较均匀,大大降低热点问题;

  • 缺点

hash 算法选择不合理,后期扩容可能需要迁移数据;

数据被切分到不同的库和表中,可能存在跨节点查询和分页等问题;

举例

比如:user 表信息,根据 user_id 对 10 取余,这样就可以通过 user_id 尾号 hash 到 user_0 到 user_9 10 张表中:

Alt text

4.3 映射表

映射表其实是 Range 范围 和 hash 切分的混合模式,将分表键和数据库的映射关系记录在一个单独的表(表的形式可以是 数据库表,文件或者配置中心)。

  • 优点

可以灵活设置路由规则;

  • 缺点:

方案比较复杂;

映射表可能也会随着业务量的增大,同样需要分库分表,带来更多的问题;

举例

某社区电商下单场景,因为全国仓库的数量有限,所以分库直接使用了仓编编码-数据库映射表(后期新增加仓库,只要在表中增加映射关系),为了保证履约的时效性,用户下单时,商城端会选择最近的仓库,服务器在映射表中根据仓库编码查询并路由到对应的数据库,最后在库中进行 order 表的操作,交互如下图:

Alt text

五、分库分表产生的问题

分库分表能够解决性能瓶颈问题,但是分库分表不是银弹,它同样也会带来一些问题:

  • 调试和维护难度
  • 分布式事务
  • 分布式事务
  • 跨库关联/分页/排序

5.1 定位和维护难度

单库单表,可以很直观在表中查看数据,分库分表后,需要先根据 key 找到库和表,这样在一定意义上增加了开发人员定位问题的难度,再因为库和表的增多,维护难度自然也上去了(公司有 DBA 可以交给他们)。

5.2 分布式 ID

单库单表,可以直接使用表自增主键保证全局唯一性,分库分表后,需要自己维护全局唯一的 ID,常用的算法有:UUID、号段模式(数据库生成全局 ID)、雪花算法。

UUID 优点:

性能非常高,本地生成,没有网络消耗;

UUID 缺点:

不易于存储:UUID 太长,16 字节 128 位,通常以 36 长度的字符串表示,很多场景不适用;

信息不安全:基于 MAC 地址生成 UUID 的算法可能会造成 MAC 地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置;

ID 作为主键时在特定的环境会存在一些问题,比如做 DB 主键的场景下,UUID 就非常不适用。

号段模式优点:

可以每次获取一个 ID,也可以每次获取一批 ID;

简单,利用现有数据库系统的功能实现;

ID 单调自增,可以实现对 ID 要求特殊的业务;

号段模式缺点:

强依赖发号 DB 的性能,可能有单点问题;

雪花算法优点:

毫秒数在高位,自增序列在低位,整个 ID 都是趋势递增的。

不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成 ID 的性能也是非常高的。

可以根据自身业务特性分配 bit 位,非常灵活。

雪花算法缺点:

强依赖机器时钟,如果机器时钟回拨,会导致重复或者服务不可用,不过发生的概率比较小;

总结

对于公司内部没有分布式 ID 相关实现的,可以使用或借鉴 美团开源的 Leaf ( https://github.com/Meituan-Dianping/Leaf ) ,该框架提供了雪花算法和号段模式两种方案。

5.3 分布式事务

单库单表可以直接使用本地事务来保障数据的正确性,分库分表之后可能就需要引入分布式事务的问题,解决方案有两种:

业务划分的时候规避分布式事务;

使用专业的的分布式框架,比如阿里开源的 Seata ( https://seata.io/zh-cn/ );

5.4 跨库关联/分页/排序

单库单表可以直接使用 MySQL limit 特性实现分页,分库分表后,可能会出现分页问题,解决方案有三种:

选择合适的分表字段,规避绝大部分高频查询场景出现跨库;

使用专业的分布式框架,比如开源框架:ElasticSearch ( https://github.com/elastic/elasticsearch );

业务代码中分别查询,然后组装数据;

六、分库分表工具

分库分表工具主要有 2 种模式:客户端模式 和 代理模式

6.1 客户端模式

客户端模式是指在客户端实现直连数据库,客户端通常是通过一些封装好的 jar 来实现,如下图所示:

Alt text

常见的开源中间件有:Apache 的 Sharding-JDBC、淘宝的 TDDL、美图的 Zebra。

6.2 代理模式

代理模式是指需要单独部署服务,客户端连接代理服务,由代理服务再和数据库交互,如下图所示:

Alt text

常见的开源中间件有:Apache 的 Sharding-Proxy、阿里的 cobar、国产的 MyCat、360 的 Atlas。

另外还有 google 的 vitess,它是基于 zookeeper,通过 RPC 方式进行数据管理。

6.3 总结

两种方案的核心思想都是类似的,都是将分库分表的逻辑进行抽象封装,业务无需关注分库分表的实现细节,只需按照规则进行简单的配置和开发,就能正常的使用分库分表。

两者各有优劣,客户端模式比较轻量,性能也会比较好;代理模式,客户端无需维护服务,但是需要部署额外的代理服务器,代理服务器的稳定性和性能等都需要保障。

七、分库分表如何落地?

敲黑板……重点,重点,重点,重要的事情说三遍!!!

互联网业内有句经典名言”Talk is cheap.Show me your code”,理论讲再多,无法付诸实际生产都是空谈。

这里以某大厂社区电商订单业务的真实案例来分享如何落地分库分表。

场景:社区电商下每日 3000 万下单场景

评估库和表的总数

一般评估的标准是:当前日订单峰值 M 支持最大的爆发增长速率 R 业务能支撑 Y 年发展 * 365 天/年,单表存储 1000 万数据按。

预估数据总数:日订单 3000 万,一年按 365 天计数,最大支持日订单 10 倍的增长速度(即日订单量 1 亿),业务能支撑 10 年发展,因此,需要存储的总订单量 Total = 3000w 365 10 * 10 ≈ 10000 亿,万亿级。

评估库和表的总数:每张表按 1000 万存储(库总数 表总数 = 10000 亿 / 1000 万),因此,库总数 表总数 = 10 万,组合方式有『1 个库 10 万张表、10 个库 1 万张表、100 个库 1000 张表 等』,整体来看,”100 个库 1000 张表”这种组合数据离散比较均匀,在计算机中,一般采用 2^n 来计数。所以,100 个库 1000 张表可以比较接近 2^7 2^10 = 128 * 1024,所以最终 128 个库,每个库 1024 张表。

分库分表字段的选择

在单库单表中,可以直接进行 join 查询和分页操作,分库分表后,数据会分到不同的数据库和表上,可能会导致跨库查询等问题,因此,分表字段的选择,决定了能否将原本需要进行分页的数据划分到同一张表上,从而避免跨库查询。

So,如何选择分库分表字段?

有用过社区电商产品(橙心优选,美团优选,多多买菜,盒马邻里)的小伙伴应该知道,社区电商的模式是:当日购买,次日履约。

为了保证履约的时效,用户在下单时,商城端都是把订单下到最近的仓库,因此,可以根据仓库编码来分库。

Alt text

在整个销售链路和履约链路中,有几个高发的订单查询场景,因此分表字段的选择必须满足这些场景:

用户视角:查询自己所有的订单,因此,可以通过 user_id 分表,把某用户所有的订单放到同一张表。

团长视角:查询用户下给自己的所有订单,因此,可以通过 tuan_user_id 分表,把某团长的所有的订单放到同一张表。

商家视角:查询用户下给自己的所有订单,因此,可以通过 merchant_id 分表,把某商家的所有的订单放到同一张表。

客服视角:通过订单号查询某个订单,因此,通过 order_id 分表能够快速查询订单信息。

上述 4 种场景都是订单表高发查询的场景,但是目前常用的分库分表中间件都只能支持一个分表字段,该如何解决上面 4 种查询问题呢?

通常的做法有:冗余数据和关系索引表。

其实在计算中的世界很多时候都是时间和空间的一个权衡问题,是拿时间换空间,还是拿空间换时间?冗余数据和关系索引表就很好的体现了时间和空间的权衡关系。

冗余数据:

相同的数据保存多份,每份数据使用不同的分表字段,从而满足各种查询需求。如下图所示:通过 user_id、tuan_user_id、merchant_id、order_id 4 个字段来分表,因此需要冗余 4 份相同数据的 order 表。

Alt text

很显然,冗余数据是通过空间换时间的做法,优点是只要一次查询请求就能满足业务需求,缺点就是相同数据保存多份,浪费了空间,增加了成本。

淘宝的订单表采用的是数据冗余,拆分买家库和卖家库两个库,一个订单,在买家和卖家库里都存储了一份。

关系索引表:

它是指建立查询条件和基表分表键的索引关系。如下图,订单表是基表,通过建立 user_id 和 order_id,tuan_user_id 和 order_id,merchant_id 和 order_id 的关系索引表来满足几种查询场景:

Alt text

很显然,关系索引表是通过时间换空间的做法,优点是相对数据冗余法节省了空间和成本,缺点是多了一次索引表的查询,因此时间相对就增加了。该方式额外增加的时间在高并发特别大的场景就能显现出来。

因此,最后分库分表模型是根据仓库编码 warehouse_code 来分库,根据分表字段路由到 order 表,如下图:

Alt text

Q&A:

  • 疑问 1:上述案例的数据库只能支撑 10 年,10 年以后的数据怎么存储?

有过网购经验的小伙伴应该都很少去查询 3 年前的数据,因此,我们可以设置一个冷热数据,比如按 3 年划分,3 年内数据可以放到数据库做热数据,3 年前的数据可以归档到 ElasticSearch/hive,做冷数据查询。

  • 疑问 2:如何查询某一时间段的订单?

可以同步到 ElasticSearch/hive,这样就可以很方便的按时间段来查询。

  • 疑问 3:上述案例是基于新业务,如果已经有线上服务和数据,该如何分库分表?

这个场景是很多公司面临的问题,因此这里给出一个切分的常用处理流程:

立项讨论:

这个步骤需要完成和相关部门以及人员确认分库分表事项、实施日程、后期周知、风险以及应对方案等事宜。

技术方案:

技术方案需要给出详细迁移方案,包括分库分表方案,代码改造,服务器过渡到新库新表方案,数据迁移方案,风险处理方案等。

代码改造:

代码改造,主要会涉及到几个部分:服务如何过渡到新库新表,如何灵活支持灰度读写操作,如何进行数据全量迁移、一致性校验等任务。

分库分表方案:

分库分表方案需要确认分库分表的字段,库和表的数量等问题,可以参考上文 社区电商分库分表落地方案。

数据同步:

数据同步有全量数据迁移、增量数据同步以及数据校验、优化和补偿。

数据全量迁移常用方案:开发代码将老库数据迁移到新库;使用中间件同步工具(比如:阿里的 canal)将老库数据同步到新库。

增量数据同步常用方案:同步双写,在写数据库的地方修改成写两份数据;异步双写,写老库,监听 binlog 异步同步到新库;中间件同步:通过中间件(比如:阿里的 canal)将数据同步到目标库表。

数据校验常用方案:增量数据校验 和 全量数据校验 和 人工抽检。

数据校验核心流程:分别读取老库数据和新库数据,然后比较,数据一致则继续比较下一条数据,数据不一致则进行补偿。

数据补偿核心流程:新库存在老库不存在,则新库删除数据;新库不存在老库存在,则新库插入数据;新库存在老库存在,则比较所有字段,不一致则将新库更新为老库数据。

风险处理方案:

风险处理包含部门间配合,技术方案的处理(服务回滚,数据修复等)

八、总结

首先,本文从分库分表的理论到分库分表的实例落地分享,但是百种业务百种架构,百种架构百种方案,本文可以给分库分表一个很好的参考意义。

其次,数据分库分表技术难度比较大,特别是从现有业务改造,需要考虑数据的迁移以及服务器平稳过渡到新库新表,因此整个迁移过程都是一个很大的考验。

最后,我们分享了一个分库分表的常用流程,因为涉及点太多,所以只能给出一个业内常用的方案,很多细节点还需要在实施前充分去补充和完善。

九、 代码实例

创建表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
CREATE TABLE `t_bill_2021_1` (
`order_id` bigint(20) NOT NULL COMMENT '订单id',
`user_id` int(20) NOT NULL COMMENT '用户id',
`address_id` bigint(20) NOT NULL COMMENT '地址id',
`status` char(1) DEFAULT NULL COMMENT '订单状态',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`order_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

CREATE TABLE `t_bill_2021_2` (
`order_id` bigint(20) NOT NULL COMMENT '订单id',
`user_id` int(20) NOT NULL COMMENT '用户id',
`address_id` bigint(20) NOT NULL COMMENT '地址id',
`status` char(1) DEFAULT NULL COMMENT '订单状态',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`order_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- 省略....
CREATE TABLE `t_bill_2021_12` (
`order_id` bigint(20) NOT NULL COMMENT '订单id',
`user_id` int(20) NOT NULL COMMENT '用户id',
`address_id` bigint(20) NOT NULL COMMENT '地址id',
`status` char(1) DEFAULT NULL COMMENT '订单状态',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`order_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.5.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>wyd</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>wyd</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<mybatis-plus.version>3.1.1</mybatis-plus.version>
<sharding-sphere.version>4.0.0-RC2</sharding-sphere.version>
<shardingsphere.version>5.0.0-beta</shardingsphere.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.0.1</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.9.8</version>
</dependency>
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
<version>${sharding-sphere.version}</version>
</dependency>
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-namespace</artifactId>
<version>${sharding-sphere.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.wyd.dao;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
@Data
@TableName("t_bill")
public class Bill {
private Long orderId;
private Integer userId;
private Long addressId;
private String status;
private Date createTime;
public void setOrderId(Long orderId) {
this.orderId = orderId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public void setAddressId(Long addressId) {
this.addressId = addressId;
}
public void setStatus(String status) {
this.status = status;
}
public void setCreateTime(Date createTime) {
this.createTime = createTime;
}
}

分库算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.wyd;
import org.apache.shardingsphere.api.sharding.standard.PreciseShardingAlgorithm;
import org.apache.shardingsphere.api.sharding.standard.PreciseShardingValue;
import java.util.Collection;
//自定义数据库分片算法
public class DBShardingAlgorithm implements PreciseShardingAlgorithm<Long> {
@Override
public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) {
//真实数据库节点
availableTargetNames.stream().forEach((item) -> {
System.out.println("actual db:" + item);
});
//逻辑表以及分片的字段名
System.out.println("logicTable:"+shardingValue.getLogicTableName()+";shardingColumn:"+ shardingValue.getColumnName());
//分片数据字段值
System.out.println("shardingColumn value:"+ shardingValue.getValue().toString());
//获取字段值
long orderId = shardingValue.getValue();
//分片索引计算 0 , 1
long db_index = orderId & (2 - 1);
for (String each : availableTargetNames) {
if (each.equals("ds"+db_index)) {
//匹配的话,返回数据库名
return each;
}
}
throw new IllegalArgumentException();
}
}

参考:
大厂实例分享:每日 3000万订单,如何分库分表
什么是分库分表,为什么要分库分表? - 华为云开发者联盟的回答 - 知乎