【分布式】分库分表

251 阅读11分钟

由于事务型数据库存在连接数较少,锁冲突较多等问题,比较容易成为系统性能瓶颈,当单表数据量超百万级后,数据库的性能会下降非常严重,此时必须使用分库分表来解决数据库的性能瓶颈问题。

分库分表的作用是解决数据库数据量大、并发度低的问题

分库

分库主要解决两个问题:单库连接数太少和单台机器存储空间不够的问题。

单台 MySQL 服务器的连接数最大是 16384,但通常连接数到达数百上千就是极限了。所以为了解决数据库的并发性能问题,我们可以将数据拆分到多台服务器上,这样就能提高整体并发能力了。同时单台机器的存储空间是有限的,随着数据的增长,总有一天会出现存储空间不足的问题,此时就必须进行分库。

分库与主从复制的区别是,分库是拆分数据,主从复制是复制数据,分库是分片操作,而复制是分组操作。
对于分片操作,各个节点的数据是不同的,多个节点各自对外服务。
对于分组操作,各个节点间的数据是相同的,多个节点祖成一个组共同对外服务。

image.png

根据数据拆分的类型划分,分库可以划分为“垂直分库”和“水平分库”。

  • 垂直分库:根据业务拆分数据,例如订单表划分到订单数据库,用户表划分到用户数据库。这样做的好处除了提高性能之外,还可以对业务进行解耦,天然符合微服务。
  • 水平分库:根据数据内容进行水平拆分,例如将订单按时间、地区划分成多个数据库,这样做主要是为了解决单个业务数据量过大的问题。

分表

分表主要解决的是单表数据量过大的问题。

当单表数据量超千万时,此时如果不适用索引进行查询,至少花费要数十秒的时间,这是用户无法接受的事情。同样根据数据的拆分方式,分表可以划分为“垂直分表”和“水平分表”。

  • 垂直分表:根据表的不同字段拆分数据,例如用户表会有用户 ID、名称、性别、备注、住址、电话、身份证号码等等信息,对于不常用的字段,可以拆到其他表中存储,减少单表存储占用。但这样依旧没有解决数据量过大的问题。
  • 水平分表:根据某一字段进行水平拆分,例如用户表按用户 ID 进行取模划分为多个数据表,这样可以有效解决单表数据量过大的问题。

分库分表的特点

分库可以与分表相结合,甚至四种拆分方式可以同时使用,个人总结的各拆分方式的特点是:

拆分方式主要操作特征
水平分库拆分同一业务异库同表
垂直分库拆分不同业务异库异表
水平分表拆分同一列数据异表同构
垂直分表拆分不同列数据异表异构

分库分表带来的问题

将数据进行拆分,不可避免地会让数据操作变得更加复杂,同时会带来多个库、表协同操作的问题:

  1. DAO 层编码问题。
  2. 事务一致性问题。
  3. 跨节点关联查询问题。
  4. 跨节点分页、排序、聚合函数问题。
  5. 主键重复问题。

DAO 层编码问题

假如原来一个库中的一个表现在被拆分为三个库九张表,那么原来的一条 SQL 现在需要拆分为九条来执行,加上各种跨库 join、跨表分页、跨表排序等操作,这对于代码编写而言十分复杂和不友好。

但是编码问题是最容易解决的,肯定是使用现有的分库分表工具解决这个问题,如今常用的分库分表中间件有:

  • sharding-JDBC:当当出品。
  • Cobar
  • MyCat:阿里出品,是 Cobar 的封装。
  • TDDL:淘宝出品,绰号头都大了,资料较少。
  • DBProxy:美团出品。
  • Atlas

事务一致性问题

解决方式适用场景
二阶段提交数据库层面的分布式事务场景
TCC跨业务的分布式事务场景
最大努力通知对事务一致性要求不高的场景

二阶段提交

二阶段提交(two-phase commit,2PC)是一种在多节点间实现事务原子提交的算法,相当于引入一个协调者来协调所有节点,要么全部提交,要么全部中止。

二阶段提交顾名思义分为两个阶段,分别是准备阶段提交阶段

  • 准备阶段:协调者会给所有参与者发送准备命令。如果参与者发现准备命令无法执行或者执行失败会返回失败,如果执行完成就会保存日志并返回成功。此时只是进行日志操作,数据并没有被真正保存。

  • 提交阶段:当准备阶段的所有节点都返回成功时,协调者会让所有节点执行事务。如果有一个参与者返回失败,那么协调者就会向所有参与者发送回滚事务的请求,即分布式事务执行失败。

优缺点:

  • 优点:简单,容易实现;可以保证强一致性。
  • 缺点:容易发生阻塞,一个节点阻塞会引起其他所有节点阻塞;容易发生死锁,因为节点锁定时间比较长;单点故障问题,协调者挂了,所有节点都会出现问题。

TCC

TCC 指 Try-Confirm-Cancel,是在服务层实现的分布式事务解决方案,TCC 同样是引入一个协调者,Try、Confirm、Cancel 是协调者对节点的操作,但这里的节点是服务层引用,而非数据库节点。

image.png

协调者使用 Try 向节点发送操作对资源进行预留和锁定,使用 Confirm 对操作进行确认,使用 Cancel 对操作进行撤销。

TCC 的好处是实现非常灵活,但缺点就是代码耦合度太高,同时还包含了二阶段提交的优缺点。

最大努力通知

最大努力通知仅保证事务的最终一致性,是一种柔性事务的思想。通常使用本地消息表来进行实现:

本地消息表是本地一张记录事务的日志表。记录日志的操作和执行的操作放在同一个事务中,因此每次操作必定会进行日志记录。如果一个操作后续的操作都更新成功,就会更新日志记录表为成功,如果失败了则更新日志表为失败。后台会有定时任务定期去扫描事务日志表,对失败的操作进行回放,以此来保证事务的最终一致性。

它的优点是不会长时间锁住资源,时延低,吞吐量高。但是缺点就是舍弃了强一致性。

跨库关联查询问题

跨节点关联查询的问题出现在垂直拆分的时候。无论是分库还是分表都会有这种问题。通常的解决方式有:多次查询全局表字段冗余

多次查询

例如查询商品所在的店铺信息,我们需要 join 商品表与店铺表,此时二者不在同一个库中。我们可以将原关联查询分为两次查询,第一次查询出商品所在商铺的商铺 ID 结果集,第二次根据结果集去店铺表所在库中查询。

全局表

对于一些通用的数据表,例如我们说说的“数据字典”,像枚举字段的映射表,这些表在大部分表查询中都会用到,因此我们可以将他们设置为全局表,在所有的库中都包含,那么无论是哪个表的查询,都可以进行随意的 join 操作了。

字段冗余

这是一个反范式的设计,反范式就意味着存储效率并不高,但是将带来非常好的性能提升。例如我们查订单通常都需要知道用户的名称,如果每次仅仅为了 name 这个字段进行昂贵的跨库联表操作,这也太浪费了。因此我们可以考虑将这些经常联表查询的少数字段冗余到主表中,消灭了 Join 操作就不存在跨库 Join 问题了。

跨节点分页、排序与聚合

这些操作都必须在每个节点执行一次,最后再汇总到应用层整合计算,这样的操作是不可避免的,必须牺牲这样的性能代价来换取分库分表的收益。

主键重复问题

分表之后,主键的自增就没有了用武之地,因此必须使用全局的主键来解决这个问题,这就用到了分布式 ID 生成,分布式 ID 生成策略常见的有 UUID、数据库自增、号段模式和 SnowFlake 算法。

UUID

Java 中有自带的 UUID 生成器可以生成世界唯一的 UUID,非常简单易用并且全局唯一,但基本不用。缺点比较多:

  1. 字符串类型,作为主键消耗太大。
  2. 无法排序,无法实现区间查询,无法建立顺序索引。
  3. 没有隐含逻辑,无法体现一些业务信息。

数据库自增

简单的可以采用 MySQL 的自增主键,但缺点是每次生成都要加锁,生成速度慢、数据库连接存在限制、并发高时容易宕机。

如果要高可用和高性能可以使用多主集群,对不同机器设置不同起始值和相同步长即可,例如 1,3,5,...2,4,6,...。但存在的问题是搭建麻烦、节点难以拓展,基本不可能考虑。

使用内存数据库如 Redis 就可以达到高性能和高并发的要求,使用 Redis 的 incr 命令,高峰时期每秒轻松生成数万个的自增值,我在 wsl 中使用 Debian 测试下达到 19 万 QPS,对于 Linux 真机而言,非常轻松。

====== INCR ======
  100000 requests completed in 0.51 seconds
  50 parallel clients
  3 bytes payload
  keep alive: 1
  host configuration "save": 3600 1 300 100 60 10000
  host configuration "appendonly": no
  multi-thread: no

Latency by percentile distribution:
0.000% <= 0.031 milliseconds (cumulative count 1)
50.000% <= 0.127 milliseconds (cumulative count 59026)
75.000% <= 0.151 milliseconds (cumulative count 78941)
87.500% <= 0.175 milliseconds (cumulative count 89595)
93.750% <= 0.207 milliseconds (cumulative count 93909)
96.875% <= 0.343 milliseconds (cumulative count 96982)
98.438% <= 0.503 milliseconds (cumulative count 98443)
99.219% <= 0.679 milliseconds (cumulative count 99222)
99.609% <= 0.959 milliseconds (cumulative count 99612)
99.805% <= 1.271 milliseconds (cumulative count 99808)
99.902% <= 1.503 milliseconds (cumulative count 99906)
99.951% <= 1.631 milliseconds (cumulative count 99955)
99.976% <= 1.743 milliseconds (cumulative count 99976)
99.988% <= 1.895 milliseconds (cumulative count 99988)
99.994% <= 1.951 milliseconds (cumulative count 99996)
99.997% <= 1.959 milliseconds (cumulative count 99998)
99.998% <= 2.087 milliseconds (cumulative count 99999)
99.999% <= 2.095 milliseconds (cumulative count 100000)
100.000% <= 2.095 milliseconds (cumulative count 100000)

Cumulative distribution of latencies:
10.183% <= 0.103 milliseconds (cumulative count 10183)
93.909% <= 0.207 milliseconds (cumulative count 93909)
96.320% <= 0.303 milliseconds (cumulative count 96320)
97.643% <= 0.407 milliseconds (cumulative count 97643)
98.443% <= 0.503 milliseconds (cumulative count 98443)
99.011% <= 0.607 milliseconds (cumulative count 99011)
99.255% <= 0.703 milliseconds (cumulative count 99255)
99.382% <= 0.807 milliseconds (cumulative count 99382)
99.569% <= 0.903 milliseconds (cumulative count 99569)
99.652% <= 1.007 milliseconds (cumulative count 99652)
99.700% <= 1.103 milliseconds (cumulative count 99700)
99.771% <= 1.207 milliseconds (cumulative count 99771)
99.823% <= 1.303 milliseconds (cumulative count 99823)
99.861% <= 1.407 milliseconds (cumulative count 99861)
99.906% <= 1.503 milliseconds (cumulative count 99906)
99.950% <= 1.607 milliseconds (cumulative count 99950)
99.973% <= 1.703 milliseconds (cumulative count 99973)
99.981% <= 1.807 milliseconds (cumulative count 99981)
99.988% <= 1.903 milliseconds (cumulative count 99988)
99.998% <= 2.007 milliseconds (cumulative count 99998)
100.000% <= 2.103 milliseconds (cumulative count 100000)

Summary:
  throughput summary: 194552.53 requests per second
  latency summary (msec):
          avg       min       p50       p95       p99       max
        0.146     0.024     0.127     0.239     0.607     2.095

号段模式

号段模式就是每次批量从数据库中取一段连续的自增 ID,然后存储到服务器内存中,当服务消耗掉所有 ID 之后再到数据库中获取新的号段。例如第一次获取 [1, 10000],下次获取 [10001, 20000],这种方式对数据库的压力比较小,而且容易实现。

同时,号段模式可以支持多个业务使用同一张表,因为不同业务是不同的记录,不会存在并发加锁的问题。具体如:

mysql> select * from id_generator where type = 3;
+----+--------+-------+------+---------+
| id | max_id | step  | type | version |
+----+--------+-------+------+---------+
|  1 |  10000 | 10000 |    3 |       4 |
+----+--------+-------+------+---------+

mysql> show create table id_generator\G
*************************** 1. row ***************************
       Table: id_generator
Create Table: CREATE TABLE `id_generator` (
  `id` int unsigned NOT NULL AUTO_INCREMENT,
  `max_id` bigint NOT NULL COMMENT '当前最大 ID',
  `step` int NOT NULL COMMENT '号段步长',
  `type` int NOT NULL COMMENT '服务类型',
  `version` int NOT NULL COMMENT '版本号',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

对于同个服务并发获取号段的情况,使用版本号可以通过乐观锁的方式解决并发更新的冲突:

mysql> update id_generator set max_id=20000, version=5 where type=3 and version=4;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0

## 返回 1 表示成功,返回 0 则:
mysql> select max_id, step from id_generator where type=3;
+--------+-------+
| max_id | step  |
+--------+-------+
|  20000 | 10000 |
+--------+-------+
1 row in set (0.00 sec)

号段模式简单易操作,而且解决了数据库的性能问题,因此使用非常常见,滴滴的 TinyID 和美团的 Leaf 也是基于号段模式研发的。

雪花算法

雪花算法(Snow Flake)是 twitter 内部分布式项目采用的 ID 生成算法,由于其实用性现在非常多公司在它的基础上进行拓展,有百度的 uid_generator 和美团的 Leaf(号段 + 雪花)。

image.png

雪花算法生成的 ID 是 64 位整型数字:

  • 符号位:首位作为符号位固定不用以保证 ID 是正整数。
  • 时间位:接下来 41 位用于表示时间,如果是表示毫秒的话,可以表示的时间范围是 69.73 年,如果表示秒的话,可以表示的时间范围就是 69730.57 年。毫秒的使用可能会有点不够用,毕竟谁也不知道自家公司 70 年后的样子。但是秒粒度又太大,后面的序列号不够分。
  • 机器位:10 位机器 ID,通常用于存储线程 ID。
  • 序列号:12 位作为序列号,可以存储 4096 个数。
    1 bit41 bit10 bit12 bit
    0时间位机器位序列号