《MMO 服务器开发》2.2 Leader 选举与服务所有权

单写者是集群里最难保证也最值钱的性质。本节用 etcd 的 TTL 租约加一次事务 CAS 从零实现 Leader 选举,讲清为什么「键不存在才写」就等价于互斥,再用单调递增的 fencing token 挡住醒过来的旧 leader,真连本机 etcd 断言同一时刻只有一个 leader。

有些状态天生只能有一个写者。世界 Boss 的血量归谁扣?跨服匹配队列由谁维护?公会排行榜的每次更新由谁落库?如果两个进程同时认为「归我管」,它们会各自把本地算出的结果写回共享存储,后写的覆盖先写的——数据看着没报错,却已经错了。

上一节我们让每个节点都能被发现,这一节解决一个更硬的问题:在一群对等节点里,怎么保证任何时刻只有一个能当 Leader。 这不只是「选个老大」,而是给共享状态找一个唯一的写者(single writer)。本节用本机 etcd 从零实现,并真跑断言「同一时刻只有一个 leader」。

2.2.1 为什么需要 Leader:单写者与所有权

先厘清两个词。**所有权(ownership)**指的是「这份状态此刻由谁负责修改」,**单写者(single writer)**是达成它的手段:对任意一份可变的共享状态,同一时刻只允许一个进程写它。Leader 选举就是「选出现在谁是写者」。

为什么不能大家都写、用版本号解决冲突?因为很多游戏状态的合并语义是非交换的。世界 Boss 掉了 1000 点血,两次并发结算各扣 300,如果两边都基于旧血量做减法再写回,最终可能是 -600 也可能是 -300,取决于谁后写——而正确答案是 -600。这类「读-改-写」不是把两个结果合并就能补回来的,它必须串行。

于是核心问题变成:怎么实现一把在网络分区下也安全的锁。天真的做法是「抢到一个键就当 Leader,自己记个标志位」。看这个分区时序:

t0  scene-01 抢到领导键,自认为 leader,开始处理 Boss 结算
t1  网络分区:scene-01 与 etcd 失联,但它自己不知道,仍在写
t2  scene-01 的租约到期,etcd 删键;scene-02 抢到键,成为新 leader
t3  分区恢复,scene-01 以为自己还是 leader,继续往共享存储写
t4  两个 leader 同时写 → 脑裂(split-brain),数据被写坏

t1 到 t3 之间就是脑裂窗口。修复它需要两件东西:选举本身要互斥(同一时刻只有一个能拿到键),以及旧 leader 的写要被拒(哪怕它自己还以为在任上)。前者靠 etcd 的事务,后者靠 fencing token。

在《晨曦大陆》的集群里,需要单写者的状态至少有这几类,它们的所有权粒度不同:

共享状态单写者粒度写错的后果
世界 Boss 血量与归属每个 Boss 一个 leader伤害重复结算或丢失
跨服匹配队列每个玩法一个 leader同一队伍被匹配进两场
分片分配表(谁托管哪个场景)全集群一个 leader同一场景被两个进程托管
全局排行榜快照每个榜单一个 leader排名前后矛盾

这张表说明「Leader 选举」不是一个全局唯一的角色,而是一组按资源划分的单例:全集群可能有一个「分配表 leader」,同时又有几十个「Boss leader」。它们共用同一套 Election 代码,只是 name 不同——name 就是资源的所有权边界。

2.2.2 用租约 + 事务 CAS 实现竞选

下面这段是 L2,真连本机 etcd,go test 可以直接跑。竞选的核心是一次事务:只有当领导键不存在(CreateRevision == 0)时才写入自己。etcd 对事务串行化,所以同一时刻至多一个事务能成功——这就是「只有一个 leader」的全部根据,不需要额外的分布式锁协议:

// file: internal/discovery/election.go
// tier: L2
// verify: go test ./internal/discovery/ -run TestEtcd -v
package discovery

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"

	clientv3 "go.etcd.io/etcd/client/v3"
)

var (
	// ErrNotLeader:这次竞选没抢到领导权(已被别人占着)。
	ErrNotLeader = errors.New("discovery: not leader")
	// ErrFenced:写操作的 fencing token 不比见过的更大,说明自己已经是旧 leader。
	ErrFenced = errors.New("discovery: fenced by newer token")
)

// Leadership 是一次成功的竞选结果。
type Leadership struct {
	Node NodeID
	// Token 是 fencing token:取得领导权那一刻 etcd 的事务 revision。
	// etcd 的 revision 全局单调递增,所以「越晚当选的 token 越大」是硬保证。
	Token int64

	cli    *clientv3.Client
	key    string
	lease  clientv3.LeaseID
	cancel context.CancelFunc
	once   sync.Once
}

// Election 是同一 name 下的一次「单例领导权」竞选。
type Election struct {
	cli    *clientv3.Client
	prefix string
	name   string
	ttl    int64
}

// NewElection 创建一个挂在 cli 上的竞选器,领导键为 prefix+"election/"+name。
func NewElection(cli *clientv3.Client, prefix, name string, ttl time.Duration) *Election {
	return &Election{cli: cli, prefix: prefix, name: name, ttl: int64(ttl.Seconds())}
}

func (e *Election) key() string { return e.prefix + "election/" + e.name }

// Campaign 尝试抢占领导权。成功返回 Leadership;已被别人占着返回 ErrNotLeader。
//
// 抢占用一次事务完成:只有当领导键的 CreateRevision == 0(即键不存在)
// 时才写入自己。etcd 对事务串行化,所以同一时刻至多一个事务能成功——
// 这就是「同一时刻只有一个 leader」的根据,不需要额外的分布式锁。
func (e *Election) Campaign(ctx context.Context, id NodeID) (*Leadership, error) {
	grant, err := e.cli.Grant(ctx, e.ttl)
	if err != nil {
		return nil, err
	}
	resp, err := e.cli.Txn(ctx).
		If(clientv3.Compare(clientv3.CreateRevision(e.key()), "=", 0)).
		Then(clientv3.OpPut(e.key(), string(id), clientv3.WithLease(grant.ID))).
		Commit()
	if err != nil {
		_, _ = e.cli.Revoke(context.Background(), grant.ID)
		return nil, err
	}
	if !resp.Succeeded {
		// 没抢到,把自己建的租约还回去,别白占资源。
		_, _ = e.cli.Revoke(context.Background(), grant.ID)
		return nil, ErrNotLeader
	}
	hbCtx, cancel := context.WithCancel(context.Background())
	kaCh, err := e.cli.KeepAlive(hbCtx, grant.ID)
	if err != nil {
		cancel()
		_, _ = e.cli.Revoke(context.Background(), grant.ID)
		return nil, err
	}
	l := &Leadership{
		Node:   id,
		Token:  resp.Header.Revision,
		cli:    e.cli,
		key:    e.key(),
		lease:  grant.ID,
		cancel: cancel,
	}
	go func() {
		for range kaCh {
			// 续约成功什么都不用做;通道关闭即租约失效、领导权自动让出。
		}
	}()
	return l, nil
}

// Resign 主动让出领导权(撤销租约,键立即消失)。
func (l *Leadership) Resign(ctx context.Context) error {
	var err error
	l.once.Do(func() {
		l.cancel()
		_, err = l.cli.Revoke(ctx, l.lease)
	})
	return err
}

// Leader 读回当前领导键指向的节点及其 token。
func (e *Election) Leader(ctx context.Context) (NodeID, int64, error) {
	resp, err := e.cli.Get(ctx, e.key())
	if err != nil {
		return "", 0, err
	}
	if len(resp.Kvs) == 0 {
		return "", 0, ErrNotLeader
	}
	return NodeID(resp.Kvs[0].Value), resp.Kvs[0].ModRevision, nil
}

// Fencer 用单调递增的 fencing token 挡住「醒过来的旧 leader」。
//
// 脑裂场景:旧 leader 与 etcd 之间网络断了,它自己还以为在任上,会继续
// 往共享资源(分片分配表、掉落结算表)写。下游只要在每次有副作用的写之前
// 调一次 Check,就能保证旧 token 的写被拒——它见过的最大值只会前进不会后退。
type Fencer struct {
	mu       sync.Mutex
	lastSeen int64
}

// Check 校验一个 token。比见过的最大值大就放行并推进;否则返回 ErrFenced。
func (f *Fencer) Check(token int64) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	if token <= f.lastSeen {
		return fmt.Errorf("%w: token %d <= seen %d", ErrFenced, token, f.lastSeen)
	}
	f.lastSeen = token
	return nil
}

// LastSeen 返回当前见过的最大 token(排障用)。
func (f *Fencer) LastSeen() int64 {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.lastSeen
}

注意 Campaign 里租约和事务的分工:租约负责「Leader 死了会自动让位」,事务负责「让位的过程互斥」。少了事务,两个节点可能同时看到键为空、同时写入,后写的赢但先写的也以为赢了;少了租约,Leader 崩溃后键永远不释放,集群卡死。两者缺一不可。

2.2.3 fencing token 与脑裂防护

选举保证「同一时刻只有一个能拿到键」,但挡不住「旧 leader 还以为自己在任上」。看 Campaign 里这一行:

// snippet: token 就是取得领导权那一刻 etcd 的事务 revision
l := &Leadership{ /* ... */ Token: resp.Header.Revision /* ... */ }

resp.Header.Revision 是这次事务提交后 etcd 的全局 revision。etcd 的 revision 只增不减,所以「越晚当选的 leader,token 越大」是中间件的硬保证,不依赖任何本地时钟。把这个 token 随每次写操作一起带上,下游就能判断「这次写是不是来自一个更晚的任期」:

  • 新 leader 上任,token 变大 → 它的写被放行;
  • 旧 leader 醒来,token 还是旧的 → 它的写被 Fencer.Check 拒掉,返回 ErrFenced。

这样即使旧 leader 真的在脑裂窗口里发了写请求,也只会被拒绝,不会污染数据。这就是 fencing 的全部:用中间件提供的单调计数器,把「我是不是最新任期的写者」变成一个可以判定的比较,而不是靠各节点互相同步状态。

落点在写路径上就一行判断——任何有副作用的写之前先过 Fencer:

// snippet: Leader 保护一次有副作用的写(在分片分配表上的落点)
func (l *Leadership) commitAssignment(ctx context.Context, f *Fencer, plan Assignment) error {
	// 写之前先过 fencing:如果自己已经不是最新任期的 leader,直接拒绝。
	if err := f.Check(l.Token); err != nil {
		return err // ErrFenced:放弃这次写,让新 leader 接手
	}
	return store.Apply(ctx, plan)
}

真跑的断言结果:

=== RUN   TestEtcdLeaderElectionSingleLeader
    election_test.go:56: elected leader=scene-03 token=451
    election_test.go:96: takeover leader=scene-01 token=453 (was 451)
--- PASS: TestEtcdLeaderElectionSingleLeader (0.08s)
PASS
ok  	aegis/internal/discovery	0.485s

这个用例做了三件事:三个候选人用栅栏同时发起竞选,断言恰好一个成功(其余都返回 ErrNotLeader);让现任 leader 撤销租约(模拟崩溃),断言另一个候选人能接管;最后用 Fencer 验证新 token 放行、旧 token 被拒。第二行日志里 was 451 到 453 的递增,就是 fencing token 的硬保证在真实 etcd 上的体现。

本节小结

  1. Leader 选举的本质是给共享状态找唯一写者。 读-改-写不是可合并的,世界 Boss 扣血这类操作必须串行,所以需要 single writer。
  2. 互斥靠事务,让位靠租约。 「键不存在才写」的 CAS 事务保证同一时刻只有一个赢家;TTL 租约保证 Leader 崩溃后能自动让位。两者缺一不可。
  3. fencing token 才是脑裂的终点。 选举只能保证「只有一个能拿到键」,挡不住旧 leader 醒来后的写;用 etcd 单调递增的 revision 做 token,让下游拒掉过期任期的写。

一句话收束:选举解决「谁是老大」,fencing 解决「前任还以为自己是老大」——分布式系统里,后者往往才是出事故的那一半。 下一节换一个角度:如果不用 etcd,同样的注册-发现-健康检查用 NATS 和 Consul 做会是什么样,我们又该怎么选。

阅读导航:上一节:2.1 注册与健康检查 · 下一节:2.3 对照:etcd / NATS / Consul 在本机的真跑选型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标