接口
并发
协程
通道
context
原子锁:atomic
互斥锁
读写锁:适合多读少写场景。
sync.Once、sync.Cond、sync.WaitGroup
项目组织
依赖管理:gomod
组合
工具与库
工具:代码分析与代码规范
静态:检查代码的结构、代码风格以及语法错误,这种工具也被称为 Linter。
测试
调试
标准库
三方库
你如何理解 Go 语言的一句名言:“不要通过共享内存来通信,通过通信来共享内存”?
共享内存
通信
通过共享内存通信相当于双方必须依靠额外的控制机制来确保通信时内存中的内容是正确的,这一点需要共享双方设置同步机制,并不通用, 还容易有bug。但是通过通信共享内存则可以利用通用的通信基建, 凡是经过通信传递的信息一定是发送方确认正确的, 接收方只需要等待即可, 不用再依赖额外的同步机制,减少了出bug的机会。
etcd etcd doc #etcd#
etcd 这个名字是 etc distributed 的缩写。我们知道,在 Linux 中 etc 目录存储了系统的配置文件,所以 etcd 代表了分布式的配置中心系统。然而,它能够实现的功能远不是同步配置文件这么简单。etcd 可以作为分布式协调的组件帮助我们实现分布式系统。
etcd 的第一个版本 v0.1 于 2013 年发布,现在已经更新到了 v3,在这个过程中,etcd 的稳定性、扩展性、性能都在不断提升。我们先来从整体上看一看 etcd 的架构。
etcd 从大的方面可以分为几个部分,让我们结合图片从右往左说起。
raft-http 模块:由于 etcd 通常为分布式集群部署方式,该层用于处理和其他 etcd 节点的网络通信。etcd 内部使用了 HTTP 协议来进行通信,由于 etcd 中的消息类型很多,心跳探活的数据量较小,快照信息较大(可达 GB 级别),所以 etcd 有两种处理消息的通道,分别是 Pipeline 消息通道与 Stream 消息通道。#raft#
etcd-raft 模块:它是 etcd 的核心。该层实现了 Raft 协议,可以完成节点状态的转移、节点的选举、数据处理等重要功能,确保分布式系统的一致性与故障容错性。之前我们也介绍过,Raft 中的节点有 3 种状态,分别是 Leader(领导者),Candidates(候选人)和 Follower(跟随者)。在此基础上,etcd 为 Raft 节点新增了一个 PreCandidate(预候选人)。
我们在讲解 Raft 协议时介绍过,如果节点收不到来自 Leader 的心跳检测,就会变为 Candidates 开始新的选举。如果当前节点位于不足半数的网络分区中,短期内不会影响集群的使用,但是当前节点在不断发起选举的过程中,当前选举周期的 Term 号会不断增长,当网络分区消失后,由于该节点的 Term 号高于当前集群中 Leader 节点的 Term 号,Raft 协议就会迫使当前的 Leader 切换状态并开始新一轮的选举。
但是,这种选举是没有意义的。为了解决这样的问题,etcd 在选举之前会有一个新的阶段叫做 PreVote,当前节点会先尝试连接集群中的其他节点,如果能够成功连接到半数以上的节点,才开始新一轮的选举。
raft-node 模块:在 etcd-raft 模块基础之上还封装,这个模块主要是上层模块和下层 Raft 模块沟通的桥梁,它同时还有一个重要任务,就是调用 storage 模块,将记录(Record)存储到 WAL 日志文件中落盘。WAL 日志文件可以存储多种类型的记录,包括下面几种。
WAL 日志文件非常重要,它能保证我们的消息在大部分节点达成一致且应用到状态机之前,让记录持久化。这样,在节点崩溃并重启后,就能够从 WAL 中恢复数据了。#wal#
WAL 日志的数量与大小随着时间不断增加,可能超过可容纳的磁盘容量。同时,在节点宕机后,如果要恢复数据就必须从头到尾读取全部的 WAL 日志文件,耗时也会非常久。为了解决这一问题,etcd 会定期地创建快照并保存到文件中,在恢复节点时会先加载快照数据,并从快照所在的位置之后读取 WAL 文件,这就加快了节点的恢复速度。快照的数据也有单独的 snap 模块进行管理。
type Record struct {
Type int64 `protobuf:"varint,1,opt,name=type" json:"type"`
Crc uint32 `protobuf:"varint,2,opt,name=crc" json:"crc"`
Data []byte `protobuf:"bytes,3,opt,name=data" json:"data,omitempty"`
}
**etcd-server 模块:**它最核心的任务是执行 Entry 对应的操作,在这个过程中包含了限流操作与权限控制的能力。所有操作的集合最终会使状态机到达最新的状态。etcd-server 同时还会维护当前 etcd 集群的状态信息,并提供了线性读的能力。
etcd-server 提供了一些供外部访问的 GRPC API 接口,同时 etcd 也使用了 GRPC-gateway 作为反向代理,使服务器也有能力对外提供 HTTP 协议。
最后,etcd 还提供了客户端工具 etcdctl 和 clientv3 代码库,使用 GRPC 协议与 etcd 服务器交互。客户端支持负载均衡、节点间故障自动转移等机制,极大降低了业务使用 etcd 的难度,提升了开发的效率。
此外,etcd 框架中还有一些辅助的功能,例如权限管理、限流管理、重试、GRPC 拦截器等。由于不是核心点,图中并没有一一列举出来。
type Storage interface {
InitialState() (pb.HardState, pb.ConfState, error)
Entries(lo, hi, maxSize uint64) ([]pb.Entry, error)
Term(i uint64) (uint64, error)
LastIndex() (uint64, error)
FirstIndex() (uint64, error)
Snapshot() (pb.Snapshot, error)
}
在 etcd 代码库中有一个示例代码,该示例代码基于 etcd-raft 模块实现了一个最简单的分布式内存 KV 数据库。在示例代码中实现了上游的 KVServer 服务器与 raft-node 节点,并与 etcd-raft 模块进行交互,去掉了 etcd 实现的日志落盘等逻辑,将键值对存储到了内存中。如果你有志于深入地学习 etcd,从这个实例入手是非常不错的选择。
怎么解决呢?办法有很多。
+ Follower 可以将读请求直接转发给 Leader,不过这样的话 Leader 的压力会很大,并且 Leader 可能已经不是最新的 Leader 了。
+ 第二种解决方案是 etcdv3.2 之前的处理方式。也就是将该请求记录为一个 Entry,从而借助 Raft 机制来保证读到的数据是最新的。
+ 还有一种更轻量级的方法。在 v3.2 之后,etcd 实现了 ReadIndex 机制,这也是在 Raft 论文当中提到过的。Follower 向 Leader 读取当前最新的 Commit Index,同时 Leader 需要确保自己没有被未知的新 Leader 取代。它会发出新一轮的心跳,并等待集群中大多数节点的确认。一旦收到确认信息,Leader 就知道在发送心跳信息的那一刻,不可能存在更新的 Leader 了。也就是说,在那一刻,ReadIndex 是集群中所有节点见过的最大的 Commit Index。Follower 会在自己的状态机上将日志至少执行到该 Commit Index 之后,然后查询当前状态机生成的结果,并返回结果给客户端。
#mvcc#
etcd 存储了当前 Key 过去所有操作的版本记录。这样做的好处是,我们可以很方便地获取所有的操作记录,而这些记录常常是实现更重要的特性的基础,例如要实现可靠的事件监听就需要 Key 的历史信息。
etcd v2 会在内存中维护一个较短的全局事件滑动窗口,保留最近的 1000 条变更事件。但是当事件太多的时候,就需要删除老的事件,可能导致事件的丢失。
etcd v3 解决了这一问题,etcd v3 将历史版本存储在了 BoltDB 当中进行了持久化。可靠的 Watch 机制将避免客户端执行一些更繁重的查询操作,提高了系统的整体性能。
借助 Key 的历史版本信息,我们还能够实现乐观锁的机制。 乐观锁即乐观地认为并发操作同一份数据不会发生冲突,所以不会对数据全局加锁。但是当事务提交时,她又能够检测到是否会出现数据处理异常。乐观锁的机制让系统在高并发场景下仍然具备高性能。这种基于多版本技术实现的乐观锁机制也被称为 MVCC。
下面就让我们来看看 etcd 是如何实现 MVCC 机制,对多版本数据的管理与存储的吧。在 etcd 中,每一个操作不会覆盖旧的操作,而是会指定一个新的版本,其结构为 revision。
type revision struct {
main int64
sub int64
}
revision 主要由两部分组成,包括 main 与 sub 两个字段。其中每次出现一个新事务时 main 都会递增 1,而对于同一个事务,执行事务中每次操作都会导致 sub 递增 1,这保证了每一次操作的版本都是唯一的。假设事务 1 中的两条操作分别如下。
key = "zjx" value = "38"
key = "olaya" value = "19"
事务 2 中的两条操作是下面的样子。
key = "zjx" value = "56"
key = "olaya" value = "22"
那么每条操作对应的版本号就分别是下面这样。
revision = {1,0}
revision = {1,1}
revision = {2,0}
revision = {2,1}
etcd 最终会默认将键值对数据存储到 BoltDB 当中,完成数据的落盘。不过为了管理多个版本,在 BoltDB 中的 Key 对应的是 revision 版本号,而 Value 对应的是该版本对应的实际键值对。BoltDB 在底层使用 B+ 树进行存储,而 B+ 树的优势就是可以实现范围查找,这有助于我们在读取数据以及实现 Watch 机制的时候,查找某一个范围内的操作记录。
看到这里你可能会有疑问,在 BoltDB 中存储的 key 是版本号,但是在用户查找的时候,可能只知道具体数据里的 Key,那如何完成查找呢?
为了解决这一问题,etcd 在内存中实现了一个 B 树的索引 treeIndex,封装了Google 开源的 B 树的实现。B 树的存储结构方便我们完成范围查找,也能够和 BoltDB 对应的 B+ 树的能力对应起来。treeIndex 实现的索引,实现了数据 Key 与 keyIndex 实例之间的映射关系,而在 keyIndex 中存储了当前 Key 对应的所有历史版本信息。 通过这样的二次查找,我们就可以通过 Key 查找到 BoltDB 中某一个版本甚至某一个范围的 Value 值了。
借助 etcd 的 MVCC 机制以及 BoltDB 数据库,我们可以在 etcd 中实现事务的 ACID 特性。etcd clientv3 中提供的简易事务 API正是基于此实现的。
MVCC(Multi-Version Concurrency Control),即多版本并发控制。MVCC 是一种并发控制的方法,可以实现对数据库的并发访问。
MySQL的MVCC工作在RC(读提交)和RR(重复读)的隔离级别。
表的行记录逻辑上是一个链表,既保留业务数据本身,还有两个隐藏字段:
ETCD的MVCC同样可以维护一个数据(key对应的值)的多个历史版本,且使得读写操作没有冲突,不使用锁,增加系统吞吐。
> docker exec etcd-gcr-v3.5.5 /bin/sh -c "/usr/local/bin/etcdctl put a 1 "
OK
> docker exec etcd-gcr-v3.5.5 /bin/sh -c "/usr/local/bin/etcdctl get a -w=json"
{"header":{"cluster_id":18011104697467366872,"member_id":6460912315094810421,"revision":22,"raft_term":3},"kvs":[{"key":"YQ==","create_revision":22,"mod_revision":22,"version":1,"value":"MQ=="}],"count":1}
> docker exec etcd-gcr-v3.5.5 /bin/sh -c "/usr/local/bin/etcdctl put a 2 "
OK
> docker exec etcd-gcr-v3.5.5 /bin/sh -c "/usr/local/bin/etcdctl get a -w=json"
{"header":{"cluster_id":18011104697467366872,"member_id":6460912315094810421,"revision":23,"raft_term":3},"kvs":[{"key":"YQ==","create_revision":22,"mod_revision":23,"version":2,"value":"Mg=="}],"count":1}
> docker exec etcd-gcr-v3.5.5 /bin/sh -c "/usr/local/bin/etcdctl put a 3 "
OK
> docker exec etcd-gcr-v3.5.5 /bin/sh -c "/usr/local/bin/etcdctl get a -w=json"
{"header":{"cluster_id":18011104697467366872,"member_id":6460912315094810421,"revision":24,"raft_term":3},"kvs":[{"key":"YQ==","create_revision":22,"mod_revision":24,"version":3,"value":"Mw=="}],"count":1}
etcd 状态机中的数据存储包含了两个部分:
另外,还要格外注意的是,客户端调用写入方法 Put 成功后,并不意味着数据已经持久化到 BoltDB 了。因为这时 etcd 并未提交事务,数据只更新在了 BoltDB 管理的内存数据结构中。BoltDB 事务提交的过程包含平衡 B+ 树、调整元数据信息等操作,因此提交事务是比较昂贵的。如果我们每次更新都提交事务,etcd 的写性能就会较差。为了解决这一问题,etcd 也有对策。etcd 会合并多个写事务请求,通常情况下定时机制会分批次(默认 100 毫秒 / 次)统一提交事务, 这就大大提高了吞吐量。
但是这种优化又导致了另一个问题。事务未提交时,读请求可能无法从 BoltDB 中获取到最新的数据。为了解决这个问题,etcd 引入了一个 Bucket Buffer 来保存暂未提交的事务数据。etcd 处理读请求的时候,会优先从 Bucket Buffer 里面读取,其次再从 BoltDB 中读取,通过 Bucket Buffer 提升读写性能,同时也保证了数据一致性。
#watch#
etdc 支持监听某一个特定的 Key,也支持监听一个范围。etcdv3 的 MVCC 机制将历史版本都保存在了 BoltDB 中,避免了历史版本的丢失。 同时,etcdv3 还使用 GRPC 协议实现了客户端与服务器之间数据的流式传输。
当客户端向 etcd 服务器发出监听的请求时,etcd 服务器会生成一个 watcher。
etcd 会维护这些 watcher,并将其分为两种类型:synced 和 unsynced。
当 etcd 收到一个写请求,Key-Value 发生变化的时候,对应的 synced watcher 需要能够感知到并完成最新事件的推送。这一步主要是在 Put 事务结束时来做的。Put 事务结束后,会调用 watchableStore.notify,获取监听了当前 Key 的 watcher,然后将 Event 送入这些 watcher 的 Channel 中,完成最终的处理和发送。监听当前 Key 的 watcher 可能很多,你可能会想到用一个哈希表来存储 Key 与 watcher 的对应关系,但是这还不够,因为一个 watcher 可能会监听 Key 的范围和前缀。因此,为了能够高效地获取某一个 Key 对应的 watcher,除了使用哈希表,etdc 还使用了区间树结构来存储 watcher 集合。当产生一个事件时,etcd 首先需要从哈希表查找是否有 watcher 监听了该 Key,然后它还需要从区间树重找出满足条件的所有区间,从区间的值获取监听的 watcher 集合。
不使用【hash表】的原因:
硬核资料:关注即可领取PPT模板、简历模板、行业经典书籍PDF。
技术互助:技术群大佬指点迷津,你的问题可能不是问题,求资源在群里喊一声。
面试题库:由技术群里的小伙伴们共同投稿,热乎的大厂面试真题,持续更新中。
知识体系:含编程语言、算法、大数据生态圈组件(Mysql、Hive、Spark、Flink)、数据仓库、Python、前端等等。
加入社区:https://bbs.csdn.net/forums/4304bb5a486d4c3ab8389e65ecb71ac0