分库分表会带来读扩散问题?怎么解决?

百家 作者:程序员的那些事 2022-07-05 16:40:14

今天这篇文章,其实也是我曾经面试中遇到过的真题。


分库分表大家可能听得多了,但读扩散问题大家了解吗?


这里涉及到几个问题。


  • 分库分表是什么?

  • 读扩散问题是什么?

  • 分库分表为什么会引发读扩散问题?

  • 怎么解决读扩散问题?


这些问题还是比较有意思的。


相信兄弟们也一定有机会遇到哈哈哈。


我们先从分库分表的话题聊起吧。


分库分表


我们平时做项目开发。一开始,通常都先用一张数据表,而一般来说数据表写到两千万条数据之后,底层 B+ 树的层级结构就可能会变高,不同层级的数据页一般都放在磁盘里不同的地方,换言之,磁盘 IO 就会增多,带来的便是查询性能变差。


于是,当我们单表需要管理的数据变得越来越多,就不得不考虑数据库分表。而这里的分表,分为水平分表和垂直分表。


垂直分表的原理比较简单,一般就是把某几列拆成一个新表,这样单行数据就会变小, B+ 树里的单个数据页(固定 16KB)内能放入的行数就会变多,从而使单表能放入更多的数据。


垂直分表没有太多可以说的点。下面,我们重点说说最常见的水平分表。

水平分表有好几种做法,但不管是哪种,本质上都是将原来的 user 表,变成 user_0, user1, user2 .... userN 这样的 N 多张小表。


从读写一张 user 大表,变成读写 user_1 … userN 这样的 N 张小表。



分表


每一张小表里,只保存一部分数据,但具体保存多少,这个自己定,一般就定 500 万~ 两千万。


那分表具体怎么做?


根据 id 范围分表


我认为最好用的,是根据 id 范围进行分表。


我们假设每张分表能放两千万行数据。那


  • user0 放主键 id 为 1~2千万的数据;

  • user1 定 id 为 两千万 +1 ~ 4千万

  • user2 定 id 为 4千万 +1 ~ 6千万;

  • userN 放 2N千万+1 ~ 2(N+1)千万。


根据id范围分表

假设现在有条数据,id=3 千万,将这个 3 千万除 2 千万= 1.5,向下取整得到 1,那就可以得到这条数据属于 user1 表。于是去读写 user1 表就行了。这就完成了数据的路由逻辑,我们把这部分逻辑封装起来,放在数据库和业务代码之间。


这样。对于业务代码来说,它只知道自己在读写一张 user 表,根本不知道底下还分了那么多张小表。


对于数据库来说,它并不知道自己被分表了,它只知道有那么几张表,正好名字长得比较像而已。


这还只是在一个数据库里做分表,如果范围再搞大点,还能在多个数据库里做分表,这就是所谓的分库分表。


不管是单库分表还是分库分表,都可以通过这样一个中间层逻辑做路由。


还真的就应了那句话,没有什么是加中间层不能解决的


如果有,就多加一层。


至于这个中间层的实现方式就更灵活了,它既可以像第三方orm库那样加在业务代码中。


通过 ORM 读写分表


也可以在 MySQL 和业务代码之间加个 Proxy 服务。


如果是通过第三方 ORM 库的方式来做的话,那需要根据不同语言实现不同的代码库,所以不少厂都选择后者加个 Proxy 的方式,这样就不需要关心上游服务用的是什么语言。


通过 Proxy 管理分表


根据 id 取模分表


这时候就有兄弟要提出问题了,"我看很多方案都对id取模,你这个方案是不是不完整?"


取模的方案也是很常见的。


比如一个 id=31 进来,我们一共分了 5 张表,分别是 user0 到 user4。对 31%5=1,取模得 1,于是就能知道应该读写 user1 表。


根据 id 取模分表


优点当然是比较简单。而且读写数据都可以很均匀的分摊到每个分表上。


但缺点也比较明显,如果想要扩展表的个数,比如从 5 张表变成 8 张表。那同样还是 id=31 的数据,31%8 = 7,就需要读写 user7 这张表。跟原来就对不上了。

这就需要考虑数据迁移的问题。很头秃。


为了避免后续扩展的问题,我见过一些业务一开始就将数据预估得很大,然后心一横,分成 100 张表,一张表如果存个两千万条,那也能存 20 亿条数据了。


也不是说这样不行吧,就是这个业务直到最后放弃的时候,也就存了百万条数据,每次打开数据库表能看到茫茫多的 user_xx,就是不太舒服。专业点叫做加了程序员的心智负担


而上面一种方式,根据 id 范围去分表,就能很好地解决这些问题。数据少的时候,表也少。随着数据增多,表会慢慢变多。而且这样表还可以无限扩展。


那是不是说取模的做法就用不上了呢?


也不是。


将上面两种方式结合起来


id 取模的做法,最大的好处是,新写入的数据都是实实在在地分散到了多张表上。


而根据 id 范围去做分表,因为 id 是递增的,那新写入的数据一般都会落到某一张表上,如果你的业务场景写数据特别频繁,那这张表就会出现写热点的问题。


这时候就可以将 id 取模和 id 范围分表的方式结合起来。


我们可以在某个 id 范围里,引入取模的功能。比如 以前 2千万~4千万 是 user1 表。现在可以在这个范围再分成 5 个表,也就是引 入user1-0、user1-2 到 user1-4,在这 5 个表里取模。


举个例子,id=3千万,根据范围,会分到 user1 表,然后再进行取模 3千万 % 5 = 0,也就是读写 user1-0 表。


这样就可以将写单表分摊为写多表


这在分库的场景下优势会更明显。不同的库,可以把服务部署到不同的机器上,这样各个机器的性能都能被用起来。


根据 id 范围分表后再取模


读扩散问题


我们上面提到的好几种分表方式,都用了 id 这一列作为分表的依据,这其实就是所谓的分片键


实际上我们一般也是用的数据库主键作为分片键。


这样,理想情况下我们已知一个 id,不管是根据哪种规则,我们都能很快定位到该读哪个分表。


但很多情况下,我们的查询又不是只查主键,如果我的数据库表有一列 name,并且加了个普通索引。


这样我执行下面的 SQL:


select * from user where name = "小白"

由于 name 并不是分片键,我们没法定位到具体要到哪个分表上去执行 SQL


于是就会对所有分表都执行上面的 SQL,当然不会是串行执行 SQL,一般都是并发执行 SQL 的。如果我有 100 张表,就执行 100 次 SQL。如果我有 200 张表,就执行 200 次 SQL。


随着我的表越来越多,次数会越来越多,这就是所谓的读扩散问题。


读扩散问题


这是个比较有趣的问题,它确实是个问题,但大部分的业务不会去处理它。读 100 次怎么了,数据增长之后读的次数会不断增加又怎么了?但架不住我的业务不赚钱啊,也根本长不了那么多数据啊。


话是这么说没错,但面试官问你的时候,你得知道怎么处理啊。


引入新表来做分表


问题的核心在于,主键是分片键,而普通索引列并不分片


那好办,我们单独建个新的分片表,这个新表里的列就只有旧表的主键 id 和普通索引列,而这次换普通索引列来做分片键。


通过新索引表解决读扩散问题


这样当我们要查询普通索引列时,先到这个新的分片表里做一次查询,就能迅速定位到对应的主键 id,然后再拿主键 id 去旧的分片表里查一次数据。这样就从原来漫无目的的全表扩散查询,缩减为只查固定几个表了。


举个例子:比如我的表原本长下面这样,其中 id 列是主键,同时也是分片键,name 列是非主键索引。为了简化,假设三条数据一张表。


此时分表里 id=1,4,6 的都有 name="小白"  数据。


当我们执行 select * from user where name = "小白";  则需要并发查 3 张表,随着表变多,查询次数会变得更多。


举例说明读扩散问题


但如果我们为 name 列建个新表 (nameX),以 name 为新的分片键。


这样我们可以先执行  select id from nameX where name = "小白";  

再拿着结果里的 ids 去查询  select * from user where id in (ids);  这样就算表变多了,也可以迅速定位到某几张具体的表,减少了查询次数。


举例说明通过新索引表解决读扩散问题


但这个做法的缺点也比较明显,你需要维护两套表,并且普通索引列更新时,要两张表同时进行更改,有一定的开发量。


有没有更简单的方案?


使用其他更合适的存储


我们常规的查询是通过 id 主键去查询对应的 name 列。


而像上面的方案,则通过引入一个新表,倒过来,先用 name 查到对应的 id,再拿 id 去获取具体的数据。这其实就像是建立了一个新的索引一样。像这种,通过 name 列反查原数据的思想,其实就很类似于倒排索引。相当于我们是利用了倒排索引的思路去解决分表下的数据查询问题。


回想下,其实我们的原始需求无非就是在大量数据的场景下依然能提供普通索引列或其他更多维度的查询这种场合,更适合使用 es,es 天然分片,而且内部利用倒排索引的形式来加速数据查询。


哦?兄弟萌,又是它,倒排索引,又是个极小的细节,做好笔记。



举个例子,我同样是一行数据 id、name、age。在 MySQL 里,你得根据 id 分片,如果要支持 name 和 age 的查询,为了防止读扩散,你得分别再建一个 name 的分片表和一个 age 的分片表。


而如果你用 es,它会在它内部以 id 分片键进行分片,同时还能建一个 name 到 id,和一个 age 到 id 的倒排索引。这是不是就跟上面做的事情没啥区别。


而且将 MySQL 接入 es 也非常简单,我们可以通过开源工具 Canal 监听 MySQL 的 binlog 日志变更,再将数据解析后写入 es,这样 es 就能提供近实时的查询能力。


MySQL  同步 es


觉得 es+MySQL 还是繁琐?有没有其他更简洁的方案?


有。


别用 MySQL 了,改用 TiDB 吧,相信大家多少也听说过这个名称,这是个分布式数据库。


它通过引入 Range 的概念进行数据表分片。比如第一个分片表的 id 在 0~2千万,第二个分片表的 id 在 2千万~4千万。


哦?有没有很熟悉,这不就是文章开头提到的根据 id 范围进行数据库分表吗?


支持普通索引,并且普通索引也是分片的,这是不是又跟上面提到的倒排索引方案很类似。


又是个极小的细节。



并且 TiDB 跟 MySQL 的语法几乎一致,现在也有非常多现成的工具可以帮你把数据从 MySQL 迁移到 TiDB 。所以开发成本并不高。


用 TiDB 替换 MySQL 


总结


  • MySQL  在单表数据过大时,查询性能会变差,因此当数据量变得巨大时,需要考虑水平分表;

  • 水平分表需要选定一个分片键,一般选择主键,然后根据id进行取模,或者根据 id 的范围进行分表;

  • MySQL 水平分表后,对于非分片键字段的查询会有读扩散的问题,可以用普通索引列作分片键建一个新表,先查新表拿到 id 后再回到原表再查一次原表。这本质上是借鉴了倒排索引的思路;

  • 如果想要支持更多维度的查询,可以监听 MySQL 的 binlog,将数据写入到 es,提供近实时的查询能力;

  • 当然,用 TiDB 替换 MySQL 也是个思路。TiDB 属实是个好东西,不少厂都拿它换个皮贴个标,做成自己的自研数据库,非常推荐大家学习一波;

  • 不要做过早的优化,没事别上来就分 100 个表,很多时候真用不上。


最后


当年我还在某个游戏项目组里做开发的时候,从企鹅那边挖来的策划信誓旦旦的说,我们要做的这款游戏老少皆宜,肯定是爆款。要做成全球同服。上线至少过亿注册,十万人同时在线。要好好规划和设计。


我们算了下,信他能有个  1 亿注册。用了 id 范围的方式进行分片,分了 4 张表。

搞得我热血沸腾。


那天晚上下班,夏蝉鸣泣,从赤道吹来的热风阵阵拂过我的手臂,我听着泽野弘之的歌,就算是开电瓶车,我都感觉自己像是在开高达。


一年后。


游戏上线前一天通知运维加机器,怕顶不住,要整夜关注。


后来上线了,全球最高在线人数 58 人。其中有 7 个是项目组成员。


还是夏天,还是同样的下班路,想哭,但我不能哭,因为骑电瓶车的时候擦眼泪不安全。


参考资料

《图解分库分表》 : mp.weixin.qq.com/s/OI5y4HMTuEZR1hoz9aOMxg


- EOF -

推荐阅读  点击标题可跳转

1、当字节跳动在美国输出中国式 996

2、我在荷兰做程序员的体验

3、浏览器可运行 Python 代码了!Python 是要前后端通吃了吗?


关注「程序员的那些事」加星标,不错过圈内事

点赞和在看就是最大的支持❤️

关注公众号:拾黑(shiheibook)了解更多

[广告]赞助链接:

四季很好,只要有你,文娱排行榜:https://www.yaopaiming.com/
让资讯触达的更精准有趣:https://www.0xu.cn/

公众号 关注网络尖刀微信公众号
随时掌握互联网精彩
赞助链接