绝对的说法都是错误的。

etcd 之坑

· damnever
#etcd   #Raft

前文 越来越臃肿了,直接抽出来另起一篇,说是坑其实还是 etcd 文档写得不全面或者使用者眼高手低..

使用 etcd 最好搞清楚 what is different about Revision, ModRevision and Version?,另外对一致性有要求,还得搞清楚 linearizabe read and serializable read,etcd 默认是最强的那种但是性能上肯定会稍微有些影响,因为所有的操作都要间接或直接过 Leader 走一遍 raft 的流程。

坑一

Lease.KeepAlive 如果刚好在没有 Leader (选举)的时候超时就会被 cancel 掉导致我们的节点 “消失”,得自己写重试逻辑。API 层面的其它坑见坑四,和 keepalive 相关的 API 也得注意,比如 concurrency 需要持续关注类似 Done() 的 channel。

进一步思考,keepalive 如何做到时钟同步的?在一个任期内 Lease 超时是没问题的,因为单机的 monotonic clock 很容易保证,但是如果在 Lease 超时的时间间隔之内发生了一次重新选举,那么由于 clock skew,新的 Leader 在没有时钟同步的情况下该怎样超时这个 Lease 呢?新的 Leader 直接提交一个 delete entries ?那么在客户端下一次 renew 之前,关联了这个 Lease 的 entries 就有一段时间是消失的… 实际上 etcd 的实现是在新的选举过后直接 renew 一波,潜在的问题就是选举太频繁而 Lease 过期时间又太长就可能永远不会过期,见 Lease TTL resetting on leader elections or single-node restart, but it isn’t doccumented,官方解决方案见 PR

坑二

etcd 的 mvcc db 会存所有版本的全量数据,如果更新比较频繁,数据相对较大,很快就会遇到 mvcc: database space exceeded,这时候所有的写入操作都会失败,只能读和删,按照 文档 操作来恢复:compact->defrag->disarm。但是 defrag 是一个比较重的操作,操作是阻塞的,期间那个节点(集群)相当于废掉了。解决方案可以增大 space quota,但是这只能暂时解决问题,而且 boltdb 太大会影响性能,特别是设置了 MAP_POPULATE 启动的时候,最好是尽早配置 auto compaction,按照实际情况来选择不同的 compaction 方式,如果写入的量不大 auto compaction 就能满足需求了,但如果量大,就得客户端层面做缓存,对数据集进行合并压缩等操作来降低写入量。值得注意的是 compaction 过后,磁盘文件并不会减小,这是由于 boltdb 删除数据后并不会把磁盘空间交还给操作系统,而是自己维护了一个 freelist,新的数据会复用这些空间,如果磁盘文件超出配额了,就必须做 defragmention 了。一定要熟读 文档

这其实不算坑,但是一般人没遇到问题之前都不知道.. 真正的坑我觉得是 mvcc: database space exceeded 之后 keepalive 竟然没掉.. 翻了下 etcd 的源码发现只有 grant/revoke lease 的时候才会通过 raft 走一波,而 renew 直接在 Leader 上面做的,落地的数据只有 LeaseID 和 TTL,其它的都是维护在内存里的,和 坑一里的进一步思考 遥相呼应..

另外 etcd 源码里面真是 channel 满天飞.. 很多 interface 来解决代码耦合的问题.. 绕来绕去的 Golang 的接口又是隐式的看代码巨特么蛋碎.. 我就瞎吐槽让我做指不定会咋样..

坑三

网络抖动或者 etcd 少部分节点网络被隔离超过 election timeout 之后重新加入集群的时候会导致重新选举。

遇到这个问题的时候我是震惊的,特别是在多机房部署的情况下这种问题发生的概率会相对比较高,影响鲁棒性,不过 etcd v3.4 将会新增 --experimental-pre-vote 来解决这个问题,v3.5 应该会默认打开。

下面具体分析加深下理解。

在没有 PreVote 的情况下线上 etcd 节点的日志所展现出的行为如下(raft 相关的细节自行阅览):

  1. 网络状态 ok

    node current term log state
    A 5 [(term:5, index:1)] Leader
    B 5 [(term:5, index:1)] Follower
    C 5 [(term:5, index:1)] Follower
  2. 网络状况 bad,节点 C 被隔离超过 election timeout

    node current term log state
    A 5 [(term:5, index:1), (term:5, index:2)] Leader
    B 5 [(term:5, index:1), (term:5, index:2)] Follower
    C > 5 (keep incresing..) [(term:5, index:1)] Follower <-> Candidate
  3. 网络恢复,节点 C 重新加入集群

    此时,C 的 Term 肯定是大于 5 的,可能会很大,假设 C 收到了来自 Leader A 的 AppendEntries,但由于 C 的 Term 比较大,C 是不会承认 A 为 Leader 的,所以继续尝试选举; C 的日志也不是最新的,AB 都会在 RequestVote 阶段拒绝掉 C 让其选举总是失败,那么问题就来了,C 不承认 A 为 Leader 继续一意孤行的进行选举增加自己的 Term ,搞不好一直无法加入集群,问题看起来更严重,按照 raft 里面的描述,Leader A 在 AppendEntries 里收到高于自己 Term 的响应就更新自己的 Term 转变成 Follower,这样 Leader A 会进行一次重新选举,大概率的情况下 A 还是会成为 Leader。

当然如果 C 的日志是最新的,C 的选举在 RequestVote 阶段直接就能成功了。

等等… 实际上日志上的行为并不是如 3 所述:C 的 RequestVote 被拒绝另有其因,如下:

  • B 收到了 C 的 RequestVote(选举)请求, B 发现在一个 election timeout 内一直都有来自 Leader A 的 AppendEntries(空则为心跳),那么 B 确定 Leader A 还是正常工作的,直接拒绝掉 C 的请求
  • Leader A 在 election timeout 内一直有收到来自 Follower B 的 AppendEntries 响应,那么 A 的权威还有一半以上的人在维护, A 也能确定自己就是 Leader 直接拒绝掉 C 的选举请求
  • 重新选举的成因当然还是 Leader A 在 AppendEntries 里收到高于自己 Term 的响应

那这里其实就有点 tricky 了… 按道理说 Leader A 在 AppendEntries 的时候也应该维持自己的权威,但是对于只有三节点的集群来说,如果不通过上述多次的选举过程来追赶 Term 好让 C 重新加入集群,那么再挂一个节点集群就没法用了。

前面提到的 PreVote 到底有啥子用呢?参考:CONSENSUS: BRIDGING THEORY AND PRACTICE (4.2.3 Disruptive servers)

简单的来说就是 PreVote 阶段不能被大多数节点 approve,那么就不能进入 RequestVote 阶段,PreVote 阶段并不自增 Term。 所以当 C 重新加入集群时,继续重试 PreVote 会被之前 RequestVote 被拒绝的两种原因之一拒绝,很快 C 会收到来自 Leader A 的 AppendEntries 进而继续保持 Follower 状态而不捣乱。

坑四

使用 golang 的 Watch API 的时候如果服务端做了 compaction,会返回 ErrCompacted 的错误,这个时候 Watch 会失败,WatchCh 会被 close 掉,如果使用方式如下会造成代码死循环(Google: closed channel, nil channel, etc.):

for {
    select {
    case <-doneC:
        return
    case resp := <-watchC:
        doSthWith(resp)
    }
}

当然其它的不可恢复的错误也不会重试导致上述问题,详见文档(⊙﹏⊙)b,解决方案就是 range channel 或者检查 channel 是不是被 closed 或者检查 WatchResponse 的状态,然后根据情况重试..

最最重要的是一定要认真读文档.. 如果不需要 watch 了一定要 cancel 掉 context,不然会造成 goroutine 泄露。

坑五

大部分的 API 都有一个显式的 context.Context 参数并且没有 Close 方法,要注意的有少量 API 比如是 concurrency.NewSessionClose 方法以及一个隐式的 ctx option,这个时候就需要注意了,最好显式的传入 concurrency.WithContext(ctx) 统一的使用 ctx 管理避免忘记 Close,之前使用这个 API 进行选举导致线上出现一个 Ghost Lock,还是比较坑的..

坑六

False-positive 的问题,比如一个简单的 PUT 操作,可能在服务端成功了,但是由于超时等问题导致客户端收到失败的响应,这个时候重试或者再次操作可能需要通过 CAS 检查一下 Version 或 Value 避免前一个值被覆盖导致写丢失的情况。

坑 N

TBD..

Buy Me a Coffee at ko-fi.com