服务启动(6.1):集群节点构建

158 阅读11分钟

在服务启动的时候,集群节点的构建过程中会初始化一些基础内容:比如初始化内存数据结构、集群的初始元数据等,这些在基础操作中都是很重要的前提,下面我们将分析jocko服务中如何启动集群的。

Jocko服务中,使用了Serf和Raft协议来构建分布式系统。
这个系统从底层到顶层的构建分为三个部分:

  1. Serf成员:首先,分布式系统中的各个节点作为Serf服务发现系统的成员进行初始化。Serf层主要负责网络中的节点间通信、成员状态变更通知以及节点故障检测。简单来说,Serf成员所做的就是确保所有节点能够找到彼此,并且知道彼此是否正在正常运行。
  2. Raft对等节点:在Serf层之上,每个节点可以被提升为Raft对等节点。这些节点在保持Serf层功能的基础上,利用Raft协议维持数据一致性和领导选举。这样就确保了即使某个节点失效,系统仍然能够正常运行,并且所有节点的数据都保持一致。
  3. Jocko Brokers:最高层是Jocko Brokers,即实际的业务处理单位。在具备节点发现能力(由Serf提供)和数据一致性(由Raft提供)基础上,Jocko Broker负责实现业务逻辑,例如,管理和维护分布式消息队列服务。这就实现了一个既有高可用性又有强一致性的分布式系统。

项目中使用了hashicorp的serf和raft框架来实现,将来会出对应的专题来阅读这两个框架,在当前框架中仅仅针对这两个框架的使用进行分析。

集群节点的核心数据结构:

type Broker struct {
    ... ...
}

该结构体的创建流程如下:

func NewBroker(config *config.Config, tracer opentracing.Tracer) (*Broker, error) {
	b := &Broker{
		... ...
	}

	if err := b.setupRaft(); err != nil {
		b.Shutdown()
		return nil, fmt.Errorf("start raft: %v", err)
	}

	var err error
	b.serf, err = b.setupSerf(config.SerfLANConfig, b.eventChLAN, serfLANSnapshot)
	if err != nil {
		return nil, err
	}

	go b.lanEventHandler()

	go b.monitorLeadership()

	go b.logState()

	return b, nil
}

创建的流程中,包含了对raft和serft的构建,以及启动三个协程,其中两个协程是针对集群中节点变动的监控。

本章节主要针对raft和serf的构建进行分析。

1、构建raft

在broker结构体中与Raft相关的字段如下:

type Broker struct {
    // 代表一个 Raft 实例,这个实例在 Jocko brokers 中使用,以确保需要强一致性的操作得到保护。  
    raft *raft.Raft  

    // 是一个 Raft 存储实例,使用 BoltDB 编写,用于持久化 Raft 日志和 Raft 状态。  
    raftStore *raftboltdb.BoltStore  

    // 是一个网络传输实例,用于在 Raft 节点之间传输信息。  
    raftTransport *raft.NetworkTransport  

    // 内存存储实例,用于临时存储raft节点的信息  
    raftInmem *raft.InmemStore  

    // 接收到raft节点的领导者状态发生的改变通知  
    raftNotifyCh <-chan bool
}

创建raft节点的函数如下:

b.raft, err = raft.NewRaft(b.config.RaftConfig, b.fsm, logStore, stable, snap, trans)

这个函数包含6个入参,每个参数的含义如下:

  • conf Config:指向 Raft 节点配置对象的指针。这个配置对象通常包含节点的身份信息(如节点 ID、选举超时、心跳间隔等)以及 Raft 算法的行为参数。这些配置项决定了 Raft 节点的行为模式和网络交互特性。
  • fsm FSM:实现 hashicorp/FSM 接口的对象。在 Raft 中,FSM(Finite State Machine,有限状态机)代表了业务逻辑或应用状态,负责处理由 Raft 节点提交的已排序的日志条目,并将日志中的操作应用到本地状态。传入这个参数是为了将 Raft 的共识机制与实际业务逻辑紧密结合。
  • logs LogStore:实现 LogStore 接口的对象。LogStore 负责存储和管理 Raft 节点的日志条目,包括日志的读取、追加、删除等操作。它是 Raft 算法实现复制状态机的关键组件,确保节点间日志的一致性。
  • stable StableStore:实现 StableStore 接口的对象。StableStore 提供了稳定的存储空间,用于保存 Raft 节点的元数据,如当前任期号、投票给谁、已提交的日志索引等。这些数据需要在节点重启后依然能够持久化保留,确保节点能够正确恢复其在 Raft 集群中的状态。
  • snaps SnapshotStore:实现 SnapshotStore 接口的对象。SnapshotStore 用于存储和管理 Raft 节点的快照数据。快照是对当前节点状态的完整备份,可以大幅减少恢复时所需重放的日志条目数量,提高节点重启速度和集群效率。
  • trans Transport:实现 Transport 接口的对象。Transport 负责 Raft 节点之间的网络通信,包括发送投票请求、心跳消息、日志复制请求等。它是 Raft 算法实现分布式协调的核心组件,确保节点间能够高效、可靠地交换信息。

下面我们来逐一解析源码中在服务启动的时候,是如何对这些字段进行初始化的。

1)config字段

代码中Config的结构体如下:

//jocko/config/config.go
type Config struct {
    ... ...
    RaftConfig *raft.Config
    ... ...
}

//hashicorp/raft/config.go

type Config struct {
    // 服务器的唯一标准
    LocalID ServerID 

    // 节点是否按照领导者的状态开始执行
    StartAsLeader bool

    //用于当领导者发生改变时候的通知
    NotifyCh chan<- bool

}

源码中初始的配置信息:


b.config.RaftConfig.LocalID = raft.ServerID(fmt.Sprintf("%d", b.config.ID))
b.config.RaftConfig.StartAsLeader = b.config.StartAsLeader

b.config.RaftConfig.NotifyCh = raftNotifyCh
b.raftNotifyCh = raftNotifyCh

配置文件中先是设置了节点加入集群时候,在raft协议下的状态以及broker字段中保留了接受领导者发生改变的通道接口。

2)fsm字段

代码中FSM的结构体如下:

type FSM struct {  
    apply map[structs.MessageType]command  
    stateLock sync.RWMutex  
    state *Store  
    tracer opentracing.Tracer  
    nodeID NodeID  
}

核心数据结构:

  • apply map[structs.MessageType]command 保存了存储日志消息的对应操作函数
  • state *Store 保存对内存中数据的操作方法

FSM结构体实现了hashicorp/FSM的接口,这个接口是客户端为了使用复制日志而实现的。

在一个分布式系统中,各个节点之间通过复制和应用日志来达到系统状态的一致性,这就是所谓的"复制状态机"(Replicated State Machine)模型。因此,客户端通过实现FSM接口,可以处理复制日志,从而达到分布式系统中的状态一致性。

这个接口包含三个方法,都是对复制日志的操作:

type FSM interface {
    /*
    这个方法用于将已提交的日志条目应用到状态机。
    在一个操作被集群中的大多数节点确认后(也就是被提交后)
    ,领导者就会调用此方法执行这个条目对应的指令。
    方法的返回值将做为同一个Raft节点中对应的Raft.Apply方法的ApplyFuture的结果值。
    */
	Apply(*Log) interface{}
    /*
    这个方法用于完成日志压缩的支持。
    */
	Snapshot() (FSMSnapshot, error)
    /*
    这个方法用于从快照恢复状态机。
    */
	Restore(io.ReadCloser) error
}

核心方法是Apply,下面是对Apply的实现

func (c *FSM) Apply(l *raft.Log) interface{} {
	buf := l.Data
	msgType := structs.MessageType(buf[0])
	if fn := c.apply[msgType]; fn != nil {
		return fn(buf[1:], l.Index)
	}
	return nil
}

从上面的代码可知,针对raft.log的内容,先解析对应的消息类型,根据消息类型从FSM结构体中的apply中得到对应的函数来处理消息的内容。所以在创建FSM结构体的时候,需要将apply字段进行初始化操作,将每个操作的函数注册到apply字段中。

对每个操作,注册函数的代码如下:

var commands map[structs.MessageType]unboundCommand

func init() {
	registerCommand(structs.RegisterNodeRequestType, (*FSM).applyRegisterNode)
	registerCommand(structs.DeregisterNodeRequestType, (*FSM).applyDeregisterNode)
	registerCommand(structs.RegisterTopicRequestType, (*FSM).applyRegisterTopic)
	registerCommand(structs.DeregisterTopicRequestType, (*FSM).applyDeregisterTopic)
	registerCommand(structs.RegisterPartitionRequestType, (*FSM).applyRegisterPartition)
	registerCommand(structs.DeregisterPartitionRequestType, (*FSM).applyDeregisterPartition)
	registerCommand(structs.RegisterGroupRequestType, (*FSM).applyRegisterGroup)
}

func registerCommand(msg structs.MessageType, fn unboundCommand) {
	if commands == nil {
		commands = make(map[structs.MessageType]unboundCommand)
	}
	if commands[msg] != nil {
		panic(fmt.Errorf("Message %d is already registered", msg))
	}
	commands[msg] = fn
}

源码中使用了一个全局变量来保存消息的处理函数,并且通过init函数进行初始化。具体每个消息的函数实现,等分析对应的操作的时候再详细说明。这里说明一下每个消息类型的含义:

const (
    // 新增节点的消息
	RegisterNodeRequestType        MessageType = 0
    // 从系统中移除某个节点的消息
	DeregisterNodeRequestType                  = 1
    // 注册主题的消息
	RegisterTopicRequestType                   = 2
    //注销主题的消息
	DeregisterTopicRequestType                 = 3
    //注册分区的消息
	RegisterPartitionRequestType               = 4
    //注销分区的消息
	DeregisterPartitionRequestType             = 5
    //注册分组的消息
	RegisterGroupRequestType                   = 6
)

在FSM结构体中还包含了数据在内存的存储方式,这包含了使用特定的数据结构来处理数据的存储规则。对于kafka中的"主题"和"分区",这都是设计上逻辑数据的概念,而具体的数据如何根据这些逻辑数据的规则来在内存中进行布局和存储,就需要特定的实现来做到。。

在jocko中引入了hashicorp/memdb来完成内存中数据的存储以及对应的操作。

memdb是一个基于不可变技术树实现的简单内存数据库,此数据库实现了ACID(Atomicity、Consistency、Isolation)中的原子性、一致性以及隔离性。但由于它是内存数据库,因此不具备持久性。在初始化数据库时需要提供一个模式,该模式指定了存在的表和索引,并允许执行事务操作。

此外,MemDB的多字段索引特性使得我们可以根据多种查询条件有效地查找数据,提升了数据操作的灵活性。这对于处理如Kafka中的主题和分区等逻辑概念尤为重要,因为这需要我们以多种方式处理和查询数据。

下面根据源码中的调用来理解hashicorp/memdb的使用:

var schemas []schemaFn

func registerSchema(fn schemaFn) {  
    schemas = append(schemas, fn)  
}

func init() {  
    registerSchema(indexTableSchema)  
    registerSchema(nodesTableSchema)  
    registerSchema(topicsTableSchema)  
    registerSchema(partitionsTableSchema)  
    registerSchema(groupTableSchema)  
    ... ...
}

在源码中也是使用一个全局变量来保存在内存中初始的表结构,下面分析每个表结构:

  1. index ---跟踪raft日志的索引
  2. nodes ---与集群节点相关,记录集群节点的元数据
  3. topic ---与主题相关,记录主题的名称
  4. partitions---与分区相关,记录与分区相关的主题以及主节点信息
  5. group ---与分组相关

针对每个操作,在之后的文章中进行分析。

3)Log stables snap字段


store, err := raftboltdb.NewBoltStore(filepath.Join(path, "raft.db"))
if err != nil {
	return err
}
b.raftStore = store
stable = store

cacheStore, err := raft.NewLogCache(raftLogCacheSize, store)
if err != nil {
	return err
}
logStore = cacheStore

snapshots, err := raft.NewFileSnapshotStore(path, snapshotsRetained, nil)
if err != nil {
	return err
}
snap = snapshots

上面的代码创建了一个完整的 Raft 存储后端,包括日志存储,缓存机制以及快照存储。

4)trans 字段


	trans, err := raft.NewTCPTransport(b.config.RaftAddr,
		nil,
		3,
		10*time.Second,
		nil,
	)
	if err != nil {
		return err
	}
	b.raftTransport = trans

通过hashicorp/raft的NewTCPTransport实现,主要传入了本节点在集群中的raft地址,相当于将节点的注册到集群中。

2、构建Serf

在broker结构体中与seft相关的字段如下:

type Broker struct {
    serf             *serf.Serf
    // serf的事件通知
	eventChLAN       chan serf.Event
}

创建serf的代码如下:

serf.Create(config)

入参只有conf *Config,这里面包含了创建serf的配置信息,下面分析对应的配置信息的字段:

//hashicrop/serf/config.go
type Config struct {
    // 节点名称
    NodeName string

    //每个节点提供任意的键/值元数据。
    Tags map[string]string

    // 接受serf事件通知
    EventCh chan<- Event

    // 磁盘文件用于存储节点快照
    // 在节点重新启动或者恢复时保持系统的一致性和持久性,同时提高系统的可靠性和效率
    SnapshotPath string
    
}

入参只有conf *Config,这里面包含了创建serf的配置信息,下面分析对应的配置信息的字段:

//hashicrop/serf/config.go
type Config struct {
    // 节点名称
    NodeName string

    //存储节点的各种配置的信息,即元数据信息
    Tags map[string]string

    // 接受serf事件通知
    EventCh chan<- Event

    // 磁盘文件用于存储节点快照
    // 在节点重新启动或者恢复时保持系统的一致性和持久性,同时提高系统的可靠性和效率
    SnapshotPath string
    
}

1)tags字段

// jocko/serf.go
func (b *Broker) setupSerf(config *serf.Config, ch chan serf.Event, path string) (*serf.Serf, error) {
	config.Init()
    config.Tags["role"] = "jocko"
	config.Tags["id"] = fmt.Sprintf("%d", b.config.ID)
	if b.config.Bootstrap {
		config.Tags["bootstrap"] = "1"
	}
	if b.config.BootstrapExpect != 0 {
		config.Tags["expect"] = fmt.Sprintf("%d", b.config.BootstrapExpect)
	}
	if b.config.NonVoter {
		config.Tags["non_voter"] = "1"
	}
	config.Tags["raft_addr"] = b.config.RaftAddr
	config.Tags["serf_lan_addr"] = fmt.Sprintf("%s:%d", b.config.SerfLANConfig.MemberlistConfig.BindAddr, b.config.SerfLANConfig.MemberlistConfig.BindPort)
	config.Tags["broker_addr"] = b.config.Addr
    ... ...
}

jocko中针对serf的config配置信息如下:

  1. "role": 角色名称,这里被设置为 "jocko"。

  2. "id": 节点ID值,这是使用 fmt.Sprintf("%d", b.config.ID) 进行的格式化为字符串的值。

  3. "bootstrap": 此项表示是否为启动节点,当 b.config.Bootstrap 为 true 时,它的值被设置为 "1"。

  4. "expect": 这个标签表示预期的启动节点数量,当 b.config.BootstrapExpect 不为 0 时,其值等于 b.config.BootstrapExpect。

  5. "non_voter": 此项表示节点是否为非投票节点,当 b.config.NonVoter为 true 时,其值被设置为 "1"。非投票节点 会接收来自领导节点的日志副本,但不会参与投票和选举过程

  6. "raft_addr": 这个标签值是 Raft 的地址,其值为 b.config.RaftAddr。

  7. "serf_lan_addr": 这是 Serf LAN 地址,其值是 fmt.Sprintf("%s:%d", b.config.SerfLANConfig.MemberlistConfig.BindAddr, b.config.SerfLANConfig.MemberlistConfig.BindPort) 的结果。

  8. "broker_addr": 这个标签值为 broker 的地址,其值为 b.config.Addr。