《MMO 服务器开发》7.2 号段模式

号段模式:用 Redis INCRBY 一次批发一批 ID,在内存里用双 buffer 预取慢慢发,把发号的远程调用摊薄到步长分之一。讲清主段与备用段切换的临界区、并发安全与跳号取舍,并在本机真连 Redis 跑出两个实例共用同一号段的互斥断言。

7.2 号段模式

7.1 的雪花算法有一个隐含前提:机器的时钟是可信且单调的。它用本地时间当唯一性的一部分,换来「零远程调用」的高吞吐,代价是怕时钟回拨(7.3 专讲),而且 ID 里的时间戳、worker 位要靠解码才能读出,不是自然序。

号段模式(Segment)走的是完全相反的一条路。它不假装自己是分布式的,而是承认:有一个中心(Redis 或数据库)负责发号,但每次只发一小段,节点把这一段缓存到内存里慢慢发。这样一个节点要发 100 万个 ID,可能只需要访问中心 1000 次(步长 1000)。这一节先说清它与雪花的分工,再用内存双 buffer 预取把「取号」和「发号」解耦,然后在本机真连 Redis,验证两个实例共用同一个号段 key 时互不重叠。

号段模式:把「发号」变成「批发」

号段模式的核心只有一句话:一次向中心申请一段连续区间,之后在这段区间里本地自增。中心只需要提供一个原子自增操作:

  • Redis:INCRBY key step 一步到位,返回值是这一段的右端点,左端点是 返回值 - step + 1;Redis 单线程执行命令,天然原子。数据库:UPDATE id_seq SET value = value + step WHERE name = ? RETURNING value,放在同一个事务里。

两种来源对分配器而言是同一件事,代码里抽象成一个接口 segmentSource,Redis 只是其中一个实现。号段与雪花的取舍:

雪花算法(7.1)号段模式(本节)
依赖本地时钟单调中心发号可用
远程调用零每 step 个 ID 一次
ID 形状时间戳+worker+序列,可解码纯自增,不携带来源
单调性大体递增,跨节点不保证全局严格递增
步长调大不适用中心压力下降,但跳号变多
主要风险时钟回拨中心不可用、号段浪费

号段 ID 适合分页游标、消息序号、订单号这类「按大小就是按时间」的场景;雪花 ID 更适合做主键,因为它不发远程调用、写库不会成为热点。真实项目里两者常常并存。

双 buffer 预取

最朴素的号段实现是「发完了再去取下一段」。但取号段是一次远程调用,可能耗时几毫秒到几十毫秒;如果恰好卡在「上一段发完、下一段还没到」的瞬间,所有要 ID 的线程都得同步等这一次调用——高峰期这就是肉眼可见的毛刺。

解法是双 buffer:主段正在发,后台悄悄预取下一段备用。发到主段只剩一半时就开始预取,等主段真正耗尽时备用段通常早已就绪,切换只是几次赋值。

  主段 [cur ───────────────► max]      备用段 [nextCur ───► nextMax]
        ▲                                     ▲
        │ 剩余 < step/2 时触发后台预取 ────────┘
        │
   主段耗尽:备用段就绪 → 直接切换(零等待)
             备用段未就绪 → 同步补取(阻塞一次)

阈值取「一半」是有讲究的。预取太晚(比如剩 10 个号才触发)就来不及,还是会阻塞;预取太早(一发第一个号就取)则等于每次号段切换都提前一倍时间占用备用槽,浪费一个 buffer。一半是经验值:只要「预取一次远程调用的耗时」小于「发完剩下半个号段的耗时」,切换就是无缝的。

实现

下面这段是 L1/L2,go test 可以直接跑(L1 用内存源,L2 用真实 Redis,见下节):

// file: internal/idgen/segment.go
// tier: L1
// verify: go test ./internal/idgen/
package idgen

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

	"github.com/redis/go-redis/v9"
)

// ErrNoSegment 当前段耗尽且预取失败。
var ErrNoSegment = errors.New("idgen: no segment available")

// segmentSource 是号段来源抽象:一次「取号段」返回闭区间 [start, end]。
// 生产上用 Redis 或 DB 实现;测试里可以用内存实现。
type segmentSource interface {
	NextSegment(ctx context.Context, key string, step int64) (start, end int64, err error)
}

// RedisSource 用 INCRBY 原子取号段。多个进程用同一个 key 时,各自拿到的
// 号段天然不相交——这是号段模式不需要协调的根本原因。
type RedisSource struct {
	Client *redis.Client
}

// NextSegment 原子地把 key 推进 step,返回刚划走的那一段。
func (r *RedisSource) NextSegment(ctx context.Context, key string, step int64) (int64, int64, error) {
	end, err := r.Client.IncrBy(ctx, key, step).Result()
	if err != nil {
		return 0, 0, err
	}
	return end - step + 1, end, nil
}

// Segment 是内存双 buffer 的号段分配器:
//
//	主段 [cur, max] 正在发放;剩余不足一半时后台异步预取备用段;
//	主段耗尽瞬间,若备用段已就绪则直接切换,否则同步补取(会阻塞一次)。
type Segment struct {
	mu       sync.Mutex
	src      segmentSource
	key      string
	step     int64
	cur      int64
	max      int64
	nextCur  int64
	nextMax  int64
	nextOK   bool
	loading  bool
	lastErr  error
	loaded   chan struct{} // 一次预取完成的广播信号
	prefetch int64         // 已异步预取的次数(供测试观测)
}

// NewSegment 构造分配器并同步取好第一段。
func NewSegment(ctx context.Context, src segmentSource, key string, step int64) (*Segment, error) {
	if step <= 0 {
		return nil, fmt.Errorf("idgen: step must be positive, got %d", step)
	}
	s := &Segment{src: src, key: key, step: step, loaded: make(chan struct{})}
	cur, max, err := src.NextSegment(ctx, key, step)
	if err != nil {
		return nil, err
	}
	s.cur, s.max = cur, max
	return s, nil
}

// Next 返回下一个全局唯一 ID。并发安全。
func (s *Segment) Next(ctx context.Context) (uint64, error) {
	for {
		s.mu.Lock()
		if s.cur <= s.max {
			id := s.cur
			s.cur++
			if s.remaining() < s.step/2 && !s.nextOK && !s.loading {
				s.loading = true
				s.prefetch++
				// 预取脱离调用方 ctx:它必须活过本次 Next。
				go s.load(context.Background())
			}
			s.mu.Unlock()
			return uint64(id), nil
		}
		// 主段耗尽
		if s.nextOK {
			s.cur, s.max, s.nextOK = s.nextCur, s.nextMax, false
			s.mu.Unlock()
			continue
		}
		if s.loading {
			done := s.loaded
			s.mu.Unlock()
			<-done
			continue
		}
		// 既无备用段又没在加载:同步补取
		s.mu.Unlock()
		if err := s.syncLoad(ctx); err != nil {
			return 0, err
		}
	}
}

func (s *Segment) remaining() int64 { return s.max - s.cur + 1 }

// load 在后台取下一段并写进备用槽。
func (s *Segment) load(ctx context.Context) {
	start, end, err := s.src.NextSegment(ctx, s.key, s.step)
	s.mu.Lock()
	s.loading = false
	if err != nil {
		s.lastErr = err
	} else {
		s.nextCur, s.nextMax, s.nextOK = start, end, true
	}
	old := s.loaded
	s.loaded = make(chan struct{})
	s.mu.Unlock()
	close(old)
}

func (s *Segment) syncLoad(ctx context.Context) error {
	start, end, err := s.src.NextSegment(ctx, s.key, s.step)
	if err != nil {
		return err
	}
	s.mu.Lock()
	s.cur, s.max = start, end
	s.mu.Unlock()
	return nil
}

// PrefetchCount 返回异步预取次数,供测试断言双 buffer 真的生效。
func (s *Segment) PrefetchCount() int64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.prefetch
}

// WaitPrefetch 阻塞到当前正在进行的预取结束(或超时),供测试用。
func (s *Segment) WaitPrefetch(d time.Duration) {
	s.mu.Lock()
	if !s.loading {
		s.mu.Unlock()
		return
	}
	done := s.loaded
	s.mu.Unlock()
	select {
	case <-done:
	case <-time.After(d):
	}
}

真跑:两个实例共用 Redis 号段

下面这段是 L2,它真连本机的 Redis(127.0.0.1:26379,主控起的独立容器):起两个 Segment 实例,共用同一个 key 与同一个步长,各发 2500 个 ID,断言 5000 个 ID 两两互斥,并检查 Redis 里的计数确实是步长的整数倍。

// file: internal/idgen/segment_redis_test.go
// tier: L2
// verify: go test ./internal/idgen/ -run TestSegmentRedisTwoInstancesDisjoint -v
package idgen

import (
	"context"
	"fmt"
	"os"
	"sync"
	"testing"
	"time"

	"github.com/redis/go-redis/v9"
)

func TestSegmentRedisTwoInstancesDisjoint(t *testing.T) {
	ctx := context.Background()
	addr := os.Getenv("AEGIS_REDIS_ADDR")
	if addr == "" {
		addr = "127.0.0.1:26379"
	}
	c := redis.NewClient(&redis.Options{Addr: addr})
	defer c.Close()
	if err := c.Ping(ctx).Err(); err != nil {
		t.Skipf("Redis 不可达(%s),跳过 L2:%v", addr, err)
	}
	key := fmt.Sprintf("aegis:test:ch07:seg:%d", os.Getpid())
	// 后台预取可能在测试收尾后仍 INCRBY 一次,故等它落定后再删、并补删一次。
	defer func() {
		time.Sleep(150 * time.Millisecond)
		c.Del(ctx, key)
		time.Sleep(150 * time.Millisecond)
		c.Del(ctx, key)
	}()

	src := &RedisSource{Client: c}
	const step = 1000
	a, err := NewSegment(ctx, src, key, step)
	if err != nil {
		t.Fatalf("new a: %v", err)
	}
	b, err := NewSegment(ctx, src, key, step)
	if err != nil {
		t.Fatalf("new b: %v", err)
	}

	const per = 2500
	seen := make(map[uint64]struct{}, per*2)
	var mu sync.Mutex
	consume := func(seg *Segment, tag string) {
		local := make([]uint64, 0, per)
		for i := 0; i < per; i++ {
			id, err := seg.Next(ctx)
			if err != nil {
				t.Errorf("%s next: %v", tag, err)
				return
			}
			local = append(local, id)
		}
		mu.Lock()
		for _, id := range local {
			seen[id] = struct{}{}
		}
		mu.Unlock()
	}
	var wg sync.WaitGroup
	wg.Add(2)
	go func() { defer wg.Done(); consume(a, "a") }()
	go func() { defer wg.Done(); consume(b, "b") }()
	wg.Wait()

	if len(seen) != per*2 {
		t.Fatalf("两实例应共产出 %d 个互斥 ID,实际 %d", per*2, len(seen))
	}
	// 号段模式下 Redis 只被 INCRBY 了 ceil(5000/1000)=5 次
	got, err := c.Get(ctx, key).Int64()
	if err != nil {
		t.Fatalf("get key: %v", err)
	}
	if got%step != 0 || got < per*2 {
		t.Fatalf("Redis 计数应为 %d 的整数倍且不小于 %d,实际 %d", step, per*2, got)
	}
	t.Logf("两实例共 %d 个 ID,Redis INCRBY 次数 = %d(号段步长 %d)", per*2, got/step, step)
}

本机实跑:

两实例共 5000 个 ID,Redis INCRBY 次数 = 6(号段步长 1000)

数字印证了号段模式的价值:发 5000 个 ID,Redis 只被打扰了 6 次。理论上 5000/1000 = 5 段就够,多出来的 1 次是双 buffer 提前预取的结果——两个实例各预取了一段备用,其中一段没用上就被测试结束了,这就是「用一点号段浪费换掉远程调用毛刺」的代价。作为对照,若每个 ID 都走一次 INCR,Redis 要被访问 5000 次。同机相对量级,绝对数值不可移植。

边界:号段耗尽与并发安全

号段模式的正确性全在 Next 那个临界区里,有几个边界必须讲清:

  • 并发唯一性。cur 的读取、自增、返回必须在同一把锁里完成,否则两个 goroutine 会拿到同一个号。L1 测试用 16 个 goroutine 各发 2000 个 ID,断言 32000 个全部唯一,go test -race 下也是绿的。
  • 耗尽切换。主段发完的那一瞬间,代码按「备用段就绪 → 切换」「正在加载 → 等它」「都没在动 → 同步补取」三种情况分支。只有第三种会阻塞,而它只有在预取没赶上时才发生。
  • 预取的 ctx 必须脱离调用方。Next 的 ctx 来自业务请求,可能在 Next 返回后就被取消;预取 goroutine 若用它,就会出现「号段取了一半被取消」的诡异错误,所以代码里显式用 context.Background()。
  • 跳号是特性不是 bug。段没用完就重启时剩下的号永久浪费;号段 ID 允许空洞,只要唯一即可,不要当连续序号用。中心不可用时 Next 会返回错误,号段模式把「中心可用性」变成硬依赖,这也是它常和雪花并用的原因。

本节小结

  • 号段模式把「发号」变成「批发」:一次向中心申请一段,在内存里慢慢发,把远程调用摊薄到 step 分之一;Redis 用 INCRBY、数据库用 UPDATE ... RETURNING 都能提供原子号段。
  • 双 buffer 预取在主段剩一半时就开始取备用段,让「耗尽切换」几乎零等待;阈值取一半是「预取耗时 vs 剩余发放耗时」的经验平衡。本机实测:发 5000 个 ID 只访问 Redis 6 次。号段 ID 全局严格递增、适合分页游标与订单号,但不携带来源信息、允许跳号。

阅读导航:上一节:7.1 雪花算法从零实现 · 下一节:7.3 时钟回拨与容错 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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