💬 前言
随着业务发展,业务数据规模急速膨胀,海量数据的存储和访问也势必会成为 MySQL 数据库的瓶颈问题,同时对于系统的稳定性和扩展性也提出很高的要求。传统的将数据集中存储在单台数据节点的解决方案面临极大的考验。出于各种原因考虑,在不做更换分布式存储和数据迁移,继续使用 MySQL 的情况下,分库分表则成为了唯一方案。
基于此,本文将讲解 MySQL 分库分表基础理论,其中包括分库分表的粒度场景,以及所带来的问题如何解决。
🎯 什么情况需要分库分表
- 数据量过大:当MySQL数据库中的数据量超过单个服务器的处理能力时,可以考虑分库分表来分散存储和查询数据,以提高性能和扩展性。
- 高并发读写:当MySQL数据库面临高并发读写需求时,单个数据库可能无法满足高并发的请求,可以通过分库分表来分散负载,提高系统的并发处理能力。
- 存储空间限:当MySQL数据库的存储空间有限时,可以通过分库分表来分散数据存储,以扩展可用的存储容量。
- 数据隔离:当业务数据需要进行隔离,例如将不同业务的数据存储在不同的数据库中,可以使用分库分表来实现数据的隔离和管理。
- 数据查询性能问题:当某些常用查询操作的性能较差,例如全表扫描、索引失效等,可以通过分库分表来重新设计数据结构和查询方式,以提高查询性能。
🧩 分库分表的粒度及应用场景
分库与分表是两个概念,它们都可以按垂直
和水平
维度两种拆分。
它们单独应用,则共有4种方案:
- 垂直分库
- 水平分库
- 垂直分表
- 水平分表
垂直分表
垂直分表下,结构不同,数据不同。
垂直分表用于应对单行数据体积大的情况,单行数据体积过大会增加 I/O 次数,也会占用网络带宽。
垂直分表可以将表中的不同字段分散存储在多个表中。例如,将用户表分为基本信息表和扩展信息表,基本信息表包含用户的基本信息(如用户名、密码),扩展信息表包含用户的额外信息(如年龄、性别)。
水平分表
水平分表下,结构相同,数据不同。
水平分表用于应对单表数据量大的情况,过大的数据量会降低查询效率,在阿里巴巴Java规范中建议单表不超过500W行记录。
水平分表可以将表中的数据按照某种规则(如订单ID的哈希值)分散存储在多个表中。例如,将订单表按照订单ID的哈希值分为多个表,每个表存储一部分订单数据。
垂直分库
垂直分库下,结构不同,数据不同
垂直分库用于单库无法承受大流量、高并发的情况。
垂直分库可以将不同的业务数据存储在不同的数据库中。例如,将用户信息存储在一个数据库中,将订单信息存储在另一个数据库中。
水平分库
水平分库下,结构相同,数据不同。
水平分库用于单表业务数据量大,且无法承受大流量、高并发的情况。
水平分库可以将同一业务数据按照某种规则(如用户ID的哈希值)分散存储在多个数据库中。例如,将用户ID的哈希值为0-9999的用户存储在一个数据库中,将哈希值为10000-19999的用户存储在另一个数据库中。
分库分表图例
在下面图例中,根据不同类型的业务,按垂直分库
将数据分别存储在用户库
、商品库
、订单库
;
用户库按垂直分表
,将原本的用户表
拆分为了用户基础信息表
和用户扩展信息表
;
商品库按水平分表
,将商品表
数据分别存储在商品分表1
和商品分表2
;
订单库按水平分库
,将一部分数据存储在订单库1
中的订单分表1
,另一部分存储在订单库2
中的订单分表2
。
⛳ 分库分表后需要考虑的问题
垂直分表后的注意点
在垂直分表中,将一张表拆分成多张表,也就是说当需要查询一条完整的数据时需要关联多张表,这一般会降低查询效率,而在涉及到增、删、改同时操作多张表时,则要注意开启事务。
水平分表后的问题
多表联查问题(Join)
在水平分表中,将原本存储在一张表的数据,分散存储在多张表中,那么此时要查询数据时,就需要知道哪些表是要关联查询。解决方案有以下:
- 如果分表数量是固定的,直接对所有表联查。当然这样的查询效率很低的。
- 如果分表数量会增加,则应当根据规则存储在不同表中,例如根据订单ID的哈希值分别存储。
聚合操作问题
单表执行聚合操作时(sum()、count()、order by、gorup by)简便有效,而多表聚合操作时则可能需要 union、union all,效率会降低很多。解决方案有以下:
- 借助中间件,将数据存储在 Elasticsearch
- 编写脚本定期执行聚合操作,并将结果放入 Redis 缓存
- 各表统计各自数据,之后在 Java 中执行聚合操作
垂直分库的问题
跨库联查问题(Join)
在垂直分库中将不同的业务数据存储在不同的数据库中,这样就会面临跨库该如何联查。解决方案有以下:
- 在不同的库表中冗余数据,避免跨库联查
- 同步数据,通过广播表/网络表/全局表同步一份数据到库中
- 在设计库表拆分时创建ER绑定表,具备主外键的表放在一个库,保证数据落到同一数据库
- 在Java中分别请求并组装数据
分布式事务
在单机MySQL中提供了完善的事务管理机制,通过begin
、commit
、rollback
灵活控制事务,在 Spring 中只需使用@Transaction
即可开始事务。然而在分布式系统中,MySQL的事务机制会失效。
MySQL 的InnoDB
存储引擎是建立在undo-log
日志上的,一个事务中所有变更前的数据存储在同一个undo-log
中,当回滚时从undo-log
使用旧数据覆盖。
在分布式系统中,垂直分库后就存在多个MySQL节点,也对应了多个undo-log
日志。当某个跨库操作回滚时,仅能回滚自身库的数据,对其他库不具备回滚能力。此时需要通过其他事务管理,解决分布式事务:
Best Efforts 1PC
模式XA 2PC
、XA 3PC
模式TTC
事务补偿模式MQ
最终一致性事务模式
目前常用的Seata
的两种模式是XA 3PC
和TCC
水平分库的问题
水平分库的问题是最复杂的,因为它同时存在水平分表和垂直分表要考虑的问题
多表联查和聚合操作问题
可以参考水平分表后的问题一节
数据分页问题
在单库中,可以通过limit offset, size
实现分页,然而水平分库后,某个业务的数据分散存储在多个库中,也就是有多个数据源,此时要实现分页,通常是将所有数据源的业务表数据返回,然后再计算分页。而如果随着数据库的扩展,对代码又会有侵入性。总的解决方案有以下:
- 常用的分页数据提前聚合到ES或中间表,运行期间跑按时更新其中的分页数据
- 利用大数据技术搭建数据中台,将所有子库数据汇聚到其中,后续的分页数据直接从中获取
- 所有数据源的业务表数据返回,然后再计算分页
通过 ES 、缓存或中间表,然后通过定时任务跑数据会存在延迟;搭建数据中台则会提高成本;查询所有数据源数据再通过代码计算则对代码侵入性强。
主键唯一ID
在单库单表中,通常使用自增主键实现唯一ID,然而在分布式系统中,都使用自增主键会导致存在相同ID。此时应当对不同库不同表规定不同的ID方案。解决方案有以下:
- 设置自增ID的起始值和步长,让不同库不同表的ID实现交叉增长
- 通过特殊算法生成有序的分布式ID,如 Twitter 的
snowflake
、美团的Leaf
- 使用中间件如
Redis
的incr
命令实现分布式ID - 在数据库中指定一张表用于存储自增ID,所有ID从中获取
数据如何落库
假设要新增一条ID=12345
的数据,那么它应该存储在哪个库中,通常这需要制定好规则,这样在存储和查询时就知道使用哪个数据库。数据的分片一般要遵循:数据分布均匀、查询方便、易扩容或迁移。解决方案有以下:
- 随机分片:随机分发写数据的请求,但查询时需要读取全部节点才能拿取数据,一般不用
- 连续分片:每个节点负责存储一个范围内的数据,如
DB1:1~500W, DB2:500~1000W...
- 取模分片:通过整数型的ID值与水平库的节点数量做取模运算,最终得到数据落入的节点
- 一致性哈希:根据某个具备唯一特性的字段值计算哈希值,然后再通过哈希值做取模分片
分库后多维度查询
在单库单表中假设有一张用户表user
,有字段user_id
和user_name
,当分别以user_id
和user_name
作为查询条件时是很简便的;
而在分库分表后,假设我们以user_id
作为路由键,当查询以user_id
为条件的时候是很容易,但是如果查询以user_name
为条件就无法通过路由键定位数据库了。解决方案有以下:
- 淘宝方案:对于订单库实现了多库多维路由键拆分,直接用了三个水平库集群,一个以用户ID作为路由键,一个以商户ID作为路由键,一个以订单时间作为路由键,三个分库集群中数据完全相同,从而满足不同维度查询数据的业务需求。
- 数据量小的时候,可以通过ES维护路由键的二级索引,当基于非路由键字段查询时,先从ES中查到路由键值,然后再根据路由键查询数据库的数据返回。
外键约束问题
这里的外键约束指逻辑上的主外键关系。
假设有一个订单表order
,它以order_id
作为路由键,以及一个订单项目表order_item
,它以order_item_id
作为路由键,在实现水平分库中都按一定规则分散存储在两个数据库中,路由算法为主键ID数值 % DB数量
的模运算;
假设order
表有数据order_id=1
,其路由计算后为1 % 2 = 1
,即order_id=1
的数据在DB1
;
假设order_item
表有数据order_item_id=1
、order_item_id=2
,计算后分别在DB1
和DB0
;
但此时order_id
和order_item_id
是存在外键关系的,这样就导致通过order_id=1
查询数据时,只能查询到DB1
中的order_item_id=1
数据。解决方案有以下:
- 外键数据落在主键数据所在数据库
- 通过绑定表去实现
🛵 分库后如何访问数据库
实现分库分表后,就需要考虑如何访问数据库,按不同的层级有不同的访问方式
- 编码层:在代码中通过框架提供的数据源动态切换类实现,如
Spring
框架提供的AbstractRoutingDataSource
类。 - 框架层:一般的
ORM
框架也会提供切换数据源的实现类,比如MyBatis
使用Interceptor
接口拦截SQL实现。 - 驱动层:在JDBC驱动层拦截SQL语句,然后改写SQL实现,如
Sharding-JDBC
框架的原理就是如此 - 代理层:所有使用数据库的业务服务都连接代理中间件,由中间件来决定落库位置,如
MyCat
、Sharding-Sphere
实现。 - 服务层:如今较为流行的分布式数据库,基本上都自带分库分表功能,如
TiDB
、OceanBase
等。
一般编码层和框架层结合应用,在MyBatis
中通过Interceptor
拦截SQL
,计算路由键的值,然而通过实现AbstractRoutingDataSource
动态切换数据源。
🎉 总结
通过前面对 MySQL 分库分表的理论讲解,能够知道分库分表实际可应用的场景很多,除开最直接数据剧增的情况,在高并发读写和需要数据隔离情况下都适用于分库分表。当然引入新的解决方案,必然会带来诸多问题,不同的分库分表粒度所产生的问题也给出了相应的解决方案,这为 MySQL 分库分表的可行提供了足够支撑。最后我们引出了分库分表后在各层面的数据访问实现方案,究其底层就是改写 SQL,也就是对分库分表原理的实现。