一、日常任务不是“每天清零”
“每天零点把日常计数清零”听起来简单,落到线上会碰到三个问题:零点用哪个时区、玩家跨天时还在线上怎么办、重置过程中服务重启了怎么办。
如果每个服务器用自己的本地时间,同一大区的玩家会在不同时刻看到日常刷新。跨时区运营时,有人凌晨刷新,有人上午刷新,客服每天都要解释“为什么我的日常还没重置”。
清零还会丢信息。玩家昨天完成了多少、哪些奖励没领,清零后只剩一个 0。没有历史记录,运营补偿、异常排查、成就累计都失去依据。
日常系统的正确抽象是“周期键加计数器”:当前属于哪个周期,由服务器时间与配置时区共同计算;周期变化时归档旧计数并开始新计数,而不是无条件执行清零命令。
二、周期键:自然日、ISO 周与时区
周期键是一个字符串。自然日可以用 2006-01-02,自然周用 ISO 年的第几周。键相同表示同一周期,键变化才触发重置。
| 周期 | 键的组成 | 边界 | 常见坑 |
|---|---|---|---|
| 自然日 | 本地日期 | 本地时间 00:00 | 用 UTC 日期导致东八区早上八点才刷新 |
| 自然周 | ISO 年 + ISO 周 | 周一 00:00 | 用月份或自定义周起始,跨年时周号重复 |
| 活动周期 | 配置的开始与结束时间 | 配置指定 | 与自然日混用,活动结束但日常仍重置 |
| 赛季 | 赛季 ID | 运营配置 | 用时间推算赛季,维护延迟后赛季错位 |
ISO 周有一个容易忽略的边界:跨年那一周可能属于上一年的第 52 或 53 周。直接用“年份 + 周数”拼接时,如果年份取自然年、周数取 ISO 周,会出现重复或跳号。必须使用同一套 ISO 年与 ISO 周。
时区应该作为运营配置的一部分,而不是读服务器操作系统的本地时区。服务器可能部署在 UTC 容器里,也可能因为迁移换到另一个机房。配置固定为 Asia/Shanghai 这类 IANA 时区,行为才可复现。
存储时间用 UTC 时间点,计算周期键时再转换到配置时区。不要把“本地时间”直接存成时间戳,否则夏令时切换时会出现一小时的空洞或重复。
三、重置的幂等与跨天在线
重置必须是幂等的。调度器重试、手动补偿、服务重启都可能让同一个周期触发两次重置,第二次不能把刚积累的进度又清掉。
实现方式很简单:记录上一次生效的周期键。新键与旧键相同就直接返回,不归档、不清零。只有键变化时才执行一次“归档旧计数、创建新计数”。
跨天在线的玩家不会主动触发登录重置。服务端需要在玩家每次产生进度时顺带检查周期,或者在游戏循环里定期扫描在线玩家。两种方式都要调用同一个重置函数,不能各写一套。
如果只在登录时重置,玩家从 23:59 挂到 00:01,期间的击杀会记到昨天。第二天再登录时,昨天的计数被归档,玩家会发现自己白打了一轮。
如果只靠定时任务,任务延迟或服务重启会漏掉整个周期。定时任务适合批量刷新离线玩家,在线玩家仍然要在进度入口做惰性检查。
重置时要先归档再清零。先清零再归档,崩溃窗口里旧计数已经丢失,历史记录却是空的。归档记录应该是深拷贝,不能把内部 map 的引用直接交出去。
四、成就:一次性、累计与隐藏
成就和日常任务共享事件,但生命周期不同。日常按周期重置,成就通常跨周期累计,或者只在第一次达成时触发。
| 类型 | 进度来源 | 是否重置 | 示例 |
|---|---|---|---|
| 一次性 | 单次事件 | 否 | 首次击杀精英怪 |
| 累计 | 多次事件累加 | 否 | 累计击杀一万只怪 |
| 周期累计 | 周期内事件累加 | 是 | 单日完成十个日常 |
| 隐藏 | 任意条件 | 视定义 | 在特定天气进入隐藏区域 |
一次性成就只需要一个布尔标记,但标记必须和奖励发放一起持久化。只写内存标记,服务重启后玩家可以再领一次。
累计成就要设上限,并且进度封顶在目标值。玩家击杀数远超目标时,继续累加没有意义,还会让显示出现“10000/100”这种奇怪状态。
隐藏成就的“隐藏”只影响展示,不影响服务端判定。客户端不下发未解锁的隐藏成就名称,但服务端仍然要记录进度,否则解锁条件永远无法满足。
成就进度和奖励领取要分开。进度达标只说明“可以领”,领取动作才发奖励。把两者合成一个“达成即发”的流程,重复事件或重试就会重复发奖。
五、进度与奖励分离
日常任务也适用同样的原则:计数、可领取、已领取是三个不同的事实。计数达标不等于奖励已发,奖励已发也不应该让计数消失。
可领取状态可以由计数推导,但“已领取”必须单独存储。只靠计数判断是否领过,重置后计数归零,玩家又能领一次;只靠领取标记判断进度,又无法处理补发。
领取接口要幂等。同一个日常奖励的领取键应该包含玩家 ID、周期键和任务 ID。重复请求返回“已领取”,而不是再发一份奖励。
未领取的奖励在重置时怎么处理,是产品规则而非技术细节。常见做法是重置前通过邮件补发,或者给一个短暂的领取宽限期。直接清零会让玩家觉得奖励被吞了。
奖励发放要有流水,流水里带周期键。玩家申诉“昨天的日常没到账”时,客服能根据周期键查到计数、领取状态和发放记录,而不是只看当前计数。
六、防刷:重复计数、异常速度、离线补算
日常与成就都是可被刷的目标。防刷不能只靠客户端,必须在服务端对事件做校验和限流。
| 风险 | 表现 | 服务端手段 |
|---|---|---|
| 重复计数 | 同一事件被多次投递 | 事件幂等键去重,计数入口再校验 |
| 异常速度 | 短时间击杀数远超正常 | 滑动窗口限速,超限记录并降权 |
| 离线补算 | 离线期间按在线速度累计 | 补算上限、按实际离线时长折算 |
| 多端并发 | 同一账号两个客户端同时上报 | 会话唯一,旧会话失效 |
| 改本地时间 | 客户端伪造跨天 | 只信服务器时间与周期键 |
异常速度的阈值要按玩法配置,不能全站一个数字。挂机玩法和手动副本的击杀速度差几个数量级,统一阈值要么误伤要么形同虚设。
离线补算要有上限。玩家离线一年和离线一天都按同一公式补,经济系统会被一次性击穿。常见做法是设置最大补算时长,并只补基础收益。
防刷命中后的处理要分级。轻微超速可以只记录,明显异常才冻结进度或转人工。直接封号会把网络抖动和客户端卡顿误判成作弊。
七、失败场景:用服务器本地时间导致跨区不一致
某游戏在三个地区部署服务器,代码里直接用 time.Now() 取日期做日常键。容器默认时区是 UTC,运维在其中一台机器上改成了当地时区,结果这台机器的玩家比其他人晚八小时刷新。
更隐蔽的是跨天瞬间。玩家在 23:59:59 完成最后一次击杀,事件在 00:00:00 到达服务端。如果事件时间与重置时间来自不同时钟,击杀可能被记到新周期,也可能被归档到旧周期,取决于处理顺序。
修复方式不是“统一改成 UTC”这么简单。UTC 对东八区玩家意味着早上八点刷新,产品上不可接受。正确做法是固定运营时区,所有周期计算都显式传入该时区,测试覆盖本地午夜前后各一分钟。
另一个常见故障是重置丢奖励。玩家昨天完成了日常但没领,零点重置直接把计数清零,奖励没有补发。玩家看到任务变成 0/10,昨天的努力完全消失。
这类问题不能靠“下次注意”解决。重置流程要把未领取奖励归档,或者生成补发邮件;至少保留历史计数,让客服可以手工补偿。
八、Go 实现:DailyReset 与 AchievementLedger
下面的实现把周期键、幂等重置、历史归档和成就领取放在一个包里。ResetNow 只在周期键变化时归档,AddAt 在累加前先做同样的检查,覆盖跨天在线的情况。
// file: ch09/quest/daily.go
// tier: L1
// verify: go test ./ch09/quest/
package quest
import (
"errors"
"fmt"
"sync"
"time"
)
var (
ErrAchievementUnknown = errors.New("achievement: unknown achievement")
ErrAchievementNotReady = errors.New("achievement: not complete")
ErrAchievementClaimed = errors.New("achievement: reward already claimed")
)
type Period int
const (
Daily Period = iota
Weekly
)
// Key 把时间点映射为周期键。所有计算都先换到 loc,再截断日期或取 ISO 周。
func (p Period) Key(now time.Time, loc *time.Location) string {
t := now.In(loc)
if p == Weekly {
year, week := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", year, week)
}
return t.Format("2006-01-02")
}
type ResetRecord struct {
Key string
At time.Time
Counters map[string]int
}
type DailyReset struct {
mu sync.Mutex
loc *time.Location
period Period
counters map[string]int
lastKey string
history []ResetRecord
}
func NewDailyReset(loc *time.Location, period Period) *DailyReset {
if loc == nil {
loc = time.UTC
}
return &DailyReset{
loc: loc,
period: period,
counters: make(map[string]int),
}
}
func (d *DailyReset) Add(id string, n int) int {
d.mu.Lock()
defer d.mu.Unlock()
if n > 0 {
d.counters[id] += n
}
return d.counters[id]
}
// AddAt 先按当前时刻滚动周期,再累加,用于“跨天仍在线上”的玩家。
func (d *DailyReset) AddAt(now time.Time, id string, n int) (int, bool) {
d.mu.Lock()
defer d.mu.Unlock()
_, reset := d.rollLocked(now)
if n > 0 {
d.counters[id] += n
}
return d.counters[id], reset
}
func (d *DailyReset) ResetNow(now time.Time) (ResetRecord, bool) {
d.mu.Lock()
defer d.mu.Unlock()
return d.rollLocked(now)
}
// rollLocked 只在周期键变化时归档并清零,因此重复调用是幂等的。
func (d *DailyReset) rollLocked(now time.Time) (ResetRecord, bool) {
key := d.period.Key(now, d.loc)
if key == d.lastKey {
return ResetRecord{}, false
}
rec := ResetRecord{Key: key, At: now, Counters: make(map[string]int, len(d.counters))}
for id, n := range d.counters {
rec.Counters[id] = n
}
d.history = append(d.history, rec)
d.counters = make(map[string]int)
d.lastKey = key
return rec, true
}
func (d *DailyReset) Count(id string) int {
d.mu.Lock()
defer d.mu.Unlock()
return d.counters[id]
}
func (d *DailyReset) PeriodKey(now time.Time) string {
return d.period.Key(now, d.loc)
}
// History 返回深拷贝,调用方修改记录不会影响内部状态。
func (d *DailyReset) History() []ResetRecord {
d.mu.Lock()
defer d.mu.Unlock()
out := make([]ResetRecord, len(d.history))
for i, rec := range d.history {
out[i] = rec
out[i].Counters = make(map[string]int, len(rec.Counters))
for id, n := range rec.Counters {
out[i].Counters[id] = n
}
}
return out
}
type Achievement struct {
ID string
Target int
Hidden bool
Progress int
Claimed bool
}
// AchievementLedger 只记录累计进度与领取状态,奖励内容由调用方按 ID 发放。
type AchievementLedger struct {
mu sync.Mutex
items map[string]*Achievement
}
func NewAchievementLedger() *AchievementLedger {
return &AchievementLedger{items: make(map[string]*Achievement)}
}
func (a *AchievementLedger) Add(id string, target int, hidden bool, n int) Achievement {
a.mu.Lock()
defer a.mu.Unlock()
it := a.items[id]
if it == nil {
it = &Achievement{ID: id, Target: target, Hidden: hidden}
a.items[id] = it
}
if target > 0 {
it.Target = target
}
if n > 0 && it.Progress < it.Target {
it.Progress += n
if it.Progress > it.Target {
it.Progress = it.Target
}
}
return *it
}
func (a *AchievementLedger) Claim(id string) (Achievement, error) {
a.mu.Lock()
defer a.mu.Unlock()
it := a.items[id]
if it == nil {
return Achievement{}, ErrAchievementUnknown
}
if it.Claimed {
return *it, ErrAchievementClaimed
}
if it.Progress < it.Target {
return *it, ErrAchievementNotReady
}
it.Claimed = true
return *it, nil
}
func (a *AchievementLedger) Get(id string) (Achievement, bool) {
a.mu.Lock()
defer a.mu.Unlock()
it := a.items[id]
if it == nil {
return Achievement{}, false
}
return *it, true
}
History 返回深拷贝。调用方拿到历史记录后修改计数,不会影响内部状态;这一点在补偿和审计场景里很重要,否则一次误改会污染后续对账。
AchievementLedger 把进度与领取分开。Add 只累加并封顶,Claim 才检查目标与领取标记。第二次领取返回 ErrAchievementClaimed,不会重复发放。
九、测试与运行
测试覆盖跨天重置、重复重置幂等、时区边界、ISO 周边界、跨天在线累加,以及成就重复领取。时区测试使用固定偏移的 UTC+8,避免测试机缺少时区数据库导致结果不稳定。
cd .local/checks/gs-practice-lab
gofmt -l ch09/quest
go vet ./ch09/quest/
go test ./ch09/quest/ -count=1
本机执行结果如下。gofmt 与 go vet 没有输出,表示格式与静态检查通过。
ok gspractice/ch09/quest 0.475s
小结
日常任务的核心是周期键,而不是定时清零。键由服务器时间与固定运营时区计算,键变化才归档并重置;重复调用同一个键必须无副作用。
成就与日常共享事件,但进度、可领取、已领取是三个事实。进度可以自动累加,奖励领取必须单独幂等;未领取奖励在重置时要有补发或宽限策略。
常见错误与边界
- 直接用服务器本地时间计算日期,不同机器或不同地区刷新时间不一致。
- 用 UTC 日期代替运营时区,东八区玩家在早上八点才看到日常重置。
- 定时任务与登录重置各写一套逻辑,边界行为不一致。
- 重置不幂等,调度重试把新周期的进度清零。
- 先清零再归档,崩溃窗口里旧计数丢失。
- 重置时直接丢弃未领取奖励,玩家完成日常却拿不到奖励。
- 成就进度达标就自动发奖,重复事件导致重复发放。
- 领取标记只存内存,服务重启后可以再次领取。
- 离线补算没有上限,长期离线玩家一次性获得过量资源。
- 异常速度阈值全站统一,挂机玩法与手动副本互相误伤。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。