活动事故通常不是玩法写错
《裂隙战场》的春节活动按北京时间排期。海外服复用同一份配置,运营以为“到点自动开”,结果东八区开服时,西五区还是前一天晚上。玩家看到入口,点进去提示未开始;客服查到的开始时间与玩家看到的倒计时相差大半天。
《群岛纪元》把双倍掉落和充值返利放在同一张活动表里。两个活动的奖励都写“额外加成”,上线后同时命中,付费玩家的收益翻了两层。策划说“本来就应该叠加”,财务说预算超了,两边都没有错,错在配置里从来没有一个字段回答“这两个活动能不能同时开”。
《泡泡工坊》的活动奖励引用了刚下线的一个道具 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 热更新与灰度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。