我们平时建表的时候,一般是下面这个样子。CREATETABLEuser(idintNOTNULLAUTO_INCREMENTCOMMENT'primarykey',namechar(10)NOTNULLDEFAULT''COMMENT'name',PRIMARYKEY(id))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;出于习惯copy代码,我们一般会加一列id作为主键,而这个主键一般旁边会有一个AUTO_INCREMENT,表示主键是自增的。自增为i++,即每次加1。但问题来了。主键id不是自增的吗?为什么要用自增id作为主键呢?离谱点,没有主键可以吗?什么情况下不应该自增?这么一波质疑,你是不是疯了?在本文中,我将尝试回答这些问题。当然也有可能主键不自增。比如我们可以去掉表sql中的AUTO_INCREMENT。CREATETABLEuser(idintNOTNULLCOMMENT'primarykey',namechar(10)NOTNULLDEFAULT''COMMENT'name',PRIMARYKEY(id))ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;复制代码并执行INSERTINTOuser(name)VALUES('debug');复制代码会报错Field'id'doesn'thaveadefaultvalue。也就是说,如果不让主键自增,那么在写数据的时候需要指定id的值,主键id随便写,写错了就报错不要写它。只需将其更改为以下INSERTINTOuser(id,name)VALUES(10,'debug');复制代码为什么要用自增主键我们保存在数据库中的数据和excel表格一样,是一行一行的。在底层,数据行存储在每页16k的页面中。每次都遍历所有的行不会有很好的表现,所以为了加快查找速度,我们可以将这些行数据按照主键id从小到大排列,将这些数据页以双向链表的形式组织起来,然后组织数据页,将这些页面中的部分信息抽取出来放入一个新的16kb的数据页中,然后加入hierarchy的概念。这样一来,每个数据页都被组织起来,成为一个B+树索引。而当我们在建表sql中声明PRIMARYKEY(id)时,mysql的innodb引擎会为主键id生成一个主键索引,以B+树的形式维护。至此,我们有两点需要注意:数据页大小固定在16k数据页以内,数据页之间,数据主键id从小到大排序,因为数据页大小固定为16k,当我们需要插入新的数据时,数据页会慢慢被填满。当超过16k时,数据页可能会被拆分。对于B+树的叶子节点,如果主键是自增的,每次生成的id都会比之前的大,所以每次都会往B+树的末尾添加数据。B+树的叶子节点本质上是双向链表。找到它的头和尾,时间复杂度O(1)。而如果此时最后一个数据页已满,则新建一个页即可。如果主键不是自增的,比如上次赋值id=7,这次赋值id=3。为了在添加新数据后保持B+树的叶子节点有序,需要去叶子节点。中间查找,查找过程的时间复杂度为O(lgn),如果恰好页面满了,则需要进行分页。而分页操作本身就需要悲观锁。总体来说,自增主键遇到分页的可能性较小,所以性能会更高。没有主键可以吗?如果mysql表没有主键索引,就得扫描全表查看数据。既然这么重要,我今天就不做人了。不声明主键可以吗?好吧,您根本不必声明主键。建表sql中确实可以这样写。CREATETABLEuser(namechar(10)NOTNULLDEFAULT''COMMENT'name')ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;复制代码似乎没有主键。但是,其实mysql的innodb引擎会帮你生成一个名为ROW_ID的列,这是一个6字节的隐藏列,平时看不到,但其实它也是自增的。有了这种自下而上的机制保证,数据表肯定会有主键和主键索引。带有ROW_ID的隐藏列还有一个trx_id字段,用于记录当前数据行被哪个事务修改,以及一个roll_pointer字段,用于指向当前数据行的上一个版本。通过这个字段,可以为这一行数据形成一个版本链,从而实现多版本并发控制(MVCC)。是不是很熟悉?这在之前的文章中出现过。有没有主键不应该自增的场景?如前所述,主键自增可以带来很多好处。事实上,在大多数场景下,我们建议将主键设置为自增。有没有不推荐主键自增的场景?mysql分库分表下的id说到分库分表,那我就需要解释一下自增和自增的区别。自增的意思是每次+1,increment的意思是新的id比之前的id高,只要大就行,多大都无所谓。之前写过一篇文章提到mysql横向分库分表一般有两种方式。一种分表方式是通过id取模来分表。这种需求是增量的,不需要严格自增,因为取模后数据会分散到多个子表中,即使id严格自增,去中心化后也只能保证每个子表中的id只能递增。另一种分表方式是根据id的范围来分表(shard)。它会划出一定的范围。比如2kw是一个分表的大小,那么02kw会放在这个分表里,2kw4kw会放在这个分表里。在另一个子表中,数据不断增加,子表也可以不断增加,很适合动态扩容,但是需要id自动增加。如果id增加,数据就会出现很多空洞。比如第一次分配了id=2,第二次分配了id=2kw,此时会填满第一个表的范围,后面再分配一个id,比如3kw,并且只能保存到2kw4kw(第二张)的子表中。那我02kw范围内的分表只存两条数据,太浪费了。但是不管是哪种分表方式,一般都不可能在原表中继续使用自增主键,原因也比较好理解。如果每张原表都从0开始自增,那么好几张表都会出现重复几次的id,按照id唯一的原则,显然是不合理的。所以,在分库分表的场景下,插入的id是由专门的id服务生成的。如果是严格自增,一般会通过redis获取。当然不会每次id请求就获取一次。一般会分批获取,比如一次获取100个。用完后获取下一批100个。但是这个解决方案有一个问题。它严重依赖redis。如果redis挂了,整个函数就变傻了。有没有不依赖其他第三方组件的方法?还有雪花算法,比如推特开源的雪花算法。雪花算法使用64位具有特殊含义的数字组成id。首先,不使用位0。接下来的41位是时间戳。精度是毫秒,这个size可以代表69年左右,因为timestamp肯定是随着时间的推移越来越大,所以这部分决定了生成的id一定是越来越大。接下来的10位是指产生这些雪花算法的工作机器的id,这样每台机器产生的id都能有一个对应的标识。接下来的12位数字,序列号,是指本工作机产生的增量编号。可以看出,只要在同一毫秒内,雪花算法id的前42位都是相同的值,所以在这个毫秒内,可以生成的id个数是2的10次方??2到12次方,400w左右,绝对够了,甚至有点多了。但!细心的兄弟肯定也发现了,雪花算法计算出的数字往往比上次的数字多了几十万,也就是说它生成的id是呈趋势递增的,并不是严格意义上的+1自增,也就是话说,不太适合按范围分表的场景。这是一个非常痛苦的问题。还有一个小问题,对于10位的工作机id,我每次扩展一个工作机,这个机器怎么知道自己的id是多少?我必须从某个地方阅读它吗?有没有一种生成id的方案,既能支持分库分表的动态扩容,又不像雪花算法那样依赖redis等第三方服务。有。这就是本文的重点。适合分库分表的uuid算法可以参考雪花算法的实现,其设计如下。请注意,下面的每一位都是十进制的,而不是二进制的。前12位仍然是时间,但不是时间戳。雪花算法的时间戳精确到毫秒。我们不需要这样的细节。我们将其更改为yyMMddHHmmss。注意,开头的yy是两位数,也就是说这个方案可以保证在2099之前,id不会重复,如果能用,也是真正的百年企业。还有,由于时间在顶部,随着时间的推移,id趋势也可以保证上升。接下来的10位十进制表示工作机器的ip,12位ip可以转换为10位数字,可以保证全局唯一。只要服务起来,你就知道自己的ip是多少,不需要像雪花算法那样从其他地方读取workerid,这是另一个小细节。接下来的6位用于生成序列号,可以支持每秒生成100w个ID。最后4位也是这个id算法最精彩的部分。前2位代表分库id,后2位代表分表id。也就是说一共支持100*100=1w个子表。例如,假设我只使用1个子数据库。当我一开始只有3个子表时,那么我可以配置生成的uuid的后2位,值只能是[0,1,2],分别对应三个表。这样我生成的id可以很均匀的落到三个子表里,顺便也解决了热点写在单个子表的问题。如果随着业务的不断发展,需要新增两张表(3和4),而0号表有点满,又不想被重写,那么将配置改为[1,2,3,4],这样生成的id就不会插入到对应的0表中。同时也可以加入生成id的概率和权重,调整哪个子表数据多。有了这个新的uuid方案,我们既可以保证生成数据的增长趋势,也可以很方便的扩展分表。很不错。数据库有很多种,mysql只是其中的一种。其他数据库也需要主键自增吗?TiDB的主键id不建议自增。TiDB是一个分布式数据库。作为MySQL分库分表场景下的替代产品,可以更好的对数据进行分片。它通过引入Range的概念来对数据表进行分片。比如第一个分片表的id是02kw,第二个分片表的id是2kw4kw。这其实就是按照id范围划分数据库表。它的语法几乎和mysql一样,大部分时候是没用的。但是和mysql的一个区别是mysql推荐使用自增id,而tidb推荐使用随机uuid。原因是如果id是自增的,按照rangesharding的规则,一段时间内产生的id几乎都会落在同一个shard上。比如下图中,从3kw开始的自增uuid几乎都落在1范围内,在分片中,几乎没有对其他表的写入,性能没有发挥出来。有一幕,一表难,众表围观。这种情况也称为写热点问题。因此,为了充分利用多个分片的写入能力,TiDB建议我们在写入时使用随机id,这样可以将数据均匀分布到多个分片中。用户ID不推荐使用自增id。前面提到的不推荐自增id的场景都是技术原因导致的,而下面介绍的场景纯粹是出于业务原因。让我举一个例子。如果您可以知道某个产品每月的新用户数量,这对您来说是否有用?对于程序员来说,这些信息可能没有太大价值。但是,如果您正在投资或分析竞争对手怎么办?反之亦然。如果你发现你的竞争对手总能非常清楚地知道你的产品每个月有多少新注册用户,你会不会感到紧张?如果真的出现这个问题,先别想有没有内行,先看看你的user表的主键是不是自增的。如果用户id是自增的,那么别人只需要每个月注册一个新用户,然后抓取这个用户的user_id,然后用上个月的值减去,就知道有多少新用户进入了这个月。类似的场景还有很多。有的时候去小店吃饭,发票上会写你今天下了多少单,这样你就可以大概估算出这家店今天的订单数。你是店主,所以你感觉不舒服。再比如,如果把一些小APP的商品订单id也做成自动增加,就很容易知道这个月下了多少订单。类似的事情还有很多。在这些场景中,推荐使用具有递增趋势的uuid作为主键。当然主键是一直自增的,但是不暴露给前端也没关系。至于我刚才说的话,你当我没说。综上所述,建表SQL中主键旁边的AUTO_INCREMENT可以让主键自增,去掉是可以的,但这需要你在插入时自己设置主键的值。建表sql中的PRIMARYKEY用于声明主键。如果去掉,可以建表成功,但是mysql会偷偷给你创建一个ROW_ID的隐藏列作为主键。由于mysql使用的是B+树索引,所以叶子节点是从小到大排序的。如果使用自增id作为主键,每次都会在B+树的末尾添加数据。最终可以有效减少分页问题。在分库分表的场景下,我们可以通过redis等第三方组件获取严格的自增主键id。如果不想依赖redis,可以参考雪花算法进行魔改,既能保证数据趋势增长,又能满足分库分表的动态扩容。并非所有数据库都建议使用自增ID作为主键。比如TiDB推荐使用随机id,可以有效避免写热点的问题。对于一些敏感数据,比如用户id、订单id等,如果使用自增id作为主键,通过外部抓包很容易知道新增用户数和订单量,所以需要慎重考虑是否继续使用自增主键。
