由于事务型数据库存在连接数较少,锁冲突较多等问题,比较容易成为系统性能瓶颈,当单表数据量超百万级后,数据库的性能会下降非常严重,此时必须使用分库分表来解决数据库的性能瓶颈问题。
分库分表的作用是解决数据库数据量大、并发度低的问题。
分库
分库主要解决两个问题:单库连接数太少和单台机器存储空间不够的问题。
单台 MySQL 服务器的连接数最大是 16384,但通常连接数到达数百上千就是极限了。所以为了解决数据库的并发性能问题,我们可以将数据拆分到多台服务器上,这样就能提高整体并发能力了。同时单台机器的存储空间是有限的,随着数据的增长,总有一天会出现存储空间不足的问题,此时就必须进行分库。
分库与主从复制的区别是,分库是拆分数据,主从复制是复制数据,分库是分片操作,而复制是分组操作。
对于分片操作,各个节点的数据是不同的,多个节点各自对外服务。
对于分组操作,各个节点间的数据是相同的,多个节点祖成一个组共同对外服务。
根据数据拆分的类型划分,分库可以划分为“垂直分库”和“水平分库”。
- 垂直分库:根据业务拆分数据,例如订单表划分到订单数据库,用户表划分到用户数据库。这样做的好处除了提高性能之外,还可以对业务进行解耦,天然符合微服务。
- 水平分库:根据数据内容进行水平拆分,例如将订单按时间、地区划分成多个数据库,这样做主要是为了解决单个业务数据量过大的问题。
分表
分表主要解决的是单表数据量过大的问题。
当单表数据量超千万时,此时如果不适用索引进行查询,至少花费要数十秒的时间,这是用户无法接受的事情。同样根据数据的拆分方式,分表可以划分为“垂直分表”和“水平分表”。
- 垂直分表:根据表的不同字段拆分数据,例如用户表会有用户 ID、名称、性别、备注、住址、电话、身份证号码等等信息,对于不常用的字段,可以拆到其他表中存储,减少单表存储占用。但这样依旧没有解决数据量过大的问题。
- 水平分表:根据某一字段进行水平拆分,例如用户表按用户 ID 进行取模划分为多个数据表,这样可以有效解决单表数据量过大的问题。
分库分表的特点
分库可以与分表相结合,甚至四种拆分方式可以同时使用,个人总结的各拆分方式的特点是:
拆分方式 | 主要操作 | 特征 |
---|---|---|
水平分库 | 拆分同一业务 | 异库同表 |
垂直分库 | 拆分不同业务 | 异库异表 |
水平分表 | 拆分同一列数据 | 异表同构 |
垂直分表 | 拆分不同列数据 | 异表异构 |
分库分表带来的问题
将数据进行拆分,不可避免地会让数据操作变得更加复杂,同时会带来多个库、表协同操作的问题:
- DAO 层编码问题。
- 事务一致性问题。
- 跨节点关联查询问题。
- 跨节点分页、排序、聚合函数问题。
- 主键重复问题。
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 是协调者对节点的操作,但这里的节点是服务层引用,而非数据库节点。
协调者使用 Try 向节点发送操作对资源进行预留和锁定,使用 Confirm 对操作进行确认,使用 Cancel 对操作进行撤销。
TCC 的好处是实现非常灵活,但缺点就是代码耦合度太高,同时还包含了二阶段提交的优缺点。
最大努力通知
最大努力通知仅保证事务的最终一致性,是一种柔性事务的思想。通常使用本地消息表来进行实现:
本地消息表是本地一张记录事务的日志表。记录日志的操作和执行的操作放在同一个事务中,因此每次操作必定会进行日志记录。如果一个操作后续的操作都更新成功,就会更新日志记录表为成功,如果失败了则更新日志表为失败。后台会有定时任务定期去扫描事务日志表,对失败的操作进行回放,以此来保证事务的最终一致性。
它的优点是不会长时间锁住资源,时延低,吞吐量高。但是缺点就是舍弃了强一致性。
跨库关联查询问题
跨节点关联查询的问题出现在垂直拆分的时候。无论是分库还是分表都会有这种问题。通常的解决方式有:多次查询、 全局表和字段冗余。
多次查询
例如查询商品所在的店铺信息,我们需要 join 商品表与店铺表,此时二者不在同一个库中。我们可以将原关联查询分为两次查询,第一次查询出商品所在商铺的商铺 ID 结果集,第二次根据结果集去店铺表所在库中查询。
全局表
对于一些通用的数据表,例如我们说说的“数据字典”,像枚举字段的映射表,这些表在大部分表查询中都会用到,因此我们可以将他们设置为全局表,在所有的库中都包含,那么无论是哪个表的查询,都可以进行随意的 join 操作了。
字段冗余
这是一个反范式的设计,反范式就意味着存储效率并不高,但是将带来非常好的性能提升。例如我们查订单通常都需要知道用户的名称,如果每次仅仅为了 name
这个字段进行昂贵的跨库联表操作,这也太浪费了。因此我们可以考虑将这些经常联表查询的少数字段冗余到主表中,消灭了 Join 操作就不存在跨库 Join 问题了。
跨节点分页、排序与聚合
这些操作都必须在每个节点执行一次,最后再汇总到应用层整合计算,这样的操作是不可避免的,必须牺牲这样的性能代价来换取分库分表的收益。
主键重复问题
分表之后,主键的自增就没有了用武之地,因此必须使用全局的主键来解决这个问题,这就用到了分布式 ID 生成,分布式 ID 生成策略常见的有 UUID、数据库自增、号段模式和 SnowFlake 算法。
UUID
Java 中有自带的 UUID 生成器可以生成世界唯一的 UUID,非常简单易用并且全局唯一,但基本不用。缺点比较多:
- 字符串类型,作为主键消耗太大。
- 无法排序,无法实现区间查询,无法建立顺序索引。
- 没有隐含逻辑,无法体现一些业务信息。
数据库自增
简单的可以采用 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(号段 + 雪花)。
雪花算法生成的 ID 是 64 位整型数字:
- 符号位:首位作为符号位固定不用以保证 ID 是正整数。
- 时间位:接下来 41 位用于表示时间,如果是表示毫秒的话,可以表示的时间范围是 69.73 年,如果表示秒的话,可以表示的时间范围就是 69730.57 年。毫秒的使用可能会有点不够用,毕竟谁也不知道自家公司 70 年后的样子。但是秒粒度又太大,后面的序列号不够分。
- 机器位:10 位机器 ID,通常用于存储线程 ID。
- 序列号:12 位作为序列号,可以存储 4096 个数。
1 bit 41 bit 10 bit 12 bit 0 时间位 机器位 序列号