snowid-server

module
v0.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: MIT

README

snowid-server

中文 | English

一个 gRPC 服务,把 bwmarrin/snowflake 包了一层, 对外发放全局唯一、大致按时间有序的 64 位 ID。它只做这一件事。

不依赖任何外部组件——不需要 ZooKeeper,不需要 Redis,也不需要数据库。

$ snowid-server --worker-id 0
INFO generator ready worker_id=0 epoch=2020-01-01T00:00:00Z worker_bits=10 step_bits=12 \
     max_workers=1024 max_ids_per_second=4096000 ids_valid_until=2089-09-06T15:47:35Z
INFO serving addr=:50051

$ grpcurl -plaintext -d '{"count": 3}' localhost:50051 snowid.v1.SnowId/Next
{ "ids": ["864842698333356032", "864842698333356033", "864842698333356034"] }

为什么需要它

雪花 ID 是一个 64 位整数:体积只有 UUID 的一半,天然按创建时间排序,做主键很便宜。但生成它 有个前提——每个进程都必须持有一个其它存活进程都不持有的身份。这个约束会渗透进每一个在本 进程内生成 ID 的服务:要么全都改成 StatefulSet,要么全都通过 Redis 协调,要么全都只能跑单副本。

把 ID 生成收拢成一个服务,这个约束就只需要在一个地方解决。其余服务调用 Next 即可,自身 保持无状态。

如果你只在单个进程里发号,那就别用这个服务——直接用 bwmarrin/snowflake,少一层网络、 少一层出错面。

ID 布局

沿用 Twitter 原版雪花的分段方式:

| 未使用(1) | 时间戳(41) | 机房(0) | 机器(10) | 序号(12) |
默认宽度 含义 术语出处
时间戳 41 位 距 epoch 的毫秒数——约 69 年 timestamp
机房 0 位 哪个机房——默认不分机房 Twitter: datacenterId
机器 10 位 该机房内的哪台机器——最多 1024 台 Twitter: workerId
序号 12 位 该毫秒内的第几个——每台机器每毫秒 4096 个 Twitter: sequence(bwmarrin 叫 step

最高位恒为 0,这样在没有无符号整数的语言里 ID 也是正数。序号段固定 12 位(bwmarrin 的默认 值),即 400 万 ID/秒/机器

(机房, 机器) 这一对,就是一个进程的身份。 默认不分机房(机房段 0 位),10 位全归机器。

各段宽度和 epoch 是永久性的。 你发出去的每一个 ID 都是相对它们解码的。发出第一个 ID 之前定好,之后绝不要改——改了会让所有已有 ID 解出错误的时间和错误的来源。

配置

参数 环境变量 默认值 含义
--worker-id SNOWID_WORKER_ID 必填 本机器在其机房内的 ID
--datacenter-id SNOWID_DATACENTER_ID 0 本机房的 ID
--worker-bits 10 机器段宽度。10 位 = 每个机房最多 1024 台机器
--datacenter-bits 0 机房段宽度。0 = 不分机房
--epoch 1577836800000 时间戳零点,Unix 毫秒(默认 2020-01-01 UTC)
--addr :50051 gRPC 监听地址

红线只有一条:任意时刻,不能有两个存活的进程持有相同的 (机房, 机器) 组合。 所以服务 不会去猜 --worker-id——不给就拒绝启动:

$ snowid-server
ERROR startup failed error="--worker-id is required: two live processes sharing an identity issue the same ids"
位数预算:加机房位,就得减机器位

64 位是固定的,最高位恒为 0,所以只有 63 位可分。序号段固定 12 位,剩下的机房段、机器段 和时间戳段在抢同一块地

时间戳位数 = 63 - 机房位数 - 机器位数 - 12

两个位宽参数是相加的,不是「机房从机器里切一块」。 所以只加 --datacenter-bits 而不减 --worker-bits,是在从时间戳里偷位——ID 的可用年限会断崖式下跌:

--datacenter-bits --worker-bits 身份总数 时间戳位数 能用多久
0(默认) 10 1024 41 69.7 年
5 5 32 × 32 = 1024 41 69.7 年 ✅(Twitter 原版)
3 7 8 × 128 = 1024 41 69.7 年
5 10 ← 忘了减 32 × 1024 = 32768 36 2.2 年 ❌ 从 2020 年算早就溢出

最后那行启动就会被拒绝,不会让你带着一个两年就崩的布局跑起来:

$ snowid-server --datacenter-bits 5 --worker-id 2 --datacenter-id 1
ERROR startup failed error="--epoch=1577836800000 is too far in the past:
      36 bits of timestamp hold only 19088h44m36.736s, and 57277h6m24.432s have passed since it"
机房(datacenter)

默认不分机房。要分的话,就是 Twitter 原版的 5/5 分法——注意 --worker-bits 要从 10 减到 5

$ snowid-server --datacenter-bits 5 --worker-bits 5 --datacenter-id 1 --worker-id 2
INFO generator ready worker_id=2 worker_bits=5 step_bits=12 max_workers=32 \
     datacenter_id=1 datacenter_bits=5 max_datacenters=32 ...

32 个机房 × 每机房 32 台机器 = 1024 个身份——总数和默认一样,时间戳也还是 41 位。你是把 那 10 位身份重新划分了,不是凭空多要位。

两个 ID 在底层是「位拼接」,不是「相加」:

identity = (datacenter_id << 机器位数) | worker_id

⚠️ 绝对不能用相加。 机房=1,机器=2机房=2,机器=1 相加都等于 3——两个不同的 进程会拿到同一个身份,同一毫秒内必然发出重复 ID。位拼接才能保证 (机房, 机器) 这个 组合是唯一的。实测这两组的底层身份值分别是 3465

任何一个 ID 溢出自己那几位,就会侵占对方的位,落到别人的身份上。所以服务会拒绝:

$ snowid-server --datacenter-bits 5 --worker-bits 5 --datacenter-id 1 --worker-id 32
ERROR startup failed error="--worker-id=32 is out of range [0,31] for --worker-bits=5"
为什么要做启动校验

bwmarrin 的 Generate() 没有 error 返回,也不做边界检查——它直接把时间戳移位塞进去。所以 位宽配错了它不会报错,而是静默地发出时间戳错误、符号位被污染(负数)、并且在段位回绕后 重复的 ID。

举例:--worker-bits=19 加上固定的 12 位序号,只剩 32 位时间戳 = 49.7 天,而默认 epoch 是 2020 年。这个配置早就溢出了。所以只能在启动时拦下:

$ snowid-server --worker-id 0 --worker-bits 19
ERROR startup failed error="--epoch=1577836800000 is too far in the past:
      32 bits of timestamp hold only 1193h2m47.296s, and 57276h16m11.412s have passed since it"

启动日志里的 ids_valid_until 会告诉你这套布局能用到哪一天。

API

参见 api/proto/snowid/v1/snowid.proto

RPC 说明
Next(count) 返回 count 个 ID,升序。上限 1000,超过返回 INVALID_ARGUMENT
GetLayout() 返回 epoch 和各段宽度,供客户端本地解码 ID

服务同时注册了标准 gRPC 健康检查和 server reflection。

Go 客户端

import "github.com/itmisx/snowid-server/pkg/client"

c, err := client.New(ctx, "dns:///snowid:50051")
defer c.Close()

id, err := c.Next(ctx)                  // 一次 RPC,一个 ID
ids, err := c.NextN(ctx, 500)           // 一次 RPC,500 个 ID

// 解码在本地做,不走网络
fmt.Println(c.Layout().Time(id))          // 生成时刻
fmt.Println(c.Layout().Datacenter(id))    // 出自哪个机房
fmt.Println(c.Layout().Worker(id))        // 该机房内的哪台机器
方法 说明
New(ctx, target, opts...) 拨号并取布局;Close 会关闭这个连接
NewWithConn(ctx, conn) 复用已有连接;Close 不会关闭它
Next(ctx) 一个 ID,一次往返
NextN(ctx, n) n 个 ID。n 可以超过服务端上限,会自动拆成多次调用
Layout() 本地解码用
Identity() / MaxBatch() 对端是哪个 (机房, 机器);服务端单次接受的上限

New 在连接时取一次布局,所以服务端不可达会立刻失败,而不是等到第一次 Next。默认是明文

  • round-robin(配合 deploy/k8s 的 headless Service);你传的 grpc.DialOption 追加在默认值 之后,所以要加 TLS 直接传就行,会自然覆盖掉默认的明文。

客户端不做缓冲,这是刻意的。 每个 Next 就是一次 RPC。要「一次一个 ID」的手感,就 NextN(ctx, 500) 取一批自己发——二十行的事。客户端内部一旦加缓冲,就得决定「服务端死了怎么 办」「客户端关了缓冲里的 ID 还发不发」「ID 在队列里躺久了时间戳过期怎么办」,任何一个判断错了, 代价都比省下的那点往返大得多。(我们试过,四个 bug:进程 panic、永久卡死、两处静默失效。)

解码 ID

在本地解码,不要走网络。 解码只是几次位运算;为了读一个 ID 的时间戳而发起一次往返纯属浪费。 GetLayout 取一次,然后:

unix_milli = (id >> (datacenter_bits + worker_bits + step_bits)) + epoch_unix_milli
datacenter = (id >> (worker_bits + step_bits)) & ((1 << datacenter_bits) - 1)
worker     = (id >> step_bits) & ((1 << worker_bits) - 1)
step       =  id & ((1 << step_bits) - 1)

不分机房时 datacenter_bits = 0,上式里 datacenter 恒为 0,机器段拿到全部宽度——同一套公式 两种情况都对,不用特判。

ID 过 JSON / JavaScript 边界时用字符串。 它们存不下精确的 64 位整数。

部署:多副本下如何保证 ID 唯一

deploy/k8s。核心只有一句话:每个 pod 必须拿到一个不同的 worker ID。

StatefulSet 已经给了你一个天然唯一、重启后稳定的东西——pod 序号。而且 StatefulSet 控制器会 把它写成一个标签(apps.kubernetes.io/pod-index),Deployment 不会写。直接用 downward API 注入即可,服务端一行解析代码都不用写

env:
  - name: SNOWID_WORKER_ID
    valueFrom:
      fieldRef:
        fieldPath: metadata.labels['apps.kubernetes.io/pod-index']
snowid-0  ->  pod-index "0"  ->  SNOWID_WORKER_ID=0
snowid-1  ->  pod-index "1"  ->  SNOWID_WORKER_ID=1
snowid-2  ->  pod-index "2"  ->  SNOWID_WORKER_ID=2

Kubernetes 保证任一时刻最多只有一个 pod 持有某个序号——滚动更新会先终止 snowid-1,再把它 重建为 snowid-1,绝不重叠。所以 worker ID 唯一、且跨重启稳定,不需要任何外部协调

把同一份 spec 当 Deployment 跑,它会 fail-closed:Deployment 不写 pod-index 标签 → downward API 返回空串 → 服务拒绝启动(CrashLoop),而不是编一个 ID 出来。

绝不要去解析主机名。 Deployment 的 pod 名(snowid-7d4b9c5f8-84272)末尾是随机后缀,而 K8s 的后缀字母表里含数字——约 1/853 的 pod 后缀是纯数字,会被当成合法序号解析出来, 于是服务带着一个每次重启都变的随机 ID 欢快启动 → 重复 ID。靠字符串形状区分不了 StatefulSet 和 Deployment。

机房 ID 是集群的属性,不是 pod 的属性,所以从集群自己的配置里来(例如 ConfigMap),同一 集群内所有副本共用一个值。

需要 Kubernetes 1.28+(pod-index 标签在 1.28 是默认开启的 beta,1.32 转 GA)。更老的集群就 自己设 --worker-id——一个 ID 一个 StatefulSet。

单副本已经能发 400 万 ID/秒,所以多副本是为了容灾,不是为了吞吐。

Docker 镜像

镜像托管在 GitHub Container Registry,同时支持 linux/amd64linux/arm64—— docker pull 会自动挑对架构。

$ docker run --rm -p 50051:50051 ghcr.io/itmisx/snowid-server:v0.1.0 --worker-id 0

镜像基于 distroless,只装了一个静态二进制:没有 shell、没有包管理器、以 UID 65532 非 root 运行。

在部署清单里请固定 tag 或 digest,不要用 :latest 两个副本在不同时刻拉 :latest 可能跑上不同的二进制,而 ID 布局是永久性的——所有副本必须永远对它保持一致。:latest 只是为了让你 docker run 时方便。

不跑容器的话,Releases 里有各平台的二进制 (linux / macOS × amd64 / arm64),静态链接,解开即用。

发布

打个 tag 就行,不需要配任何 secret——用的是内置的 GITHUB_TOKEN

git tag v0.1.0
git push origin v0.1.0

release.yml 会依次:

  1. 先跑测试——tag 绝不会指向一棵没绿过的树
  2. 用 buildx 构建 linux/amd64 + linux/arm64(Go 交叉编译,不走 QEMU 模拟,快一个数量级)
  3. 推到 ghcr.io/itmisx/snowid-server,打上 v0.1.0 / 0.1 / 0 / latest / sha-<commit>
  4. 把两个架构的镜像都拉回来真的跑一遍——能构建 ≠ 能启动(那个 USER nonroot 的 bug 就是 构建得好好的、pod 永远起不来)
  5. 生成 SBOM 和 build provenance 证明,可用 gh attestation verify 校验来源
  6. 最后才创建 GitHub Release,附上四个平台的二进制和 checksums.txt——一个指向拉不下来的 镜像的 Release,比没有 Release 更糟

本地想先验证多架构能不能构建:

make docker-multiarch

首次发布后,镜像默认是 private。 GitHub 不会让它继承仓库的可见性。要公开的话去 Packages → snowid-server → Package settings → Change visibility 改一次,之后所有版本都继承。

开发

make test                     # go test -race
make lint                     # gofmt + go vet
make build
make run                      # 等价于 go run ./cmd/snowid-server --worker-id 0
./buf.gen.sh                  # 修改 .proto 后重新生成桩代码

许可证

MIT——见 LICENSE

Directories

Path Synopsis
cmd
snowid-server command
Command snowid-server serves snowflake IDs over gRPC.
Command snowid-server serves snowflake IDs over gRPC.
gen
internal
server
Package server exposes a bwmarrin/snowflake generator over gRPC.
Package server exposes a bwmarrin/snowflake generator over gRPC.
pkg
client
Package client is a Go client for snowid-server.
Package client is a Go client for snowid-server.
snowflake
Package snowflake is the wire format of a snowflake ID: how one is packed, and how to take it apart again.
Package snowflake is the wire format of a snowflake ID: how one is packed, and how to take it apart again.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL