Gossip是最终一致性协议,是目前性能最好、容错性最好的分布式协议。
Gossip协议
Gossip协议又称epidemic协议(epidemic protocol),是基于流行病传播方式的节点或者进程之间信息交换的协议,在P2P网络和分布式系统中应用广泛:
在一个处于有界网络的集群里,如果每个节点都随机与其他节点交换特定信息,经过足够长的时间后,集群各个节点对该份信息的认知终将收敛到一致。
节点会周期性地选择一些节点(一般是N,即fanout系数),把要交互的信息传递出去。
特点
Gossip协议的主要用途就是信息传播和扩散;也被用于数据库复制、信息扩散、集群成员身份确认、故障探测等。其有以下特点:
- 扩展性:允许意增加和减少节点任,新增加的节点的状态最终会与其他节点一致。
- 容错性:任何节点的宕机和重启都不会影响消息的传播,Gossip协议具有天然的分布式系统容错特性。
- 去中心化:所有节点都可以是对等的,任何一个节点无需知道整个网络状况,只要网络是连通的,任意一个节点就可以把消息散播到全网。
- 一致性收敛:消息会以一传十、十传百一样的指数级速度在网络中快速传播(传播速度达到了 logN),因此系统状态的不一致可以在很快的时间内收敛到一致。
- 简单:协议的过程极其简单。
缺陷
分布式网络中,没有一种完美的解决方案;Gossip协议也存在不可避免地缺陷:
- 消息延迟(不适合用于实时性要求较高的场景):节点只会随机向少数几个节点发送消息,消息最终是通过多个轮次的散播而到达全网的,因此使用 Gossip 协议会造成不可避免的消息延迟。
- 消息冗余:节点会定期随机选择周围节点发送消息,而收到消息的节点也会重复该步骤,因此就不可避免的存在消息重复发送给同一节点的情况,造成了消息的冗余,同时也增加了收到消息的节点的处理压力。
memberlist
memberlist是gossip协议的go实现库:
import "github.com/hashicorp/memberlist"
节点故障检测
所有节点分为三种状态:
- StateAlive:活动节点
- StateSuspect:可疑节点
- StateDead:死亡节点
memberlist利用点对点随机探测机制实现成员的故障检测:
- 随机探测:节点每隔一定间隔,会随机选取一个其他节点发送PING消息。
- 重试与间隔探测请求:PING消息失败后,会随机选取N(由config中IndirectChecks设置)个节点发起PING请求,并对刚才失败的节点再发起一个TCP PING消息(可能UDP不通,但TCP通)。
- 探测超时标识可疑:若节点在指定时间内没有收到要探测节点的ACK消息,则标记要探测的节点状态为suspect。
- 可疑节点广播:启动一个定时器用于发出一个suspect广播
- 此期间内如果收到其他节点发来的相同的suspect信息时,将本地suspect的确认数+1;
- 当定时器超时后,仍然收到不到探测节点的alive消息,且确认数达到要求,则将该节点标记为dead。
- 可疑消除:当节点收到标记本节点为suspect的消息时,会发送alive广播,以消除suspect标记。
- 死亡通知:当本节点离开集群时或者探测的有其他节点被标记为死亡,会向集群发送节点dead广播。
- 死亡消除:当节点收到标记本节点为dead广播消息时(可能本节点相对于其他节点网络分区了),此时会发起一个alive广播,以消除dead标记。
消息
Memberlist在整个生命周期内,有两种类型的消息:
- UDP协议消息:传输PING消息、ACK消息、NACK消息、Suspect消息、Alive消息、Dead消息、广播消息;
- TCP协议消息:用户数据同步、节点状态同步、PUSH-PULL消息
push/pull协程周期性的从已知的alive的集群节点中选1个节点进行push/pull交换信息。交换的信息包含2种
- 集群信息:节点数据;
- 用户自定义的信息:Delegate接口LocalState返回的数据。
方法
memberlist中一些方法说明:
func DefaultLANConfig() *Config
:返回一个适合LAN的默认配置,在create之前可根据需要修改。func Create(conf *Config) (*Memberlist, error)
:使用给定的配置创建一个新的成员节点。不会连接到任何其他节点(需通过Join连接);但会启动侦听器,以允许其他节点连接加入。func (m *Memberlist) GetHealthScore() int
:数字越低越好,0意味着“完全健康”。func (m *Memberlist) Join(existing []string) (int, error)
:尝试通过连接给定主机并执行状态同步来加入集群。返回成功连接的主机数量,如果无法连接到主机,则返回错误。func (m *Memberlist) Leave(timeout time.Duration) error
:广播一条Leave消息,但不会关闭后台侦听器,这意味着节点将继续参与gossip和状态更新。func (m *Memberlist) LocalNode() *Node
:返回本地节点信息。func (m *Memberlist) Members() []*Node
:返回所有活着的节点列表。func (m *Memberlist) NumMembers() (alive int)
:返回活着的节点的数量。func (m *Memberlist) Ping(node string, addr net.Addr) (time.Duration, error)
:向指定名称的节点发起Ping。func (m *Memberlist) ProtocolVersion() uint8
:返回当前使用的协议版本。func (m *Memberlist) SendBestEffort(to *Node, msg []byte) error
:使用UDP向指定节点发送消息。消息的最大值取决与配置UDPBufferSize。func (m *Memberlist) SendReliable(to *Node, msg []byte) error
:使用TCP向指定节点发送消息。func (m *Memberlist) Shutdown() error
:关闭本节点。如果希望更优雅地退出集群,请在关闭之前调用Leave。func (m *Memberlist) UpdateNode(timeout time.Duration) error
:更新节点数据(metaData)。
集群示例
要通过memberlist创建集群,需要:
- 创建节点
- 设定消息队列
- 加入集群
客户端代理
所有客户端节点,都需要实现以下接口:
type Delegate interface {
// 节点meta数据,可用于存放节点相关的客户数据;可通过Node结构体的Meta获取
NodeMeta(limit int) []byte
// 节点接收到的所有用户数据,都会触发此消息(不能阻塞)
// 数据可以是其他节点通过broadcast广播,或类似SendReliable主动发送的
NotifyMsg([]byte)
// 获取要广播的消息
GetBroadcasts(overhead, limit int) [][]byte
// 要push的本地数据
LocalState(join bool) []byte
// 接收到的其他节点的数据
MergeRemoteState(buf []byte, join bool)
}
LocalState与MergeRemoteState会定时周期调用,不适合做大量数据的收发。
type messageData struct {
Action int `json:"action"`
SeqId uint64 `json:"seqId"`
Data any `json:"data"`
}
type NodeClient struct {
Cluster *memberlist.Memberlist
DataQueue *memberlist.TransmitLimitedQueue
metaData string
}
func (cs *NodeClient) NodeMeta(limit int) []byte {
return []byte(cs.metaData)
}
func (cs *NodeClient) NotifyMsg(msg []byte) {
if len(msg) == 0 {
return
}
data := &messageData{}
if err := json.Unmarshal(msg, &data); err != nil {
log.Printf("[ERROR] unmarshal fail: %v", err)
return
}
log.Printf("[DEBUG] received notify message %v", data)
go func() { // handle data
}()
}
func (cs *NodeClient) GetBroadcasts(overhead, limit int) [][]byte {
return cs.DataQueue.GetBroadcasts(overhead, limit)
}
func (cs *NodeClient) LocalState(join bool) []byte {
return []byte{}
// if has data to push, return the data
}
func (cs *NodeClient) MergeRemoteState(buf []byte, join bool) {
log.Printf("Remote state: %v, join: %v", string(buf), join)
if len(buf) > 0 {
// merge the data from other node
}
}
func (cs *NodeClient) addBroadcastData(action int, data any) error {
if cs.Cluster.NumMembers() > 1 { // only broadcast when more than one node in cluster
msg := &messageData{
Action: action,
SeqId: GetSequenceId(),
Data: data,
}
out, err := msg.marshal()
if err != nil {
return err
}
broadcast := &messageBroadcast{out}
cs.DataQueue.QueueBroadcast(broadcast)
}
return nil
}
事件代理
当节点加入或离开时会触发节点事件:在以下事件中,不能再去操作节点(如获取节点列表中),否则会引起死锁。
type NodeEvent struct {
client *NodeClient
}
func (ce *NodeEvent) NotifyJoin(node *memberlist.Node) {
log.Printf("[INFO] node %v joined: %v", node, node.Address())
}
func (ce *NodeEvent) NotifyLeave(node *memberlist.Node) {
log.Printf("[INFO] node %v leaved: %v", node, node.Address())
}
func (ce *NodeEvent) NotifyUpdate(node *memberlist.Node) {
log.Printf("[INFO] node %v updated: %v", node, node.Address())
}
对于标识为dead的节点,将不会再尝试连接;所以,若本节点发现所有其他节点都dead后,将会永久脱离集群,除非有其他节点主动连接。为了避免此类问题,可在NotifyLeave中(发现所有其他节点都dead时)通过后台线程主动尝试连接其他节点。
创建节点
以创建一个Lan节点,并加入集群为例:
- 若节点需要映射对外端口,则需要设定AdvertiseAddr与AdvertisePort;
- 通过设定Logger,可控制节点的日志输出;
- Name是节点的名称,在集群中要保证唯一;
client = &NodeClient{}
lanConf := memberlist.DefaultLANConfig()
lanConf.BindPort = bindPort // port to bind
lanConf.Name = nodeName
lanConf.Logger = log.Default()
lanConf.Events = &NodeEvent{client}
lanConf.Delegate = client
outAddr := natAddress // if need nat address(ex. it is in docker)
if len(outAddr) > 0 {
log.Printf("[INFO] node %v use nat address %v", idConfig.NodeId, outAddr)
if h, p, err := net.SplitHostPort(outAddr); err == nil {
lanConf.AdvertiseAddr = h
lanConf.AdvertisePort, _ = strconv.Atoi(p)
} else {
log.Printf("[ERROR] invalid nat address: %v", err)
}
}
cluster, err := memberlist.Create(lanConf)
if err != nil {
log.Printf("[ERROR] creat node %v on port %v fail: %v", idConfig.NodeId, options.BindPort, err)
return nil, err
}
local := cluster.LocalNode()
log.Printf("[INFO] self %v start on %v ok", local.Name, local.Address())
client.Cluster = cluster
client.DataQueue = &memberlist.TransmitLimitedQueue{
NumNodes: func() int {
return cluster.NumMembers()
},
RetransmitMult: 3,
}
count, _ := cluster.Join(members) // members is the address({IP}:{Port}) of other node in the cluster
log.Printf("[INFO] joined %v members", count)