MySQL 分库分表必修课

205 阅读12分钟

💬 前言

随着业务发展,业务数据规模急速膨胀,海量数据的存储和访问也势必会成为 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中提供了完善的事务管理机制,通过begincommitrollback灵活控制事务,在 Spring 中只需使用@Transaction即可开始事务。然而在分布式系统中,MySQL的事务机制会失效。

MySQL 的InnoDB存储引擎是建立在undo-log日志上的,一个事务中所有变更前的数据存储在同一个undo-log中,当回滚时从undo-log使用旧数据覆盖。

在分布式系统中,垂直分库后就存在多个MySQL节点,也对应了多个undo-log日志。当某个跨库操作回滚时,仅能回滚自身库的数据,对其他库不具备回滚能力。此时需要通过其他事务管理,解决分布式事务:

  • Best Efforts 1PC模式
  • XA 2PCXA 3PC模式
  • TTC事务补偿模式
  • MQ最终一致性事务模式

目前常用的Seata的两种模式是XA 3PCTCC

水平分库的问题

水平分库的问题是最复杂的,因为它同时存在水平分表和垂直分表要考虑的问题

多表联查和聚合操作问题

可以参考水平分表后的问题一节

数据分页问题

在单库中,可以通过limit offset, size实现分页,然而水平分库后,某个业务的数据分散存储在多个库中,也就是有多个数据源,此时要实现分页,通常是将所有数据源的业务表数据返回,然后再计算分页。而如果随着数据库的扩展,对代码又会有侵入性。总的解决方案有以下:

  • 常用的分页数据提前聚合到ES或中间表,运行期间跑按时更新其中的分页数据
  • 利用大数据技术搭建数据中台,将所有子库数据汇聚到其中,后续的分页数据直接从中获取
  • 所有数据源的业务表数据返回,然后再计算分页

通过 ES 、缓存或中间表,然后通过定时任务跑数据会存在延迟;搭建数据中台则会提高成本;查询所有数据源数据再通过代码计算则对代码侵入性强。

主键唯一ID

在单库单表中,通常使用自增主键实现唯一ID,然而在分布式系统中,都使用自增主键会导致存在相同ID。此时应当对不同库不同表规定不同的ID方案。解决方案有以下:

  • 设置自增ID的起始值和步长,让不同库不同表的ID实现交叉增长
  • 通过特殊算法生成有序的分布式ID,如 Twitter 的snowflake、美团的Leaf
  • 使用中间件如Redisincr命令实现分布式ID
  • 在数据库中指定一张表用于存储自增ID,所有ID从中获取

数据如何落库

假设要新增一条ID=12345的数据,那么它应该存储在哪个库中,通常这需要制定好规则,这样在存储和查询时就知道使用哪个数据库。数据的分片一般要遵循:数据分布均匀、查询方便、易扩容或迁移。解决方案有以下:

  • 随机分片:随机分发写数据的请求,但查询时需要读取全部节点才能拿取数据,一般不用
  • 连续分片:每个节点负责存储一个范围内的数据,如DB1:1~500W, DB2:500~1000W...
  • 取模分片:通过整数型的ID值与水平库的节点数量做取模运算,最终得到数据落入的节点
  • 一致性哈希:根据某个具备唯一特性的字段值计算哈希值,然后再通过哈希值做取模分片

分库后多维度查询

在单库单表中假设有一张用户表user,有字段user_iduser_name,当分别以user_iduser_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=1order_item_id=2,计算后分别在DB1DB0;

但此时order_idorder_item_id是存在外键关系的,这样就导致通过order_id=1查询数据时,只能查询到DB1中的order_item_id=1数据。解决方案有以下:

  • 外键数据落在主键数据所在数据库
  • 通过绑定表去实现

🛵 分库后如何访问数据库

实现分库分表后,就需要考虑如何访问数据库,按不同的层级有不同的访问方式

画板

  • 编码层:在代码中通过框架提供的数据源动态切换类实现,如Spring框架提供的AbstractRoutingDataSource类。
  • 框架层:一般的ORM框架也会提供切换数据源的实现类,比如MyBatis使用Interceptor接口拦截SQL实现。
  • 驱动层:在JDBC驱动层拦截SQL语句,然后改写SQL实现,如Sharding-JDBC框架的原理就是如此
  • 代理层:所有使用数据库的业务服务都连接代理中间件,由中间件来决定落库位置,如MyCatSharding-Sphere实现。
  • 服务层:如今较为流行的分布式数据库,基本上都自带分库分表功能,如TiDBOceanBase等。

一般编码层和框架层结合应用,在MyBatis中通过Interceptor拦截SQL,计算路由键的值,然而通过实现AbstractRoutingDataSource动态切换数据源。

🎉 总结

通过前面对 MySQL 分库分表的理论讲解,能够知道分库分表实际可应用的场景很多,除开最直接数据剧增的情况,在高并发读写和需要数据隔离情况下都适用于分库分表。当然引入新的解决方案,必然会带来诸多问题,不同的分库分表粒度所产生的问题也给出了相应的解决方案,这为 MySQL 分库分表的可行提供了足够支撑。最后我们引出了分库分表后在各层面的数据访问实现方案,究其底层就是改写 SQL,也就是对分库分表原理的实现。