存储拆分后,如何解决唯一主键问题?
在单库单表时,业务 ID 可以依赖数据库的自增主键实现,现在我们把存储拆分到了多处,如果还是用数据库的自增主键,就会出现主键重复的情况。
所以我们不得不面对的一个选择,就是 ID 生成器,使用一个唯一的字符串,来标识一条完整的记录。
这时候,不能使用 md5 或者 sha1 来对整个记录做摘要,因为我们后续还要改动这个记录。也不能使用单机的计数器,因为计数器容易重启清零,也会存在多台机器上的数值重复,这违背了无状态服务的建设目标。
UUID
虽然 UUID 在大多数语言中都有相关的类库,但除非迫不得以,我们一般不会使用它。UUID 虽然不会重复,但它非常的长,长的让人望而生畏。
标准的 UUID 有 5 个部分组成:8-4-4-4-12,一共 32 个十六进制字符。因此,一共是 128 位。当把 UUID 作为数据库的索引时,会因为它没有顺序性造成索引的随机分布和因为数据量巨大造成查询性能降低。
且无序会造成每一次 UUID 数据的插入都会对主键的 b+树进行很大的修改, 会产生离散 IO,从而产生性能瓶颈。
同时,UUID 也是不可读的,如果你把它打印在纸质的订单上,并不是一个好的主意。UUID 同时还有信息安全的隐患,它的数据计算里有 MAC 地址的参与,比较知名的是,曾被用于寻找梅丽莎病毒的制作者位置。
MySQL8 以后
MySQL 8.0 推出了函数 UUID_TO_BIN,它可以把 UUID 字符串:
通过参数将时间高位放在最前,解决了 UUID 插入时乱序问题;
去掉了无用的字符串"-",精简存储空间;
将字符串其转换为二进制值存储,空间最终从之前的 36 个字节缩短为了 16 字节。
同时还提供了 BIN_TO_UUID,支持将二进制值反转为 UUID 字符串,不用担心 UUID 的性能和存储占用的空间问题,相关的插入性能测试,结果如下表所示:
由于 UUID_TO_BIN 转换为的结果是 16 字节,仅比自增 ID 增加 8 个字节,最后存储占用的空间也仅比自增大了 3G。
而且由于 UUID 能保证全局唯一,因此使用 UUID 的收益远远大于自增 ID。在海量并发的互联网业务场景下,更推荐 UUID 这样的全局唯一值做主键。
但请牢记:分布式数据库架构,仅用 UUID 做主键依然是不够的。
数据库自增 ID
当数据量庞大时,在数据库分库分表后,数据库自增 id 不能满足唯一 id 来标识数据;因为每个表都按自己节奏自增,会造成 id 冲突,无法满足需求
改造时间戳
如果你是单机应用,那么使用时间戳没什么问题,即使不用纳秒,使用毫秒也是足够的。但在分布式环境下面,时间戳同样不是一个好的选择。
即使你在机器安装了 ntpd 时间同步,但由于网络和机器的差异,计算机的时钟总是存在差异,你的时间戳总会出现重复。为了解决这个问题,你需要增加一些其他的标识,比如机器的 ID,或者更多细分的信息减少时间的碰撞。
这种自定义的 ID 生成器,只适合特定的业务,做着做着你就会发现,它本质上是雪花算法的变种。
全局 ID 生成器服务
可以设计一个全局 ID 生成器服务,每次找服务索要主键,这样虽然可以在业务间实现全局唯一,但是完全依赖全局 ID 生成服务,依赖性大,服务一旦宕机,会影响所有相关依赖服务。
例如使用 Redis 的计数器,原子性自增,好处在于使用内存,并发性能好,但存在数据丢失;自增数据量泄露的问题
雪花算法
Twitter 雪花算法生成后是一个 64bit 的 long 型的数值,默认字符串长度是 19 位,它分为 4 个部分,基本保持了自增
包含四个组成部分
不使用:1bit,最高位是符号位,0 表示正,1 表示负,固定为 0
时间戳:41bit,毫秒级的时间戳(41 位的长度可以使用 69 年)
标识位:5bit 数据中心 ID,5bit 工作机器 ID,两个标识位组合起来最多可以支持部署 1024 个节点(2^10 = 1024 个节点)
如果是分布式应用部署应保证每个工作进程的标识位 id 是不同的
序列号:12bit 递增序列号,表示节点毫秒内生成重复,通过序列号表示唯一,12bit 每毫秒可产生 4096 个 ID
通过序列号 1 毫秒可以产生 4096 个不重复 ID,则 1 秒可以生成 4096 * 1000 = 409w ID
默认的雪花算法是 64 bit,具体的长度可以自行配置。如果希望运行更久,增加时间戳的位数;如果需要支持更多节点部署,增加标识位长度;如果并发很高,增加序列号位数
总结:雪花算法并不是一成不变的,可以根据系统内具体场景进行定制
SnowFlake 算法的优点:
高性能高可用:生成时不依赖于数据库,完全在内存中生成
高吞吐:每秒钟能生成数百万的自增 ID
ID 自增:存入数据库中,索引效率高
SnowFlake 算法的缺点: 依赖与系统时间的一致性,如果系统时间被回调,或者改变,可能会造成 ID 冲突或者重复
适用场景
因为雪花算法有序自增,保障了 MySQL 中 B+ Tree 索引结构插入高性能
所以,日常业务使用中,雪花算法更多是被应用在数据库的主键 ID 和业务关联主键
存在的问题
机器标识位一致
标识位重复的情况下,雪花 ID 也可能会重复,比如:
服务通过集群的方式部署,其中部分机器标识位一致
时钟回拨的问题
为什么会有时钟回拨问题
有人篡改了宿主机的系统时间
集群中可能会进行整体的时钟同步,从而修改机器的本地时间
时钟回拨对雪花算法的影响
如果篡改了本地时间,那就有风险产生重复的 ID,而且无法满足趋势递增了。
解决思路
方案一:想办法探测到时钟回拨,然后做出对应的策略
方案二:探索一种 ID 生成的方式,不完全依靠时间戳来保证雪花算法,或者直接使用别的策略替代时间戳
JS 的坑
值得注意的是,雪花算法在 JavaScript 中有一个坑。后端在返回 ID 的时候,需要使用 String 类型代替 Long 类型,否则会产生预想不到的错误。
这是因为。在 JavaScript 中,存在两种数字。Number 和 BigInt。最常用的,就是 number。
最大的 Number,叫做Number.MAX_SAFE_INTEGER
,它的值为:
2^53-1 或者
+/- 9,007,199,254,740,991
众所周知,Java 中的 Long,是 64 位的。Js 中的这个安全 Integer,完全达不到 Java 中定义的长度。
这就是万恶的IEEE_754
规范,它在 Long 长度大于 17 位时会出现精度丢失的问题。
常见实现方案
百度(uid-generator)
uid-generator
是由百度技术部开发,项目地址:uid-generator
uid-generator
是基于Snowflake
算法实现的,与原始的snowflake
算法不同在于,uid-generator
支持自定义时间戳、工作机器 ID 和序列号等各部分的位数,而且uid-generator
中采用用户自定义workId
的生成策略。
uid-generator
需要与数据库配合使用,需要新增一个WORKER_NODE
表。 当应用启动时会向数据库表中去插入一条数据,插入成功后返回的自增 ID 就是该机器的workId
数据由host
,port
组成。
美团(Leaf)
github 地址:Leaf
美团的Leaf
也是一个分布式 ID 生成框架。它非常全面,即支持号段模式,也支持snowflake
模式。
号段模式:依赖于数据库,但是区别于数据库主键自增的模式。假设 100 为一个号段 100,200,300,每取一次可以获得 100 个 ID,性能显著提高。
评论