这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天。
本文以 CC-BY-SA 4.0 发布。
分布式系统中的唯一 ID 生成
说到 ID 生成的话,可能最容易想到的就是 AUTO_INCREMENT 了。
但是这种生成方式是中心化的,例如在分库分表之后,就没法保证最基本的唯一性了。
我们势必要寻找其它的解决方案。
而对于生成的 ID,除了唯一性以外,我们还希望它具有一些其它特性:
- 全局唯一性:最基本的要求
- 趋势递增:用作主键时保证数据库写入性能
- 特殊要求:单调递增 / 信息安全
实现这样的分布式 ID 生成有不同的思路:
-
设置一个中心化的派号服务,可以是一个一个 ID 进行分派, 也可以是一段一段地分配。但无论如何,因为这样本质上和数据库的
AUTO_INCREMENT没有什么差别,所以各种处理都还比较方便。 可以说,通过适当的 ID 设计,上面所说的各种要求它全能满足。但是,它也有相应的缺点:可用性不足。派号服务下线了整个系统就全完了。 成段分配的设计还有一定的缓冲,但是一个一个分配的基本上无力回天。
-
分布式生成。各个节点的 ID 由自己生成,各个节点不需要相互同步 (也许在启动时需要),通过某种 ID 生成的算法来保证各个节点之间 生成的 ID 不相互重合。
(类)Snowflake ID
前段时间青训营的系统设计实践里有提到 Snowflake ID。 Snowflake ID 就是上面提到的分布式生成 ID 的算法之一。 其基本思路利用了时间戳以及节点 ID,把生成的 ID 分为三部分, 从 MSB 到 LSB 分别是:
- 符号位留空
- 时间戳
- 当前节点 ID
- 每一时间单位内的递增 ID
通过在 ID 内引入节点 ID 可以保证两个节点生成的 ID 绝不重合; 高位的时间戳保证了数据的整体递增性以及不同机器之间的大体同步。 这样就实现了上面要求里的“全局唯一性”“趋势递增”以及某种程度的“信息安全”。
此外,Snowflake ID 还有一个优势:它基本上就是大半个时间戳,可以满足大多数的时间索引需求。
在设计数据库时,我们很多时候都会加入一个 created_at 列来记录创建时间。
如果毫秒精度已经能满足需求的话,
使用 Snowflake ID 作为主键可以让我们直接省去这一列以及对应的索引。
实现
算法非常简单。当然,结合自己的服务发现配置等实际情况,节点 ID 的获取可能会有各种的不同方法。
Go 语言的 Snowflake 相关库我找到两个:
前者用了 Mutex 来同步,而后者默认使用的是 atomic 包提供的原子操作。
代码都比较简单。
获取节点 ID
不同的系统有不同。大项目我用的 etcd,看起来没有现成的库可以用, 所以需要自己模仿类似 kitex-contrib/registry-etcd 之类的代码来实现。
一个可能的思路是:
- 想办法给当前节点找一个 ID,可以通过全局的数据或者干脆随机多重试几次
- 在 etcd 里用一个键值对占位,表示这个 ID 已有节点占用
- 向 etcd 申请 lease,绑定到对应键值对上,让键值对能在节点下线时自动清除
- 使用一个 goroutine 不断对 lease 进行刷新,如果害怕高并发时饥饿的话,可以考虑分配一个操作系统线程
时间同步
Snowflake ID 算法假设时间戳是递增的。 这大多时候正确,但少数情况——系统时间进行更新或者不同节点之间的一些时间同步差异, 可能会使 Snowflake ID 生成出重复的 ID。
一个可能的应对思路是:
- 向 etcd 里隔段时间 t 便写入一下当前时间;
- 启动时向 etcd 同步,如果时间偏慢直接报错,如果时间相差无几也需要再等待 t 时间;
- 如果在系统运行期间,系统时间回调了,则将 Snowflake ID 退化为顺序 ID,直至时间赶上为止。