日10亿级单据类业务ID发号器的实践经验

111 阅读9分钟

写在前面

新的技术公众号启航,既然是发新号,开篇就从“发号”这件事开始讲起 —— 白龙马在发号器上的一些实践和经验分享给大家。

引言

在分布式环境中,很多地方都需要一个不会重复的唯一标识符,更具体的结合在线交易业务场景(各类订单、流水单等等)来说,单据类ID往往还会运用在DB的分库分表键中。故作为一个业务单据ID,一般来说至少要具备以下几方面特性:

  • 唯一性(废话)
  • 趋势自增(为了DB性能,其实如果能做到单调递增更好,只是会有成本,且存在业务场景能否接受的问题,读者可思考下为什么)
  • 具备业务特定定义标识(具备一定业务语义,例如号段中存在某几位代表业务类型,某几位代表时间,某几位代表用户的分桶key等等)
  • 安全性(即此ID没有明显规律,号码不连续,防止被拉数据) -- PS: 信息安全和严格递增是可以同时达到的,因单调递增并非一定连续,具体反例可以由读者思考下。

本文讲的发号器,就是用来生成这种业务单据唯一ID 的工具。

当前常见的发号器有:

  • UUID(并不满足于以上特性,一般不会采用uuid当做业务单据ID)

    • 实现方式:JDK提供现成的UUID生成方式

        UUID uuid = UUID.randomUUID();
      
    • 优点:简单易用一行代码实现,纯内存操作,生成速度快,无三方网络消耗

    • 缺点:

      • 可读性差,通常是一长串字符串
      • 占用空间大,其是一个128位的字符串
  • 雪花算法

    • 实现方式:一般采用41位时间戳+10位机器id+12位序列号生成(具体想怎么设计都可以,只要保证业务在单位时间内需要生成的号码量在对应时间范围内单台机器生成的序号不会越界即可)
    • 优点:算法简单,易于实现,本机计算性能非常高,可随意扩缩容
    • 缺点:
      • 依赖时间戳,如服务器时间回退,可能会生成重复id
      • 可能会暴露机器部分信息(IP、Mac地址等)
  • 基于中间件实现

    • 实现方式:任意高可用的提供了原子自增能力的中间件(Redis、ZK、Mysql)

    • 优点:可保证唯一号严格单调递增

    • 缺点:

      • 存在IO调用,性能受中间件限制
      • 稳定性依赖中间件

白龙马的业务单据发号器

针对实际业务对稳定性的高要求,在单据ID生成上,必然不会选择uuid或者单机雪花算法这类存在业务风险的ID生成方式(业务稳定永远是第一位),留给我们的只剩下基于中间件的ID发号器方案了。

但基于中间件的方案依然存在的问题是需要被解决的:

  • 性能问题
  • 稳定性问题

白龙马在实践上使用了Mysql作为发号器的中间件,接下来,就是这两个问题的具体解法了。

  • 若mysql一次IO仅生成一个业务ID的性能较实际业务诉求差距极大,需要跨量级的增加生成ID的速度。
  • mysql存在单点问题,即使采用主备方案,在主出现异常时造成的主备切换,依然有短暂的不可用时间(一般30S内),在业务上会存在影响。

(以下内容仅介绍白龙马实践的经验,本质上是一类可行的方案,具体在其他业务场景中如何选型和设计方案,需各位读者结合实际场景衡量)

1. 数据库性能问题

性能上基于数据库每次递增均为一批号段的设计思路,每次IO并非从数据库中获取单个 ID,而是一个连续的号段区间。该号段被加载至内存后,后续的 ID 分配将直接在内存中进行,从而大幅减少了与数据库的交互次数,提升系统性能。

  • 其具体实现如下:在数据库表中维护当前已分配的最大 ID 值。服务在启动时会从数据库加载一个号段区间,并在内存中维护两个关键值——当前已发放的 ID 值 和 该号段内最大可使用的 ID 值。

  • 每次请求生成唯一 ID 时,服务会在独立进程中通过加锁机制确保并发安全,逐一分配唯一的 ID。如果当前使用的 ID 值达到该号段的最大可使用值时,服务会再次访问数据库,申请并加载一个新的号段,从而继续提供 ID 发放服务。

    • 图解详细见下 image.png
  • 上述方案中,尽量有效减少了与数据库的交互频率,但在内存中的号段耗尽时,仍需向数据库发起一次新的申请。此时可能会出现短暂的延迟尖刺,其最大耗时通常取决于数据库的响应时间,可能对发号性能造成一定影响。

  • 常用的解决方案,预加载思路,在系统快要发放完号码时异步去获取下一号段,具体实现见下

    • 在启动异步线程轮询监听内存中号段使用情况,当使用了80%时,提前获取号段 image.png
  • 上述思路中还存在比较明显的短板,步长1000是固定的,以及监听线程固定每隔1秒检测,但我们面对的流量是不固定的,当流量增长10倍,内存中的号段很快会使用完成,来不及预加载,此时依然会导致数据库压力上升,出现RT波动。

  • 为了解决这个问题,引入两个概念根据流量大小可自适应调整步长及检测时间

    • 动态调整step步长

      • 假设服务QPS为Q,内存号段长度有L,号段更新周期为T,那么 Q*T = L。
      • 目前现状:号段长度L为固定值,随着QPS越来越高,T会越来越小。但是我们希望T是固定,也就是说需要随着QPS的增长,步长L可以动态调整。所以:可以根据上一次更新号段的周期T和号段长度L,来确定下一次号段长度值nextStep。
      • 如:
        • T<=10秒,nextStep = step*2
        • 10秒<T<=10min,nextStep = step
        • T>10min,nextStep = step/2
      • 注意边界问题:step不宜过大,如果过大当机器重启时,会造成号段池的浪费,可设定最大步长,最小步长,如:maxStep=20000、minStep=500
    • 动态调整监听线程的执行时间

      • 预加载号段有两个触发时机

        • 发号时:内存中号段不足触发预加载,剩余不足20%触发
        • 监听线程:每隔X秒检测触发预加载,剩余不足30%触发
      • 在发号时触发预加载可能会存在多个线程同时触发,我们更期望预加载是由单点监听线程去加载,具体实现:image.png

      • 注意边界问题:轮询时间不宜过大或过小,过大会造成预加载号段不及时,过小对服务机器存在压力,如:maxTime=5000ms、minTime=200ms

2. 数据库单点问题

  • 引入多套数据源,使整体服务出现故障的概率降低为(1-Mysql SLA)的N次方(假设mysql的sla为99999,则2套数据源将使发号器服务的整体SLA提升为10个9),在引入多套数据源的情况下,需要解决的核心问题是:如何确保不同数据源所分配的 ID 不发生冲突。为此,可采用如下策略进行控制:
    • 通过对号段值与数据源总数进行取模运算(即 ID % 数据源总数),并将结果与当前数据源的编号进行比对。只有当余数等于该数据源编号时,该 ID 才允许被该数据源使用;否则舍弃该值,继续尝试下一个 ID。通过这种方式,可以实现多个数据源之间 ID 分配的互不重叠,从而保证全局唯一性。
    • 具体图解见下:假设配置了3套数据源,使用号值时采用轮询机制db-01、db-02、db-03image.png
    • 自动切换容错:当从某一数据源获取号段发生异常时,系统将触发熔断机制,暂停对该数据源的使用。同时,后台异步线程将持续监听该数据源的状态,并定期尝试重新获取号段。一旦成功获取到号段,表明该数据源已恢复可用,系统将自动将其重新加载至内存,并恢复其正常发号功能。

方案总结

  • 基于数据库实现的唯一 ID 发号器,要实现高并发和高可用,主要依赖以下设计策略:

    • 高并发保障
      • 号段获取机制:每次不是从数据库中获取单个 ID,而是批量获取一个连续的 ID 号段,并缓存在内存中,用于后续快速分配。
      • 预加载机制:在当前号段即将用尽前,提前触发下一次数据库请求,加载新的号段,避免因等待数据库响应而导致阻塞。
      • 动态自适应调节:根据实际的请求流量,自动调整号段的步长大小以及监听检测频率。在高流量时增大号段,降低数据库压力;低峰期则减小号段,节省资源。
    • 高可用保障
      • 多数据库实例部署:配置多个独立的数据源(如主从数据库或跨机房部署),防止单点故障导致服务中断。
      • 自动切换容错:当其中一个数据源不可用时,系统会自动标记该数据源为不可用状态,并暂时停止从该数据源获取服务。一旦数据源恢复正常,系统将自动重新启用该数据源,恢复其正常服务。

白龙马ID-Generater

目前,白龙马采用基于 3DB 架构的分布式发号器系统,实现了理论高达 15 个 9 的 SLA 稳定性保障,日均可支撑 10 亿次级别的发号请求。经过实际压测,在 4C8G 单机QPS约5万,TP999 延迟稳定在 1ms 以内,且发号器场景支持了多业务自定义号段编码的能力,可以满足当前绝大部分场景的使用。