一般在分库分表场景,就会有分布式 ID的需求,因为需要有一个唯一标识来标记一个订单或者其他类似的数据等。全局唯一ID有很多种实现,例 UUID,优势就是本地生成,很简单,但它是无序的,如果需要将唯一ID作为主键,则 UUID不合适,首先是太长了,其次无序插入会导致数据页频繁分裂,性能不好。在回答这个面试题的时候可以先提下 UUID,简单说下优缺点 (UUID 的缺点其实侧面能体现出你对 MySQL的了解,这都是小心机呀)
常见的分布式 ID 实现有两种:
1)雪花算法
2)基于数据库
雪花算法
雪花算法有 64bit,实际就用到了 63bit,最前面有一个 1bit 没用,图中就没画了。
其中 41bit是时间戳,10bit是机器ID(可以表示1024台机器,如果有机房的划分,可以把5bit分给机房号,剩下5bit 分给机器)。最后 12bit是自增序列号,12bit 可以表示2的12次方个ID,
可以看到,以时间打头可以保证趋势是有序性,其中分配了机器号避免了多机器之间重复ID 的情况,后面的自增序号使得理论上每台机器每毫秒都可以产生2的12 次方个 ID。
简述优点:
- 分布式 ID 从整体来看有序的。
- 简单、灵活配置,无序依赖外部三方组件。
缺点:
- 依赖时钟,如果发生时钟回拨,可能会导致重复ID。
常见的 hutool 就有提供了雪花算法工具类。
面试官可能会问雪花算法的机器 ID 如何动态配置,因为现在机器都是动态扩容部署,机器数都是不固定的,如果机器 ID 没配置好,容易导致冲突。可以借助 Redis 或者 zookeeper 来实现机器 ID 的分配。redis 的执行是单线程的,机器启动时候调用 redis incr 即可得到自增 id ,可将这个 id 作为机器 ID;zookeeper 的使用也很简单,机器启动时候可以利用 zookeeper 持久顺序节点特性,将注册成功的顺序号作为机器 ID。
基于数据库
对 MySQL 来说,直接利用自增 id 即可实现。
REPLACE INTO table(bizTag) VALUES ('order');
select last_insert_id();将 bizTag 设为唯一索引,可以填写业务值(也可以不同业务多张表),REPLACE INTO 执行后自增 ID会+1、通过 last insert id 即可获得自增的 ID)
优点:简单、利用数据库就能实现,且ID 有序。
缺点:性能不足。
可以利用 auto_increment_increment 和 auto_increment_offset 实现横向扩展
比如现在有两台数据库,auto_increment_increment都设置为2,即步长是2,第一台数据库表 auto_increment_offset 设置为1,第二台数据库表 auto_increment_offset 设置为 2,这样一来,第一台的 ID 增长值就是 1、3、5、7、9.第二台的 ID 增加值就是 2、4、6、8、10。
这样也能保证全局唯一性,多加几台机器弥补性能问题,只要指定好每个表的步长和初始值即可。不过单调递增特性没了,且加机器的成本不低,动态扩容很不方便。
这里我们可以思考下,每次操作数据库就拿一个ID,我们如果一次性拿 1000 个,那不就大大减少操作数据库的次数了吗?性能不就上去了吗?重新设计下表,主要字段如下:
- bizTag: 业务标识
- maxld: 目前已经分配的最大ID
- step: 步长,可以设置为 1000 那么每次就是拿 1000 ,设置成 1w 就是拿 1w 个
每次来获取 ID 的 SQL如下:
UPDATE table SET maxId = max_id + step WHERE bizTag = xxx
SELECT maxId, step FROM table WHERE biz_tag = xxx这其实就是批量思想,大大提升了ID 获取的性能。
到这里可能面试官会追问,假设业务并发量很高,此时业务方一批ID刚好用完后,来获取下一批ID,因为当前数据库压力大,很可能就会产生性能抖动,即卡了一下才拿到ID,从监控上看就是产生毛刺这样怎么处理?
其实这就是考察你是否有预处理思想,如果你看过很多开源组件就会发现预处理的场景很多,例RocketMQ commitlog文件的分配就是预外理,即当前 commitlog 文件用完之前,就会有后台线程预先创建后面要用的文件,就是为了防止创建的那一刻的性能抖动。
同理,这个场景我们也可以使用预处理思想
发号器服务可以本地缓存两个buffer,业务方请求ID每次从其中一个buffer里取,如果这个buffer发现ID已经用了20% (或者另外的数量),则可以起一个后台线程,调用上面的SQL语句,先把下一批的ID 放置到另一个bufer中
当前面那个 buffer ID 都用完了,则使用另一个 buffer 取号,如此循环使用即可,这样就能避免毛刺问题
将 ID 放在本地缓存性能好,即使服务重启了也没事,无非就是中间空了一点点 ID 罢了,整体还是有序的。
一些关键点
雪花算法面试官可能会追问如果时钟回拨了怎么办,理论上可以存储上一次发号的时间,如果当前发号的时间小于之前的发号时间,则说明时钟回拨,此时拒绝发号,可以报警或者重试(重试几次时间可能就回来了)。数
据库方案如果面试官说数据库故障怎么办?理论上数据库故障了其实很多业务都无法执行下去。这属于不可抗柜因素,但是我们有本地缓存,可以将 step 设置大一些,例qps最高时候的600 倍,这样至少有 10分钟的缓冲时间,可能数据库就恢复了,其次数据库可以做主从,但是主从会有复制延迟,导致 maxId 重复,这里可以采取和雪花算法对抗时钟回拔一样的套路,服务记录上次 maxId,如果发现,maxId 变小了,则再执行一次 update.
还有一点,数据库实现的ID是完全连续的,如果用在订单场最,竟对早上下一单,晚上下一单,两单一减就知道你今天卖了多少单,所以很不安全,因此这种|D不适合用在这种场景(再比如文章的id容易被爬虫简单按序一次性爬完)。
这一套如果跟面试官说下来,我相信这场面试你肯定稳了,整体设计没问题,还有很多性能方面的考虑并且还想到了安全性问题。
