【技术设计】如何实现一个高性能的全局唯一 ID 生成服务 (一)

如何实现一个高性能的全局唯一 ID 生成服务(一)


如果觉得对你有帮助,能否点个赞或关个注,以示鼓励笔者呢?!博客目录 | 先点这里

  • 设计准备
    • 需求与目的
    • 设计目的与约束
    • 相关预研
  • ID 生成服务设计
    • 基于 MySQL 的号段机制
    • 数据库设计
    • ID 缓冲区
    • ID 分配流程
    • 客户端分配策略

设计准备


需求与目的

迫切性

  • 满足需要全局 ID 的业务场景
  • 单机架构中的 ID 生成策略难以满足分布式系统的 ID 标识需求

需求

  • 一次支持生成单个/多个唯一 ID
  • 支持设置 ID 初始值
  • 支持多业务场景使用
  • ID 趋势递增,区间有序

设计目标与约束
  • ID 标识满足基本两个特性
    • 唯一性
    • 趋势有序性
  • ID 生成服务需要满足以下特性
    • 支持多业务场景,可根据 tag 隔离 ID 生产线,互不干扰
    • 至少保障 99.9 的服务可用率
    • 在高性能,高并发场景下,具有优秀的 ID 生成速率
    • 支持水平扩展

相关预研

由上文设计准备的需求和目标可知,我们需要一个满足 “可水平扩展”,“支持高并发高性能”,“ID 趋势有序”,“实现简单,维护成本低” 的 ID 生成方案,而

常见方案

  • 关系型数据库自增
    • 优点
      • ID 有序
      • 实现简单,可用性高
    • 缺点
      • ID 生成速率天花板低
      • 无法水平扩容 ❌
  • 关系型数据库号段
    • 优点
      • ID 趋势有序
      • 实现成本低,可用性高
      • 可支撑较大速率的 ID 生成
      • 支持水平扩展
    • 缺点
      • 机器重启,会丢失号段,造成浪费
  • Redis 自增
    • 优点
      • ID 趋势有序
      • 基于内存计算,可支撑较大速率的 ID 生成
      • 支持水平扩展
    • 缺点
      • 需要设计持久化方案,否则会丢失数据,造成重复 ❌
  • UDID
    • 优点
      • 实现简单,可单机实现
      • 可支撑较大速率的 ID 生成
    • 缺点
      • ID 无序 ❌
  • 雪花算法
    • 优点
      • ID 趋势有序
      • 可支撑较大速率的 ID 生成
    • 缺点
      • 时间回拨
      • 获取机器号
      • 实现复杂,依赖较多中间件 ❌

综上对比,关系型数据库号段方案目前是落地性价比比较优秀的方案,唯一的缺点仅仅是在机器重启时,会丢失号段,造成资源浪费。这在常见的业务场景都是可接受的


ID 生成服务设计


基于 MySQL 的号段机制

在设计前准备中,我们述说了很多关于全局 ID 设计选型的方案,最终经过对比和比较,选择了一个落地性比较强的基于 “关系型数据库号段” 的方案,那么这个基于号段的方案到底是怎么样的呢?

号段方案?
发号段的思想说白了就是基于 “关系型数据库 ID 自增方案” 的升级版或优化版,采用了分而治之的思想。我们可以这么理解,假设关系型数据库自增方案只能支撑每秒1000 个 ID 的生成,那么瓶颈就在于数据库的读写性能上。那我们就会想,有什么办法可以降低数据库的读写性能,还能提供相同或是更强的 ID 生成速率呢?

这就是号段方案要去解决的问题,假设数据库的每个写操作从只能提供一个 ID,升级到提供 1000 个 ID, 那么原先的 ID 生成速率将从 1000/s 提升到 1000000/s,这将是一个巨大的提升,基本上满足市面上大多的分布式 ID 的要求。

那么如何降低 IO ,提升 ID 的生成速率呢?只要我们将生成一个 ID 的行为变成生成一段 ID ,然后将分配 ID 的操作放在应用层上实现。即把数据库性能瓶颈的部分压力释放,把分配 ID 的工作交给应用层上水平扩容去保证生成速率。

怎么做到的?
设计核心就是在关系型数据库中持久化一张记录表,这个表的主要作用就是记录当前的 ID 号段分配到哪了?
在这里插入图片描述

客户端每次都会根据这张记录表,获取一块独立无二的号段,即每个客户端的号段都不相同。然后客户端再根据自己获取到的号段,进行真实的 ID 的分配。
在这里插入图片描述
因为每个客户端可分配的 ID 都处在不同的段落,如图,所以我们只要保证客户端不会拿到相同的号段,那么就不可能出现 ID 相同的情况。那么自然也满足了全局 ID 的唯一性和趋势增长的特性,同时在降低读写 IO 的情况下,大幅提升了 ID 的生成速率

以上我们了解了号段机制的基本思想,后续我们将会分享一些具体的实现细节


数据库设计

首先整个 ID 生成服务核心只需要一张表 IDGen 即可实现,即 ID 生产表 ,也就是上面所说的记录表,当然叫什么不重要。它的作用就是存储 ID 生产线的元数据内容。怎么理解呢?举个通俗的粟子,记录某条生产线在 为谁生产从什么位置开始生产当前生产到了那, 每批次生产多少

/**
 * @author snailmann
 */
@Data
@Entity
@Table(name = "IDGen", indexes = {@Index(name = "tag", columnList = "tag", unique = true)})
@EntityListeners(AuditingEntityListener.class)
public class IDGen {

  /**
   * ID
   */
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  /**
   * 业务组
   */
  @Column(columnDefinition = "VARCHAR(64) NOT NULL DEFAULT 'GLOBAL'")
  private String tag;

  /**
   * 步长
   */
  @Column(columnDefinition = "INT(11) NOT NULL DEFAULT '100'")
  private Integer step;

  /**
   * 初始 ID
   */
  @Column(columnDefinition = "BIGINT(20) NOT NULL DEFAULT '1'")
  private Long sid;

  /**
   * 当前可分配的极限 ID(不包含)
   */
  @Column(columnDefinition = "BIGINT(20) NOT NULL DEFAULT '1'")
  private Long mid;

  /**
   * 乐观锁版本
   */
  @Column(name = "`version`", columnDefinition = "BIGINT(20) NOT NULL DEFAULT '1'")
  private Long version;

  /**
   * 编辑者
   */
  @Column(columnDefinition = "VARCHAR(64) NOT NULL DEFAULT 'SYSTEM'")
  private String editor;

  /**
   * 记录创建时间
   */
  @CreatedDate
  private Date creation;

  /**
   * 记录修改时间
   */
  @LastModifiedDate
  private Date modification;

}
CREATE TABLE `IDGen` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `tag` varchar(64) NOT NULL DEFAULT 'GLOBAL',
  `creation` datetime(6) DEFAULT NULL,
  `maxId` bigint(20) NOT NULL DEFAULT '1',
  `version` bigint(20) NOT NULL DEFAULT '1',
  `modification` datetime(6) DEFAULT NULL,
  `startId` bigint(20) NOT NULL DEFAULT '1',
  `step` int(11) NOT NULL DEFAULT '1000',
  PRIMARY KEY (`id`),
  UNIQUE KEY `tag` (`tag`)
) ENGINE=InnoDB AUTO_INCREMENT=1

如上就是 IDGen 表的全部信息,非常简单, 但是我们还是要过一下核心的字段

  • tag 业务组
    • 作用让服务可以支撑多种业务场景,隔离各自所需的 ID 生产线
  • sid 初始 ID
    • 让业务方自定义 ID 生产线的初始 ID , 比如有的业务方需要从 1 开始递增,有的业务方需要从 100000 开始递增
  • mid 可分配的最大 ID (非包含)
    • mid 实际就是下一个号段区间的最小值(包含)[mid, mid + step),当前区间的最大值 (不包含) [mid - step, mid)
  • step 步长
    • 步长就是每一个号段的大小,即号段是以一个 step 为单位,来分配下一个号段的

我们了解上面的核心字段, 我们就来过一下发号段的过程, 假设有一条 ID 生产线配置,如 tag = test, sid = 1, mid = 11, step = 10, 那么简简单单的几个属性,它是如何与号段机制相联系起来的呢 ?

(一)根据配置,先确认什么意思?

  • tag = test 代表该生产线属于 test 业务线,在 test tag 下,所生产的 ID 都是唯一并趋势递增的
  • sid = 1 代表这条 ID 生产线的初始值是 1, 从 1 开始递增
  • mid = 11 代表当前可以分配的号段范围是 [mid-step, mid), 即 [1, 11)
  • step = 10 代表每个号段的大小是 10 , 即相隔 10 个 ID 会构成一个新的号段

(二)根据配置,我们先分配第一个号段,第一个号段是什么?

  • 首先判断该生产线是否第一次分配号段,怎么判断?
    • 如果 mid - step = sid 为真,那么属于第一次分配号段,第一个号段则持有 [1,11) 的号段生产所有权
    • 如果 mid - step = sid 不为真,那么不属于第一次分配号段,比如分配第二个号段时,IDGen 配置已变成 tag = test, sid = 1, mid = 21, step = 10, 那么此时分配第二号段,第二号段则拥有 [11,21) 之间号码的生产所有权

(三)当配置为 tag = test, sid = 1, mid = 1001, step = 10 时,当前可分配的号段的 ID 区间是?

  • mid = 1001, step = 10 所以当前可以分配的号段区间是 [991,1001)

相信上面的描述已经足够清晰明了,我们就切到下一主题


ID 缓冲区

由数据库的设计可知,我们需要根据 IDGen 的记录信息,才能知道某条业务线,当前号段分配到哪?下一个号段分配什么?那么这就引出了几个疑问,号段要分配给谁? 什么时候分配? 在此,我们引出了 ID 缓冲区 的概念,来回答大家的疑问

什么是 ID 缓冲区?
首先回答第一个问题,号段分配给谁?答案就是分配给 ID 缓存区ID 缓存区 就是用于承接经过 IDGen 分配出现的号段实体,用于存放当前分配到的号段。

那么 ID 缓存区 获取到分配的号段后,又有什么功能?可以承担什么样的能力?

  • 具备分配 ID 能力,可提供方法供使用方使用
  • 具备号段监控能力,知道号段资源什么时候消耗完了,知道当前号段消耗到什么部分
  • 具备自主加载号段的能力,并在当前号段消耗完后,可自主加载下一批号段

IDBuffer 的定义 ?

为了满足以上的几种能力,那么 IDBuffer 是怎么实现的呢?我们先来看下 IDBuffer 的定义

/**
 * @author snailmann
 */
@Slf4j
public class IDBuffer

  private String tag;
  private final Segment[] segments = new Segment[]{new Segment(), new Segment()};
  private volatile int cpos = 0;
  private volatile boolean ready = false;
  private IDBFFactory idbfFactory;

  public IDBuffer(String tag, IDBFFactory idbfFactory) {
    log.info("build id buffer, tag: {}", tag);
    this.idbfFactory = idbfFactory;
    this.tag = tag;
    allocSegment(this.cpos);
    this.ready = true;
  }
...
}
  • tag 我们需要知道该 IDBuffer 属于哪个业务场景?这样才能知道 “我” 应该为谁生成 ID
  • Segment ID 缓存区中具体承接号段的实体,一个 IDBuffer 中具有两个 Segment
  • cpos 当前 IDBuffer 正在使用的 Segment 的索引
  • ready 当前 IDBuffer 是否准备就绪?
  • idbfFactory ID 缓冲区工厂,获取 ID 生产信息和获得下一个号段

好的,那么了解完 IDBuffer 的定义,我们再看一下号段 Segment 的数据结构

Segment

/**
 * id section [min,max)
 *
 * @author snailmann
 */
@Data
@NoArgsConstructor
public class Segment {

  /**
   * offset
   */
  private AtomicLong offset;

  /**
   * min id
   */
  private Long min;

  /**
   * max id (no contains)
   */
  private Long max;

  /**
   * step
   */
  private Integer step;

  /**
   * consumed or uninitialized
   */
  private boolean ready = false;
  private Date updateTime;

  /**
   * get id
   *
   * @return id
   */
  public Long offsetAndIncr() {
    return offset.getAndIncrement();
  }

  /**
   * score20
   */
  public Long offsetOfScore20() {
    return this.min + this.getStep() / 4;
  }

  /**
   * idle size
   */
  public Integer idle() {
    return (int) (this.max - this.min);
  }
}

Segment 就是 IDBuffer 中存储 ID 号段的实体实现,用于存储即将被分配的一串 ID 号,每个 Segment 的 ID 区间范围都在 [mid - step, mid) 之间。

刚开始也许我们不好理解字段的含义,那么我们先思考一个问题,如何简单的表示一串 ID 号码,比如简单的使用一个数组去表示 [mid - step, mid - step + 1, mid - step + 2, ... , mid) ,当然现在有更节省空间,更加优秀的方式去实现,我们又何乐而不为呢?

  • mid 属于当前 Segment 的最大值 (不包含)
  • offset 即当前 Segment 的偏移量,初始值是 mid - step, 在调用 offsetAndIncr() 的过程中,会逐渐自增,
  • step 即每个 Segment 的大小

即通过 mid, offset,step 三个字段,我们就可以代替存放 ID 号段的数组实现啦

为什么需要两个 Segment ?

ID 缓冲区的双 Segment 设计参考美团 Leaf 和滴滴的 TinyID,属于技术优化的方案。优点是可以让 ID 分配在高并发环境下变得更加平稳顺滑,缺点就是需要预分配多一段号段,当机器重启时,会造成多一段号段的浪费
在这里插入图片描述

  • ID 缓存区由两个 Segment 组成,每个 Segment 将保留一个完整的号段;如某个业务组 tag 的初始 ID 为 1,step 步长为 10 ,那么 ID 环的 Segment[0] 将预分配 [0,10] 的号段,Segment[1] 将预分配 [11,20] 号段

  • ID 生成将在 IDBuffer 中生成,ID 的具体生成则由 IDBuffer 的其中的一个 Segment 分配,并有且同时只有一个 Segment 处于分配状态

  • ID 缓存区的两个 Segment 互为备份,在 Segment[0] 号段将 ID 分配完毕后,将由 Segment[1] 继续分配,以此循环

  • ID 缓存区初始化阶段,只有一个 Segment 分配号段,只有处于分配状态的 Segment 的 ID 分配数达到其所持 ID 数的 20%,才会触发后置 Segment 的加载


ID 分配流程

(一)获取 ID

首先我们从 IDBuffer 的 nextId() 入口开始分析流程,因为这是 ID 缓冲区提供 ID 的入口

  /**
   * 获得 ID
   */
  public long nextId() {
    while (true) {
      final Segment segment = avaliableSegment();
      Long offset = segment.offsetAndIncr();
      Long max = segment.getMax();
      
      // double check
      Long score20 = segment.offsetOfScore20();
      if (!nextSegment().isReady() && offset >= score20) {
        synchronized (this) {
          if (!nextSegment().isReady()) {
            allocSegment(nextPos());
          }
        }
      }

      if (offset < max) {
        return offset;
      } else {
        // id resources are exhausted
        segment.setReady(false);
      }
    }
  }

步骤

  • 第一步,调用 avaliableSegment () 方法得到一个可用的 Segment
    • 什么是可用状态?即 segment 是 ready 的,可以对外分配 ID 的
  • 第二步,从可用的 Segment 中分配 ID ,获得本次调用需要返回的 ID 结果
  • 第三步,判断当前 Segment 分配的 ID 数已经超过了号段的百分之 20 ?
    • 如果超过了,则触发是否加载另一个号段
    • 如果没有超过,则跳过
  • 第四步,判断当前获得的 ID 是否小于当前 Segment 的 ID 最大值 mid
    • 如果 offset < mid , 则认为该 ID 可被当前 Segment 生产出来,可被返回
    • 如果 offset >= mid , 则认为该 ID 的生产权不属于当前 Segment,属于越界生产,同时证明该 Segment 号段已经消耗完毕,需要获取下一个可用号段

为什么采取 20 分位值?

  • 至于为什么采取 20 分位值,纯属拍脑袋决定。可以根据自己的需求调整 10~50,但不建议过大或过小

为什么要加锁?又为什么要使用双重检查锁?

     if (!nextSegment().isReady() && offset >= score20) {
        synchronized (this) {
          if (!nextSegment().isReady()) {
            allocSegment(nextPos());
          }
        }
      }
  • 为什么要加锁?nextId 是一个处于高并发环境的方法,如果不加锁,会导致多个线程同时预加载 Segment,最终导致分配了多个号段,但是只有一个号段能被使用
  • 为什么采用双重检查锁?因为双重检查锁实现简单,还可以避免大量后续线程进入获取锁的环节,相比直接加锁可以大大提升性能

为什么不异步实现?

  • 当然可以使用异步分配,但是异步分配会增加代码逻辑的复杂度,更加考验实施人的技术功底和维护成本
  • 此处采用异步分配的收益不明显,因为异步分配的核心目的是不阻塞主线程进行 ID 的分配。在维护成本和易读写性的考虑下,最终采取简单的同步方案,牺牲一丢丢的性能,而且同步分配也可以达到相同类似的目的(本文没有实现,在优化环节会提及)

(二)获取可用的 Segment

  /**
   * 获取可用的 Segment
   */	
  public Segment avaliableSegment() {
    Segment segment = segment();

    // alloc segment if current segment no ready
    // under the synchronization mechanism, this problem will not occur
    if (!segment.isReady()) {

      // next segment also no ready, alloc current segment
      if (!nextSegment().isReady()) {
        allocSegment(this.cpos);
      } else { // or switch next segment
        switchPos();
      }
      segment = avaliableSegment();
    }

    return segment;
  }
  

这里采用递归的方式去获取一个可用的 Segment

  • 第一步,我们要获取当前 segments[this.cpos]
  • 第二步,判断当前 segment 是否准备就绪 ready = true
    • 如果 ready = true ,则直接将当前 segment 返回
    • 如果 ready = false, 则进入第四步
  • 第四步,判断下一个 segment 是否准备就绪
    • 如果 ready = true,则代表下一个号段是可用的,则进行 switchPos(), 更换当前 cpos, 进入第五步骤
    • 如果 ready = false,则代表两个 segment 都是不可用的,则直接分配 segment, 进入第五步骤
  • 第五步,递归 avaliableSegment() ,获取可用的 segment

当然这里要注意的是

  • switchPos()allocSegment() 都是线程安全的
  • allocSegment() 是同步分配的

(三)加载 Segment

    IDBean bean = idbfFactory.alloc(tag);

    // alloc new segment object
    Long min = Predicates.trueOr(bean.firstUse(), bean::getSid, bean::minOfSegment);
    final Segment segment = new Segment();
    segment.setXXX();
    ...
   
    this.segments[pos] = segment;

什么时候会触发 Segment 加载号段?

  • IDBuffer 首次初始化时
  • 前置 Segment 消耗完毕时,会触发加载后置 Segment, 以此循环
  • 前置 Segment 消耗到 xx 分位时,会触发加载后置 Segment, 以此循环

allocSegment 做了什么?

  • 从 idbfFactory 获取到下一号段元数据信息,从而更新 Segment

客户端分配策略

ID 生成服务会提供两种生成 ID 的方式

  • 集中分配
  • 本地分配

集中分配
由 ID 生成服务管理 ID 缓冲区,并对外提供 ID 生成,可用性与风险压力皆有 ID 生成服务集中承担

  • 优点:
    • 只有 ID 生成服务重启才会导致号段浪费,号段浪费的可能性更低
    • 实现简单,集中式管理,易排查问题
  • 缺点:
    • 有 HTTP 延迟
    • ID 生成速率较慢

本地分配
由 ID 生成服务对外提供号段,调用服务拿到号段, 在本地分配 ID

  • 优点:
    • 无网络延迟
    • ID 生成速率块,ID 生成速率瓶颈不在于 ID 生成服务
  • 缺点
    • 调用方频繁重启,导致号段接口不稳定,并引起资源浪费

压测报告

压测工具: ngrinder
实例规模: 4 核心 8 G * 1 (AWS ECS Spot 实例)
服务: Java 11, G1, SpringBoot, Tomcat
接口: 集中式分配接口,一次分配一个 ID

60 并发用户, 4 tag, step = 1000

  • TPS = 10000 ,tp avg = 5.5 ms, tp 99 = 20 ms
    在这里插入图片描述

150 并发用户, 4 tag, step = 1000

  • TPS = 10000 ,tp avg = 14 ms, tp 99 = 96 ms
    在这里插入图片描述

300 并发用户, 4 tag, step = 1000

  • TPS = 10000 ,tp avg = 27 ms, tp 99 = 100 ms
    在这里插入图片描述

600 并发用户, 4 tag, step = 1000

  • TPS = 10000 ,tp avg = 57 ms, tp 99 = 100 ms
    在这里插入图片描述
    综上报告,我们可以得出结论,如果我们采用集中式分配的方案,单台 4C8G 实例,一个 SpringBoot Tomcat 服务可以支撑 1w TPS 的吞吐量。如果我们采用本地分配的方案,在步长依然是 1000 的情况下,本地分配 TPS = 集中式分配 TPS * 1000 = 1000 w,基本上可以满足大部分的业务场景

完整的代码可以参考 myid - @github


参考


  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值