跳转到内容

分布式 ID

主键、消息标识、对外短码各需要不同形状的唯一标识。这一章讲四种方案怎么选,以及雪花算法里那几个上线后改不动的配置。

完整 API 与全部配置项见 DistributedIds 包

四种方案

方案类型产物能否反解需要节点配置
SnowflakeSnowflakeIdGeneratorlong,时间有序可提取时间 / 机器码 / 序列号必须WorkerId 全局唯一
SequentialGuidSequentialGuidGeneratorGuid,毫秒级有序可提取时间不需要
NanoIdNanoIdGenerator随机字符串不可不需要
SqidsSqidsEncoder<T>数字的短码可解回原数字(同一实例内)不需要

前三个都实现 IDistributedIdGenerator<TKey>TKeylongGuid),可以注入。Sqids 是编码器不是生成器——它不产生新 ID,只把你已有的数字换个样子。

选型走这条链:

问题选择
主键是 long,想按时间排序、想从 ID 反查生成时间Snowflake
主键是 Guid,只想让索引写入友好SequentialGuid
要一段猜不出来的短串(邀请码、分享链接、外部令牌串),不需要还原NanoId
已有自增/雪花数字 ID,只想对外藏住真实数值和数据量Sqids

只有两个生成器在 DI 里

AddXiHanDistributedIds 只注册 IDistributedIdGenerator<Guid>(SequentialGuid)和 IDistributedIdGenerator<long>(Snowflake)。

NanoId 和 Sqids 的 Options 虽然也绑定了配置节,但没有任何服务消费它们——要用得自己经 IdGeneratorFactory / new SqidsEncoder<T>(...) 构造。

安装与启用

bash
dotnet add package XiHan.Framework.DistributedIds
csharp
[DependsOn(typeof(XiHanDistributedIdsModule))]
public class MyModule : XiHanModule { }

多数应用不用手写这行——XiHanApplicationModuleXiHanDataModuleXiHanEventBusModuleXiHanWorkflowModule 都已经 DependsOn 了它。

启用后拿到的两个默认生成器:

注入类型实现选项来源
IDistributedIdGenerator<Guid>SequentialGuidGeneratorXiHan:DistributedIds:SequentialGuid,未配置时用 SequentialAtEnd
IDistributedIdGenerator<long>SnowflakeIdGenerator基线(WorkerIdBitLength=6SeqBitLength=12WorkerId=1)再由 XiHan:DistributedIds:SnowflakeId 覆盖

基线只是「配置没写这个键时的取值」,写了的键真正生效。

核心用法

注入生成器

csharp
public class OrderService(
    IDistributedIdGenerator<long> idGenerator,
    IDistributedIdGenerator<Guid> guidGenerator)
{
    public long NewOrderNo() => idGenerator.NextId();

    public Guid NewRowId() => guidGenerator.NextId();

    public Task<long[]> PreallocateAsync(int count) => idGenerator.NextIdsAsync(count);

    // 从雪花 ID 反查生成时间
    public DateTime WhenCreated(long orderNo) => idGenerator.ExtractTime(orderNo);
}

同步方法之外还有 NextIdAsync / NextIdStringAsync / NextIdsAsync / NextIdStringsAsync,实现都是 Task.FromResult 包装同步逻辑——它们是为了签名统一,不会带来并发收益。

实体主键自动填充

数据层已经接好了雪花生成器:SqlSugar 的 DataExecuting 事件在插入时调用 TrySetSnowflakeId四个条件同时满足才会写入。

条件不满足时
该列是主键跳过
该列不是数据库自增(IsIdentity跳过,交给数据库
属性类型是 longlong?跳过,Guid 主键不由这里填
当前值是默认值(0 / null)跳过,尊重你手动赋的值

所以 long 主键实体直接 InsertAsync 就有 ID,不用手动 NextId()Guid 主键需要自己注入 IDistributedIdGenerator<Guid> 赋值。

按场景构造 NanoId

csharp
// URL 安全字符集,长度 21
var nano = IdGeneratorFactory.CreateNanoIdGenerator_UrlSafe(21);
string inviteCode = nano.NextIdString();

// 去掉易混字符(1/l/I、0/O/o 等),适合人工抄写
var readable = IdGeneratorFactory.CreateNanoIdGenerator_Safe(12);
string pickupCode = readable.NextIdString();

工厂里还有 _Numeric / _Lowercase / _Uppercase / _Hex / _Custom(alphabet, size)

NanoId 的 NextId() 不是分布式唯一

NextIdString() 才是 NanoId——加密安全随机串。

NextId() 返回的 long(时间戳 << 22) | 进程内序列不含任何节点标识,多实例部署下不同进程会算出相同的值。别拿它当分布式主键。

Sqids 短码

csharp
var encoder = new SqidsEncoder<long>(new SqidsOptions { MinLength = 8 });

string code = encoder.Encode(orderId);      // 对外展示
long[] back = encoder.Decode(code);         // 解回原值,失败返回空数组

// 一次编多个数字(例如 租户 + 主键)
string packed = encoder.Encode(tenantId, orderId);

也有便捷扩展方法 1234L.ToSqid() / code.FromSqidToInt64(),但它们走的是内部静态编码器(默认选项),不读 XiHan:DistributedIds:Sqids 配置节

关键机制

雪花的位布局

64 位从高到低:时间戳 → 数据中心码 → 机器码 → 序列号。

位长来源说明
时间戳TimestampType 决定:Milliseconds 41 位(约 69 年)、Seconds 32 位(约 136 年)存的是「当前时间 − BaseTime」的偏移量
数据中心码DataCenterIdBitLength,默认 5传统雪花模式才参与反解
机器码WorkerIdBitLength,默认 6(即 WorkerId 取值 0–63)必须全局唯一
序列号SeqBitLength,默认基线 12同一时间片内的自增计数

硬约束:WorkerIdBitLength + SeqBitLength ≤ 22,越界抛 ArgumentExceptionWorkerIdBitLength 范围 1–15,SeqBitLength 范围 3–21。

单时间片的实际上限由 MaxSeqNumber 决定

这是最容易误判吞吐的地方。SeqBitLength=12 看上去每毫秒能发 4096 个,但生成器真正用的上限是 MaxSeqNumber默认 63),只有把它显式配成 0 才会回落成 2^SeqBitLength - 1(负数会被选项校验拒绝)。

序列从 MinSeqNumber(默认 5)起步,到 MaxSeqNumber 为止——默认配置下单节点每毫秒约 59 个 ID。要放开就显式配 MaxSeqNumber,取值上限是 2^SeqBitLength - 1

序列耗尽后的行为看 LoopedSequence

取值行为
false(默认)自旋等待下一个时间片,吞吐被限住但 ID 不重复
true序列直接绕回 MinSeqNumber时间片不变

LoopedSequence 会发出重复 ID

回绕时时间戳、机器码都没变,序列号又回到起点——同一毫秒内前后两个 ID 完全相同。除非你清楚自己在做什么,否则保持默认 false

时钟回拨

算法SnowflakeIdType回拨时的行为
雪花漂移(默认)SnowFlakeMethodThread.Sleep(5) 后重试;回拨幅度超过 MaxBackwardToleranceMs(默认 10000)抛异常;重试次数超过 TopOverCostCount(默认 2000)抛异常
传统雪花ClassicSnowFlakeMethod直接抛异常,不等待

两种模式都不会静默发出可能重复的 ID,代价是回拨期间调用方会阻塞或收到异常。生产环境把 NTP 校时配成缓步调整(slew),别让系统时间跳变。

反解的适用范围

ExtractTime / ExtractWorkerId / ExtractSequence / ExtractDataCenterId 是接口的统一签名,但不是每个实现都有意义:

生成器ExtractTimeExtractWorkerIdExtractSequenceExtractDataCenterId
Snowflake(漂移)有效有效有效恒返回 0
Snowflake(传统)有效有效有效有效
SequentialGuid有效恒 0恒 0恒 0
NanoIdNextId()long 有效恒 0有效恒 0

反解用的是当前生成器实例的配置去拆位。配置和生成时不一致,解出来就是错的时间,而且不会报错。

SequentialGuid 的三种排序模式

生成的 Guid = 6 字节毫秒时间戳 + 10 字节加密安全随机数,区别只在时间戳放哪儿、字节序怎么排。

SequentialGuidType时间戳位置适用
SequentialAsString前 6 字节,小端系统上前 4 字节与随后 2 字节各自反转按字符串比较排序的场景
SequentialAsBinary前 6 字节,不反转按二进制排序的数据库
SequentialAtEnd后 6 字节SQL Server 聚集索引,框架默认

有序性只到毫秒

同一毫秒内生成的多个 Guid 之间没有单调计数器,先后顺序由随机部分决定。它保证的是「批量插入落在相邻页」,不是「严格递增」。

另外 ExtractTime 按当前 DefaultSequentialGuidType 拆字节——排序模式改过之后,历史 Guid 的时间解不出来。

Sqids 的短码不跨进程稳定

SqidsEncoder 构造时会用 _options.GetHashCode() 当种子洗牌字母表。SqidsOptions 没有重写 GetHashCode,拿到的是对象引用哈希——每个 encoder 实例、每次进程启动都不一样。

场景是否可靠
同一个 encoder 实例内 EncodeDecode 往返可靠
进程内经扩展方法 ToSqid() / FromSqidToInt64() 往返(走同一个静态实例)可靠
应用重启后解码重启前发出的短码不可靠
两个不同的 encoder 实例互相解码不可靠

不要把 Sqids 短码落库或写进长期链接

短码只适合「本次会话内生成、立刻使用」的一次性展示。需要长期稳定的对外标识,用 NanoId 生成一个真实存在的列,别指望从数字算出来。

另外两点:Encode 不接受负数(抛 ArgumentException);Decode 遇到字母表以外的字符静默返回空数组,扩展方法则返回 0——解码结果要自己判空。

配置

四个配置节,各自独立:

Options配置节
SnowflakeIdOptionsXiHan:DistributedIds:SnowflakeId
SequentialGuidOptionsXiHan:DistributedIds:SequentialGuid
NanoIdOptionsXiHan:DistributedIds:NanoId
SqidsOptionsXiHan:DistributedIds:Sqids

上线前必须想清楚的雪花键(其余键见包文档):

默认上线后可改吗
WorkerId基线 1可改,但每个实例必须互不相同
WorkerIdBitLength6不可改,改了位布局就变了
SeqBitLength基线 12不可改,同上
BaseTime2026-01-01 UTC不可改
TimestampTypeMilliseconds不可改,41 位与 32 位布局不同
SnowflakeIdTypeSnowFlakeMethod不可改,两种算法位段划分不同
MaxSeqNumber63可改,决定单时间片吞吐
MaxBackwardToleranceMs10000可改
json
{
  "XiHan": {
    "DistributedIds": {
      "SnowflakeId": {
        "WorkerId": 1,
        "WorkerIdBitLength": 6,
        "SeqBitLength": 12,
        "MaxSeqNumber": 4095,
        "TimestampType": "Milliseconds"
      },
      "SequentialGuid": {
        "DefaultSequentialGuidType": "SequentialAtEnd"
      }
    }
  }
}

WorkerId 必须逐实例分配

这是最常见的重复 ID 来源

不配置 WorkerId 时,所有实例都拿基线值 1。两个副本在同一毫秒生成,序列号各自从 5 开始——直接撞主键

容器/多副本部署必须给每个实例注入不同的值,例如环境变量:

bash
XiHan__DistributedIds__SnowflakeId__WorkerId=3

有状态副本(StatefulSet 序号)、注册中心分配、或按主机名映射都可以,关键是同一时刻集群内不重号,且实例重建后不要立刻把号让给别人。

超过 64 个节点时别只靠配置绑定

WorkerId 的合法上限由赋值那一刻已生效的 WorkerIdBitLength 决定,而选项是「先落基线位长 6,再套配置」。所以在同一配置节里同时写大 WorkerIdBitLength 和大于 63 的 WorkerId,可能在绑定阶段就抛「工作机器唯一标识必须在 0-63 之间」。

需要更多节点时,用 IdGeneratorFactory.CreateSnowflakeIdGenerator(options) 自己构造选项对象(顺序自己控制)并 Replace 掉默认注册。

BaseTime 与位长为什么不能改

雪花 ID 里存的不是绝对时间,是相对 BaseTime 的偏移量;每一段占多少位由 WorkerIdBitLength / SeqBitLength / TimestampType 决定。

改了什么后果
BaseTime 往后调偏移量整体变小,新 ID 落回历史 ID 已用过的数值区间
BaseTime 往前调新 ID 变大,暂时不撞,但所有历史 ID 的 ExtractTime 全部解错
任一位长 / TimestampType / SnowflakeIdType位段边界移动,新旧 ID 之间既不保证有序也不保证不撞,反解全错

这几个键属于「建库时定好,之后当成常量」。生成器构造时只会校验 BaseTime 不晚于当前系统时间,不会替你发现「和历史数据对不上」。

UseCustomEpoch 目前不起作用

SnowflakeIdOptions.UseCustomEpoch 这个属性存在,但生成器没有读取它——纪元起点始终取 BaseTime。配它没有效果。

替换默认生成器

模块用 AddSingleton 的工厂委托注册(不是 TryAdd),要换实现在自己模块里 Replace

csharp
public override void ConfigureServices(ServiceConfigurationContext context)
{
    context.Services.Replace(ServiceDescriptor.Singleton(
        IdGeneratorFactory.CreateSnowflakeIdGenerator_Classic(workerId: 3, dataCenterId: 1)));
}

自定义算法实现 IDistributedIdGenerator<TKey> 后同样注册即可,接口与内置实现之间没有耦合。

常见问题

现象原因
多实例部署出现重复主键WorkerId 没有逐实例分配,全都是基线值 1
单节点吞吐上不去、插入偶发变慢MaxSeqNumber 默认 63,序列耗尽后自旋等下一毫秒;调大 SeqBitLength 不会自动放开这个上限
开了 LoopedSequence 之后出现重复 ID序列回绕但不换时间片,见上文
抛「时钟回拨太多」回拨幅度超过 MaxBackwardToleranceMs;检查 NTP 是否在跳变式校时
传统雪花模式下回拨直接抛异常ClassicSnowFlakeMethod 不做等待重试,这是预期行为
ExtractDataCenterId 恒为 0漂移模式不反解数据中心,只有 ClassicSnowFlakeMethod 有意义
ExtractTime 解出的时间不对当前配置的 BaseTime / 位长 / TimestampType 与生成时不一致
SequentialGuid 的 ExtractTime 解不出来DefaultSequentialGuidType 改过,字节位置对不上
同一毫秒的 Guid 顺序不严格递增毫秒内由随机部分决定,设计如此
NanoId 的 NextId() 跨实例撞了那个 long 不含节点标识,只有 NextIdString() 是 NanoId
应用重启后旧的 Sqids 短码解不回来字母表种子随 encoder 实例变化
改了 XiHan:DistributedIds:SqidsToSqid() 没变化扩展方法用内部静态编码器,走默认选项
Decode 返回空数组 / FromSqidTo* 返回 0短码含字母表以外的字符,静默失败不抛异常
注入 IDistributedIdGenerator<long> 报未注册模块没有 DependsOn(typeof(XiHanDistributedIdsModule))
注入 NanoId / Sqids 失败它们不在 DI 里,只能自己构造

下一步

Released under The MIT License