场景介绍
数据库表中存储的数据,需要一个唯一标识,就是主键。
主键设计原则
- 原则1:唯一性。这是主键的定义决定。
- 原则2:越短越好。和B+树的性质有关,主键占用的空间越小,单个B+树节点存储的主键越多。在相同数据规模的情况下,主键字段越短,B+树的高度越低,取到目标数据所用的IO次数越少,读取效率也就越高。另一方面,数据库有缓存机制,主键占用的空间越小,缓存同样的数据,占用的内存空间就越小。
- 原则3:单调性要求。B+树的是一种平衡的树结构,插入数据后,如果超过了节点的存储数据的上线,会发生树的再平衡。无序的主键插入,会频繁引发的B+树的再平衡。而递增或递减的主键,在插入数据时,只会影响最左侧或最右侧的节点,引发的在平衡的可能性降低。
主键方案
方案1:数据库自增ID。使用数据库的 auto_increment 来生成全局唯一递增ID,数据类型是int或者long,占用4个字节或8个字节。完美的适用上述3各原则,也满足了大部分也业务需求,是比较推荐的方式,在实践中是最常用的,也是推荐的方案。但这种方案在某些应用场景下,并不合适,例如:
- 场景1:数据分表。单表的数据量过大,会采用分表的方案存储数据。数据库表的自增ID,只能在单个表内实现自增,不能做到全局的唯一性。有个讨巧的方案,分表采用不同的初始ID,配置成同样的自增步长,也能实现ID的全局唯一性。这个方案比较脆弱,基本不具有扩展性,如果ID有一个错的,会导致全部的数据错乱,所以不建议采用。
- 场景2:对外暴露的ID。在前端页面显示有个订单的数据,请求一个这样的接口,/order/get?id=xxx。恶意的攻击者,可以通过ID自增的性质,逐个请求数据,导致数据泄露。在公网上暴露的ID,用自增的ID策略,有严重的安全隐患。
方案2:UUID。代替数据库自增的方案,最简单的莫过于UUID了。UUID保证了全局的唯一性,也能支持上述两个特殊场景的应用。UUID是由一组32位数的16进制数字所构成,也就是128位,一般转换成长度为32的字符串使用。存储一个UUID需要用32个字节,和int、long类型的比较真是太长了。并且UUID并不是单调递增或递减的。与原则2和原则3相悖,在任何的应用场景,都不建议使用UUD作为数据库表的主键。因为还有更好的方案。
方案3:分布式自增ID。这种方案利用long存储数据,占用8个字节,并且是单调递增的。实现细节请参考Snowflake算法。
Snowflake算法介绍
- 符号位永远是0,代表正整数。
- 时间戳41位,精确到毫秒,最多可支持69年。时间戳如果从1970年算起,最多可用到2039年。但是,可以让时间戳从产品上线的时间算起,可以支持线上运行69年,基本上足够了。
- 10位用作服务器编号,分布式ID的服务集群,最多可以有1K个服务节点。
- 12位用作单调递增序列号,也就是说每个服务节点,在1毫秒时间内,最多可生成4K个ID。再乘以1K个服务节点,集群每毫秒可提供4M个ID。
根据这个算法生成的ID,虽然不是严格的单调递增,是按照时间以毫秒为单位单调递增的,是基本满足要求的。此算法不依赖数据库,服务之前没有依赖,效率非常高,扩展性强。
具体实现的时候,根据业务场景,可做适当的调整。例如,并不需要那么多的服务节点,可以用4个bit表示,最多支持16个服务节点;想通过ID快速判断业务类型,可以选择4个bit表示业务类型,支持16种业务类型。
Snowflake算法缺陷
缺陷1:时钟回拨问题。此算法依赖服务器的时钟,如果时钟拨回到之前的一个时间,生成的ID就可能出现重复的现象。
解决此问题,主要从两个方面考虑。1、开启服务器的时钟同步。2、程序内自定义时间计数器,如果获取的当前时间小于计数器,则使用时间计数器;否则使用当前时间并更新时间计数器。这样可解决程序运行期间小范围的时钟问题。服务开始时的时钟就是不准的,或者人为的大范围回拨时钟,这种问题超出了程序设计考虑的范畴,只能交给上帝去裁决了。
缺陷2:尾数散列性差。在系统压力不大的情况下,假设每秒只需要产生100个ID,平均到每个毫秒生成0.1个ID,这时生成的ID尾数大部分是1。在一些分表的方案中,根据余数来确定存储到哪个库,假如只有两个库,会发现大部分数据都存储到了其中的一个数据库中。
解决此问题方法有两种:一种是采用一致性哈希的方式,选择存储到哪个库中;另一种产生序列号的时候,不是每次从1开始,而是从一个随机的数字开始。
持续分享IT互联网相关技术点,欢迎关注我。