《游戏服务端架构·平台化与架构治理》2.1 活动编排与配置

春节、双倍掉落和充值返利同时上线时,先出问题的往往不是玩法,而是时间窗、互斥组和奖励表。本文把活动定义拆成设计版本与配置版本,说明上线前的静态校验、多环境隔离与生产冻结窗口,并用 internal/liveopsconfig 的 Go 实现演示时间窗、依赖环、互斥重叠和奖励溢出的检查方式。

活动事故通常不是玩法写错

《裂隙战场》的春节活动按北京时间排期。海外服复用同一份配置,运营以为“到点自动开”,结果东八区开服时,西五区还是前一天晚上。玩家看到入口,点进去提示未开始;客服查到的开始时间与玩家看到的倒计时相差大半天。

《群岛纪元》把双倍掉落和充值返利放在同一张活动表里。两个活动的奖励都写“额外加成”,上线后同时命中,付费玩家的收益翻了两层。策划说“本来就应该叠加”,财务说预算超了,两边都没有错,错在配置里从来没有一个字段回答“这两个活动能不能同时开”。

《泡泡工坊》的活动奖励引用了刚下线的一个道具 ID。配置保存成功,预发环境也没有报错,因为预发不连生产道具库。开服后第一批玩家领奖失败,补偿邮件又触发了另一个未配置的模板。

这三个问题的共同点是:团队把活动配置当成一张可以随时改的表,而不是一组需要校验、需要版本、需要隔离的发布物。本文谈的是这组发布物在服务端架构里的落点,不写活动玩法,也不写通用配置中心怎么选型;配置存储只作为约束出现。

活动模型:五个维度必须分开

一个活动至少包含五类信息。它们的修改频率、审批人和失败后果都不同,混在一个 JSON 里会让“改一个数字”变成“重新发布整个活动”。

维度内容混在一起的后果
时间窗与时区绝对起止时间 + IANA 时区跨区开服时间漂移,夏令时切换偏移
参与条件账号、角色、渠道、等级、区服资格判断散落在业务代码,无法复用
奖励表道具 ID、数量、上限溢出与重复发放只能事后对账
互斥与依赖互斥组、前置活动叠加发奖、顺序错乱、死锁式依赖
版本活动版本、配置版本出问题时分不清是设计错还是数值错

前四个维度决定“活动是什么”,第五个维度决定“这次发布的是哪一版”。活动版本跟随设计稿走,配置版本跟随整份配置快照走。策划调整奖励数量,配置版本加一,活动版本不变;玩法规则改了,两个版本都要动。把两者合成一个递增号,回滚时就无法回答“我只想退回数值,不想退回规则”。

时间窗使用绝对时间加时区名,不使用“每天 20:00”这类由客户端解释的表达式。服务端在保存配置时把时区解析成 time.Location,开服判断只比较绝对时间。夏令时切换由时区数据库负责,配置里不写偏移量。互斥关系采用半开区间:[start, end),前一个活动结束的瞬间,后一个活动可以开始。

校验分三层,失败动作不同

配置校验不是一次“保存时跑个函数”。不同层能拿到的事实不同,失败后能做的动作也不同。

层时机能发现失败动作
静态校验保存、提交审批时间倒置、窗口过长、奖励溢出、重复道具、未知依赖、依赖环、同组重叠拒绝保存或拒绝进入审批
联调校验预发环境道具不存在、价格或库存不可用、资格规则引用了不存在的标签阻断上线,回到配置修改
运行时兜底开服后下游超时、配置漂移、缓存未刷新降级、告警、停止发奖,不回写配置

静态校验刻意不查道具库。道具服务可能正在发布,配置平台不应该因为下游抖动而拒绝保存一份结构正确的草稿;但进入预发前必须查,因为那时道具 ID 已经确定。把“结构正确”和“业务可用”分成两个门,故障时才能判断该重试哪一步。

运行时兜底的原则是“停止扩大影响”,不是“自动修正”。发现奖励配置引用不到道具时,正确动作是停止该活动的发奖并告警,而不是临时替换成默认道具。自动修正会让审计记录与玩家实际收到的物品不一致,事后无法追责。

多环境与生产冻结窗口

开发、测试、预发、生产不是四份互相复制的 JSON,而是同一份配置模型在不同数据源上的投影。环境之间只允许通过显式发布动作迁移,不允许手工同步文件。

环境数据源审批典型用途
开发本地或共享测试库无结构与校验规则调试
测试隔离测试库单人联调、自动化回归
预发生产只读副本 + 影子道具库双人上线前最终校验
生产生产库双人 + 工单正式开服

生产配置需要冻结窗口。活动开始前的一段时间内,普通修改只允许走加急工单;冻结时长按活动类型配置,示例值为开始前 24 小时。冻结不是技术限制,而是把“最后一刻改数值”的冲动变成一个需要第二个人签字的动作。冻结期内仍然允许两类操作:紧急停服开关,以及回滚到上一个已校验快照。

活动版本与配置版本

快照解决两个问题:发布前比较,出事后回滚。

发布前,运营把待发布的整份活动列表生成一个快照,计算校验和。校验和覆盖排序后的活动、奖励、依赖与版本字段,因此两个人即使以不同顺序编辑,只要内容一致,校验和就一致。上线评审看的是校验和,不是某个人截图里的字段。

出事后,回滚需要知道“回到哪一版”。配置版本必须单调递增,重复提交同一版本应当被拒绝,因为这通常意味着有人打开了过期的编辑页。历史快照保留数量有上限,示例值为最近 20 版;更早的版本进入归档存储,不参与在线回滚。

快照必须是深拷贝。调用方拿到快照后修改了奖励数组,不能影响存储里的当前版本;否则一次“只读查询”就会悄悄改掉生产配置。snapshot.go 里的 BuildSnapshot、Store.Apply 和 cloneEvents 就是这条边界:校验、排序、计算校验和、复制,全部在进入存储之前完成。

校验器:validate.go

下面的实现只做静态校验。它把问题收集成列表而不是遇到第一个就返回,因为一次发布评审需要看到全部问题;每个问题带稳定的 code,前端和 CI 可以按 code 分组,不需要解析中文消息。

// file: internal/liveopsconfig/validate.go
// tier: L1
// verify: go test ./internal/liveopsconfig/
package liveopsconfig

import (
	"fmt"
	"sort"
	"strings"
	"time"
)

// Event is one LiveOps activity as the platform stores it. ActivityVersion
// changes when the design changes; ConfigVersion changes when any editable
// field changes. Keeping them separate lets an operator answer "which build
// of the event" and "which revision of the numbers" without conflating them.
type Event struct {
	ID              string
	Name            string
	Start           time.Time
	End             time.Time
	Timezone        string
	MutexGroup      string
	Requires        []string
	Rewards         []Reward
	ActivityVersion int
	ConfigVersion   int
}

// Reward is one row of the reward table. Count is validated before publish,
// because an overflow is cheaper to reject here than to reconcile after the
// first player claims it.
type Reward struct {
	ItemID string
	Count  int64
}

// Problem is a validation finding tied to one event and one field. Codes are
// stable so a UI or a CI gate can group them without parsing messages.
type Problem struct {
	EventID string
	Field   string
	Code    string
	Message string
}

func (p Problem) String() string {
	return fmt.Sprintf("%s.%s: %s (%s)", p.EventID, p.Field, p.Message, p.Code)
}

// Policy carries limits that operations may tune per environment. Zero means
// "use the default" for MaxWindow; tests set explicit values.
type Policy struct {
	MaxWindow      time.Duration
	MaxRewardCount int64
}

func DefaultPolicy() Policy {
	return Policy{MaxWindow: 30 * 24 * time.Hour, MaxRewardCount: 1_000_000}
}

func (p Policy) normalized() Policy {
	if p.MaxWindow <= 0 {
		p.MaxWindow = DefaultPolicy().MaxWindow
	}
	if p.MaxRewardCount <= 0 {
		p.MaxRewardCount = DefaultPolicy().MaxRewardCount
	}
	return p
}

// Validate checks structural problems that can be decided without a running
// server: windows, reward rows, dependencies and mutual exclusion. It never
// resolves item names or player eligibility; those need data owned by other
// services and belong to a publish-time check with those services online.
func (p Policy) Validate(events []Event) []Problem {
	p = p.normalized()
	var out []Problem
	byID := make(map[string]Event, len(events))
	for _, e := range events {
		if e.ID == "" {
			out = append(out, Problem{EventID: e.ID, Field: "id", Code: "empty_id", Message: "event id is empty"})
			continue
		}
		if _, dup := byID[e.ID]; dup {
			out = append(out, Problem{EventID: e.ID, Field: "id", Code: "duplicate_id", Message: "event id is duplicated"})
		}
		byID[e.ID] = e
		out = append(out, p.validateOne(e)...)
	}
	out = append(out, validateDependencies(events, byID)...)
	out = append(out, p.validateMutex(events)...)
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].EventID != out[j].EventID {
			return out[i].EventID < out[j].EventID
		}
		if out[i].Field != out[j].Field {
			return out[i].Field < out[j].Field
		}
		return out[i].Code < out[j].Code
	})
	return out
}

func (p Policy) validateOne(e Event) []Problem {
	var out []Problem
	if e.Name == "" {
		out = append(out, Problem{e.ID, "name", "empty_name", "event name is empty"})
	}
	if e.Timezone == "" {
		out = append(out, Problem{e.ID, "timezone", "empty_timezone", "timezone is empty"})
	} else if _, err := time.LoadLocation(e.Timezone); err != nil {
		out = append(out, Problem{e.ID, "timezone", "unknown_timezone", err.Error()})
	}
	switch {
	case e.Start.IsZero() || e.End.IsZero():
		out = append(out, Problem{e.ID, "window", "window_zero", "start and end must be set"})
	case !e.End.After(e.Start):
		out = append(out, Problem{e.ID, "window", "window_inverted", "end must be after start"})
	case e.End.Sub(e.Start) > p.MaxWindow:
		out = append(out, Problem{e.ID, "window", "window_too_long", "window exceeds the configured maximum"})
	}
	seen := make(map[string]bool, len(e.Rewards))
	for _, r := range e.Rewards {
		if r.ItemID == "" {
			out = append(out, Problem{e.ID, "rewards", "empty_item", "reward item id is empty"})
			continue
		}
		if seen[r.ItemID] {
			out = append(out, Problem{e.ID, "rewards", "duplicate_item", "reward item appears twice: " + r.ItemID})
		}
		seen[r.ItemID] = true
		if r.Count <= 0 {
			out = append(out, Problem{e.ID, "rewards", "reward_non_positive", "reward count must be positive: " + r.ItemID})
		} else if r.Count > p.MaxRewardCount {
			out = append(out, Problem{e.ID, "rewards", "reward_overflow", "reward count exceeds the configured maximum: " + r.ItemID})
		}
	}
	if e.ActivityVersion <= 0 {
		out = append(out, Problem{e.ID, "activity_version", "bad_activity_version", "activity version must be positive"})
	}
	if e.ConfigVersion <= 0 {
		out = append(out, Problem{e.ID, "config_version", "bad_config_version", "config version must be positive"})
	}
	return out
}

func validateDependencies(events []Event, byID map[string]Event) []Problem {
	var out []Problem
	for _, e := range events {
		for _, req := range e.Requires {
			switch {
			case req == "":
				out = append(out, Problem{e.ID, "requires", "empty_dependency", "dependency id is empty"})
			case req == e.ID:
				out = append(out, Problem{e.ID, "requires", "self_dependency", "event depends on itself"})
			case byID[req].ID == "":
				out = append(out, Problem{e.ID, "requires", "unknown_dependency", "dependency does not exist: " + req})
			}
		}
	}
	color := make(map[string]int, len(byID))
	var visit func(string)
	visit = func(id string) {
		color[id] = 1
		for _, req := range byID[id].Requires {
			if byID[req].ID == "" || req == id {
				continue
			}
			switch color[req] {
			case 0:
				visit(req)
			case 1:
				out = append(out, Problem{id, "requires", "dependency_cycle", "dependency cycle through " + req})
			}
		}
		color[id] = 2
	}
	for _, e := range events {
		if e.ID != "" && color[e.ID] == 0 {
			visit(e.ID)
		}
	}
	return out
}

func (p Policy) validateMutex(events []Event) []Problem {
	var out []Problem
	for i := 0; i < len(events); i++ {
		for j := i + 1; j < len(events); j++ {
			a, b := events[i], events[j]
			if a.MutexGroup == "" || a.MutexGroup != b.MutexGroup {
				continue
			}
			if a.Start.IsZero() || a.End.IsZero() || b.Start.IsZero() || b.End.IsZero() {
				continue
			}
			if a.Start.Before(b.End) && b.Start.Before(a.End) {
				out = append(out, Problem{a.ID, "mutex_group", "mutex_overlap", "overlaps with " + b.ID})
				out = append(out, Problem{b.ID, "mutex_group", "mutex_overlap", "overlaps with " + a.ID})
			}
		}
	}
	return out
}

// ValidationError keeps every finding. A publish UI shows the whole list so
// one fix cycle is enough; returning only the first problem hides the rest.
type ValidationError struct {
	Problems []Problem
}

func (e *ValidationError) Error() string {
	parts := make([]string, 0, len(e.Problems))
	for _, p := range e.Problems {
		parts = append(parts, p.String())
	}
	return "liveopsconfig: " + strings.Join(parts, "; ")
}

三个可测试的决策点

第一,全量返回。支付活动同时存在奖励溢出、依赖不存在和互斥重叠时,三个问题都会出现在列表里。评审只需要一轮修改,而不是“修完一个再发现下一个”。

第二,半开区间。前一个活动 12:00 结束,后一个活动 12:00 开始,不算重叠。这个约定必须写进测试,否则不同实现会在端点上产生分歧,运营看到的是“有时候能开、有时候不能开”。

第三,依赖环。活动 A 依赖 B、B 又依赖 A 时,静态校验直接报 dependency_cycle。运行时再发现这个问题,通常已经进入开服流程,回滚成本更高。自依赖和未知依赖分开报错,因为修复动作不同:前者删掉一行,后者需要先创建或恢复被依赖的活动。

失败模式与兜底

失败直接后果兜底
时区名写错开服时间偏移,甚至无法解析保存时 LoadLocation 拒绝,运行时不再猜
奖励数量溢出发奖失败或经济系统被击穿静态上限 + 发奖前二次检查
同组活动重叠双倍奖励叠加互斥校验阻断发布,运行时按活动 ID 去重
依赖活动被下线前置条件永远不满足未知依赖阻断;运行时把活动标记为不可用
配置版本重复旧编辑页覆盖新配置版本必须递增,重复提交拒绝
快照被调用方修改生产配置悄然变化深拷贝 + 校验和复核

兜底的目标是让故障可见、可停、可回滚,而不是让系统“看起来没出事”。一个被静默修正的奖励错误,比一次明确的发奖失败更难排查。

上线前检查表

  • 时间窗使用绝对时间加 IANA 时区,跨区活动已分别核对。
  • 同互斥组的活动没有重叠,端点相接的情况已在测试中覆盖。
  • 奖励表中的道具 ID 已在预发道具库验证,数量没有超过上限。
  • 依赖关系无环,被依赖活动处于已排期或进行中状态。
  • 活动版本与配置版本都已递增,配置版本没有重复。
  • 生产冻结窗口已确认,加急工单的审批人可用。
  • 上一个稳定快照的校验和已记录,回滚路径演练过。

常见错误

第一,把时区偏移写死。夏令时一变,所有排期都要手工改;正确做法是存时区名,让时区数据库处理偏移。

第二,用“开始时间早于结束时间”代替完整的窗口校验。窗口倒置只是其中一种,过长窗口、零值时间、跨年排期都需要单独判断。

第三,互斥关系只写在策划文档里。文档不会阻止发布,配置里的互斥组才会。

第四,快照返回内部切片。调用方一次排序就把存储里的顺序改掉,校验和与内容对不上。

第五,静态校验去查在线道具库。下游抖动时,一份结构正确的草稿也保存不了,配置平台变成新的故障点。

小结

活动编排的核心不是把字段堆进一张表,而是把时间、资格、奖励、关系和版本拆成可分别校验、可分别审批、可分别回滚的维度。静态校验负责结构,联调校验负责业务事实,运行时兜底负责止损;三者不能互相替代。快照和校验和让“这次发布的是什么”成为一个可比较的对象,而不是一段口述。

下一节讨论这些配置怎样到达每一台服务器:哪些字段可以热更,灰度按什么维度切,一次更新在什么时刻才算对所有节点生效,以及失败时怎样在玩家感知之前退回上一版。

阅读导航:上一节:1.3 平台服务的版本与兼容 · 下一节:2.2 热更新与灰度 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查