下面让我们一起来多角度的辩证的探究一下这个问题!
占用空间角度
首先我们来看一下自增id,在MySQL中自增id通常选用int类型或者阿里巴巴手册推荐的bigint类型,其中int类型占用4个字节,而bigint占用8个字节。
阿里巴巴手册之所以推荐使用bigint类型是因为无符号int最大值为2^32-1这个值不到43亿,对于阿里的业务体量这个数字可能不太够用。
这时候可能有人就会想到,我们在表数据量千万级左右就已经需要考虑分表了,根本不会让单表数据达到43亿这个量级,怎么会不够用呢?但是一旦这个表存在频繁的插入、删除操作,那么43亿这个自增值也是可能达到的。
不过像一些字典表的情况本身数据量就很小,所以这个时候可以不那么死板,大胆的使用int类型的自增id作为主键。
接下来我们分析一下UUID,通常情况下UUID由32个字符+4个'-'组成,长度为36字符,即便使用ASCII编码一个字符也要占用一个字节。
这么看来在占用空间角度UUID败给了自增id。
占用空间过大会带来什么问题呢?
MySQL通过使用页来组织数据,每一个页的大小是固定的,当主键占用空间越大,每一个页存放的主键索引就越少,这样最终就可能导致主键索引树深度变深,从而使得在搜索数据的时候发生的磁盘IO次数变多。
而我们最常用的Innodb存储引擎是索引组织表,其他索引树底层会记录主键值以便回表使用,所以主键索引树变深最终将会影响绝大多数涉及索引查询的效率。
插入效率角度
想要探究插入效率问题,就绕不开MySQL最常用的索引底层数据结构b+树了。
网上大多数文章谈到插入效率问题,往往都是:索引树是有序的,将无序数据插入到索引树中间去,这样可能会频繁地导致页分裂,从而导致性能下降,这样一笔带过。
深入探究
当我们通过b+树操作在线演示网站实践就会神奇的发现,即使是插入有序数据依然会导致频繁的页分裂,这是为什么呢?
这是因为b+树的默认插入算法,考虑的是插入的数据是随机的,定位到每一个叶子结点的概率是均等的,所以在页分裂的时候采取了对半分的策略。
下图是一个五阶b+树,顺序插入14个数据的演示结果。从结果中页的数量我们可以看出频繁的发生了页分裂,而且现有页的空间占用率浪费了近%50。
图片
如果算法直接拿到MySQL中使用,假设一个MySQL页可以存储1000条数据,那一次分页后每一个页只存储500条数据,这样的浪费是不被容忍的。
所以MySQL默认所使用的InnoDB存储引擎就对插入算法进行了优化,通过在Page Header中记录以下几个字段:
- PAGE_LAST_INSERT
- PAGE_DIRECTION
- PAGE_N_DIRECTION
InnoDB存储引擎会通过上述几个字段动态分析页分裂的方向和页分裂的位置,回归到我们的问题,当插入的数据是有序的为什么会减少页分裂的发生?
这是因为当插入的数据是自增的,索引的插入模式近似于:叶子结点在发生分裂时,保持原有的叶子结点不变,将新增的数据插入到新的叶子结点,并把这个当前插入值,提升到父结点。
但是如果仅是上述这种模式,又会引出新的漏洞,也就是MySQL比较有名的Bug #67718,详情见文末参考1官方文档。
下面我将通过手绘的示意图为大家简单介绍,需要注意示意图中的五阶b+树,仅是为了更方便大家理解,并不代表实际存储结构。
图片
通过示意图我们可以发现,当顺序插入数据的时候这种算法一切安好,先是铺满了页1,然后分裂出页2继续填充,在这种情况下空间利用率达到了最大,页分裂出现频率也降到了最低。
但当不再按照顺序插入数据,而是在已满的页1后面追加数据18,此刻按照下述规则新增数据就会分裂出页3。
保持原有的叶子结点不变,将新增的数据插入到新的叶子结点,并把这个当前插入值,提升到父结点
再按照同样规则追加数据16,就会分裂出页4,显然这种算法在极端情况下带来了更严重的空间浪费。
官方也很快发现并解决了这个问题,采用的方法大致是:如果叶子结点满了之后,如果该叶子结点后面有还有叶子结点,则会将新的数据合并到后续的叶子结点中。
比如在插入数据18的时候不会再新增一个分页,而是将数据18插入到页2中,详情仍见文末参考1官方文档,不再展开赘述。
回归正题
通过上述底层原理分析,其实我们不难发现,InnoDB存储引擎在设计的时候就更希望我们使用顺序值作为主键。
这一点在我们建表时没有指定主键,并且在不含有非NULL的唯一键的情况下,InnoDB存储引擎会自动生成一个隐式rowid作为聚簇索引的索引键也可以看出。
自增id毋庸置疑肯定是有序的,而UUID因为在设计的过程中将时间低位放置在了时间高位前面,所以生成的数据肯定是无序的。
这么看来UUID在插入效率角度也是败给了自增id
实际应用角度
我们通过在占用空间和插入效率两个角度进行对比分析,发现UUID竟然都败给了自增id,可能这也是阿里巴巴开发手册规定单表的主键id必须为无符号的bigint类型,且是自增的原因。
但是在实际应用中我们建表就可以无脑使用自增id了吗?
其实并不然,以上的分析都是站在数据库的角度进行的分析,强调了单表。在实际业务中自增id有很多弊端。
- 安全方面:对外暴露的接口可能会泄露信息。比如一个外卖平台的订单查询接口:/order/1/,订单编号采用了自增的id,那么完全可以通过在两个时间分别下一单,然后根据编号差值就能推算出该平台该段时间的订单量,亦或被别有用心者轻易的进行信息爬取。
- 性能方面:自增id需要在数据库服务端进行生成,对性能有损耗。并且自增id需要在插入后才能生成,如后续业务需要获取本次id其实是潜藏着一次与数据库的交互。
- 唯一性方面:对于自增id最大的缺陷其实就是局部唯一性,无法做到在全局任意服务器唯一,这一点使得自增id在分布式时代有些失宠。
而UUID的全局唯一性,恰好与分布式系统更加契合,正因如此在Mysql8.0对UUID进行了一波优化。
一定程度上解决UUID存在的空间占用的问题,不仅除去了UUID字符串中无意义的"-"字符串,而且将字符串用二进制类型保存,从而使存储空间降低至16字节。
更为重要的是MySQL8.0提供了调换UUID时间低位和时间高位的方法,使得时间高位在前低位在后,进而将UUID从无序变为有序,大大提高了数据插入效率。
通过MySQL8.0提供的uuid_to_bin函数即可实现上述操作。
uuid_to_bin(UUID())可获取二进制的UUID。
uuid_to_bin(UUID(),TRUE)可以在将UUID转换为二进制的同时,将时间的低位和高位互换,便于排序。
同时MySQL 8.0也提供了BIN_TO_UUID函数,支持将二进制值反转为UUID字符串。
有序性+全局唯一性,这不正是分布式系统主键所追求的!
那么16字节的有序UUID,与8字节的自增id、原始的UUID相比,性能和占用空间究竟如何呢?
这里直接粘来大佬提供的测试数据,插入1亿条数据,每条数据占用500字节,含有3个二级索引,最终的测试结果如下所示:
图片
可以看出在效率方面有序的UUID最优,在占用空间方面略逊于自增id。
总结
通过上述分析我们可以看出无论是自增id还是UUID都各有优势,在实际开发中,我们可以根据不同的场景选用不同的主键策略。
对于非核心业务,可以使用对应表的主键自增id,例如告警、日志、监控等信息。
对于核心业务,主键的设计应该优先考虑全局唯一且单调递增即分布式id。除非已经确定系统一定是单机系统,未来也没有迭代成分布式系统的可能。
对于分布式id目前主流方案包括UUID、雪花算法、MySQL维护自增id表、redis的incr命令、美团技术团队的Leaf分布式id生成服务等。
在软件开发中只有平衡,没有银弹!大家可以在实践中灵活选用,在面试过程中也可以辩证的分析,展现出自己的思考。
今天的分享到此结束,希望大家能够有所收获!
参考
https://bugs.mysql.com/bug.php?id=67718
https://blog.csdn.net/qq_42389764/article/details/108152624
https://cloud.tencent.com/developer/article/2163702
https://blog.csdn.net/shenchaohao12321/article/details/83243501