分库分表是在面试里一个非常热门而且偏难的话题,下面了解UUID、自增主键和雪花算法的特点,并且在面试的时候刷出亮点。
前置
所谓的分库分表严格来说是分数据源、分库和分表。例如每个公司订单表的分库分表策略就是用了8个主从集群,每个主从集群4个库,每个库有32张表,合起来有8432张表。
不过根据数据规模和读写压力,也可以共享一个主从集群,然后只分库或者只分表。如果面试面到了分库分表的内容,那么主键生成基本上就是一个绕不开的话题。在没有分库分表的时候,我们可以使用自增主键。
比如在MySQL里的建表语句,指定了AUTO_INCREMENT
CREATE TABLE order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
buyer_id BIGINT NOT NULL
)
在分库分表的场景下,这种自增主键就没法使用了,因为存在冲突的可能。举个最简单的例子,假如我们分库分表只分表,而且按照buyer_id % 2
的值来分成两张表,分别是order_1
和order_2
。如果这两张表都依赖于自增生成主键,那么两张表会生成相同的ID
,但是订单这一类的业务,需要一个全局唯一的ID
。
主键生成一般还伴随两个要点:
- 全局唯一的ID依旧能够保持自增,因为自增与否会显著影响插入的性能;
- 只有数据量大的才会考虑分库分表,而数据量大一般意味着并发高,所以还需要考虑怎么支持高并发
面试准备
首先需要把话题引到主键生成上,如果接触过使用分库分表的项目,那么在简历和项目介绍的时候一定要提及分库分表关键词,后面等面试官主动问你主键是如何生成的。
面试过程中被问到了数据库自增主键相关的问题,那么要主动提起自增主键不适用于分库分表场景,然后面试官自然就会追问分库分表场景下主键的生成问题。
准备工作:
- 深入理解市面上常见的主键生成策略
- 准备一个有亮点、微创新的主键生成方案
- 记住一些可行的优化方案
常见主键生成策略
UUID
最简单粗暴的方案,也是面试的时候必须要回答出来的一种策略。如果想要拉开差距,即使是最简单的UUID方案也需要下一番功夫,首先需要详细地解释UUID的两个弊端。
- 过长:这个弊端在面试里讨论的比较少,毕竟采用UUID的地方就不会在意它的长度
- UUID不是递增的:要重点描述的,并且要尝试刷出亮点
亮点1:页分裂
UUID不是递增的这个弊端,要想讲清除,就要先描述为什么会希望在数据库里面使用自增主键。那么可以引用数据库为什么使用自增主键的知识点来回答这个问题,关键词就是页分裂。
如果你尝试往23之后插入一个25,这个叶子节点已经放不下了,不得已需要分裂成(20,21)和(22,23,25)两个节点。原本的(10,20,30)多了一个元素之后变成了(10,20,22,30),即页分裂这个东西是可能引起连锁反应的,从叶子节点沿着树结构一路分裂到根节点。
可以这样回答
UUID最大的缺陷是它产生的ID不是递增的。一般来说,我们倾向于在数据库中使用自增主键,因为这样可以迫使数据库的数朝着一个方向增长,而不会造成中间叶节点分裂,这样插入性能最好。而整体上UUID生成的ID可以看作是随机,那么就会造成导致数据往页中间插入,引起更加频繁地页分裂,在糟糕的情况下,这种分裂可能引起连锁反应,整棵树的树形结构都会受到影响。所以我们普遍倾向于采用递增的主键。
亮点2:顺序读
此外,我们还可以从一个比较新奇的角度,解释为什么要使用自增主键,关键词就是顺序读。
自增主键还有一个好处,就是数据会有更大的概率按照主键的大小排序,两条主键相近的记录,在磁盘上的位置也是相近的。那么可以预计,在范围查询的时候,我们能够更加充分地利用到磁盘地顺序读特性。
亮点3:InnoDB的数据组织
如果希望在面试官面前树立在数据库方面积累比较多的形象,就要进一步解释数据库的页分裂究竟是怎么回事,这时候可以用MySQL InnoDB引擎来举例子。
MySQL的InnoDB引擎,每一页上按照主键的大小放着数据。假如说我现在有一个页放着主键1、2、3、5、6、7这六行数据,并且这一页已经放慢了。现在我要插入一个ID为4的行,那么InnoDB引擎就会发现,这一页已经放不下4这行数据了,于是逼不得已,就要把原本的页分成两页,比如1、2、3放到一页,5、6、7放到另外一页,然后把4放到第一页。
这种页分裂会造成一个问题,就是虽然从逻辑上来说 1、2、3 这一页和 5、6、7 这一页是邻近的两个页,但是在真实存储的磁盘上,它们可能离得很远。
到这一步,你的回答就已经算是可以了。面试官如果想要进一步探讨,那么可能会继续追问 InnoDB 引擎上的页这种数据结构有什么字段,各自有什么用处。如果你现在是临时抱佛脚准备面试,那么就没必要去背这些八股文。但是如果你只是平常在学习技术知识,想要夯实基础,那么我建议你深入去看看这部分内容。
数据库自增
还有一种常见的方案也叫自增,不过这种自增有点特殊,它是设置了步长的自增。
经过分库分表后我有10个表,可以让每个表按照步长生成自增ID,比如第一个表生成的是1,11,21,31
这种ID,第二个表生成的是2,12,22,32
这种ID,这种方案的关键是表内部自增。这种方案非常简单,而且本身我们在应用层面并不需要做任何事情,只是需要在创建表的时候指定好步长就可以了。ID虽然并不一定是全局递增的,但是在一个表内部,它肯定是递增的,这种方案的性能基本取决于数据库性能,应用层面上也不需要关注。
雪花算法
还可以从雪花类算法上找找亮点,雪花算法的原理不难,它的关键点在于分段。
雪花算法保证ID唯一性的理由:
- 时间戳是递增的,不同时刻产生的ID肯定是不同的
- 机器ID是不同的,同一时刻不同机器产生的ID肯定也是不同的
- 同一时刻同一机器上,可以轻易控制序列号
面试中要先回答这几个理由,然后解释:
雪花算法采用64位来表示一个ID,其中1比特保留,41比特表示时间戳,10比特作为机器ID,12比特作为序列号
刷亮点有以下方法
亮点1:调整分段
第一个方法是深入讨论每个字段,关键点就是根据需求自定义各个字段含义、长度
大多数情况下,如果自己设计一个类似的算法,那么每个字段的含义、长度都可以灵活控制的,比如时间戳41比特可以改的更短或更长。
机器ID虽然明面上是机器ID,但是实际上并不是指物理机器,准确说是算法实例。例如,一台机器部署两个进行,每个进行的ID是不同的;又或者进一步切割,机器ID前半部分表示机器,后半部分可以表示这个机器上用于产生ID的进程、协程或线程。甚至机器ID也并不一定非得表示机器,也可以引入一些特定的业务含义。而序列号也是可以考虑加长或缩短的。
最后一句总结,升华主题
雪花算法可以算是一种思想,借助时间戳和分段,我们可以自由切割ID的不同比特位,赋予其不同的含义,灵活设计自己的ID算法。
亮点2:序列号耗尽
有一个问题,不管你怎么设计雪花算法,你的序列号长度都有可能不够。比如前面标准的是12比特,那么有没有可能并发非常高,以至于12比特在某一个特定的时刻机器上的比特全部用完了吗?
解决方案:
- 如果12比特不够,你就给更多比特,这部分比特可以从时间戳里面拿出来
- 如果还不够,那么就让业务方等待一下,到下一个时刻,自然又可以生成新的ID了,也就是时间戳变了,这也算是一种变相的限流
所以可以这样回答:
一般来说可以考虑加长序列号的长度,比如缩减时间戳,然后挪给序列号ID。当然也可以更加粗暴地将64位地ID改成96位地ID,那么序列号ID就可以有三四十位。不过彻底地兜底方案还是要有地,我们可以考虑引入类似限流地做法,在当前时刻地ID已经耗尽以后,可以让业务方等一下。我们的时间戳一般都是毫秒数,那么业务方最多就会等一毫秒。
这里也有个问题就是:如果业务方等一下会有什么问题?
业务方等待算是一个比较危险的方案,因为这可能导致大量业务方阻塞住,导致线程耗尽或协程耗尽之类的问题,不过如果是偶发性的序列号不够,那么问题不大,因为阻塞的业务方很快就能拿到ID。
那么如果序列号耗尽不是偶发性一个问题,是长期的问题,还是要考虑从业务角度切割,不同业务使用不同的ID生成,就不要共享了。又或者,逼不得已还是用96或128位的。
亮点3:数据堆积
设想一个场景:分库分表是按照ID除以32的余数来进行的,那么如果你的业务非常低频,以至于每个时刻都只生成了尾号为1的ID,那么是不是所有的数据都分到了一张表里呢?
解决方案也很简单:
- 某一个时刻使用随机数作为起点,而不是每次从0开始计数
- 使用上一个序列号作为起点,比如上一个序列只分到了3,那么下一个时刻的序列号就从4开始
随机数算是比较正统的方案,第二个方案看起来有点奇怪。
在低频场景下,很容易出现序列号几乎没有增长,从而导致数据在经过分库分表之后只落到某一张表里的情况。为了解决这种问题,可以考虑这么做,序列号部分不再是从0开始增长,而是从一个随机数开始增长。还有一个策略就是序列号从上一时刻的序列号开始增长,但是如果上一时刻序列号已经很大了,那么就可以退化为从0开始增长,这样比随机数更可控一点,而且性能也更好一点。
一般来说,这个问题只在利用ID进行哈希的分库分表里面有解决的意义。在利用ID进行分库分表的情况下,很显然某一段时间内产生的ID都会落到同一张表里。不过这也是我们的使用范围分库分表预期的行为,不需要解决
主键内嵌分库分表键
大多数时候,我们会面临一个问题,就是分库分表的键和主键并不是同一个。比如在C端的订单分库分表,我们可以采用买家ID来进行分库分表。但是一些业务场景,比如说查看订单详情,可能是根据主键或是订单SN来查找的。
那么可以考虑借鉴雪花算法的设计,将主键生成策略和分库分表键结合在一起,也就是在主键内部嵌入分库分表键。例如,我们可以这样设计订单ID的生成策略,假设分库分表使用的是买家ID的后四位。第一段依旧是采用时间戳,第二段换成了买家后四位,第三段采用随机数。
普遍情况下,我们都是用买家ID来查询对应的订单信息,在别的场景下,比如我们只有一个订单ID,这时候我们可以取出订单ID里嵌入进去的买家ID后四位,来判断数据存储哪个库、哪个表。类似的设计还有答题记录按照答题者ID来分库分表,但是答题记录ID本身可以嵌入这个答题者ID里用于分库分表的部分。
最后要记得升华一下这种设计思想
这一类解决方案,核心就是不拘泥于雪花算法每一段的含义。比如第二段可以使用具备业务含义的ID,第三段可以自增,也可以随机。只要我们能够保证ID生成是全局递增且独一无二的就可以。
为什么要这样升华呢?因为这段话里,我们很明显的埋下了两颗雷,一个是全局递增,一个是独一无二。也就是说这个亮点方案保证不了这两点,可能会被面试官追问这两个点。
全局递增
问题:这个方案能够保证主键递增吗?
这个保证不了,但是能够做到大体上是递增的。可以设想,同一时刻如果有两个用户来创建订单,其中用户ID为2345的先创建,用户ID为1234的后创建,很显然用户1234会产生一个比用户2345更小的订单ID;又或者同一时刻一个买家创建了两个订单,但是第三段是随机数,第一次100,第二次99,显然第一次产生的ID更大。
但是这并不妨碍我们认为,随着时间推移,后一时刻产生的ID肯定比前一时刻产生的ID要大。这样一来,虽然性能比不上完全严格递增的主键好,但是比完全随机的主键好。
独一无二
这个方案不能保证ID唯一,但是这并不是一个很大的问题。
可以想到,不能保证独一无二是因为在第三段里使用了随机数,既然是随机数,就可能随机到同样的数字。但是,产生冲突ID的可能性是很低的。它要求同一时刻同一个用户创建了两个订单,然后订单ID的随机数部分随机到了同一个数字。
产生一样ID的概率不是没有,而是极低,他还要求同一个用户在同一时刻创建了两个订单,然后订单ID的随机数部分一模一样,这是一个很低的概率。
在下单场景下,正常的用户都是一个个订单慢慢下,如果同一时刻同时创建两个订单,对于用户来说,是一件不可能的事情。而如果有攻击者下单,那就更加无所谓,失败就失败了。而即使真的由用户因为共享账号之类的问题同一时刻下两个订单,那么随机到同一个数的概率也是十万分之一。(这里的十万分之一,是假设产生的随机数的范围是0到十万)
解决方案也很简单,就是在插入数据的时候,如果返回了主键冲突,就重新产生一个,再次尝试就可以了。
还想继续刷亮点的话,可以假装不经意地说:
实际上,还有一种非常偶发性的因素也可能会引起 ID 冲突,也就是时钟回拨,不过相比正统雪花算法,时钟回拨问题在这个方案里面不太严重,毕竟还有一个随机数的部分。
时钟回拨现象指的是系统时钟被调整到之前的时间,这通常发生在服务器或系统时间同步时。在分布式系统中,如果使用基于时间戳生成的主键策略(如雪花算法),时钟回拨可能会导致ID冲突。
优化思路
这些思路说只是想过,但是并没有落地
优化的点就是:批量取、提前取、singleflight 取、局部分发。注意,批量取和提前取对应的还有批量生成和提前生成,思路是一样的。万一面试官问到了如何设计一个高性能的发号器,你也可以回答这些点。
发号器是一种在分布式系统中用于生成唯一ID的工具或服务。它可以确保在多个并发请求或业务流程中生成的ID是全局唯一的,通常用于数据库的主键生成、事务标识符、分布式锁等场景。
批量取
业务方每次跟发号器打交道,不是只拿走一个ID,而是拿走一批,比如拿100个,拿到之后业务方内部自己慢慢消耗,消耗完了再去取下一批。
优点:极大地减轻发号器地并发压力,比如一批是100个,那么并发数就降低为原来的1%了。
缺点:可能破坏递增的趋势。比如说一个业务方 A 先取走了 100 个 ID,然后业务方 B 又取走 100 个,结果业务方 B 先用完了自己取的 ID,插到了数据库里面;然后业务方 A 才用完自己的 100 个
提前取
业务方提前取到ID,这样就不需要真的等待需要ID的时候再临时取。
提前取可以和批量取结合在一起,即提前取一批,然后内部慢慢使用。在快用完的时候再取一批。
同时也要设计一个兜底措施,如果要是用完了,新的一批还没取过来,要让业务方停下来等待。
优点: 提高业务方性能
缺点: 可能破坏递增的趋势
singleflight取
类似于在缓存中应用singleflight模式。假如说业务方A有几十个线程或协程需要ID,那么没有必要让这些线程都去取ID,而是派一个代表去取。这个代表取到之后再分发给需要的线程,这也能降低发号器的并发负载。
思路可以进一步优化,每次取得时候多取一点,后续还有别的线程需要,也不用自己去取了。
局部分发
假如说现在整个实例上有 1000 个 ID,这些 ID 是批量获取的。那么一个线程需要 ID 的时候,它就不再是只拿一个,而是拿 20 个,然后存在自己的 TLB(thread-local-buffer) 里面,以后这个线程需要 ID 的时候,就先从自己的 TLB 里面拿,避开了全局竞争,减轻了并发压力。