关于分库分表,我的一些新思考

124 阅读17分钟

概述

我曾一度认为分库分表这个主题已无新意,直到一次对扩容方案的重新审视,才发现自己仍有许多问题看不清。这促使我重新出发,去探究这个‘旧’概念下的新挑战。我们先看分库分表这个词,在MySQL中一个数据库像是一个大房间一样里面放着若干表:

img

那分库的动机呢,我直接想到的就是这个房间里面放的表或者数据太多了,不方便查找东西。表太多根据业务将不同的表分到不同的库里面这种一般我们称之为垂直分片:

img

但这种垂直分库也不是没有好处,在微服务架构下将相同业务语义的表划到一个数据库下面方便维护。更进一步的操作是我们将不同的数据库挂载到不同的磁盘上面来,为不同的业务选择不同的磁盘阵列,这样就能避免数据都在一个磁盘上,热点业务的高频写入影响其他业务的读写。

一般是因为数据太多的原因导致分库的场景比较多,原因在于关系型数据库大多采取B+树类型的索引,在数据量到达阈值之后,索引深度的增加也将增加磁盘访问的IO次数增加,进而导致查询性能下降。我们虽然可以对单独的数据量比较多的数据库使用专门的磁盘阵列来获得更高的数据承载量,但这并不意味着可以无限制的突破单实例的存储瓶颈。从运维成本方面来考虑,当一个数据库实例的数据达到阈值以上,对运维的压力就会增大。数据备份和恢复的时间成本都将随着数据量的大小而愈发不可控,所以我们需要控制单一数据实例的数据量。

由此就引出了水平分库或水平分表,所谓水平分的意思就是通过若干个字段,根据规则将数据分散到多个库或表里面。我们可以选择将数据先路由到对应的库中,然后再路由到对应的表里面。也可以选择将数据直接路由到某个库的表里面。这被称为水平分表,一个简单的分片规则是根据主键进行分片,偶数进入0库,奇数进入1库,如下图所示:

img

这样似乎解决了单机瓶颈的一部分,但是单机还是不够支撑访问量该怎么办? 我们就需要接着集群,读写分离来对数据库进行扩展:

img

本篇我们讨论的就是水平分库分表所面临的问题,让我们从最简单的业务场景开始,我们的目标是易于水平扩展的,同时又符合业务场景。该怎么理解这个易于水平扩展呢?

让我们从分片算法说起

假定数据是均匀的情况下

假设我们采取水平分库分表,为了方便讨论我们目前都放在一个库里面,现在有4张表, 我们采取普通哈希分片算法,也就是取余来让数据落到不同的表里面。假设分片键为userId:

  • userId 为1 -> hash(1) = 1001 -> 1001 % 4 = 1, 存入到表1
  • userId 为2 -> hash(2) = 2332 -> 2332 % 4 = 0, 存入到表0
  • userId 为3 -> hash(3) = 3011 -> 3011 % 4 = 3 , 存入到表3
  • userId 为4 -> hash(4) = 4554 -> 4554 % 4 = 2 , 存入到表2

img

现在我们的业务飞速增长,我们需要扩容,我们扩展到了五张表,我们再计算一下原先的数据应该去哪里:

  1. 1001 % 5 = 1
  2. 2332 % 5 = 2
  3. 3011 % 5 = 1
  4. 4554 % 5 = 4

可以看到,仅仅增加了一张表,就可能导致大部分数据需要迁移,惯性思考扩容导致的工作量这么大,这是我们想要的扩容吗? 这引出了一个更深层次的问题: 我们扩容的首要目标,究竟是"最小化数据迁移",还是别的什么? 让我们考虑一个非常简单的场景,设想我们原先分的五张表是均匀增长的,假设我们找到了一个理想的算法,只需要一个表的数据进行迁移,那其实只缓解了一个表的增长压力,其他几个表被忽视了。我们不可能不迁移数据,原因在于如果数据不迁移,新表难以发挥作用,我们扩容就扩了个寂寞。那我们想要的其实新加入的表缓解一下压力,每个表都缓解一下,这样才是延长了我们整个存储体系的存储上限。 当我们扩容的时候我们希望的是旧有的表和库的增长压力都到了缓解,而不是只缓解一部分。

那该怎么选择分片算法呢? 我们的理想目标是每个表都迁移一半数据量到新扩容的表上,对机器数量进行取余,我们可以看到上面的数据旧有的数据还迁移进入了旧的表。一般成熟的方案是对2的n次方幂进行取余,原先n = 2,扩展到8个表:

  1. 1001 % 8 = 1
  2. 2332 % 8 = 4
  3. 3011 % 8 = 3
  4. 4554 % 8 = 2

但是这么看我们无法看出来数据迁移量,于是我们需要做更一般化的推导哈希均匀的情况下,对于一个输入,对应的哈希输出应当均匀的,理想的情况下应当不出现哈希冲突。那意味着得到的哈希值在理想情况下应该是不同的。那对2的n次方取余,等价于

hash(x)&2n1hash(x) \& 2^n-1

而2^n-1, 我们可以这么推导2^n是最高位为1,其余位置为0。减一之后,那代表最高位是0其余为数是1。那有没有什么规律呢。我们先推导一下这个规律 设n = 2,则2^n-1 = 3 , 0011, 若n = 3 ,则2^-1 = 7, 则是0111,于是2^n次方代表高位1其余为0,然后减一之后就是高位借一变成0,其他位置变成1。

那么做与运算其实关注的是哈希值的最后两位,在原先的情况下哈希值最后为00的落入编号为0这个库,编号01落入为1这个库,编号10落入2这个库,编号11落入3这个库。然后现在让我们扩容到8个表,也就是和后三位111做与运算。我们现在想证明的命题是分散在各个库的数据的分片键第三位是均匀分布的,也就是说将分片键二进制的第三位是0和1应当各占一半。在《算法导论》 我们可以看到下面这一句话:

一个好的散列函数应当近似地满足一致散列的假设:每个关键字都等可能地散列到m个槽位的任何一个之中去,并与其他的关键字已被散列到哪一个槽位中无关。

在理想的哈希函数下面,任意一个位置出现出现的1或0的概率都是1/2 , 这样我们可以大致做出推断,对于一个桶里面分片键中对应的哈希值对应的二进制展开任意一个位置1的数量应当是总数量的一半。现在我们在这个理想的证明下,我们可以认为采取对2^n次方进行取余可以满足我们的需求,每次都能均匀的从分表挪一半数据出来。证明见后文的附录。

数据不均匀了怎么办

一个常见的思维定式,是倾向于为所有潜在风险都构建最坚固的防御。但这可能导致我们不自觉地为抵御“几十年一遇的特大暴雨”,而去修建一座维护成本极高的“超级大坝”。

当然,这并非是说“大坝”本身是错误的。如果暴雨带来的潜在损失(如核心业务瘫痪、数据永久丢失)远超大坝的建造成本,那么修建坚固的防御工事是完全必要且明智的。

架构设计的真正挑战在于,我们需要避免将“超级大坝”作为解决所有问题的唯一、默认的方案。正确的路径,是转向精准备灾:我们需要的不是一座孤零零的大坝,而是一套完整的、与风险等级相匹配的“水利工程体系”,其中“气象观测站”正是这套体系的眼睛和大脑。

在技术架构中,这个‘气象站’就是我们完善的监控和预警体系。它负责持续观测系统的各项指标(如分片的数据量、QPS、CPU负载等),并尝试回答我们刚才提出的那三个关键问题:

  • “何时扩容?” -> 当监控系统观测到关键指标(如CPU使用率、队列长度)持续超出安全阈值,并呈现快速增长趋势时,预警系统就应该自动触发,通知我们“暴雨”即将来临。
  • “扩多少才够?” -> 基于历史数据和增长趋势,我们可以建立容量模型,来预测需要增加的资源量。是需要将实例规格翻倍,还是只需要增加20%的容量?这不再是凭感觉拍板,而是基于数据的科学决策。
  • “何时缩容?” -> 当监控显示流量高峰已过,各项指标回落到正常水平并持续稳定一段时间后,就触发了缩容的信号。

有了这个‘气象站’,我们才能摆脱被动救火的窘境,从容地启动相应的‘应急预案’——无论是手动的临时扩容,还是更高级的自动化弹性伸缩。其最终目的,都是以最小的成本和风险,平稳地度过每一次流量的挑战。

当然,我们必须清醒地认识到,这种“成本-风险”的权衡思想,不仅适用于判断是否要建“大坝”,同样也适用于“气象观测站”的建设本身。它的复杂度和成本,必须与业务的重要性和风险相匹配。

  • 对于一个初创业务或非核心系统,一个“基础款”的监控——能看到CPU、内存等基本指标,并设置简单的告警——可能就足够了。它的作用就是及时告诉我们“开始下雨了”,然后由工程师手动进行临时扩容。这是一种低成本、高效率的务实之举。
  • 只有对于那些核心的、高并发的、对稳定性要求极高的系统,我们才有必要投资建设一个功能完备的、具备预测能力的“全天候气象中心”。

归根结底,架构设计的智慧,是在动态的、不确定的世界里,围绕成本、风险、效率这三个永恒的变量,持续地寻找那个当下最合适的“解”。没有一劳永逸的银弹,只有永无止境的权衡与演进。

数据归档

同时,我们还需要考虑数据的"时间属性", 商家或用户通常更关心近期的数据, 而海量的历史数据持续堆积在表里面,不断拖累性能,这也就引出了数据生命周期管理的需求 -归档,也就是,将一部分数据迁移到更廉价的存储系统里面,或者是专门的OLAP系统里面, 比如ClickHouse等。我们可以加一个分片键也就是时间,按照月份或者一个季度进行分片,这样查询的时候在一个分片里面就能查到最新的订单,那如果想查更远的数据怎么办? 我们可以计算时间范围,然后将请求扔到专门的数据分析数据库进行查询,减轻数据库的压力。没有统一的方案,业务和技术总在互相适应。

另一个维度的查询

但上面的只是站在商家的维度进行查询,一个客户在多个商家下单怎么办? 这样就不方便聚合数据,用户总比店铺多,所以理应优先满足用户的需求。一般的思路是两份数据,用户端按userId+季度分片,然后也定期归档,用户下单之后同步到商家那一端的数据库。也定期归档。那如果我想对数据做聚合分析怎么办,全量的数据分析,我们其实可以将数据同步到OLAP里面。这样时间近的查OLTP,时间远的查OLAP。

img

除了在应用层解决,我们是否可以求助数据库自身的能力呢?这就引出了分区表。

也许你需要分区表

那我们聊了这么久,那你真的需要分库分表嘛? 有没有别的方案,将表的数据量打散到其他文件上,实现分表的效果呢? 那当然也是有的, 那就是分区表。所谓分区表指的就是将一个表划为若干个部分,逻辑上看起来是一个表,但每个部分是独立的物理存储。

MySQL 在5.1引入这个特性,支持的分区表类型有:

  • RANGE分区:基于给定的有序区间将表中数据分成若干段,每一段称为一个分区。比如按日期进行分区

  • HASH分区:根据哈希函数将行数据分配到分区中。此方法适用于任何数据类型,并且具有随机性。此类型的分区通常用于随机分布的数据,如日志数据。

  • LIST分区:List分区使用一个列表定义分区,每个分区包含了特定的值集合。如果记录的值包含在列表中,那么记录将存储在相应的分区中。

  • KEY分区其实跟HASH分区差不多,不同点如下:

    1. KEY分区允许多列,而HASH分区只允许一列。
    2. 如果在有主键或者唯一键的情况下,key中分区列可不指定,默认为主键或者唯一键,如果没有,则必须显性指定列。
    3. KEY分区对象必须为列,而不能是基于列的表达式。
    4. KEY分区和HASH分区的算法不一样,PARTITION BY HASH (expr),MOD取值的对象是expr返回的值,而PARTITION BY KEY (column_list),基于的是列的MD5值。

那么,我们是否应该使用分区表呢?这个问题的答案,取决于我们所处的环境:

  1. 在传统的自建(Self-Managed)环境中,由于需要DBA自行处理底层的存储、备份和版本兼容性问题,分区表确实对运维能力提出了更高要求。因此,在过去它常被视为一种需要谨慎评估的‘高级特性’。
  2. 但在现代的云原生(Cloud-Native)环境中,情况则完全不同。主流云厂商的数据库服务(PaaS)已经将分区表的复杂性完全封装。它们不仅提供了稳定、高效的原生分区功能,更通过存储计算分离等架构,从根本上解决了单机存储瓶颈。

因此,我们今天的架构选型原则应该是:在云上,当遇到单表数据量过大的问题时,分区表应作为优先考虑的、比应用层分库分表更简单的解决方案。它能高效地实现数据生命周期管理(如归档)和查询性能优化(通过分区裁剪),而无需引入应用层改造的复杂性

终局之路:分库分表的未来是“消失”吗?

有朋友认为有了分布式数据库是趋势,在未来将会逐步取代分库分表。但是我们从第一性原理上开始思考,单机瓶颈是否客观存在,答案是还是存在,我们还是需要解决单机瓶颈,那么还是需要将数据。那么意味着分布式数据本质上还是一个方案整合商,试图在数据库这一层提供一个完整的对应用层无感知的解决方案。但这分布式数据库兼容某一类型的关系型数据库,但又不是完全兼容还有生态圈不完善,但 NoSQL 对 SQL 的不兼容性以及生态圈的不完善,使得它们在与关系型数据库的博弈中始终无法完成致命一击,而关系型数据库的地位却依然不可撼动。所以真的不需要分库分表了嘛,我觉得是随着发展,中间件会越来越强封装,倾向于对开发者无感知,但冰山之下分库分表还在暗流涌动。

写在最后

到现在我们为止我们已经通过应用层分库分表来突破了存储系统的上限,根据业务逻辑将不同的表分散到不同的数据库上,为不同的数据库挂上不同的磁盘阵列。我们可以不断的给这个数据库进程不断的加配置能垂直增长它的能力上限,但是随着数据量的上升我们的运维难度也在不断上升,数据备份以及恢复压力也在上升。于是就引出了水平分库和水平分表,将一张表扩展到几个表,如果还是不够,我们就专门部署几个数据库实例来放表。那么查询的流程就是先根据分片键找到对应的库然后再库里面找到对应的表。但是这里又引入一个问题,当我们需要扩容的时候该如何均匀的分摊压力,就比如说假设一个部分是四个人都很忙了,如果是招收的新人只分摊到了一个人的工作,那么其实过去三个人的压力还在。于是我们选择分库和分表的大小都是2的次幂,再根据完美哈希假设,然后每个表就能均匀的迁移一半的压力。坏处是每次扩容都要扩展一倍。

当我们用‘分而治之’的思想攻克了单机容量这座大山后,却发现数据的分散化,又在我们面前劈开了另一道更深的峡谷:数据一致性。如何跨越这道峡谷,正是分布式事务这门深奥的学问所要征服的下一个领域。

附录

关于完美哈希函数输出比特位分布的普遍性证明

待证明命题

对于一个将输入均匀散列到m=2n个槽位的完美哈希函数,其输出值的任意一个比特位bi(其中0i<n),是1的概率都精确地等于12。即:对于一个将输入均匀散列到 m = 2^n 个槽位的完美哈希函数,其输出值的任意一个比特位 b_i (其中 0 \le i < n),是 1 的概率都精确地等于 \frac{1}{2}。 即:
i{0,1,,n1},P(bi=1)=12\forall i \in \{0, 1, \dots, n-1\}, \quad P(b_i = 1) = \frac{1}{2}

由全概率公式,我们可以做下面的转换:

img

核心计算推导过程

我们证明的目标是求解 P(b_i = 1)。利用全概率公式,我们将问题分解,并进行逐步推导:

image.png

参考资料

[1] 大众点评订单系统分库分表实践 tech.meituan.com/2016/11/18/…

[2] PolarDB MySQL 大表实践-分区表篇 mysql.taobao.org/monthly/202…