MySQL-高可用篇-②分库分表
MySQL-高可用篇-②分库分表
学习核心
- 分库分表
- 分库分表概念核心
- 什么场景需要分库分表
- 分库、分表各自解决什么问题?
- 分库分表后会产生什么问题
- 分库分表的方式、分片键的选择
学习资料
分库分表概念核心
1.什么是分库分表?
分库分表的背景
联网业务的一个特点就是用户量巨大,BAT等头部公司都是亿级用户,产生的数据规模也飞速增长,传统的单库单表架构不足以支撑业务发展,存在下面的性能瓶颈:
大数据量 =》分表
- 读写的数据量限制:数据库的数据量增大会直接影响读写的性能,比如一次查询操作,扫描 5 万条数据和 500 万条数据,查询速度肯定是不同的。
- 读写:单表数据量很大时,查询和写入也会变得很慢
- 维护成本:单表数据量很大时,索引的维护成本也会增加(备份、恢复时间变长)
- 减少锁竞争:高并发场景下,大表容易出现锁竞争,进而导致性能下降
- 读写的数据量限制:数据库的数据量增大会直接影响读写的性能,比如一次查询操作,扫描 5 万条数据和 500 万条数据,查询速度肯定是不同的。
高并发(性能支持)、微服务(业务支持) =》 分库
高并发场景:
数据库连接限制:单个数据库实例无法支持高并发请求,可通过将请求分散到其他实例进行缓解
数据库的连接是有限制的,不能无限制创建(MySQL 中可以使用 max_connections 查看默认的最大连接数,当访问连接数过多时,就会导致连接失败)。虽然可以通过使用数据库连接池优化连接数问题,但当业务达到一定程度时,数据库连接就会成为业务瓶颈,无法支撑高并发请求。以电商为例,假设存储没有进行分库,用户、商品、订单和交易,所有的业务请求都访问同一个数据库,产生的连接数是非常可观的,可能导致数据库无法支持业务请求
访问集中、资源受限:在高并发场景下,如果不进行数据库拆分,大量数据访问都集中在单台机器上,对磁盘 IO、CPU 负载等都会产生很大的压力,并且直接影响业务操作的性能
微服务场景:进行微服务拆分时,会根据业务边界将业务的数据从单一数据库中拆分出来,一方面是是让业务职责更加清晰独立,另一个方面也是降低单个数据库实例的连接数
关于 MySQL 单库和单表的数据量限制,和不同的服务器配置,以及不同结构的数据存储有关,并没有一个确切的数字。参考阿里巴巴的《Java 开发手册》中数据库部分的建表规约:单表行数超过 500 万行或者单表容量超过 2GB,才推荐进行分库分表。基于阿里巴巴的海量业务数据和多年实践,这一条数据库规约,可以认为是数据库应用中的一个最佳实践。也就是在新业务建表规划时,或者当前数据库单表已经超过对应的限制,可以进行分库分表,同时也要避免过度设计。因为分库分表虽然可以提高性能,但是盲目地进行分库分表只会增加系统的复杂度。
分库分表核心概念(分库、分表、分库分表)
- 分库(Database Sharding):把数据拆分到不同的数据库中
- 分表(Table Sharding):把数据拆分到同一个库的多张表里面
在考虑到底是分库还是分表之前,需要先明确一个原则:能不拆就不拆,能少拆不多拆。原因在于考虑到数据的可维护性,如果把数据拆分得越散,开发和维护起来就越麻烦,系统出问题的概率就越大。因此要进一步思考,什么情况下适合分表,什么情况下不得不分库?
分库分表的目的是为了解决两个问题:数据量大就分表,高并发就分库
什么时候分表
- ==数据查询慢:==此处的查询针对的是查询更新操作(因为针对只读的查询可以通过读写分离来进行优化),解决因为数据量大导致事务执行满(查询慢)可以通过减少每次查询数据总量来优化,这点可以通过分表来实现优化
什么时候分库
- ==应对高并发:==当一个数据库实例无法支撑高并发请求时(连接数不够时),可以通过将并发请求分散到多个实例中去完成。解决高并发可以通过分库来实现优化
- ==微服务场景:==进行微服务拆分时,会根据业务边界将业务的数据从单一数据库中拆分出来,一方面是是让业务职责更加清晰独立,另一个方面也是降低单个数据库实例的连接数
分库分表扩展方案
一般场景下根据现有业务场景需求进行评估,做好分库分表方案(结合预估的数据量和并发量来计算要拆分多少个库、多少个表),不建议在方案中考虑二次扩容的问题(所谓二次扩容的问题:考虑未来的数据量,把这次分库分表设计的容量都填满了之后,数据如何再次分裂的问题)
考虑到科技和业务迭代更新太快,等真正到了那个时候,现有的方案可能反而用不上,因此没必要徒增方案的复杂度=》越简单的设计可靠性和可维护性越高。
分库分表原理
1.切分方式(如何选择分片策略)
分库分表:将原本存储于单个数据库上的数据拆分到多个数据库,把原来存储在单张数据表的数据拆分到多张数据表中,实现数据切分,从而提升数据库操作性能。分库分表的实现可以分为两种方式**:垂直切分和水平切分**
垂直切分
垂直切分一般是按照业务和功能的维度进行拆分,把数据分别放到不同的数据库中。(可以理解为不同业务使用不同的数据库)
案例分析:电商场景
问题:早期电商网站中的商品数据、会员数据、订单数据等都是集中在一个数据库,随着业务发展,单库处理能力成为性能瓶颈,需要进行优化以支撑更多的业务
优化:按照业务和功能维度将数据库拆分为多个物理库:会员库、订单库、商品库、库存库等,然后每个库中维护相应的数据表。针对业务字段比较多的大表再进行垂直分表(主表、扩展表等)
垂直分库:针对一个系统中对不同的业务进行拆分,根据业务维度进行数据的分离,剥离为多个数据库。例如此处按照业务和功能维度拆分为会员库、订单库、商品库、库存库等
垂直分表:针对业务上的字段比较多的大表进行的,一般是把业务宽表中比较独立的字段,或者不常用的字段拆分到单独的数据表中。例如商品表中,可能包含了商品信息、价格、库存等,可以拆分出来商品主表和相关的扩展表(价格扩展表、库存扩展表)等,扩展表和主表之间通过商品主键ID进行关联。
水平切分
垂直切分可以把不同的业务数据进行隔离,让系统和数据更为“纯粹”,更有助于架构上的扩展。但它依然不能解决某一个业务的数据大量膨胀的问题,一旦系统中的某一个业务库的数据量剧增,比如商品系统接入了一个大客户的供应链,对于商品数据的存储需求量暴增,在这个时候,就要把数据拆分到多个数据库和数据表中,也就是对数据做水平拆分
水平切分是把相同的表结构分散到不同的数据库和不同的数据表中,避免访问集中的单个数据库或者单张数据表。
例如,电商业务中的订单信息访问频繁,可以将订单表分散到多个数据库中,实现分库;在每个数据库中,继续进行拆分到多个数据表中,实现分表。路由策略可以使用订单 ID 或者用户 ID进行取模运算,路由到不同的数据库和数据表中
水平分库:将相同的表分散到不同的数据库中
水平分表:将相同的表分散到不同的数据表中
案例分析:水平分表(订单表)
以1个900w数据的订单表t_order
为例,其水平分表的核心在于将900w的数据按照一定的规则均匀分配到多张表中。选择一种最简单的方式,假设目前设定根据订单号order_id
对3 取模拆分为3个表分别为t_order_01
、t_order_02
、t_order_03
。则其拆分参考如下:通过这种方式可以将900w的数据均匀地分布到3个表中
垂直水平切分
垂直水平切分,是综合垂直和水平拆分方式的一种混合方式,垂直拆分把不同类型的数据存储到不同库中,再结合水平拆分,使单表数据量保持在合理范围内,提升性能。
2.分库分表后引入后衍生的问题
分库分表是应对大数据量、高并发系统的一种常见技术方案。它虽然能有效的缓解单机和单库带来的性能瓶颈和压力,突破网络IO、硬件资源、连接数的瓶颈性。然而,分库分表在带来性能提升的同时,也引入了一些新的问题,比如事务一致性问题,跨节点join问题,非分片键查询问题,扩容问题、全局唯一主键问题等。
数据一致性问题(分布式事务)
对业务进行分库之后,同一个操作会分散到多个数据库中,涉及跨库执行 SQL 语句,也就出现了分布式事务问题。
(1)场景分析
例如数据库拆分后,订单和库存在两个库中,当执行一个下单减库存的操作,就涉及跨库事务处理,则需通过一些方案来确保该事务的完整性
(2)解决方案
跨库事务一般需要借助分布式事务来完成,常见的分布式事务方案有:
- 2PC(两阶事务提交) =》强一致性
- 3PC(三阶事务提交) =》强一致性
- TCC(补偿事务提交) =》最终一致性
- 本地消息表 =》最终一致性
- MQ事务 =》最终一致性
最终需要根据业务形态来进行选择,比如银行,证券交易这类业务对一致性要求高的业务就可以选择3PC这类解决方案;而对于那些性能要求很高,但对一致性要求不高的系统,往往不苛求系统的实时一致性,只要在允许的时间段内达到最终一致性即可的业务则可以选择最终一致性的解决方案
不同的分布式解决方案对应的数据一致性,容错性等性能等指标分析如下表,在选择具体方案的时候需结合自身业务需求和分布式事务解决方案特点来取舍
2PC | 3PC | TCC | 本地消息表 | MQ事务 | |
---|---|---|---|---|---|
数据一致性 | 强 | 强 | 弱 | 弱 | 弱 |
容错性 | 低 | 低 | 高 | 高 | 高 |
复杂性 | 中 | 高 | 高 | 低 | 低 |
性能 | 低 | 低 | 中 | 中 | 高 |
维护成本 | 低 | 中 | 高 | 中 | 中 |
跨库关联查询问题
分库分表后,跨库和跨表的查询操作实现起来会比较复杂,性能也无法保证。
既然查询场景涉及到跨库,那么其解决核心主要聚焦于解决“跨库”这个问题,有两种思路:
- 一种是从源头解决,去掉“跨库”场景,将要查询的内容整到同一个库中(例如适当冗余表或字段、采用ER分片思路)
- 一种是提升跨库查找的效率(例如引入索引维护关联库信息、分段查询)
(1)场景分析
例如【用户查询订单时关联查找商品信息,而商品信息可能在另一个库中,则涉及跨库JOIN查询】
(2)解决方案
方案1:通过合理的冗余表、冗余字段来优化跨库JOIN查询
冗余表:对于一些基础表,例如商品信息表,可以在每一个订单分库中复制一张基础表,避免跨库 JOIN 查询。可以理解为全局表
(系统中存储共享或者基础信息的表,这些信息通常在各个模块都会使用,它常用于查询、涉及少量的数据变动)
冗余字段:对于一两个字段的查询,可以考虑将少量字段冗余在表中,从而避免 JOIN 查询,也就避免了跨库 JOIN 查询(例如此处可以适当将商品部分核心字段冗余到订单表中)
方案2:ER分片
调整分片规则,采用ER分片思路让有关联关系的数据表记录放到同一个分片上,以此避免跨分片join问题。即根据业务实体和其关联关系对数据库进行合理的拆分。但这种方式需要对业务数据和关系有深入的理解,需预先明确表之间的关联关系。
例如订单系统中的订单表(t_order)、订单明细表(t_orderdetail),其比例关系可能是1:1或者1:n,则可根据主表的ID主键进行切分,让有关联关系的记录落到同一个分片。
方案3:使用额外的存储,比如维护一份文件索引
例如可以通过这个”索引“定位到订单号关联的商品所在的库
方案4:分段查询
在系统层面上,分多次单表查询,然后在业务层做聚合
例如第一次查找的结果集中找出关联数据ID,然后根据ID发起第二次请求得到关联数据,最后将得到的数据进行字段拼装
全局主键ID问题
(1)场景分析
在分库分表后,自增主键将会面临着全局主键ID冲突问题,因此无法纯粹地使用自增长来实现主键ID的生成规则,在不同的表中需要统一全局主键 ID。因此,需要单独设计全局主键,避免不同表和库中的主键重复问题。
首先要分析全局主键ID应该具备的条件:
- 全局唯一:最基础的需求,确保主键全局唯一
- 有序性:满足单调递增或者一段时间内递增,主要处于两方面的考虑
- 数据库写入性能:有序的主键可以保证写入性能
- 业务考虑:一些场景中会使用主键来进行一些业务处理,比如通过主键排序等。如果生成的主键是乱序的,就无法体现一段时间内的创建顺序
- 性能要求:要求尽可能快地生成主键,以支撑高可用
- 存储拆分后,业务写入强依赖主键生成服务,假设生成主键的服务不可用,订单新增、商品创建等都会阻塞,这在实际项目中是绝对不可以接受的
(2)解决方案
方案1:构建一张主键表,专门用于生成唯一主键
思考一个问题:既然多张单表生成的自增主键会冲突,如果所有表中的主键都从一张单表中生成是不是就可行了?
CREATE TABLE IF NOT EXISTS `order_sequence`(
`order_id` INT UNSIGNED AUTO_INCREMENT,
PRIMARY KEY ( `order_id` )
)ENGINE=InnoDB DEFAULT CHARSET=utf8;
理论可行:可实现唯一、自增,但是这个用于生成主键的单表会存在两个问题:
- 性能无法保证:在高并发场景下,该表容易成为性能瓶颈
- 存在单点故障问题:一旦这个表挂掉,会直接影响创建功能
方案2: UUID(随机生成一个 32 位 16 进制数字)
优点:可以确保 UUID 的唯一性,水平扩展能力以及性能都比较高
缺点:字符串太长,连续性差且不具备业务含义,如果作为主键使用,性能相对来说会比较差
以 MySQL 为例,MySQL 建议使用自增 ID 作为主键, MySQL InnoDB 引擎支持索引,底层数据结构是 B+ 树,如果主键为自增 ID 的话,那么 MySQL 可以按照磁盘的顺序去写入;如果主键是非自增 ID,在写入时需要增加很多额外的数据移动,将每次插入的数据放到合适的位置上,导致出现页分裂,降低数据写入的性能
方案3:数据库维护自增ID区间
设置每个实例的起始值和步长,只要确保每个实例生成的主键范围不重合即可,例如此处拆分为4个实例:
- 实例1:起始1000,步长1000 =》 1000-1999
- 实例2:起始2000,步长1000 =》 2000-2999
- 实例3:起始3000,步长1000 =》 3000-3999
- 实例4:起始4000,步长1000 =》 4000-4999
如果说实例1的ID已经用到了1999怎么办?类似的,重新生成一个起始值,然后按照一定步长来约定每个实例的主键ID生成范围。以此类推
- 实例1:起始5000,步长1000 =》 5000-5999
- 实例2:起始6000,步长1000 =》 6000-6999
- 实例3:起始7000,步长1000 =》 7000-7999
- 实例4:起始8000,步长1000 =》 8000-8999
其实现思路是通过维护一个sequence表实现: sequence 表中的每一行,用于记录某个业务主键当前已经被占用的 ID 区间的最大值。sequence 表的主要字段是 name 和 value,其中 name 是当前业务序列的名称,value 存储已经分配出去的 ID 最大值
CREATE TABLE `sequence` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'Id',
`name` varchar(64) NOT NULL COMMENT 'sequence name',
`value` bigint(32) NOT NULL COMMENT 'sequence current value',
PRIMARY KEY (`id`),
UNIQUE KEY `unique_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
当服务器(例如实例1)获取主键增长区段时,首先访问对应数据库的 sequence 表,更新对应的记录,占用一个对应的区间。比如设置步长为 200,原先的 value 值为 1000,更新后的 value 就变为了 1200。当实例获取到对应的 ID 区间后,在服务器内部可以使用AtomicInteger等方式进行ID分配,涉及的并发问题可以依赖乐观锁等机制解决。
不同的服务器在相同的时间内分配出去的 ID 可能不同,这种方式生成的唯一 ID,不保证严格的时间序递增,但是可以保证整体的趋势递增,在实际生产中有比较多的应用。为了防止单点故障,sequence 表所在的数据库,通常会配置多个从库,实现高可用
方案4:基于 Redis 分布式锁实现一个递增的主键 ID
这种方式可以保证主键是一个整数且有一定的连续性,但分布式锁存在一定的性能消耗
方案5:基于 Twitter 开源的分布式 ID 生产算法——snowflake,具有全局唯一、递增、高可用的特点
snowflake 是通过分别截取时间、机器标识、顺序计数的位数组成一个 long 类型的主键 ID。这种算法可以满足每秒上万个全局 ID 生成,不仅性能好,而且低延时
Snowflake 是 Twitter 开源的分布式 ID 生成算法,由 64 位的二进制数字组成,一共分为 4 部分
- 第 1 位默认不使用,作为符号位,总是 0,保证数值是正数;
- 41 位时间戳,表示毫秒数,我们计算一下,41 位数字可以表示 241 毫秒,换算成年,结果是 69 年多一点,一般来说,这个数字足够在业务中使用了;
- 10 位工作机器 ID,支持 210 也就是 1024 个节点;
- 12 位序列号,作为当前时间戳和机器下的流水号,每个节点每毫秒内支持 212 的区间,也就是 4096 个 ID,换算成秒,相当于可以允许 409 万的 QPS,如果在这个区间内超出了 4096,则等待至下一毫秒计算。
Snowflake 算法可以作为一个单独的服务,部署在多台机器上,产生的 ID 是趋势递增的,不需要依赖数据库等第三方系统,并且性能非常高,理论上 409 万的 QPS 是一个非常可观的数字,可以满足大部分业务场景,其中的机器 ID 部分,可以根据业务特点来分配,比较灵活。
Snowflake 算法优点很多,但有一个不足,那就是存在时钟回拨问题,时钟回拨是什么呢?
因为服务器的本地时钟并不是绝对准确的,在一些业务场景中,比如在电商的整点抢购中,为了防止不同用户访问的服务器时间不同,则需要保持服务器时间的同步。为了确保时间准确,会通过 NTP 的机制来进行校对,NTP(Network Time Protocol)指的是网络时间协议,用来同步网络中各个计算机的时间。
如果服务器在同步 NTP 时出现不一致,出现时钟回拨,那么 SnowFlake 在计算中可能出现重复 ID。除了 NTP 同步,闰秒也会导致服务器出现时钟回拨,不过时钟回拨是小概率事件,在并发比较低的情况下一般可以忽略。关于如何解决时钟回拨问题,可以进行延迟等待,直到服务器时间追上来为止。
非sharding键查询问题
(1)场景分析
在大多数场景中,在大多数场景下,选择分片键的时候都是选用查询场景最多的字段来做分片键,这样查询的时候一次就可以轻松地路由到对应的分片表,查询到所有的数据。但可能还是会有少部分请求,可能需要查询非分片键下的所有所有数据。而这些数据可能被分到了不同的库,因此需要聚合所有库的查询,然后返回给前端,这样多次数据库连接非常麻烦,且低效。应对这种问题一般有两种方法:关系映射表和基因法
(2)解决方案
解决方案1:映射关系表
映射关系表用于维护待查询字段和分片键映射关系的表,当要使用非分片键查询的守候,先到映射关系表中查询所有字段对应的分片键,然后根据分片键查询所有信息
例如商品 t_product 表根据uid做分片,根据取模分片算法拆分为两张表 t_product_01、t_product_02。如果现要查询主键ID为1 (非sharding)的数据,就无法明确是哪个表了,因此可以构建一个待查询键id和分片键uid的映射关系表。每次查询的时候先通过映射关系表查询到对应的分片键(可通过引入缓存提高效率),然后再根据分片键查找数据
需注意的是,如果数据量特别大的场景,t_mapping 的维护也会变成一个性能瓶颈,也是要考虑优化场景(例如可以引入缓存)
解决方案2:基因法
商品 t_product 表根据uid做分片,根据取模分片算法拆分为两张表 t_product_01、t_product_02。如果现要查询主键ID为10011001(非sharding)的数据。可以看到id中已经包含了关键的sharding key信息,可以通过解析ID的方式获取到关键信息,进而定位到要操作的表
这个特征基因的选择取决于在非sharding条件检索条件下能够根据这个特征基因唯一定位到数据所在库,至于如何设计可以结合业务层面进行思考
扩容问题
(1)场景分析
随着用户的订单量增加,根据用户 ID Hash 取模的分表中,数据量也在逐渐累积。此时,我们需要考虑动态增加表,一旦动态增加表了,就会涉及到数据迁移问题
(2)解决方案
在最开始设计表数据量时,尽量使用 2 的倍数来设置表数量。当需要扩容时,也同样按照 2 的倍数来扩容,这种方式可以减少数据的迁移量
跨节点分页、排序、函数查询
(1)场景分析
例如订单表采用的分库分表方案是基于用户ID&哈希分片算法:
- 当用户在订单列表中查询所有订单时,可以通过用户 ID 的 Hash 值来快速查询到订单信息
- 而运营人员在后台对订单表进行查询时,通过订单付款时间来进行查询的,无法直接定位分片信息,这些数据可能都分布在不同的库以及表中,此时就存在一个跨节点分页查询的问题
一些业务场景中,对于跨节点的查询可能不仅仅局限于简单检索的场景,可能还涉及到分页、排序、函数查询等操作。对于非sharding键的处理往往也会使得检索操作变得越来越复杂。常规思路是通过从各个分区里筛选数据,然后再在内存中统一对数据进行汇总、排序等处理
(2)解决方案
对于这种场景,一般非要查则是硬核查的方式,需要遍历每个分片的内容。通常一些中间件是通过在每个表中先查询出一定的数据,然后在缓存中排序后,获取到对应的分页数据。但随着业务增长,这种方式的查询到后期(获取的页数越来越大,需要处理的数据页越来越多,操作就会尤其耗费CPU和内存资源)会越来越消耗性能。
传统的解决方案有全局查询、禁止跳页查询、二次查询等
一些特殊场景中,也会建议使用两套数据来解决跨节点分页查询问题。例如针对此处的场景,一套是基于分库分表的用户单条或多条查询数据,一套则是基于 Elasticsearch、Solr 存储的订单数据,主要用于运营人员根据其它字段进行分页查询。为了不影响提交订单的业务性能,一般使用异步消息来实现 Elasticsearch、Solr 订单数据的新增和修改。
案例准备
# 构建t_order表
DROP TABLE IF EXISTS `t_order`;
CREATE TABLE `t_order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`customer_id` int(11) NOT NULL,
`order_date` datetime NOT NULL,
`status` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4;
# 插入数据
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (1, 1, '2023-01-01 10:00:00', 'Pending');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (2, 2, '2023-01-02 11:00:00', 'Completed');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (3, 3, '2023-01-03 12:00:00', 'Cancelled');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (4, 2, '2023-01-02 11:00:00', 'Completed');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (5, 3, '2023-01-03 12:00:00', 'Cancelled');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (6, 1, '2023-01-01 10:00:00', 'Pending');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (7, 2, '2023-01-02 11:00:00', 'Completed');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (8, 3, '2023-01-03 12:00:00', 'Cancelled');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (9, 1, '2023-01-01 10:00:00', 'Pending');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (10, 2, '2023-01-02 11:00:00', 'Completed');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (11, 3, '2023-01-03 12:00:00', 'Cancelled');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (12, 1, '2023-01-01 10:00:00', 'Pending');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (13, 2, '2023-01-02 11:00:00', 'Completed');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (14, 3, '2023-01-03 12:00:00', 'Cancelled');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (15, 2, '2023-01-03 12:00:00', 'Cancelled');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (16, 3, '2023-01-03 12:00:00', 'Cancelled');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (17, 1, '2023-01-03 12:00:00', 'Cancelled');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (18, 3, '2023-01-03 12:00:00', 'Cancelled');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (19, 1, '2023-01-03 12:00:00', 'Cancelled');
INSERT INTO `t_order` (`id`, `customer_id`, `order_date`, `status`) VALUES (20, 3, '2023-01-03 12:00:00', 'Cancelled');
# 模拟分表,此处设定拆为2个表
CREATE TABLE `t_order_01` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`customer_id` int(11) NOT NULL,
`order_date` datetime NOT NULL,
`status` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_order_02` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`customer_id` int(11) NOT NULL,
`order_date` datetime NOT NULL,
`status` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4;
# 分片规则(简单取模)
insert into t_order_01(id,customer_id,order_date,status) select id,customer_id,order_date,status from t_order where id%2=1;
insert into t_order_02(id,customer_id,order_date,status) select id,customer_id,order_date,status from t_order where id%2=0;
# 检索分表数据
select * from t_order_01;
select * from t_order_02;
方案1:全局查询
全局查询概念核心
全局查询的核心思路:
- 在各个分区中作排序分页查询
- 汇总各个分区的执行结果
- 对各个结果集再次做整体的分页排序
优点:查询简单、数据准确,不用做业务兼容,数据库中间件都支持
缺点:
- 随着页码的增加,每个节点返回的数据会非常多(要返回前几页的所有数据),性能非常低
- 随着业务发展,在大数据量场景下,需要二次处理的数据越来越多,对内存和CPU要求也非常高
全局查询案例
# 单表分页检索(跳过前3条数据查询3条记录,实际上就是查询第2页的数据)
select * from t_order order by id asc limit 3 offset 3;
# 分表检索错误思路
select * from t_order_01 order by id asc limit 3 offset 3;
select * from t_order_02 order by id asc limit 3 offset 3;
从上述筛选结果可以看到,如果分片采用和单表一样的检索条件所得到的结果集必然是错误的。各个分片的检索要从第一条记录开始查询,直到查询到最终分页的最后一条数据。例如查找的是第2页,则分片要将前两页的所有数据检索出来,因此它的检索条件应该为
# 例如此处检索的是第2页,每页检索3条数据,则检索从第1条开始,检索2*3=6条数据
select * from t_order_01 order by id asc limit 0,6;
select * from t_order_02 order by id asc limit 0,6;
# 这个SQL的作用是将各个分区的结果集进行合并,然后排序、分页获取到正确的结果集(union的两个表多嵌套一层selec *主要是为了让语法通过,否则union不支持union的两个表中有order by、limit关键字)
select t.*
from (
select * from (select * from t_order_01 order by id asc limit 0,6)t1
union
select * from (select * from t_order_02 order by id asc limit 0,6)t2
)t
order by t.id asc limit 3,3;
以此类推,假设设定为倒序排序,分页为每页2行数据,定位第4页的数据,则语句执行参考
# 单表分页检索(跳过前6条数据查询2条记录,实际上就是查询第4页的数据)
select * from t_order order by id desc limit 6,2;
# 模拟分库分表操作合并结果集(分片要获取前4页的数据)
select t.*
from (
select * from (select * from t_order_01 order by id desc limit 0,8)t1
union
select * from (select * from t_order_02 order by id desc limit 0,8)t2
)t
order by t.id desc limit 6,2;
结合实践结果分析,当查询页数递增时,例如查询第1000页,每页100条数据,则对于每个分片而言需要将前1000页的数据全部检索出来,其检索记录条数为1000*100 = 10w条记录,然后再将每个分片的结果集组合起来再进一步做排序、分页查询,这个操作成本到后期是非常可怕的。
方案2:禁止跳页查询
核心概念
结合上述实践结果分析,当数据量非常大、查询页数很大的时候,全局查询法的效率会急剧下降,从而导致检索性能下降。为此可以从业务上进行限制,禁止进行跳页查询,只允许下一页操作(例如APP或者小程序中下拉刷新,这是一种业务折中的方案,虽然业务体验较差,但是却能极大地降低业务复杂度,并有效提升检索效率)
# 下一页(此处max(id)为上一页合并生成结果集的最大id值,N为分页大小)
select * from t_order where id > max(id) order by id asc limit 0,N;
- 优点:不会随着页数增大而影响性能(解决了全局查询中的“大页数”问题)
- 缺点:需要在业务层处理,无法注解跳页查询(例如在第2页无法直接跳到第5页,因为无法获取到第4页记录的最大ID,需要经过2=》3=》4=》5)
禁止跳页查询 案例
还是基于上述场景分析,分页数设定为3,假设查找的是第2页的数据
# 单表检索
# 先获取到第1页的记录
select * from t_order order by id asc limit 0,3;
# 根据第1页的记录的最大id值获取下一页(因为设定了检索条件为大于上一页数据的max(id),在检索当页数据时已经不需要偏移了)
select * from t_order where id > 3 order by id asc limit 0,3;
对于分区检索的思路也是如此,获取到上一页的max(id),然后获取当页数据(此时已经不需要像全局查询那样获取当页之前的所有数据),只需要正常获取当页数据即可
# 模拟分区查询
# 1.获取各个分区第1页记录
select * from t_order_01 order by id asc limit 0,3; # 1、3、5
select * from t_order_02 order by id asc limit 0,3; # 2、4、6
# 2.在内存中筛选出第1页数据(对各个分区的结果集做合并,得到第1页的最大值)
1、2、3、4、5、6 =》 [1,2,3] => 得到合并后的第1页的最大值为3
# 3.获取各个分区第2页记录(此时设定条件为大于第1页合并记录的最大ID值,检索同样的分页大小的数据记录)
-- 需注意此处的id>max(id)条件设定,此处的max(id)不是各个分区上一页的最大ID值,而是合并处理后的上一页的结果集的最大ID
select * from t_order_01 where id > 3 order by id asc limit 3; # 5、7、9
select * from t_order_02 where id > 3 order by id asc limit 3; # 4、6、8
# 4.将步骤3中得到的分区结果集进行合并排序,然后直接按照分页大小取数(例如此处分页大小为3,则取3条)
4、5、6、7、8、9 =》最终结果:4、5、6
方案3:二次查询
核心概念
- 优点:不会对业务造成局限性,每次返回的数据量都很有限,不会随着翻页增加而增加数据的返回量
- 缺点:需进行两次数据库查询
案例拆解
以上两种方案或多或少都有一些缺点。此处引入二次查询法的方案,其既能满足性能要求,也能满足业务需求,虽然相对前面两种方案理解起来更复杂一些,为了便于说明,先从单DB开始讨论。假设一个DB中保存了用户年龄数据,从1岁到30岁,共有30条记录。
# 构建单表
DROP TABLE IF EXISTS `t_user`;
CREATE TABLE `t_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL,
`age` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `index_age` (`age`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (1, '路飞', 1);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (2, '索隆', 2);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (3, '山治', 8);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (4, '乌索普', 3);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (5, '香克斯', 4);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (6, '小张', 9);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (7, '小白', 7);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (8, '小红', 5);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (9, '小李', 11);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (10, '小黄', 10);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (11, '小谢', 6);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (12, '小吴', 12);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (13, '小毛', 14);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (14, '小赵', 13);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (15, '小钱', 15);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (16, '小王', 16);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (17, '小乐', 17);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (18, '小乐', 18);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (19, '小虎', 21);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (20, '小胡', 19);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (21, '小于', 23);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (22, '小余', 22);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (23, '小鱼', 20);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (24, '小马', 25);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (25, '小仔', 24);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (26, '小包', 26);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (27, '小宝', 27);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (28, '小好', 28);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (29, '小尼', 29);
INSERT INTO `t_user` (`id`, `name`, `age`) VALUES (30, '小许', 30);
# 模拟分库分表
CREATE TABLE `t_user_01` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL,
`age` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `index_age` (`age`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `t_user_02` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL,
`age` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `index_age` (`age`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `t_user_03` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL,
`age` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `index_age` (`age`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
# 分片规则(简单取模)
insert into t_user_01(id,name,age) select id,name,age from t_user where id%3=1;
insert into t_user_02(id,name,age) select id,name,age from t_user where id%3=2;
insert into t_user_03(id,name,age) select id,name,age from t_user where id%3=0;
# 检索分表数据
select * from t_user_01;
select * from t_user_02;
select * from t_user_03;
分表分库:拆分为3个DB,按简单取模进行分区,每个DB存储说明如下(需注意ID为分片键,而年龄是属性,不要混淆概念)
# 此处表示单表检索第3页数据,分页大小为5
select * from t_user order by age limit 5 offset 10; # 11、12、13、14、15
步骤【1】:改写查询语句,分库执行SQL
# 分库执行(offset 3是通过全局偏移量/水平切分数据库格式得到 =》10/3)
select * from t_user_X order by age limit 5 offset 3;
select * from t_user_01 order by age limit 5 offset 3;
select * from t_user_02 order by age limit 5 offset 3;
select * from t_user_03 order by age limit 5 offset 3;
步骤【2】:找到步骤【1】中结果集的最小值,从上述图示分析age最小值为6(min_age = 6)
步骤【3】:二次改写查询语句,分库执行SQL
# 其中min_age为步骤【2】中得到的,db_max_age则为步骤【1】中检索的每个分区中各自的age的最大值
select * from T where age between min_age and db_max_age order by age;
# DB1 执行
select * from t_user_01 where age between 6 and 22 order by age;
# DB2 执行
select * from t_user_02 where age between 6 and 20 order by age;
# DB3 执行
select * from t_user_03 where age between 6 and 25 order by age;
步骤【4】:找到 min_age 在全局的offset (此处offset表示所查询的结果集中前面有多少条数据没有被查询)
在每个结果集中虚拟一个min_age记录,找到min_age在各个分库的offset,也就是找到min_age在各个分库中前面有多少条数据:
- DB1:2、DB2:3、DB3:0 =》 min_age 的全局offset为2+3+0 = 5
步骤【5】:查找最终数据
已经确定了全局的min_age的偏移量为5,则以全局视角进行分析,将步骤【3】中的二次查询的结果集进行统一合并排序(此处用SQL模拟实现效果)
select t.*
from(
select * from (select * from t_user_01 where age between 6 and 22 order by age)t1
union
select * from (select * from t_user_02 where age between 6 and 20 order by age)t2
union
select * from (select * from t_user_03 where age between 6 and 25 order by age)t3
)t
order by t.age
已知min_age的全局offset为5,即age为6的记录前面已经有5条记录了,最终结果要取offset 10 limit 5
,即要保证有10条数据不会被取到(跳过10条数据),则偏移量offset要往后移动5个单位,然后再取5条数据以满足要求
3.sharding key & 分片算法
选择合适 Sharding Key 和分片算法非常重要,直接影响了分库分表的效果
sharding key
如何选择sharding key?
即如何选择一个合适的列或者说是属性,作为分表的依据,这个属性一般称为 Sharding Key。
例如【归档历史订单】场景中会用到一个简单的按照时间范围来分片的算法,每次查询的时候,查询条件中必须带上订单完成时间,而这个订单完成时间实际上就是作为Sharding Key
设定,后台通过这个Sharding Key
来判断是要查询哪个时间范围的数据,进而定位到相应的库表。
对于 Sharding Key 的选择 最重要的参考因素是:**业务是如何访问数据的?**可以结合实例理解分析不同场景下Sharding Key的选择会有什么样的效果(思考要对订单数据做分片应选用什么方式?)
【1】比如将 订单时间作为 Sharding Key ,以时间维度进行范围分片(❌)
- 按照月份进行分片(12个月),那么可能存在热点问题(数据倾斜),因此对于订单数据存储来说这种分片方式并不太适合,一般选用更加均匀的hash分片(用户ID、订单ID)
【2】比如将 订单 ID 作为 Sharding Key 来拆分订单表,那拆分之后,可能会有以下访问情况
如果按照订单 ID 来查订单,就需要先根据订单 ID 和分片算法计算出要查的这个订单它在哪个分片上(位于哪个库表),然后再去那个分片执行查询
但是如果是查找**「我的订单」**,它的 查询条件是用户 ID,此时如果查询条件没有订单 ID,就无法定位该从哪个分片检索订单信息。如果要强行查找的话,只能将所有分片都查一遍,再合并查询结果,不仅处理麻烦且性能也差
【3】如果将用户 ID 作为 Sharding Key ?
- 使用订单 ID 作为查询条件来查订单的时候也会面临同样的问题(无法定位从哪个分片进行检索),但是这个场景有相应的解决方案(所谓的基因法):在生成订单 ID 的时候,把用户 ID 的后几位作为订单 ID 的一部分(例如约定18 位订单号中第 10-14 位是用户 ID 的后四位),则在检索的时候可通过订单ID拆解出用户ID,然后再确定分片信息
对于实际场景而言,例如系统对订单的查询方式往往并不局限于按订单 ID 或者按用户 ID 。例如商家希望看到的是自己店铺的订单,还有各种和订单相关的报表。对于这些查询需求,如果对订单做了分库分表,就会变得特别棘手。要解决这种特殊查询需求的场景,一般的做法是,把订单数据同步到其他的存储系统中去,在其他的存储系统里面解决问题。
- 方案1:可以再构建一个以店铺 ID 作为 Sharding Key 的只读订单库,专门供商家来使用
- 方案2:把订单数据同步到 HDFS 中,然后用一些大数据技术来生成订单相关的报表
基于上述场景拆解,一旦做了分库分表,就会极大地限制数据库的查询能力,之前很简单的查询在分库分表之后就变得非常棘手,因此在针对大数据量、高并发的场景中,往往优先借助其他优化方案来进行缓解。分库分表的引入是在数据量和并发大到所有优化的招数都不好使了才不得已采取的方案。
分片算法
什么是分片算法?
分片算法即所谓的数据路由规则,这个规则决定着某一条数据最终会落到哪个数据库下的哪张表
如何选择分片算法?
以【归档历史订单】场景中用到的一个简单的按照时间范围来分片的算法为例进行分析,如果按照时间范围进行划分,可以分为12个分片(每个月一个分片来兼容查询)。如何理解兼容查询:即查询条件中带上时间范围检索的控制即可(这点可以通过前端进行强制控制),让查询只落到一个分片上
但是这种分片算法存在一个问题:热点问题(数据倾斜)
例如查询更新操作如果集中在当月/某个月份(例如7月份),那么查询就会集中在7月份的这个分片上,其他分片就会处于相对空闲的状态(浪费资源),甚至可能出现7月份的这个分片无法承载大量(几乎全部)的请求,即产生了所谓的热点问题
即实际上希望并发请求和数据能均匀地分布到每一个分片上,尽量避免出现热点问题这个思路也是对于分片算法的选择的一种重要的考虑因素
(1)范围分片(存在热点问题)
基于范围分片有3种分片思路:
- 以时间维度来划分:
- 比如某些数据中心、订单中心,按照月份为维度来划分历史订单
- 以地域维度来划分:
- 如果业务是广域的业务,则可将数据按照地域维度(以省或市的维度)来分散存储到不同的数据库或数据表中
- 以大小维度来划分:
- 例如将
t_user
表拆分为3张表,按照均匀的数据比例将数据分散到各个表中,例如t_user_01(1-1000w)
、t_user_02(1000w-2000w)
、t_user_03(2000w-3000w)
- 例如将
基于范围进行连续分片容易产生热点问题,并不适合作为订单的分片方法,但是这种分片方法的 优点也很突出,即对查询非常友好,只需要通过时间范围检索就能简单便捷定位分片信息。
范围分片适用于数据量非常大,但并发访问量不大的 ToB 系统。例如电信运营商的监控系统,它可能要采集所有人手机的信号质量并做一些分析,这个数据量会非常大,但是这个系统的使用者是运营商的工作人员,并发量很少,则适合采用范围分片算法
(2)哈希分片算法(尽量保证哈希分片均匀)
一般来说,订单表都采用更均匀的哈希分片算法
例如选定 Sharding Key 是用户 ID、分 24 个分片,那决定某个用户的订单应该落到那个分片上的算法可以采用简单的取模算法:将用户 ID 除以 24,得到的余数就是分片号。当然也有一些更复杂的哈希算法,像一致性哈希之类的,特殊情况下也可以使用。常见的Hash分片算法有两种思路:
思路1:简单取模(对关键字段取模)
直接取模:最简单的做法,直接对分片字段进行取模(
userId%N
)Hash取模:如果分表字段不是数字类型,而是字符串类型,可以通过先对这个分表字段取Hash,然后再取模(
hash(userId)%N
)
思路2:一致性Hash
上述两种简单取模的方式可以使数据比较均匀的分布到多张分表中,但是在集群的伸缩性支持和扩容场景上存在不足(本质上是表的数量发生变化导致hash重新计算)
在集群环境中,如果有一台机器宕机,那么原本存储在该数据库的数据将无法访问,为了应对这种情况,宕机的实例会被移出集群(如果不移出集群,会影响到关联该库的用户操作)。此时集群中的机器数量减少,则算法就会变为userId%(N-1)
| hash(userId)%(N-1)
。基于这种情况,则可能导致某个userId原来本该落到宕机库的数据却落到了其他库。一旦宕机库恢复正常运作,计算规则又会恢复为userId%N
| hash(userId)%N
,那么此时再通过userId进行检索时,检索出该用户关联的数据就会出现不完整的情况。
在扩容场景中,如果需要扩容二次分表,表的总数量发生变化时,就需要重新计算hash值,则涉及到数据迁移
为了更好地支持节点N的增加或者减少,可以采用一致性哈希的方式来做分表,一方面是减少数据的重新分配量,另一方面是为了更好地支持集群的动态伸缩
一致性Hash:一致性哈希可以按照常用的hash算法来将对应的key哈希到一个具有2^32次方个节点的空间中,形成成一个顺时针首尾相接的闭合的环形。所以当添加一台新的数据库服务器时,只有增加服务器的位置和逆时针方向第一台服务器之间的键会受影响
Hash 分片算法存在问题
需要注意的一点是,在这个场景中哈希分片算法能够分得足够均匀的前提条件是:用户 ID 后几位数字必须是均匀分布的
如果是自定义用户ID规则可能会因为自定义规则不小心打破了这个均匀性,例如生成用户 ID 时指定最后一位0 是男性,1 是女性,那么通过这种规则生成的用户ID哈希出来的数据可能就没那么均匀,可能触发热点问题
(3)查表法(相对灵活)
查表法其实就是没有分片算法,全靠人为分配决定某个 Sharding Key 落在哪个分片上,即通过预先构建映射表来决定数据的分片位置。每次执行查询的时候,先去表里查一下要找的数据在哪个分片中。
如果使用上面两种分片算法都没法分均匀的情况下,就可以用查表法,人为介入将数据分均匀。该方法的灵活性在于其分片是可以随时改变的。比如发现某个分片已经是热点,则可以把这个分片再拆成几个分片,或者把这个分片的数据移到其他分片中去并修改分片映射表,进而完成在线完成数据拆分操作
但需要注意的是,分片映射表本身的数据不能太多,否则这个表反而成为热点和性能瓶颈了。查表法相对其他两种分片算法来说,缺点是需要二次查询,实现起来更复杂,性能上也稍微慢一些。但是,分片映射表可以通过缓存来加速查询,实际性能并不会慢很多
查表法应用场景
适用于数据量不大但是分片维度多,需要频繁调整分片策略的业务
【1】多租户Saas应用,需确保每个租户的数据严格隔离,且需要支持租户数据的动态迁移和分片调整
【2】一些小型的游戏服务器,需要存储玩家数据,玩家的活跃度和地理位置可能会频繁变化,需要灵活调整分片策略以确保负载均衡和高性能
(4)分片算法总结
分片算法 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
范围分片 | 实现简单便捷 | 存在热点问题 | 适用于大数据量、并发量小的场景(例如数据收集分析等功能) 范围属性明确、查询目标带有范围属性的业务:日志系统、历史数据等 |
哈希分片 | 可控制数据均匀分布 | 如果选用的Sharding Key 自定义规则生成可能会打破数据分布的均匀性 | 适用于数据量大、分布相对均匀的场景(例如社交平台、电商平台、CRM系统的用户存储) |
查表法(无分片算法,人为分配) | 灵活 | 需进行二次查询,但可通过引入缓存来减轻压力 需注意分片映射表数据不能太大,否则其自身反而成为热点和性能瓶颈 | 上述两种方式都无法实现数据均匀分布时考虑引入,需要灵活介入分片的场景 (例如多租户Saas应用) |
分库分表的架构模式
分库分表在业务上的实现主流上有两种模式:客户端模式、代理模式
1.客户端模式
客户模式(client 模式)是指分库分表的逻辑都在系统应用内部进行控制。在这种模式下,应用程序负责将拆分后的SQL语句直接发送到多个数据库进行操作,然后在本地合并和汇总数据。
客户端模式是一种无中心化的架构模式,但是这种分片方式逻辑会侵入业务代码,和业务代码的耦合严重。在Java开发中,通过以jar的方式提供服务(例如Sharding-JDBC)就是这种模式的典型实现,包括美团的Zebra、MTDDL、阿里TDDL都是基于这种模式的实现
优缺点
- 优点
- 实现简单,运维成本低,也避免了中间件模式的proxy故障问题
- 直连数据库操作,性能上相对较好
- 缺点
- 对业务逻辑代码有侵入
- 版本升级问题:一般是依赖架构团队的jar,如果有版本升级或者bug修改,所有应用到的项目都要联动升级
2.代理模式
代理模式(proxy 模式)将应用程序与 MySQL 数据库隔离开来。在这种模式下,业务应用不需要直接连接数据库,而是连接到代理服务。代理服务实现了 MySQL 协议,对于业务应用来说,代理服务会将SQL语句分发到具体的数据库执行,并返回结果。代理服务内部包含分库分表的配置,并根据这些配置自动创建分片表。
代理模式是一种中心化的架构模式,这种模式旨在实现透明化的数据库代理端,并独立于应用部署。因为独立部署,所以对异构语言没有限制,不会对应用造成侵入。ShardingSphere-Proxy 就是 DB 代理模式的经典实现,包括阿里的 MyCat、美团的 Meituan Atlas 和百度 Heisenberg 就是基于代理模式的实现
优缺点
- 优点
- 代理服务是一个独立部署的服务,支持异构语言,对业务程序代码没有侵入性
- 版本升级:当有新功能发布或者bug修复,只需要重新新版本的部署代理服务即可
- 缺点
- 在业务层和数据库之间加了一层代理,整个执行链路延长:应用 =》代理服务 =》数据库,无形增加问题调试成本
- 需要单独维护代理服务,维护成本较高
分库分表中间件
在选定了分表字段和分表算法之后,如何把这些功能给实现出来?如何可以做到像处理单表一样处理分库分表的数据呢?
- 业务中实现分库分表,需要自己去实现路由规则,实现跨库合并排序等操作,具有一定的开发成本
- 使用开源的分库分表中间件(分库分表工具),参考分库分表的开源框架: Apache ShardingSphere(其前身是开源地sharding-jdbc)、淘宝的TDDL和Mycat
Sharding-JDBC
现在叫ShardingSphere(Sharding-JDBC、Sharding-Proxy和Sharding-Sidecar这3款相互独立的产品组成)。它定位为轻量级Java框架,在Java的JDBC层提供的额外服务。它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。
TDDL
TDDL 是淘宝开源的一个用于访问数据库的中间件, 它集成了分库分表, 读写分离,权重调配,动态数据源配置等功能。封装 jdbc 的 DataSource给用户提供统一的基于客户端的使用。
Mycat
Mycat是一款分布式关系型数据库中间件。它支持分布式SQL查询,兼容MySQL通信协议,以Java生态支持多种后端数据库,通过数据分片提高数据查询处理能力。