一、静态地图与动态实例要分开
一张地图上同时存在两类数据。静态数据是阻挡、安全区、出生点、传送门、导航网格,它们由关卡配置导出,所有玩家共享,适合只读缓存。动态状态是怪物、掉落、门开关、副本进度,它们只属于某一次副本实例,不能跨实例共享。
| 数据 | 归属 | 是否共享 | 版本策略 | 加载方式 |
|---|---|---|---|---|
| 阻挡、导航网格 | 地图配置 | 全服共享 | 强版本,必须一致 | 启动或首次进入时加载 |
| 出生点、传送门 | 地图配置 | 全服共享 | 强版本,必须一致 | 随地图一起加载 |
| 怪物、掉落 | 副本实例 | 实例内共享 | 随实例创建 | 实例创建时初始化 |
| 门、机关状态 | 副本实例 | 实例内共享 | 随实例创建 | 实例创建时初始化 |
| 副本进度 | 副本实例 | 实例内共享 | 实例级持久化 | 进入时恢复 |
把动态状态塞进静态地图缓存,是副本串状态的主要原因:A 队打完的 Boss 在 B 队进本时仍然是死的。反过来,把阻挡数据按玩家复制一份,内存会被迅速吃光,而且热更时无法保证所有副本用的是同一版规则。
二、分片键由三个维度组成
加载单元不能只用地图 ID。大地图要按区域拆,副本要按实例拆,同一个 MapID 下可能同时存在几十个互不相干的运行时副本。
| 维度 | 作用 | 变化频率 | 示例 |
|---|---|---|---|
MapID | 指向静态配置与版本 | 发版时 | dungeon_ember |
InstanceID | 隔离副本、战场、活动 | 每次创建 | 10023 |
Zone | 大地图内的区域分片 | 玩家跨区时 | (12,-3) |
三者组合成 MapKey,作为缓存的唯一键。MapID 只决定“加载哪份配置”,InstanceID 决定“哪一份运行时状态”,Zone 决定“只加载这张图的哪一块”。小型副本可以固定用 Zone{0,0},但键的结构不要省,否则以后想把副本拆成多区会改不动。
键必须是可比较的值类型,不能塞指针或切片。日志里用稳定的字符串格式输出,排查时能直接对上缓存条目。用玩家 ID 做键则粒度太细,同一张地图会被加载成千上万次,缓存失去意义。
三、常驻、懒加载与预加载
不是所有地图都值得常驻。选择策略看访问频率和加载成本。
| 策略 | 做法 | 优点 | 代价 | 适用 |
|---|---|---|---|---|
| 常驻 | 启动时加载,永不卸载 | 首次进入无延迟 | 内存固定占用 | 主城、跨服中心 |
| 懒加载 | 首次访问时加载 | 内存按需增长 | 首次进入有卡顿 | 野外分区、低频地图 |
| 预加载 | 创建实例前主动加载 | 进入时无卡顿 | 需要预测容量 | 副本、战场、活动 |
懒加载必须做“同一键只加载一次”的合并。副本开服瞬间几十个玩家同时进入,如果每个请求都读文件、解析导航网格,CPU 会在几秒内被打满,而且缓存里可能留下多份互相覆盖的数据。正确做法是单飞(singleflight):第一个请求负责加载,其余请求等待同一个结果。
加载动作要放在 tick 之外。解析几兆的阻挡数据、构建导航网格可能耗时几十毫秒,放在主循环里会造成整服卡顿。可以在独立 goroutine 完成解析,再把只读结果交给场景线程。
四、引用计数决定卸载时机
缓存的生命周期由使用者决定,不由时间决定。每次 Acquire 增加引用,每次 Release 减少引用,归零才允许卸载。
- 玩家进入分区:
Acquire,引用加一。 - 玩家跨区或下线:
Release,引用减一。 - 定时器、AI、掉落物如果持有地图指针,也要计入引用。
- 引用归零:从缓存删除,下次访问重新加载。
- 重复
Release:直接报错,不能把计数减成负数。
引用计数最大的价值不是省内存,而是把“还有人在用”变成可检查的状态。卸载前必须确认引用为零;如果 shutdown 流程里还有玩家,说明关闭顺序错了,应该先停止进入、迁走玩家、停掉 AI 和定时器,最后才释放地图。
生产环境通常不会在归零瞬间卸载,而是加一个宽限期,避免玩家在分区边界来回走导致反复加载。宽限期只是延迟删除,不能绕过引用计数;只要还有引用,任何热更和卸载都必须拒绝。
五、加载失败与版本不一致
加载失败的处理原则是“失败关闭”:宁可不让玩家进入,也不要拿半张地图跑逻辑。
| 失败 | 典型原因 | 处理 |
|---|---|---|
| 找不到地图 | 配置未发布、ID 拼错 | 返回明确错误,不创建实例 |
| 解析失败 | 文件损坏、格式升级 | 记录文件版本与行号,告警 |
| 返回了别的键 | loader 实现错误、缓存串键 | 拒绝并丢弃结果,不写入缓存 |
| 版本不一致 | 滚动发布期间新旧配置混用 | 拒绝加载,等待配置统一 |
| 加载超时 | 存储抖动、文件过大 | 取消并重试,不占用场景线程 |
版本不一致比“找不到”更危险,因为服务看起来是正常的。地图 v7 改了阻挡,v6 的副本还在跑,玩家会看到“空气墙”或者直接走进墙里。地图版本应该来自同一份发布清单,同一 MapID 的所有分区必须使用同一版本;滚动发布期间要么整图切换,要么用新的 MapID 并存,不能按分区各用各的。
失败结果也不要缓存。把一次读取失败缓存成“地图不存在”,会让后续重试全部失败,直到进程重启。
六、热加载的边界
热更能改什么,要提前写清楚:
- 可以热更:掉落表、怪物刷新表、活动参数,且只对新生效的实例或下一轮刷新生效。
- 谨慎热更:出生点、传送门,必须保证新旧坐标都能站立。
- 不要热更:阻挡和导航网格,正在战斗的玩家可能被卡进墙里。
安全的热更流程是“构建新版本,空闲时切换”:
- 新版本在后台加载并校验,失败则保留旧版本。
- 对目标键调用失效;仍有引用时拒绝,等待空窗。
- 引用归零后删除旧条目,下一次
Acquire加载新版本。 - 回滚时保留旧版本文件,按同样的流程切回。
不要在读取方持有地图指针时原地替换切片内容。Go 的切片赋值不是原子操作,读者可能看到一半新一半旧的数据。要么整份替换指针,要么让旧版本自然退场。
七、Go 实现
MapStore 用 inflight 表合并并发加载,用 refs 控制卸载,用 required 表校验版本。等待者共享第一次加载的结果和错误,不会因为一个失败请求引发重试风暴。
// file: ch07/world/loader.go
// tier: L1
// verify: go test ./ch07/world/
package world
import (
"errors"
"fmt"
"sync"
)
var (
ErrMapNotFound = errors.New("world: map data not found")
ErrMapVersion = errors.New("world: map version mismatch")
ErrMapKeyMismatch = errors.New("world: loader returned data for another key")
ErrMapBusy = errors.New("world: map is still referenced")
ErrMapReleaseUnheld = errors.New("world: release without acquire")
)
// MapKey 唯一标识一个可加载单元:地图 ID + 实例 ID + 分区。
// 副本用 InstanceID 区分,同一张地图的不同实例绝不共享动态状态。
type MapKey struct {
MapID string
InstanceID uint32
Zone ZoneID
}
func (k MapKey) String() string {
return fmt.Sprintf("%s/%d/%s", k.MapID, k.InstanceID, k.Zone)
}
// MapData 是加载后的只读地图数据。加载完成后调用方不得原地修改,
// 需要修改时必须走版本化重建,而不是在共享数据上加锁。
type MapData struct {
Key MapKey
Version uint32
Blocks [][2]WorldCoord // 阻挡矩形:左下角、右上角
Spawns []WorldCoord
}
// MapLoader 抽象存储:可以是本地文件、对象存储或版本化配置中心。
type MapLoader interface {
Load(key MapKey) (MapData, error)
}
type mapEntry struct {
data MapData
refs int
}
type inflightLoad struct {
done chan struct{}
err error
}
// MapStore 按 key 懒加载地图数据,并用引用计数决定何时卸载。
// 同一个 key 的并发加载只会真正执行一次。
type MapStore struct {
mu sync.Mutex
loader MapLoader
entries map[MapKey]*mapEntry
inflight map[MapKey]*inflightLoad
required map[string]uint32
loads int
}
func NewMapStore(loader MapLoader) *MapStore {
return &MapStore{
loader: loader,
entries: make(map[MapKey]*mapEntry),
inflight: make(map[MapKey]*inflightLoad),
required: make(map[string]uint32),
}
}
// RequireVersion 声明某张地图当前必须使用的数据版本。加载到不一致的
// 数据时直接失败,避免新旧阻挡规则混用。
func (s *MapStore) RequireVersion(mapID string, version uint32) {
s.mu.Lock()
s.required[mapID] = version
s.mu.Unlock()
}
// Acquire 取得地图数据并增加一次引用。调用方用完必须 Release。
func (s *MapStore) Acquire(key MapKey) (MapData, error) {
s.mu.Lock()
if e, ok := s.entries[key]; ok {
e.refs++
data := e.data
s.mu.Unlock()
return data, nil
}
if f, ok := s.inflight[key]; ok {
s.mu.Unlock()
<-f.done
if f.err != nil {
return MapData{}, f.err
}
s.mu.Lock()
e, ok := s.entries[key]
if !ok {
s.mu.Unlock()
// 极端情况:加载完成后、本协程拿到锁之前,最后一个引用已释放。
return s.Acquire(key)
}
e.refs++
data := e.data
s.mu.Unlock()
return data, nil
}
f := &inflightLoad{done: make(chan struct{})}
s.inflight[key] = f
s.mu.Unlock()
data, loadErr := s.loader.Load(key)
s.mu.Lock()
s.loads++
err := loadErr
if err == nil {
err = s.checkLocked(key, data)
}
delete(s.inflight, key)
f.err = err
if err == nil {
s.entries[key] = &mapEntry{data: data, refs: 1}
}
close(f.done)
s.mu.Unlock()
if err != nil {
return MapData{}, err
}
return data, nil
}
func (s *MapStore) checkLocked(key MapKey, data MapData) error {
if data.Key != key {
return fmt.Errorf("%w: got %s, want %s", ErrMapKeyMismatch, data.Key, key)
}
if want, ok := s.required[key.MapID]; ok && data.Version != want {
return fmt.Errorf("%w: got %d, want %d", ErrMapVersion, data.Version, want)
}
return nil
}
// Release 减少一次引用;归零时立刻从内存卸载,下次访问重新加载。
func (s *MapStore) Release(key MapKey) error {
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.entries[key]
if !ok {
return fmt.Errorf("%w: %s", ErrMapReleaseUnheld, key)
}
if e.refs <= 1 {
delete(s.entries, key)
return nil
}
e.refs--
return nil
}
// Invalidate 让下一次 Acquire 重新加载。仍有引用时拒绝执行,
// 否则正在使用地图的玩家会拿到半新半旧的数据。
func (s *MapStore) Invalidate(key MapKey) error {
s.mu.Lock()
defer s.mu.Unlock()
if e, ok := s.entries[key]; ok {
if e.refs > 0 {
return fmt.Errorf("%w: %s refs=%d", ErrMapBusy, key, e.refs)
}
delete(s.entries, key)
}
return nil
}
// Stats 返回当前已加载的 key 数量与累计实际加载次数,供监控和测试使用。
func (s *MapStore) Stats() (loaded, loads int) {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.entries), s.loads
}
几个实现细节值得单独说明:
inflight在加载开始前写入,加载结束后删除并关闭 channel;等待者被唤醒后重新检查缓存,避免与“刚释放完”的竞态。- 版本校验和键校验都在持锁状态下完成,
RequireVersion不会与加载过程产生数据竞争。 - 失败结果只交给本次等待者,不写入
entries,所以下一次Acquire会重新尝试。 Release在计数归零时立即删除条目;生产环境可以在这一层加延迟队列,但不要改变引用计数的判定。Invalidate在仍有引用时返回ErrMapBusy,把“现在不能热更”变成显式错误,而不是静默替换。
八、测试要点
测试文件与实现放在同一个包,覆盖六类行为:
| 测试 | 断言 |
|---|---|
| 并发加载 | 40 个协程同时 Acquire,loader 只被调用一次 |
| 卸载时机 | 引用未归零不卸载;归零后重新加载 |
| 版本不一致 | 返回 ErrMapVersion,且不写入缓存 |
| 键不一致 | 返回 ErrMapKeyMismatch,且不写入缓存 |
| 忙碌热更 | 有引用时 Invalidate 返回 ErrMapBusy |
| 分片隔离 | 不同 zone、不同实例分别计数,互不命中 |
并发测试使用带延迟的假 loader,确保等待者真的在 inflight 上排队,而不是碰巧串行通过。
九、运行验证
cd .local/checks/gs-practice-lab && gofmt -l ch07/world && go vet ./ch07/world/ && go test ./ch07/world/ -count=1
ok gspractice/ch07/world 0.266s
本次运行全部测试通过;gofmt -l 无输出,go vet 无告警。耗时随机器负载变化。
十、失败场景:卸载时仍有玩家
副本关闭流程先释放了地图,再逐个踢出玩家。最后一个玩家还在场景里,AI 下一次 tick 访问阻挡数据,拿到的是已经被删除的条目;如果实现返回零值而不是报错,玩家会看到一张没有墙的地图,直接走出边界。更隐蔽的版本是:地图重新加载成 v8,玩家的坐标还是按 v7 存的,落点在新的阻挡体内部,登录即卡死。
修复不是加判空,而是固定关闭顺序:停止新玩家进入,迁走或踢出存量玩家,停止该实例的 AI、定时器和掉落,确认引用归零,最后释放并允许热更。任何一步失败都要中止关闭,让实例保持可服务状态,而不是带着半关闭的状态继续跑。
小结
静态地图数据共享且强版本,动态状态按实例隔离。分片键由地图 ID、实例 ID 和区域组成,缺一个都会导致串状态或缓存失控。加载用单飞合并并发请求,生命周期用引用计数管理,失败和版本不一致一律失败关闭,热更只能在引用归零后切换。
常见错误与边界
- 每次请求都读文件、解析导航网格,开服瞬间打满 CPU。
- 只用地图 ID 做键,不同副本共享了怪物和掉落状态。
- 引用计数只加不减,地图永远不卸载;或者减成负数,提前卸载。
- 版本不一致时仍然加载,新旧阻挡规则混用。
- 把加载失败缓存成“不存在”,后续重试全部失败。
- 有玩家时原地替换地图切片,读者看到半新半旧的数据。
- 副本关闭先释放地图再踢人,最后一帧访问空数据。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。