前言

在我学习MYSQL的一些原理之前,我曾一度觉得MYSQL最好的主键策略是使用uuid,甚至觉得自增id这种方式很low。但当我经过一系列学习以后,恨不得坐时光机回去恨恨地啪啪啪自己(打脸拟声词)。

正巧前段时间某个群里有人问了这个问题,那就趁此机会记录一下。

观前提示:本文会涉及到AUTO-INC锁(姑且称作主键自增锁)概念, 如果你完全不认识这个锁,建议去看看《MySQL技术内幕 InnoDB存储引擎》或者掘金的MYSQL小册子或者去百度。不过便于理解,本文还是会简单介绍一下这个锁。

若无特别说明,引擎默认使用InnoDB。

以下将尝试从不同角度去分析此问题。

从空间方面考虑

  1. 使用uuid(varchar)所占用的存储空间一般都比int甚至bigint占用的存储空间都要大。
  2. InnoDB的数据是按数据为单位来读写的,一个页的大小是16kb

从第二条我们可以得出一个结论:数据类型越小或平均每条记录所占用的空间越小,那么在一个页中,可以放的数据条数就越多。这可以有效的减少磁盘I/O。

说白了就是使用自增id占用的存储空间比uuid要小,当加载一个页面时,能获取更多的记录,从而减少磁盘交互。(网络I/O和磁盘I/O都是系统最容易达到瓶颈的地方)

除此之外,当一个页快要满的时候再插入记录,如果是uuid这种无序字符则可能会产生页分裂,而页分裂也是比较消耗性能的操作,如果能减少数据的平均大小,就意味着可以减少页分裂的次数,从而提高性能。


从插入性能考虑

我们知道MYSQL的索引树是按索引列进行排序的,而如果我们用无序的uuid直接插入数据的话很可能会破坏这个平衡,而自增id则可以避免破坏这个平衡。

为了保持这个平衡,MYSQL在插入时uuid时肯定会进行二分查找,而二分查找的过程肯定需要对数据进行比较,这样无疑就增加了成本。

还有一点,还是针对页分裂,因为uuid的无序性,页分裂时可能要将一部分数据移动到新页中,这样不仅消耗额外性能,也容易生成空间碎片。而使用自增id,则不会出现"将一部分数据移动至新页"这种操作,因为本来就是有序的,直接在新页往下写就是了。


从查询优化方面考虑

关于查询方面的影响,这里就先针对页分裂进行讨论。
在上一段文字中,说了页分裂会导致"将一部分数据移动至新页",这句话其实也在侧面说明:页分裂后,一个页面中的数据减少了。

说白点就是:本来查询某个范围的数据,只需要加载一页,但因为页分裂,现在需要查询两页才能获取完整结果。这意味着加载数据时,多了一次磁盘I/O。



现在谈完uuid的坏处了,是时候来瞧一瞧自增id能给我们带来什么好处了。

深分页,无论是使用什么样的数据库这都是一个老生常谈的问题。

但对于大部分小白来说(指没有一定量级的数据操作经验,比如我),在早期并不知道MYSQL的OFFESET做分页其实会带来一些性能问题。

比如:

mysql> select * from test where val=4 limit 300000,5;

简述执行过程:遍历前30w条数据(包括select *造成的回表),然后再把前30w条数据丢弃,取后面符合条件的5条。

可见上面这种分页方式其实及其浪费性能。


那么自增id能为深分页带来什么好处呢?我们不妨来看下下面这条sql。

mysql> select * from test where id>300000 and val=4 limit 5;

假如id=300001这条数据前面真的有30w条数据,且id都在1~30w这个区间,那么这条sql就可以利用主键索引直接跳过了前30w条数据,而不是像OFFSET一样还要遍历前30w数据达到计数的目的。

这种利用索引来提高分页效率的手段被称为seek方法(查询)。


当然,关于seek查询,上面只是举了个简单的例子便于大家理解,下面推荐一些有更完整例子的博客供参考:

  • JOOQ中关于seek方法的描述 -> 传送门
  • 忘记是谁推给我看的博客 -> 传送门

扩展

那么竟然上面讲的seek查询这么nb,那是不是可以随心所欲的用呢?这取决于具体情况,因为id自增可能没有你想象的那么"乖"。

先说一条结论:事务回滚、ON DUPLICATE KEY UPDATE等情况都有可能导致自增id不连续

记得以前(也可能记错)还有一种说法是批量插入也有可能导致id不连续,但是我自己测试不出来,理论上我也推敲不到。。如果有相关实验能证明这说法,请务必发我个传送门。


下面来看看事务回滚造成id不连续的情况:

事务回滚造成id不连续

可以看到上图中,即便事务回滚了,但id生成器生成的id并不会-1,而是继续增加。

其实出现这种状况的原因和AUTO-INC锁有关。

AUTO-INC锁简述:

  • 它是一个表级但轻量级的锁,为什么说它是轻量级的锁呢?因为它不像gap锁或行锁那样等事务结束才释放锁,AUTO-INC锁会在insert执行完之后就释放,不管事务有没有结束。说白了它的作用就是产出id+保证产出id不重复,并不会"跟随"事务,所以图中即便我们回滚的事务,也并不会把id+1的操作回退。


综上所述,seek查询的好坏取决于你的具体业务需求,如果表经常物理删除数据,那肯定是不适用seek查询的。

但如果是某些场景,即便有数据有那么一点偏差,但看起来反而是更加"稳定"了,比如上面JOOQ文章中的一个例子:

A side effect of the Seek Method is the fact that the paging is more “stable”. When you’re about to display page 2 and a new player has reached page 1 in the mean time, or if any player is removed entirely, you will still display the same players on page 2. In other words, when using the Seek Method, there is no guarantee that the first player on page 2 has rank 11.

翻译过来就是:
  • 现在表中有如下数据,它们的主键是
  • 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21
  • 每页十条数据,此时利用主键id分页,第一页id>0 & limit 10 & order by id,第二页id>1*10 & limit 10 & order by id,那么一二页数据如下
  • 第一页:1,2,3,4,5,6,7,8,9,10;第二页:11,12,13,14,15,16,17,18,19,20
  • 随后物理删除主键为10的数据,之后依旧利用个主键id分页,一二页数据如下
  • 第一页:1,2,3,4,5,6,7,8,9,11;第二页:11,12,13,14,15,16,17,18,19,20

这种并没有因为删除数据(插入同理)而导致数据缺失,但出现了重复数据的情况,被JOOQ看作是一种更加"稳定"的体现。