一种简易但设计全面的ID生成器思考

本文涉及的产品
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
云数据库 RDS MySQL Serverless,0.5-2RCU 50GB
简介: 一种简易但设计全面的ID生成器思考

分布式系统中,全局唯一 ID 的生成是一个老生常谈但是非常重要的话题。随着技术的不断成熟,大家的分布式全局唯一 ID 设计与生成方案趋向于趋势递增的 ID,这篇文章将结合我们系统中的 ID 针对实际业务场景以及性能存储和可读性的考量以及优缺点取舍,进行深入分析。本文并不是为了分析出最好的 ID 生成器,而是分析设计 ID 生成器的时候需要考虑哪些,如何设计出最适合自己业务的 ID 生成器。

项目地址: https://github.com/JoJoTec/id-generator



image.png


首先,先放出我们的全局唯一 ID 结构:



image.png


这个唯一 ID 生成器是放在每个微服务进程里面的插件这种架构,不是有那种唯一 ID 生成中心的架构:

  • 开头是时间戳格式化之后的字符串,可以直接看出年月日时分秒以及毫秒。由于分散在不同进程里面,需要考虑不同微服务时间戳不同是否会产生相同 ID 的问题。
  • 中间业务字段,最多 4 个字符。
  • 最后是自增序列。这个自增序列通过 Redis 获取,同时做了分散压力优化以及集群 fallback 优化,后面会详细分析。


image.png


序列号的开头是时间戳格式化之后的字符串,由于分散在不同进程里面,不同进程当前时间可能会有差异,这个差异可能是毫秒或者秒级别的。所以,要考虑 ID 中剩下的部分是否会产生相同的序列

自增序列由两部分组成,第一部分是 Bucket,后面是从 Redis 中获取的对应 Bucket 自增序列,获取自增序列的伪代码是:

1. 获取当前线程 ThreadLocal 的 position,position 初始值为一个随机数。
2. position += 1,之后对最大 Bucket 大小(即 2^8)取余,即对 2^8 - 1 取与运算,获取当前 Bucket。
   如果当前 Bucket 没有被断路,则执行做下一步,否则重复 2。
   如果所有 Bucket 都失败,则抛异常退出
3. redis 执行: incr sequence_num_key:当前Bucket值,拿到返回值 sequence
4. 如果 sequence 大于最大 Sequence 值,即 2^18, 对这个 Bucket 加锁(sequence_num_lock:当前Bucket值),
   更新 sequence_num_key:当前Bucket值 为 0,之后重复第 3 步。否则,返回这个 sequence
-- 如果 3,4 出现 Redis 相关异常,则将当前 Bucket 加入断路器,重复步骤 2

在这种算法下,即使每个实例时间戳可能有差异,只要在最大差异时间内,同一业务不生成超过 Sequence 界限数量的实体,即可保证不会产生重复 ID

同时,我们设计了 Bucket,这样在使用 Redis 集群的情况下,即使某些节点的 Redis 不可用,也不会影响我们生成 ID



image.png


当前 OLTP 业务离不开传统数据库,目前最流行的数据库是 MySQL,MySQL 中最流行的 OLTP 存储引擎是 InnoDB。考虑业务扩展与分布式数据库设计,InnoDB 的主键 ID 一般不采用自增 ID,而是通过全局 ID 生成器生成。这个 ID 对于 MySQL InnoDB 有哪些性能影响呢?我们通过将 BigInt 类型主键和我们这个字符串类型的主键进行对比分析。

首先,由于 B+ 树的索引特性,主键越是严格递增,插入性能越好。越是混乱无序,插入性能越差。这个原因,主要是 B+ 树设计中,如果值无序程度很高,数据被离散存储,造成 innodb 频繁的页分裂操作,严重降低插入性能。可以通过下面两个图的对比看出:

插入有序


微信图片_20220625163919.jpg


插入无序


微信图片_20220625163934.jpg


如果插入的主键 ID 是离散无序的,那么每次插入都有可能对于之前的 B+ 树子节点进行裂变修改,那么在任一一段时间内,整个 B+ 树的每一个子分支都有可能被读取并修改,导致内存效率低下如果主键是有序的(即新插入的 id 比之前的 id 要大),那么只有最新分支的子分支以及节点会被读取修改,这样从整体上提升了插入效率

我们设计的 ID,由于是当前时间戳开头的,从趋势上是整体递增的。基本上能满足将插入要修改的 B+ 树节点控制在最新的 B+ 树分支上,防止树整体扫描以及修改


image.png


和 SnowFlake 算法生成的 long 类型数字,在数据库中即 bigint 对比:bigint,在 InnoDB 引擎行记录存储中,无论是哪种行格式,都占用 8 字节。我们的 ID,char类型,字符编码采用 latin1因为只有字母和数字),占用 27 字节,大概是 bigint 的 3 倍多。

  • MySQL 的主键 B+ 树,如果主键越大,那么单行占用空间越多,即 B+ 树的分支以及叶子节点都会占用更多空间,造成的后果是:MySQL 是按页加载文件到内存的,也是按页处理的。这样一页内,可以读取与操作的数据将会变少。如果数据表字段只有一个主键,那么 MySQL 单页(不考虑各种头部,例如页头,行头,表头等等)能加载处理的行数, bigint 类型是我们这个主键的 3 倍多。但是数据表一般不会只有主键字段,还会有很多其他字段,其他字段占用空间越多,这个影响越小
  • MySQL 的二级索引,叶子节点的值是主键,那么同样的,单页加载的叶子节点数量,bigint 类型是我们这个主键的 3 倍多。但是目前一般 MySQL 的配置,都是内存资源很大的,造成其实二级索引搜索主要的性能瓶颈并不在于此处,这个 3 倍影响对于大部分查询可能就是小于毫秒级别的优化提升。相对于我们设计的这个主键带来的可读性以及便利性来说,是微不足道的


image.png


业务上,其实有很多需要按创建时间排序的场景。比如说查询一个用户今天的订单,并且按照创建时间倒序,那么 SQL 一般是:

## 查询数量,为了分页
select count(1) from t_order where user_id = "userid" and create_time > date(now());
## 之后查询具体信息
select * from t_order where user_id = "userid" and create_time > date(now()) order by create_time limit 0, 10;

订单表肯定会有 user_id 索引,但是随着业务增长,下单量越来越多导致这两个 SQL 越来越慢,这时我们就可以有两种选择:

  1. 创建 user_id 和 create_time 的联合索引来减少扫描,但是大表额外增加索引会导致占用更多空间并且和现有索引重合有时候会导致 SQL 优化有误。
  2. 直接使用我们的主键索引进行筛选:
select count(1) from t_order where user_id = "userid" and id > "210821";
select * from t_order where user_id = "userid" and id > "210821" order by id desc limit 0, 10;

但是需要注意的是,第二个 SQL 执行会比创建 user_id 和 create_time 的联合索引执行原来的 SQL 多一步 Creating sort index 即将命中的数据在内存中排序,如果命中量比较小,即大部分用户在当天的订单量都是几十几百这个级别的,那么基本没问题,这一步不会消耗很大。否则还是需要创建 user_id 和 create_time 的联合索引来减少扫描。

如果不涉及排序,仅仅筛选的话,这样做基本是没问题的。


image.png


我们不希望用户通过 ID 得知我们的业务体量,例如我现在下一单拿到 ID,之后再过一段时间再下一单拿到 ID,对比这两个 ID 就能得出这段时间内有多少单。

我们设计的这个 ID 完全没有这个问题,因为最后的序列号:

  1. 所有业务共用同一套序列号,每种业务有 ID 产生的时候,就会造成 Bucket 里面的序列递增。
  2. 序列号同一时刻可能不同线程使用的不同的 Bucket,并且结果是位操作,很难看出来那部分是序列号,那部分是 Bucket。


image.png


从我们设计的 ID 上,可以直观的看出这个业务的实体,是在什么时刻创建出来的:

  • 一般客服受理问题的时候,拿到 ID 就能看出来时间,直接去后台系统对应时间段调取用户相关操作记录即可。简化操作。
  • 一般的业务有报警系统,一般报警信息中会包含 ID,从我们设计的 ID 上就能看出来创建时间,以及属于哪个业务。
  • 日志一般会被采集到一起,所有微服务系统的日志都会汇入例如 ELK 这样的系统中,从搜索引擎中搜索出来的信息,从 ID 就能直观看出业务以及创建时间。


image.png


在给出的项目源码地址中的单元测试中,我们测试了通过 embedded-redis 启动一个本地 redis 的单线程,200 线程获取 ID 的性能,并且对比了只操作 redis,只获取序列以及获取 ID 的性能,我的破电脑结果如下:

单线程
BaseLine(only redis): 200000 in: 28018ms
Sequence generate: 200000 in: 28459ms
ID generate: 200000 in: 29055ms
200线程
BaseLine(only redis): 200000 in: 3450ms
Sequence generate: 200000 in: 3562ms
ID generate: 200000 in: 3610ms
相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore     ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库 ECS 实例和一台目标数据库 RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
30天前
|
算法
雪花算法id生成器
雪花算法id生成器
33 0
|
4月前
|
JSON API 开发者
如何使用分类ID参数过滤搜索词推荐数据?
一、背景介绍 阿里巴巴中国站的搜索词推荐数据对于开发者来说具有重要的参考价值。通过使用获得搜索词推荐 API,开发者可以获取到用户在平台上的搜索行为数据,了解用户的需求和行为,优化产品和服务。在获取搜索词推荐数据的过程中,有时候需要对数据进行更精细的过滤和分析。其中,分类ID参数是一个非常重要的过滤条件,可以帮助开发者更好地筛选数据。本文将详细介绍如何使用分类ID参数过滤搜索词推荐数据,帮助读者更好地理解和使用该 API。
|
6月前
|
iOS开发
id的本质
id的本质
32 1
|
7月前
|
存储 算法 NoSQL
​浅谈分布式唯一Id生成器之最佳实践
​浅谈分布式唯一Id生成器之最佳实践
146 1
|
8月前
生成器函数, re中函数的使用,模拟range的功能
1、生成1-10使用next(generator)方法获取1-10 2、使用for循环获取
42 0
|
9月前
|
XML JSON 缓存
Java实现根据CID ID获取1688商品分类数据API方法
Java实现根据CID ID获取1688商品分类数据API方法
105 0
|
10月前
|
算法 Java 数据中心
实现高性能ID生成器:详解Java雪花算法
实现高性能ID生成器:详解Java雪花算法
506 0
|
10月前
|
机器学习/深度学习 存储 算法
MXNet定义计算步骤的方式以及数据流编程和 Symbol
MXNet定义计算步骤的方式以及数据流编程和 Symbol
665 0
|
10月前
|
设计模式 存储 Java
【Java设计模式 规范与重构】 五 重构实战:基于ID生成器case(下)
【Java设计模式 规范与重构】 五 重构实战:基于ID生成器case(下)
174 0
|
10月前
|
设计模式 存储 SQL
【Java设计模式 规范与重构】 五 重构实战:基于ID生成器case(上)
【Java设计模式 规范与重构】 五 重构实战:基于ID生成器case(上)
103 0