《游戏服务端架构·核心系统与契约》7.3 每日重置、时区与赛季

《群岛纪元》全球同服后,每日重置到底按 UTC、服务器时区还是玩家本地时区切,直接决定公平性与运营成本。本节比较三种口径,讨论重置窗口键、错过补做、崩溃重试的幂等,以及赛季切换时归档、结算和未领取奖励的兜底,并给出可运行的 dailyreset 包。

一次重置,三种“今天”

《群岛纪元》开服时只有 UTC+8 一个区,每日重置定在凌晨 05:00,运维把它当成“低峰期重启窗口”的一部分。全球同服之后,同一个重置时刻对东京玩家是早上 6 点,对柏林玩家是晚上 11 点,对洛杉矶玩家是下午 2 点。问题很快出现:洛杉矶玩家发现自己的日常在下午重置,刚打完的任务进度被清空;柏林玩家则在睡前看到“新的一天”,但排行榜还没结算。

《裂隙战场》的赛季切换是另一类问题:第 8 赛季结束时,结算任务在切换前 10 分钟启动,部分玩家的未领取奖励还没到账;运维为了“保险”手动重跑了一次结算,结果已经领取的奖励又发了一遍。《泡泡工坊》最轻,但它的周任务按玩家手机时区显示,玩家把设备时区改到 UTC+14,就能提前看到下一周的任务。

三个事故指向同一组决策:重置边界用哪个时区、重置任务如何幂等、赛季切换时未完成的数据归谁。本节把这三件事分开讨论,并给出一个可以计算窗口、补做和结算的可运行包。

重置边界:三种时区口径

口径边界公平性运维成本适用场景
UTC全球同一瞬间最高,所有玩家同时换日低,边界固定全球竞技、赛季排名
服务器时区运营设定的本地时刻,如 UTC+8 05:00中,跨区玩家本地时间不自然中,按区配置全球同服 SLG、MMO
玩家本地时区玩家当地 00:00体感最自然,但可被改时区利用高,每玩家一套边界单机向、轻社交休闲

北辰的结论是按产品分开选。《裂隙战场》的赛季和每日首胜用 UTC:竞技排名必须同一瞬间结算,否则不同赛区的时间差会被用来刷分。《群岛纪元》用服务器时区 UTC+8 的 05:00:全球同服但运营、客服、运维都在一个时区,出问题能立刻处理;跨区玩家的本地时间不自然,但换来一个可预测的低峰窗口。《泡泡工坊》按区域集群配置服务器时区,不按玩家设备时区,原因很简单:玩家可以改设备时区,不能改服务端配置。

这条分界线比“哪个更公平”更重要。玩家本地时区看起来最友好,但它把边界控制权交给了客户端;一旦排行榜、限时活动或交易与重置挂钩,改时区就能变成作弊入口。如果产品确实需要本地时间体验,正确做法是按注册地区分配服务器时区,而不是信任设备上报。

窗口键:让“今天”有唯一身份

重置任务不能只记录“上次重置时间”,因为时间会被重复写入、会被时区转换、会在故障转移时被两个实例同时读到。更稳的做法是给每个重置窗口一个键:边界所在本地日期的 ISO 日期字符串。

例如重置时刻是 UTC+8 05:00,那么 2026-10-29 04:59 仍属于 2026-10-28 的窗口,05:00 之后才属于 2026-10-29。窗口本身是半开区间 [start, end),键取自 start 的本地日期。这样即使服务器换了时区库、即使边界时刻因为夏令时发生偏移,键仍然由“哪一天的 05:00”决定,而不是由某个 UTC 时间戳决定。

场景键重复执行的结果
玩家每日任务daily:{player}:{window}已执行则跳过
全服排行榜结算rank:{season}:{window}已执行则跳过
赛季切换season:{from}->{to}已执行则跳过
补做多天每个窗口一个键逐天幂等,不合并

键的设计要避免两种东西:处理时间、以及“第几次执行”。处理时间会让补算重跑变成新任务;执行次数会让故障转移后的重试变成第二次重置。业务日期和业务对象才是稳定的身份。

幂等执行与补做

有了窗口键,执行器只需要做一件事:同一个键最多执行一次。示例实现用一个内存 Ledger 表达这个契约,生产环境把 done 换成带唯一约束的表或 Redis SETNX 即可。

这里有一个必须写进注释的边界:Ledger 只能保证“成功返回后不重复”,不能保证“崩溃后不重复”。如果副作用已经发生、进程在写 Ledger 之前崩溃,下一次重试仍会再执行一遍。所以被执行的函数本身必须幂等,或者副作用与 Ledger 写在同一个事务里。示例把这一点写进了 Execute 的注释,因为它是所有重置任务共有的坑。

补做是另一个容易出错的点。玩家可能离线三天,重置任务不能只处理“今天”,也不能无限补做。示例的 CatchUp 从上次键的下一天开始,逐天返回直到当前窗口;超过 limit 时返回截断标记,由调用方分页处理。截断必须显式返回,不能静默丢弃——静默丢弃会让玩家永久少三天进度,而且日志里看不出来。

last=2026-10-26, now=2026-10-29 12:00, limit=2
-> [10-27, 10-28], truncated=true

调用方看到 truncated=true 后,应继续用最后一个键调用,直到 truncated=false。补做顺序必须从旧到新,因为后一天的任务可能依赖前一天的状态;反过来处理会让依赖检查看到“未来”的状态。

赛季切换:归档、结算与未领取奖励

赛季切换不是“把赛季号加一”,而是三件事同时发生:旧赛季数据归档、排行榜结算、未领取奖励处理。三件事的顺序和幂等键必须提前定好。

未领取奖励处理优点风险适用
直接丢弃实现最简单玩家投诉,客服无法补不推荐
自动领取玩家无感可能突破背包上限,邮件洪水奖励轻、可叠加
转入新赛季并带过期可追溯、可补发需要邮件与过期清理大多数赛季制产品
永久保留不丢东西数据无限增长,破坏赛季经济特殊纪念品

北辰选择“转入新赛季并带 7 天过期”(示例值):结算时只处理未领取部分,已领取的跳过;按玩家聚合后写入新赛季邮件,过期未领再清理。这样既不会重复发奖,也不会让奖励无限堆积。

结算的幂等键是 season:{from}->{to},并且要求两个赛季首尾相接:旧赛季的结束时间必须等于新赛季的开始时间。出现空隙会让玩家在一段时间内没有赛季;出现重叠会让同一时刻有两个排行榜在结算。示例的 NewSeasonTable 在启动时检查连续性,Settle 再检查一次传入的赛季对,避免调用方绕过表直接结算。

归档则要区分“热数据”和“证据”。排行榜名次、赛季奖励领取记录属于证据,要保留完整;过程日志可以聚合后冷存。归档任务同样要用赛季键做幂等,不能因为重跑而把同一批数据写两遍。

可运行片段:dailyreset

下面的 internal/dailyreset 把窗口计算、补做、Ledger 和赛季结算放在同一个包里。它不依赖数据库和定时器,所有时间都显式传入,方便在测试里覆盖边界。

// file: internal/dailyreset/dailyreset.go
// tier: L1
// verify: go test ./internal/dailyreset/
package dailyreset

import (
	"errors"
	"fmt"
	"time"
)

// Policy is one reset rule. A policy has exactly one location: mixing the
// server's local time with UTC is how a reset lands on the wrong day.
type Policy struct {
	Location *time.Location
	Hour     int
	Minute   int
}

var (
	ErrNoLocation    = errors.New("dailyreset: nil location")
	ErrBadClock      = errors.New("dailyreset: hour/minute out of range")
	ErrBadKey        = errors.New("dailyreset: bad window key")
	ErrBadLimit      = errors.New("dailyreset: limit must be positive")
	ErrEmptyKey      = errors.New("dailyreset: empty key")
	ErrBadSeason     = errors.New("dailyreset: invalid season")
	ErrNotContiguous = errors.New("dailyreset: seasons are not contiguous")
)

func (p Policy) validate() error {
	if p.Location == nil {
		return ErrNoLocation
	}
	if p.Hour < 0 || p.Hour > 23 || p.Minute < 0 || p.Minute > 59 {
		return ErrBadClock
	}
	return nil
}

// Window is a half-open interval [Start, End) with a date key in the policy's
// location. The key is the idempotency key of the reset job.
type Window struct {
	Key   string
	Start time.Time
	End   time.Time
}

func (w Window) Contains(t time.Time) bool {
	return !t.Before(w.Start) && t.Before(w.End)
}

func windowForDate(p Policy, year int, month time.Month, day int) Window {
	start := time.Date(year, month, day, p.Hour, p.Minute, 0, 0, p.Location)
	end := time.Date(year, month, day+1, p.Hour, p.Minute, 0, 0, p.Location)
	return Window{Key: start.Format("2006-01-02"), Start: start, End: end}
}

// WindowAt returns the window containing t. The comparison is done on local
// wall time, so a player at 04:59 belongs to the previous day's window when
// the reset hour is 05:00.
func WindowAt(p Policy, t time.Time) (Window, error) {
	if err := p.validate(); err != nil {
		return Window{}, err
	}
	local := t.In(p.Location)
	w := windowForDate(p, local.Year(), local.Month(), local.Day())
	if local.Before(w.Start) {
		w = windowForDate(p, local.Year(), local.Month(), local.Day()-1)
	}
	return w, nil
}

// CatchUp lists the windows after last up to the one containing now. A capped
// result reports truncation instead of silently skipping the remaining days.
func CatchUp(p Policy, last string, now time.Time, limit int) ([]Window, bool, error) {
	if limit <= 0 {
		return nil, false, ErrBadLimit
	}
	if err := p.validate(); err != nil {
		return nil, false, err
	}
	current, err := WindowAt(p, now)
	if err != nil {
		return nil, false, err
	}
	if last == "" {
		return []Window{current}, false, nil
	}
	day, err := time.ParseInLocation("2006-01-02", last, p.Location)
	if err != nil {
		return nil, false, fmt.Errorf("%w: %q", ErrBadKey, last)
	}
	var out []Window
	for offset := 1; ; offset++ {
		w := windowForDate(p, day.Year(), day.Month(), day.Day()+offset)
		if w.Start.After(current.Start) {
			break
		}
		out = append(out, w)
		if len(out) == limit {
			next := windowForDate(p, day.Year(), day.Month(), day.Day()+offset+1)
			return out, !next.Start.After(current.Start), nil
		}
	}
	return out, false, nil
}

// Ledger records completed reset keys. Execute runs fn at most once per key.
// fn must still be idempotent: a crash between its side effect and the ledger
// write makes the next retry run it again.
type Ledger struct {
	done map[string]bool
}

func NewLedger() *Ledger {
	return &Ledger{done: map[string]bool{}}
}

func (l *Ledger) Execute(key string, fn func() error) (bool, error) {
	if key == "" {
		return false, ErrEmptyKey
	}
	if l.done[key] {
		return false, nil
	}
	if err := fn(); err != nil {
		return false, err
	}
	l.done[key] = true
	return true, nil
}

func (l *Ledger) Done(key string) bool {
	return l.done[key]
}

type Season struct {
	ID    string
	Start time.Time
	End   time.Time
}

func (s Season) Contains(t time.Time) bool {
	return !t.Before(s.Start) && t.Before(s.End)
}

// SeasonTable requires ordered, contiguous seasons. A gap would leave players
// with no active season; an overlap would let two ranks claim the same time.
type SeasonTable struct {
	seasons []Season
}

func NewSeasonTable(seasons []Season) (*SeasonTable, error) {
	for i, s := range seasons {
		if s.ID == "" || !s.Start.Before(s.End) {
			return nil, fmt.Errorf("%w: %q", ErrBadSeason, s.ID)
		}
		if i > 0 && !seasons[i-1].End.Equal(s.Start) {
			return nil, fmt.Errorf("%w: %q ends at %s, %q starts at %s", ErrNotContiguous, seasons[i-1].ID, seasons[i-1].End, s.ID, s.Start)
		}
	}
	return &SeasonTable{seasons: append([]Season(nil), seasons...)}, nil
}

func (t *SeasonTable) At(at time.Time) (Season, bool) {
	for _, s := range t.seasons {
		if s.Contains(at) {
			return s, true
		}
	}
	return Season{}, false
}

func (t *SeasonTable) byID(id string) (Season, bool) {
	for _, s := range t.seasons {
		if s.ID == id {
			return s, true
		}
	}
	return Season{}, false
}

type Reward struct {
	PlayerID string
	Amount   int64
	Claimed  bool
}

// Settlement is the result of closing one season pair. Carry holds unclaimed
// rewards keyed by player; the caller delivers them with an explicit expiry.
type Settlement struct {
	From  string
	To    string
	Carry map[string]int64
	Total int64
}

func Settle(table *SeasonTable, fromID, toID string, rewards []Reward) (Settlement, error) {
	if table == nil {
		return Settlement{}, ErrBadSeason
	}
	from, ok := table.byID(fromID)
	if !ok {
		return Settlement{}, fmt.Errorf("%w: %q", ErrBadSeason, fromID)
	}
	to, ok := table.byID(toID)
	if !ok {
		return Settlement{}, fmt.Errorf("%w: %q", ErrBadSeason, toID)
	}
	if !from.End.Equal(to.Start) {
		return Settlement{}, fmt.Errorf("%w: %q -> %q", ErrNotContiguous, fromID, toID)
	}
	out := Settlement{From: fromID, To: toID, Carry: map[string]int64{}}
	for _, r := range rewards {
		if r.PlayerID == "" || r.Amount < 0 {
			return Settlement{}, fmt.Errorf("%w: reward %q amount %d", ErrBadSeason, r.PlayerID, r.Amount)
		}
		if r.Claimed {
			continue
		}
		out.Carry[r.PlayerID] += r.Amount
		out.Total += r.Amount
	}
	return out, nil
}

包内另有 5 个表驱动测试,覆盖:重置前属于前一天、重置瞬间进入新窗口、UTC 输入按策略时区换算、月末边界、非法时区配置;补做的无缺口/三天缺口/截断/非法键;Ledger 的重复执行、失败重试、空键;赛季结算的未领取聚合与非法赛季对;赛季表的空 ID、零长度、空隙与重叠。本机执行 gofmt -l internal/dailyreset 与 go vet ./internal/dailyreset/... 无输出,go test -count=1 ./internal/dailyreset/ 通过。

失败模式与兜底

失败表现兜底
用处理时间做键补算重跑变成第二次重置键取业务窗口日期
故障转移双实例执行同一天重置两次唯一约束或分布式锁 + 幂等函数
崩溃在副作用与记账之间重试重复发奖副作用与 Ledger 同事务,或函数幂等
补做静默截断玩家永久少几天进度显式返回 truncated,调用方分页
赛季有空隙或重叠无赛季或双排行榜启动时校验首尾相接
未领取奖励直接丢弃客服无法补发转入新赛季并设过期
夏令时边界重置落在不存在或重复的时刻避开 00:00–03:00,或固定 UTC

夏令时值得单独提醒:如果策略时区有夏令时,而重置时刻又落在春季跳过的那个小时,time.Date 会把不存在的本地时间规范化到另一个时刻;秋季重复的那个小时则可能让同一个窗口出现两个 UTC 起点。最省事的做法是把重置时刻放在 04:00 之后,或者对全球产品直接用 UTC。

常见错误

第一,把“服务器本地时间”当成“服务器时区”。容器里 time.Local 可能是 UTC,也可能是镜像构建时的时区;策略必须显式携带 *time.Location,不能读全局默认值。

第二,只记录上次执行的时间戳。时间戳无法表达“哪一天的重置”,也无法在补做时逐天幂等;窗口键才是可重放的。

第三,补做只处理今天。离线玩家会永久丢失中间几天的进度;正确做法是从上次键逐天补到当前窗口。

第四,赛季切换先发奖再归档。进程在中间崩溃会留下“奖励已发但赛季没切”的状态;应先完成状态迁移,再异步投递奖励。

第五,用玩家设备时区做排行榜边界。设备时区可以被修改,排行榜边界必须由服务端配置决定。

小结

重置系统的三份契约是:边界用哪个时区、窗口用什么键、失败后如何重试。时区决定公平性与运营成本,窗口键决定幂等,重试策略决定补做会不会丢数据。把这三件事写进接口,重置任务才能在全球同服、故障转移和赛季切换时保持同一个结果。

下一节离开成长与任务,进入社交关系:好友、黑名单与关系一致性。关系数据比任务进度更难收敛,因为它天然是双向的,而且两端可能同时修改。

阅读导航:上一节:7.2 任务事件与进度服务 · 下一节:8.1 好友、黑名单与关系一致性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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