分库分表的原理和实战【一】

你好,我是leo,分库分表是一个大数据量下的常见话题,包含的内容很多,细节也十分丰富,我把它总结成了三篇文章,第一篇是分库分表的综述,结合了现有的资料和我个人的理解,目的是说明白分库分表在干什么,怎么干,需要注意哪些事情。第二篇和第三篇从实战出发,结合leo实际工作中分库分表的经验,具体说明在java中如何使用ShardingSphere中间件实现分库分表。

分库分表的本质

分库分表说的不是一件事情,而是三件事情,只分库不分表、只分表不分库、以及既分库又分表。
分库分表的本质是通过水平拆分数据(数据的垂直拆分通常意味着服务的拆分),将数据分散存储在多个数据库或表中,从而提高数据库的并发处理能力和查询性能。

为什么做分库分表

为什么做分库

数据库的QPS太高,并发连接数太高,导致服务器的CPU、内存、硬盘、网络IO出现了物理瓶颈,这时候需要把一个库分成多个库,用多台机器分担压力。

为什么做分表

数据库的数据量太大,单表的读写性能明显下降,这时候需要把一个表拆成多个表,减小每个表的锁粒度和索引树的高度,改善单表的读写性能。

分区表能否代替分表

分区表在逻辑上是一个表,在物理上是多个文件存储。分区表只能在单个库中对表做分区。另外,分区表有个限制条件:如果表中存在主键或唯一索引时,分区列必须包含在唯一索引或主键中。因此分区表使用最多的场景,是用 Range 分区做历史数据归档。

分区表的好处是它由数据库自动管理,应用透明,业务应用无需修改代码。但是分区表的所有分区共用同一个表的 MDL锁,新增删除分区时会锁表。

分表和分区不一样。分表后每个表在逻辑和物理上都是独立的表,可以存在单库,也可以存在多个库,相比之下更灵活,也没有分区表的种种限制条件,在分库分表时一般不会用分区表代替分表。

什么时候做

单表的数据量达到了1000万以上,可以考虑分表,但不是一定要分,比如单表只会根据主键读写数据,性能也不会太差,单表2000万、3000万数据量都没有问题。

单库QPS到达1000以上,可以考虑分库,但这并不是绝对的,数据库能抗多少并发和服务器配置,数据库配置,SQL复杂程度都有关系。

怎么做分库分表

分片策略设计

分片策略用于确定数据应该存储在哪个数据库和表中,即:用哪个(或哪些)分片键,通过什么路由算法,将数据路由到对应的物理表。分片策略要根据业务场景而定义,并且要保证数据在各个库表分布均匀,避免数据倾斜。

  1. 分片键的选择

分片键一定要选择最常用的查询字段,或者,和常用的查询字段存在直接的映射关系,这样才能提升查询的效率。

比如,电商领域用户量巨大,选择用户id作为分片键,用户查询订单会路由到该用户所在的库表,避免查所有用户表或订单表。

那么用非分片键查数据时,如果非分片键和分片键存在直接的联系也可以路由到分片键所在的库表。例如,按用户名查询数据,可以在创建用户的时候,取用户名哈希值的最后3bit位(支持8个分片),3bit位数字写入用户id(如下图),这样无论用id还是name查数据,都可以根据3bit位的信息路由到对应的库表。这就是所谓的“基因法”,这种方式需要提前规划好分库分表的容量。
在这里插入图片描述

用这个思路再扩展一下,假如按照订单号查数据,则需要建立订单号和用户id的联系。创建订单时,把用户id的最后3bit位写入订单号,(甚至可以在订单号中包含用户id),只要订单号和用户id建立了联系,按订单号路由就等价于按用户id路由。

再举一个例子,如果业务领域频繁按单号查询数据,那么在设计单号的时候,可以附带地域、时间、业务规则等信息,然后按单号分片,就能实现按地域、时间、业务规则多个维度的数据分片了。

  1. 分片路由算法

常见的路由算法有:哈希取模,时间范围路由,自定义路由

哈希取模:对分片键的值先哈希再取模,根据取模的值路由到对应的库和表。这种路由算法适用范围比较广泛,而且分片后的数据很均匀。分库时对库的数量取模,分表时对表的数量取模。如果分片键是数字,也可以不用哈希直接取模。

按时间范围路由:这种方式要求分片键具有时间信息,按年、月、日等时间范围创建表,比较适合大数据量归档类的业务。但缺点是无法保证数据均匀分布,而且存在数据冷热不均的现象。

自定义路由:如果分片键本身带有明显的业务特征数据,那么比较适合用自定义路由的方式,按照特定的业务规则决定数据的分布情况。

  1. 分布式主键

为什么要用分布式 id 呢?根本原因是为了保证 id 全局唯一。

  • 当分片键是某个表的主键时,水平拆分后的若干个表必然要求主键全局唯一,否则按主键读写数据将无法路由到某一张特定的表。
  • 当分片键不是表的主键时,为了避免将来分库分表的数据迁移时主键冲突,也需要保证每个表的主键是全局唯一的。

全局唯一主键的方案有雪花算法,数据库号段,派号器等。应避免使用数据库自增主键。

容量规划和数据扩容

在实施分库分表之前,一定要根据业务的数据量级做容量规划,以及后续数据扩容的方案。

容量规划包括两个方面

  • 数据量估算:估算当前业务的数据量级有多大,增长速度是多少,可以通过统计历史数据或者业务需求来进行估算。数据量级决定了要分多少张表。
  • 并发量估算:除了数据量,还需要考虑系统的并发访问量。根据业务需求和系统负载情况,估算 QPS 指标。并发量决定了要分多少个库。

一般情况下,分库分表的容量规划会支撑业务未来 N 年的发展,但是如果业务的发展高于预期(恭喜),原来规划的库和表数量可能不够了,就要做数据的扩容。而分片键和分片路由算法对数据的扩容有很大的影响。

  • 用哈希取模的路由算法
    增加一个库或表后,旧数据按照新的库表个数取模,得到的库表位置和原先不同,全量旧数据都要按新路由算法重新计算放到哪张表,数据迁移工作量非常大。

有一个巧妙的方案是双倍扩容,将原先的 N 库扩容为 2N 个库,或者,将原先的 M 张表扩容为 2M 张表。例如,把旧的 4 张表各复制一份,变成 8 张表,第 0 1 2 3 张表分别和第 4 5 6 7张表的数据完全相同,原先 %4 =0 的数据现在 %8 后 一定分布在第 0 和第 4 张表,第 0 张表冗余存储了 %8=4 的数据,第 4 张表冗余存储了%8=0 的数据,以此类推,原先第 1 张表的数据,现在一定分布在第 1 和第 5 张表,并且各冗余存储一半数据,最后,把每张表中多余的一半数据删掉。这样就通过复制数据+删除冗余数据完成了数据的迁移。

在这里插入图片描述

  • 用时间字段范围分片的路由算法
    按时间字段范围分片的数据是递增的,只要路由算法不变,数据的位置就不会变,新增的分片并不会影响旧数据,所以不需要做数据迁移。

中间件的选择

分库分表的技术方案总体上来讲分为两大类:客户端中间件、代理类中间件。

客户端中间件是集成在业务应用内,和业务应用耦合在一起,好处是维护成本相对代理类中间件较低,并且实现方式灵活,这类中间件的典型代表是ShardingSphere-JDBC。

代理类中间件需要独立部署和维护,自身要保证高可用,维护成本高。另外,业务应用的数据库请求经过中间件转发给数据库,调用链路多了一层,性能有所下降。好处是对业务透明,业务应用不需要代码改造。这类中间件的典型代表是ShardingSphere-PROXY、Mycat、Vitess

截止到2023.11,以前很多资料上谈到的中间件都不再更新维护了,包括TDDL、Cobar、Atlas、Mycat。Mycat的功能已经有1年多没有更新维护了,使用这些中间件将面临很大的风险。目前开源社区还在活跃的中间件只剩下ShardingSphere(包括JDBC和PROXY)和Vitess了。Vitess的资料比较少,推荐使用的是ShardingSphere-JDBC和ShardingSphere-PROXY。

分库分表引入的新问题及解决方案

非分片键查询

个别几个非分片键的查询,可以考虑“基因法”,在上述的分片键选择部分已经提过了。对于频繁的多个非分片键的复杂查询,leo建议这种情况就不要折腾关系型数据库了,关系型数据库并不具备任意字段组合索引的能力。把数据存储一份到高性能查询的数据中间件(比如ElasticSearch),升级为存储和查询分离的架构,可以同时发挥两者的优点:数据库发挥强事务机制的长处,做数据的安全存储,ES专注多条件组合的高效查询,做海量数据的通用搜索。

分页和排序

数据分片后,带有分页和排序条件的查询要取每个分片数据的前 n 条数据,合并数据之后重新排序再取前 n 条,性能损耗大,无法支持深度分页。在不考虑跳页功能的前提下,可以在查询第一页数据之后,记录每个分片返回的第一页数据的位置,查下一页数据则在上一页位置的基础上取下一页的数据,以此类推,每次只返回一页的数据量,性能不会因为深分页而下降。如果用了 ES,分页和排序由 ES 来实现就好了,不需要我们自己在业务应用里取每个分片的分页数据再合并。

聚合查询

max、min、sum、count 之类的函数在跨分片聚合查询时,查询思路和跨分片分页类似,也需要先在每个分片上执行相应的函数,然后将各个分片的结果集在内存进行汇总和再次计算,得到最终结果。
但是在数据库里跨分片统计数据性能很差,如果聚合查询的频率不高,可以定期汇总一次数据,把汇总结果单独写到一张表,用空间换时间。如果查询频率高,又有实时性要求,那么可以考虑专门用于实时计算的大数据产品(如Flink、ClickHouse)。

分布式事务

分库分表后,用单库的本地事务的写法将不再适用。最常用的@Transactional声明式注解是无法保证分布式事务的,以这段代码为例:

@Transactional(rollbackFor=Exception.class)
public void doBusiness() {
    //SQL1
    //insert into db0.table1 values(?,?);
	
	//SQL2
	//insert into db1.table1 values(?,?);
}

假如SQL1和SQL2分别写的是不同的数据库,那么会出现3种情况:

  1. 两个SQL都写数据成功,事务提交后两个库的数据均正确。
  2. 两个SQL都写数据成功,事务提交后其中一个库因网络或断电宕机,则只有另一个库的数据提交成功
  3. SQL1执行成功,SQL2出现错误抛出了异常,SQL1会回滚数据

因此,分库分表后,应该将同一个分片键相关联的数据放在一个库中,尽量避免分布式事务。如果实在无法避免,那么要用本地消息表、TCC、事务消息等手段实现数据的最终一致性。

跨库 join

跨数据库的 join 操作是禁止的。正确的做法是从第一张表查出一部分关联数据,再到另一个库的第二张表筛选剩下的数据。也可以适当增加数据库字段的冗余,避免跨库查询。

总结

以上是分库分表的理论指导,具体用什么方式来实施需结合实际业务场景。下一篇文章leo将模拟几个业务场景,用ShardingSphere演示如何实现分库分表。


参考资料:

要不要使用分区表?
分库分表需要考虑的问题及方案
数据库分库分表及动态扩容缩容必知必会
用uid分库,uname上的查询怎么办?
业界难题-“跨库分页”的四种方案
数据库秒级平滑扩容架构方案

  • 50
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值