分表分库:单表数据量大读写缓慢如何解决?
02 讲中,我们讲了查询分离的方案存在三大不足,其中一个不足就是:当主数据量越来越大,写操作缓慢,遇到这个问题我们该如何解决呢?
为此,04 讲我们主要围绕这个问题来讲解,其中我会结合个人真实的业务场景告知你拆分存储时如何进行技术选型?分表分库的实现思路是什么?分表分库存在哪些不足?
业务背景(架构经历三)
为了便于你理解,我们先从一个真实的业务场景(我的一次真实架构经历)入手。
曾经,我负责过一个电商系统的架构优化,该系统中包含用户和订单 2 个主要实体,每个实体涵盖数据量如下表所示:

从上表中我们发现,目前订单数据量已达上亿,且每日以百万级的速度增长,之后还可能是千万级。
面对如此庞大的数据量,此时存储订单的数据库表竟然还是一个单库单表。对于单库单表而言,一旦数据量实现疯狂增长,无论是 IO 还是 CPU 都会扛不住。
为了使系统抗住千万级数据量的压力,我们尝试过很多解决方案,最终我们想到了先将订单表拆分,再进行分布存储,这也就是本文要讲的内容——分表分库。
说到分表分库解决方案,我们首先需要做的就是搞定拆分存储的技术选型问题。
拆分存储的技术选型
关于拆分存储常用的技术解决方案,市面上目前主要分为 4 种:MySQL 的分区技术、NoSQL、NewSQL、基于 MySQL 的分表分库。
1.MySQL 的分区技术

MySQL 架构图(来源:MySQL 官方文档)
从上面的 MySQL 架构图中,我们不难发现 MySQL 的分区主要在文件存储层做文章,它可以将一张表的不同行存放在不同存储文件中,这对使用者来说比较透明。
在以往的实战项目中,我们不使用它的原因主要有三点。
MySQL 的实例只有一个,它仅仅分摊了存储,无法分摊请求负载。
正是因为 MySQL 的分区对用户透明,所以用户在实际操作时往往不太注意,使得跨分区操作严重影响系统性能。
当然,MySQL 还有一些其他限制,比如不支持 query cache、位操作表达式等。感兴趣的同学可以打开这篇文章看看:https://dev.mysql.com/doc/refman/5.7/en/partitioning-limitations.html。
2.NoSQL(如 MongoDB)
比较典型的 NoSQL 数据库就是 MongoDB 啦。MongoDB 的分片功能从并发性和数据量这两个角度已经能满足一般大数据量的需求,但是还需要注意这三大要点。
约束考量: MongoDB 不是关系型数据库而是文档型数据库,它的每一行记录都是一个结构灵活可变的 JSON,比如存储非常重要的订单数据时,我们就不能使用 MongoDB,因为订单数据必须使用强约束的关系型数据库进行存储。
业务功能考量: 多年来,事务、锁、SQL、表达式等千奇百怪的操作都在 MySQL 身上一一验证过, MySQL 可以说是久经考验,因此在功能上 MySQL 能满足我们所有的业务需求,MongoDB 却不能,且大部分的 NoSQL 也存在类似问题。
稳定性考量: 我们对 MySQL 的运维已经很熟悉了,它的稳定性没有问题,然而 MongoDB 的稳定性我们没法保证,毕竟不熟悉,因此在我之前的拆分存储技术选型过程中,我没使用过 NoSQL。
3.NewSQL(如 TiDB)
NewSQL 技术还比较新,我们曾经想在一些不重要的数据中使用 NewSQL(比如 TiDB),但从稳定性和功能扩展性两方面考量后,最终没有使用,具体原因与 MongoDB 类似。
4.基于 MySQL 的分表分库
什么是分表分库?分表是将一份大的表数据拆分存放至多个结构一样的拆分表;分库就是将一个大的数据库拆分成多个结构一样的小库。
前面介绍的三种拆分存储技术,在我以往的项目中我都没使用过,而是选择了基于 MySQL 的分表分库,主要是有一个重要考量:分表分库对于第三方依赖较少,业务逻辑灵活可控,它本身并不需要非常复杂的底层处理,也不需要重新做数据库,只是根据不同逻辑使用不同 SQL 语句和数据源而已。
如果我们使用分表分库,存在 3 个技术通用需求需要实现。
SQL 组合: 因为我们关联的表名是动态的,所以我们需要根据逻辑组装动态的 SQL。
数据库路由: 因为数据库名也是动态的,所以我们需要通过不同的逻辑使用不同的数据库。
执行结果合并: 有些需求需要通过多个分库执行,再合并归集起来。
而市面上能解决以上问题的中间件分为 2 类:Proxy 模式、Client 模式。
(1)Proxy 模式: 直接拿 ShardingSphere 官方文档里的图进行说明,我们重点看看中间 Sharding-Proxy 层,如下图所示:

proxy 模式图(来源:ShardingSphere 官方文档)
以上这种设计模式,把 SQL 组合、数据库路由、执行结果合并等功能全部存放在一个代理服务中,而与分表分库相关的处理逻辑全部存放在另外的服务中,这种设计模式的优点是对业务代码无侵入,业务只需要关注自身业务逻辑即可。
(2)Client 模式: 还是继续借用 ShardingSphere 官方文档里的图来说明,如下图所示:

client 模式(来源:ShardingSphere 官方文档)
以上这种设计模式,把分表分库相关逻辑存放在客户端,一般客户端的应用会引用一个 jar,然后在 jar 中处理 SQL 组合、数据库路由、执行结果合并等相关功能。
市面上,关于这两种模式的中间件有如下选择:

学到这,我们已经知道市面上开源中间件的设计模式,那我们到底该选择哪种模式呢?简单对比下这 2 个模式的优缺点,你就能知道答案了。

client 模式与 proxy 模式优缺点对比
当时因为看重代码灵活可控这个优势,所以最终我们选择了 Client 模式里的 Sharding-JDBC 来实现分表分库,如下图所示:

Client 模式—— Sharding-JDBC(来源:ShardingSphere 官方文档)
当然,关于拆分存储选择哪种技术,在实际工作中我们需要根据各自的实际情况来定。
分表分库实现思路
技术选型这一大难题解决后,具体如何落地分表分库解决方案成了我们亟待解决的问题。
在落实分表分库解决方案时,我们需要考虑 5 个要点。
1. 使用什么字段作为分片键?
我们先来回顾下业务场景中举例的数据库:

下面我们把上表中的数据拆分成了一个订单表,表中主要数据结构如下:

从上面表中可知,我们是使用 user_id 作为分片主键,为什么这样分呢,我来讲讲当时的实现思路。
在选择分片字段之前,我们首先了解了下目前存在的一些常见业务需求:
用户需要查询所有订单,订单数据中肯定包含不同的 merchant_id、order_time;
后台需要根据城市查询当地的订单;
后台需要统计每个时间段的订单趋势。
根据这些常见业务需求,我们判断了下优先级,用户操作也就是第一个需求必须优先满足。
此时,如果我们使用 user_id 作为订单的分片字段,就能保证每次用户查询数据时(第 1 个需求),在一个分库的一个分表里即可获取数据。
因此,在我们的方案里,最终还是使用 user_id 作为分片主键,这样在分表分库查询时,首先会把 user_id 作为参数传过来。
这里我需要特殊说明下,选择字段作为分片键时,我们一般需要考虑三点要求:数据尽量均匀分布在不同表或库、跨库查询操作尽可能少、这个字段的值不会变(这点尤为重要)。
2. 分片的策略是什么?
决定使用 user_id 作为订单分片字段后,我们就要开始考虑分片的策略问题了。
目前,市面上通用的分片策略分为根据范围分片、根据 hash 值分片、根据 hash 值及范围混合分片这三种。
根据范围分片: 比如用户 id 是自增型数字,我们把用户 id 按照每 100 万份分为一个库,每 10 万份分为一个表的形式进行分片,如下表所示:

特殊说明:这里我们只讲分表,至于分库把分表分组存放在一个库即可,我就不另行解释了。
根据 hash 值分片: 指的是根据用户 id 的 hash 值 mod 一个特定的数进行分片。(为了方便后续扩展,一般是 2 的几次方。)
根据 hash 值及范围混合分片: 先按照范围分片,再根据 hash 值取模分片。比如:表名=order_#user_id%10#_#hash(user_id)%8,即被分成了 10*8=80 个表。为了方便你理解,我们画个图说明下,如下图所示:

以上三大分片策略我们到底应该选择哪个?我们只需要考虑一点:假设之后数据量变大了,需要我们把表分得更细,此时保证迁移的数据尽量少即可。
因此,根据 hash 值分片时我们一般建议拆分成 2 的 N 次方表。比如分成 8 张表,数据迁移时把原来的每张表拆一半出来组成新表,这样数据迁移量就小了。
当初的方案中,我们就是根据用户 id 的 hash 值取模 32,把数据分成 32 个数据库,每个数据库再分成 16 张表。
我们简单计算了下,假设每天订单量 1 千万,每个库日增 1000万/16=31.25 万,每个表日增 1000万/32/16=1.95 万。而如果每天千万订单量呢?3 年后每个表的数据量就是 2 千万左右,也还在可控范围内。
因此,如果业务增长特别快,且运维还扛得住,为避免以后出现扩容问题,我们建议库分得越多越好。
3. 业务代码如何修改?
分片策略定完后,我们就要考虑业务代码如何修改了。因修改业务代码部分与业务强关联,所以我的方案并不具备参考性。
但是,在这里我想分享一些个人观点。近年来,分表分库操作愈发容易,不过我们需要注意几个要点。
我们已经习惯微服务了,对于特定表的分表分库,其影响面只在该表所在的服务中,如果是一个单体架构的应用做分表分库,那真是伤脑筋。
在互联网架构中,我们基本不使用外键约束。
随着查询分离的流行,后台系统中有很多操作需要跨库查询,导致系统性能非常差,这时分表分库一般会结合查询分离一起操作:先将所有数据在 ES 索引一份,再使用 ES 在后台直接查询数据。如果订单详情数据量很大,还有一个常见做法,即先在 ES 中存储索引字段(作为查询条件的字段),再将详情数据存放在 HBase 中(这个方案我们就不展开了)。
一般来说,业务代码的修改不会很复杂,最麻烦的是历史数据的迁移。
4. 历史数据迁移?
历史数据的迁移非常耗时,有时迁移几天几夜都很正常。而在互联网行业中,别说几天几夜了,就连停机几分钟业务都无法接受,这就要求我们给出一个无缝迁移的解决方案。
还记得讲解查询分离时,我们说过的方案吗?我们再来回顾下,如下图所示:

历史数据迁移时,我们就是采用类似的方案进行历史数据迁移,如下图所示:

此数据迁移方案的基本思路:存量数据直接迁移,增量数据监听 binlog,然后通过 canal 通知迁移程序搬运数据,新的数据库拥有全量数据,且校验通过后逐步切换流量。
数据迁移解决方案详细的步骤如下:
上线 canal,通过 canal 触发增量数据的迁移;
迁移数据脚本测试通过后,将老数据迁移到新的分表分库中;
注意迁移增量数据与迁移老数据的时间差,确保全部数据都被迁移过去,无遗漏;
第二步、第三步都运行完后,新的分表分库中已经拥有全量数据了,这时我们可以运行数据验证的程序,确保所有数据都存放在新数据库中;
到这步数据迁移就算完成了,之后就是新版本代码上线了,至于是灰度上还是直接上,需要根据你们的实际情况决定,回滚方案也是一样。
5. 未来的扩容方案是什么?
随着业务的发展,如果原来的分片设计已经无法满足日益增长的数据需求,我们就需要考虑扩容了,扩容方案主要依赖以下两点。
分片策略是否可以让新表数据的迁移源只是 1 个旧表,而不是多个旧表,这就是前面我们建议使用 2 的 N 次方分表的原因;
数据迁移:我们需要把旧分片的数据迁移到新的分片上,这个方案与上面提及的历史数据迁移一样,我们就不重复啰唆了。
分表分库的不足
分表分库的解决方案讲完了,以上就是业界常用的一些做法,不过此方案仍然存在不足之处。
ES+Hbase 做数据查询分离的方案: 前面我们讲了单独使用 ES 做查询分离解决方案,这里就不再单独展开了。
增量数据迁移: 如何保证数据的一致性及高可用性?这个问题在 13 讲中我们会单独展开。
短时订单量大爆发: 分表分库仍然扛不住时解决方案是什么?这个会在缓存和秒杀架构课程中单独展开。
各位亲爱的同学,04 讲分表分库解决方案中肯定还有一些遗漏的问题没有考虑到,欢迎你在评论区多多留言,与我互动、交流。
另外,喜欢的同学可以把本课程分享给更多的好友。