使用 etcd 分配全局唯一节点 ID | 青训营笔记

879 阅读5分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 5 天。

本文以 CC-BY-SA 4.0 发布。

etcd

A distributed, reliable key-value store for the most critical data of a distributed system. (etcd)

根据官网的介绍,etcd (读音 /ˈɛtsiːdiː/ ) 是分布式的、有高可靠性的键值数据库。 其命名由来是 “distributed etc directory”,分布式的 etc 目录, 可以用来安全可靠的储存各种软件及机群的配置情况,在分布式系统中进行协调。

etcd 使用

废话不多说。 之前在 Snowflake ID 相关阅读 | 青训营笔记 一文里我提到了可以使用 etcd 来生成节点 ID,供 Snowflake ID 的生成使用。 下面我们来看一看到底我们如何实现这一点。

下面主要使用 Go 的 go.etcd.io/etcd/client/v3 这一库与 etcd 进行交互。 但各种概念都是通用的。

// go.etcd.io/etcd/client/v3 的 client 初始化
client, err := clientv3.New(clientv3.Config{
  Endpoints: endpoints,
})

etcd 中的原子操作

与 Redis 相比,etcd 中的原子操作显得非常有限。 但是,毕竟这两者的主要功能和应用范围都不同,对同步和可靠性的要求也不一样,这也可以理解。

go.etcd.io/etcd/client/v3 这一库中的原子操作 API 主要通过事务 Txn 进行。 但是 etcd 里的事务并没有普通数据库的事务的功能那么强大,Txn 主要提供了一个进行 CAS(Compare-&-Swap)操作的接口。

例如,下面的代码就是在 etcd 里实现分布式版本的 atomic.CompareAndSwap* 的方法。

import . "go.etcd.io/etcd/client/v3"

resp, err := client.Txn(context.Background()).
  If(Compare(Value(key), "=", oldValue)).
  Then(OpPut(key, newValue)).
  Commit()
if err != nil {
  // 各种错误处理
}
if resp.Succeeded {
  // CAS 成功写入值
} else {
  // CAS 未能写入值
}

我们可以从 etcd 提供的 gRPC API 中看出更多的细节。以下摘自官方文档: etcd3 API | etcd

message TxnRequest {
  repeated Compare compare = 1;
  repeated RequestOp success = 2;
  repeated RequestOp failure = 3;
}

根据文档里的解释,一个 etcd 事务请求,可以携带多个 Compare 对值进行各种比较。 当所有的 Compare 均为真时,携带的 success 操作会被执行; 当其中任意为假时,则 failure 操作会被执行。

和所有的 CAS 操作一样,etcd 会返回一个布尔值(例中的 resp.Succeeded), 用来指示最后到底执行的是 success 操作还是 failure 操作。

用 CAS 操作进行单调递增

Go 的 atomic 包是用汇编写的。 例如在 AMD64 平台上,我们只需要给 ADD 语句加上一个 LOCK 前缀就可以进行 单节点版本的原子递增操作。 但是,在没有 LOCK ADD 这种指令的情况下,想要在 etcd 平台上进行递增, 我们只能采取更笨的方式。

没有 LOCK ADD 的递增:单机版本

下面的代码来自 OpenJDK 的 AtomicInteger.java

int v;
do {
    v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;

我们可以提取出思路:

  1. 获取当前的目标值 v
  2. 手动计算出递增后的值 v + delta
  3. 尝试利用 CAS 将值写回目标位置
  4. 如果 CAS 未能成功,从第一步开始重试

我们只是需要把上面的思路用 etcd 的方法写出来罢了。这里就不贴代码了。

空值处理

在进行递增操作之前,我们需要保证值已经初始化了,而且我们不能重复初始化。

当然,我们可以直接在启动 etcd 时顺便手动初始化一下:

$ etcdctl put <key> <value>

我们也可以用原子操作进行初始化,把 If 里面的比较内容换为 Compare(Version(key), "=", 0) 即可判断值为空。

保证节点 ID 唯一性

就算我们实现了递增的原子操作,但是这并不能保证节点 ID 的唯一性。 以 Snowflake ID 为例,其节点 ID 只有 10 位(最大值 1023), 虽然我们的小项目大概到不了十个节点,但是在我们重启多几次之后, 我们必须要让计数在到达 1024 之前归零,这样才能使 ID 符合我们 10 位的范围。 重置也可能会带来 ID 冲突。

总之,我们需存一个机制让节点在 etcd 标记出自己占用的节点 ID, 并在节点离线之后自动让出 ID。标记占用的节点利用 CAS 操作即可完成, 而自动回收 ID 的机制我们需要利用 etcd 的 lease。

Lease 机制

etcd 中的 lease 机制可以用来检测节点是否存活,当节点离线后, 对应的键值对便会被回收。

我们可以利用 Grant 来获取一个 lease:

import . "go.etcd.io/etcd/client/v3"

grant, err := client.Grant(context.Background(), 10)
lease := grant.ID

resp, err := etcd.Txn(context.Background()).
  If(Compare(LeaseValue(key), "=", clientv3.NoLease)).
  Then(OpPut(key, "occupied", WithLease(lease))).
  Commit()

上面的代码向 etcd 申请了一个 lease,并将 lease 绑定在了一个值上。 代码里使用了原子操作,以防我们绑定到其它节点占用的值。 (如果原子操作失败了,那只能再递增一次、获取一个新的 ID 再重试了。)

上面 client.Grant(context.Background(), 10) 里的 10 是我们的 TTL(time-to-live),在下 10 秒内, 我们必须向 etcd 发送一个 keepAlive 请求,用来表示我们还在线。 当 etcd 过了 10 秒还没能受到我们的 keepAlive 信息, 那么它就会把我们的 lease 对应的键值对给删掉,实现了自动让出 ID 的功能。

代码用 KeepAlive 来进行:

ch, _ := client.KeepAlive(context.Background(), lease)
go func() {
  for {
    if _, ok := <-ch; !ok {
      panic("可能是 etcd 宕了")
    }
  }
}()

总结

结合 etcd 的原子操作Lease 绑定KeepAlive 操作, 我们可以实现一个能够严格保证节点 ID 不相重合的分布式节点 ID 分发系统。 虽然比起单机来说代码量可能的确有点多了(相较一行解决的 atomic 系列函数), 但是还是很有趣的,而且对于真正的分布式系统来说,节点 ID 分发还是很有必要的吧。 (但真要说的话,人工管理也不是不行……)

把上面的内容串起来也就是:

  1. 用原子操作对计数器进行单次初始化;
  2. 对计数器进行原子递增(超过上限会进行重置),以此取得一个相对不易重合的 ID;
  3. 请求一个 Lease,并开始对 Lease 进行 KeepAlive 操作;
  4. 将 ID 作为 Key 的一部分,原子写入对应的键值对,同时绑定上面的 Lease;
  5. 如果成功写入了,则这个 ID 就是我们唯一的节点 ID;
  6. 如果写入失败了,则需要从第二步开始重试。