《游戏服务端架构·核心系统与契约》10.3 批量领取、补偿与红点

一键领取看似只是循环调用单封领取,真正的问题是部分成功如何表达、失败要不要回滚、补偿怎样避免重复,以及红点计数怎样和邮件列表保持一致。《裂隙战场》的赛季结算、《群岛纪元》的全服补偿、《泡泡工坊》的日常奖励,都需要同一套批量语义。本节给出批量领取结果模型、补偿幂等键与审计日志,以及由同一份列表派生的红点聚合。

一键领取不是循环调用单封领取

《裂隙战场》赛季结束后,玩家邮箱里可能同时躺着排名奖励、参与奖励、段位保护卡和举报回执;《群岛纪元》维护补偿一次发三封;《泡泡工坊》的日常奖励每天一封。玩家不想逐封点,客户端提供一个“一键领取”。服务端最直接的做法是循环调用单封领取,把每一封的结果拼成一个数组返回。这个做法能跑,但很快会遇到三个问题。

第一,部分成功怎么表达。十封邮件里三封过期、一封已被另一个请求领过、六封成功,客户端需要知道每一类的数量,而不是一个笼统的“成功”或“失败”。第二,事务边界在哪里。批量领取不可能把十封邮件放进一个事务:其中一封因为背包满失败,不应该回滚已经成功的六封,否则玩家会看到“点了没反应”。第三,重复请求怎么办。玩家手抖点两次,或者网关重试,第二次请求不能重复发奖。

所以批量领取需要一个显式的结果模型:逐封原子,整批不做事务;部分成功是正常结果,不是异常。这句话是 10.3 所有设计的前提。

三种批量语义

方案事务边界部分失败代价适用
整批一个事务全部邮件全部回滚锁范围大,一封失败全失败邮件少、资产强一致
逐封独立事务单封邮件保留已成功需要结果模型与重放一键领取、批量补偿
先预留再确认两阶段预留可释放复杂,预留本身会过期跨服务、超大额资产

《裂隙战场》和《泡泡工坊》选逐封独立事务:批量领取的每封邮件沿用 10.2 的领取逻辑,结果按状态聚合。《群岛纪元》的联盟集体奖励如果跨服务,选“先预留再确认”:先在邮件侧把附件标记为领取中,再调用资产服务发货,发货成功后确认;发货失败则释放预留。这个方案复杂,只在跨服务无法共享事务时使用。

无论哪种方案,批量接口都必须有上限。示例值:一次最多 100 封,超过则要求客户端分批。上限不是为了性能好看,而是为了把单次请求的锁持有时间、响应大小和失败重试成本控制在可解释的范围内。

批量结果模型

批量领取的返回值至少包含五类信息:请求数、成功数、已领取数、过期数、缺失数,以及成功领取的物品聚合。缺失数单独列出,是因为客户端可能拿着旧列表请求,服务端已经删除了某些邮件;这不是错误,而是需要客户端刷新列表的信号。

ClaimBatch(ids):
  requested = len(ids)
  for id in sorted(ids):
      outcome = ClaimOne(id)
      granted   -> count, append grants
      already   -> count
      expired   -> count
      missing   -> count
      error     -> failures[]
  return counts + grants + failures

排序不是为了展示,而是为了审计。同一个请求重放时,失败列表和物品聚合的顺序一致,对账脚本可以直接比较。成功领取的物品要按物品 ID 聚合,而不是把每封邮件的物品原样拼回去;玩家关心的是“一共拿到多少金币”,不是“哪封邮件给了多少”。

重放语义要明确:第二次请求里,第一次已经成功的邮件返回 already,不重复发奖;第一次过期的邮件仍然返回 expired;第一次缺失的邮件仍然返回 missing。客户端把 granted + already 都视为“已到账”,把 expired 和 missing 视为“需要刷新列表”。这样即使响应丢失,客户端重试也能收敛。

补偿:谁发起,凭什么只发一次

补偿邮件是客诉和事故处理的主要通道。它有三个常见发起方:客服手工补发、结算任务自动补发、运营批量补发。三个入口如果各写一套逻辑,重复补偿几乎必然发生。

我们的做法是统一到一个 IssueCompensation,并且强制要求调用方提供幂等键。键的组成不是“操作人 + 时间戳”,而是业务事实:

compensation_key = reason + source_id + recipient + grants_digest

reason 区分补偿类型,例如 refund、maintenance、season-fix;source_id 是订单号、事故单号或原邮件 ID;recipient 是角色 ID;grants_digest 是物品集合的稳定摘要。这样同一个事故单对同一个玩家的同一组物品,无论客服点几次、任务重跑几次,都只会落一封补偿邮件。

重复来源只用时间戳只用操作人业务键
客服重复点击生成多封生成多封只落一封
结算任务重跑生成多封生成多封只落一封
两个客服先后处理生成多封生成多封只落一封
同一事故不同物品可能误判重复可能误判重复摘要不同,各落一封

补偿邮件的 ID 直接由键派生,例如 comp- 加键,这样唯一性由存储结构保证,而不是靠“先查再写”的应用逻辑。先查再写在并发下会穿透:两个请求同时查不到,然后同时写入。派生 ID 加唯一索引,才能把并发收敛成一次成功、一次重复。

补偿必须留审计。审计记录至少包含时间、操作来源、键、收件人、邮件 ID 和是否重复。重复请求也要记录,因为“有人试图重复补偿”本身就是需要观察的信号。高价值补偿还应该有审批和双人复核,但那是流程问题,代码侧要保证即使流程失效,幂等键仍然能挡住重复。

补偿不要修改原邮件。原邮件是事实记录,补偿是一封新邮件,带自己的正文、附件和有效期;审计里保留原邮件 ID 作为来源。这样玩家看到的是“原邮件 + 补偿邮件”,客服也能解释资产为什么多了一份。

红点:计数必须从列表派生

红点通常有三个数字:未读数、可领取数、即将过期数。最常见的事故是维护两套数据:列表一套,红点计数器一套,领取或已读时只更新了其中一套,玩家看到红点一直在,点进去却没有可领邮件。

我们的规则是:红点不是独立状态,而是邮件列表的一个投影。每次请求红点时,从同一份邮件数据计算未读、可领和即将过期;客户端缓存红点时带一个版本号,服务端任何会改变计数的操作都递增版本。版本变化,客户端重新拉取;版本没变,客户端继续用缓存。这样不存在“计数器漂移”,只存在“缓存短暂过期”。

方案一致性读成本失效方式适用
独立计数表容易漂移低每次写操作更新不推荐
缓存计数短暂不一致低TTL 或主动失效大规模在线
列表派生 + 版本强,读时计算中版本变化即失效默认方案

《泡泡工坊》的邮件量小,直接用列表派生;《群岛纪元》的邮件量大,在列表派生外面加一层短 TTL 缓存,但版本号仍然由写操作递增,缓存键包含版本,版本一变旧缓存自然不再命中。不要用“定时全量重算”作为唯一手段:重算间隔内的红点错误,玩家是能感知的。

三个数字的口径要写死。未读数统计所有未读邮件,不管是否已领取;可领取数统计未领取、未过期、有附件的邮件;即将过期数统计可领取且到期时间落在配置窗口内的邮件。窗口是配置值,示例值 24 小时。口径不写死,客户端、服务端和客服就会各自解释“红点为什么亮”。

可运行片段:批量领取、补偿与红点

下面的 internal/mailclaim 把批量领取、补偿和红点拆成四个文件。types.go 定义邮件、结果和补偿结构;claim.go 实现逐封领取与批量聚合;compensation.go 实现幂等补偿与审计;reddot.go 从同一份邮件列表派生计数。

先看批量领取。ClaimBatch 按请求顺序逐封调用 ClaimOne,把结果分成成功、已领取、过期、缺失和失败五类;任何一封失败都不会回滚之前成功的邮件。ClaimAll 只是把当前收件人的所有邮件 ID 排序后交给 ClaimBatch。

// file: internal/mailclaim/claim.go
// tier: L1
// verify: go test ./internal/mailclaim/
package mailclaim

import (
	"errors"
	"sort"
	"time"
)

// Add inserts a mail in the unread, unclaimed state.
func (s *Store) Add(m Mail) error {
	if m.ID == "" || m.Recipient == "" {
		return ErrEmptyID
	}
	if !validGrants(m.Grants) {
		return ErrInvalidGrant
	}
	if _, ok := s.mails[m.ID]; ok {
		return ErrDuplicateID
	}
	cp := m
	cp.Grants = cloneGrants(m.Grants)
	s.mails[cp.ID] = &cp
	s.order = append(s.order, cp.ID)
	s.version++
	return nil
}

// MarkRead clears the unread bit. It is idempotent and does not bump the
// version twice.
func (s *Store) MarkRead(recipient, id string) error {
	m, ok := s.mails[id]
	if !ok {
		return ErrNotFound
	}
	if m.Recipient != recipient {
		return ErrNotOwner
	}
	if !m.Read {
		m.Read = true
		s.version++
	}
	return nil
}

// ClaimOne decides one mail. Expiry is a status, not an error, because a
// batch must continue with the remaining mails.
func (s *Store) ClaimOne(recipient, id string, now time.Time) (ClaimOutcome, error) {
	m, ok := s.mails[id]
	if !ok {
		return ClaimOutcome{MailID: id, Status: ClaimMissing}, ErrNotFound
	}
	if m.Recipient != recipient {
		return ClaimOutcome{MailID: id, Status: ClaimMissing}, ErrNotOwner
	}
	if m.Claimed {
		return ClaimOutcome{MailID: id, Status: ClaimAlready, Grants: cloneGrants(m.Grants)}, nil
	}
	if now.After(m.ExpiresAt) {
		return ClaimOutcome{MailID: id, Status: ClaimExpired}, nil
	}
	m.Claimed = true
	s.version++
	return ClaimOutcome{MailID: id, Status: ClaimGranted, Grants: cloneGrants(m.Grants)}, nil
}

// ClaimBatch claims ids independently. A missing or expired mail is reported
// in the result; an earlier grant stays granted when a later mail fails.
func (s *Store) ClaimBatch(recipient string, ids []string, now time.Time) BatchResult {
	result := BatchResult{Requested: len(ids)}
	for _, id := range ids {
		outcome, err := s.ClaimOne(recipient, id, now)
		if err != nil {
			if errors.Is(err, ErrNotFound) {
				result.Missing++
			} else {
				result.Failures = append(result.Failures, Failure{MailID: id, Reason: err.Error()})
			}
			continue
		}
		switch outcome.Status {
		case ClaimGranted:
			result.Granted++
			result.Grants = append(result.Grants, outcome.Grants...)
		case ClaimAlready:
			result.Already++
		case ClaimExpired:
			result.Expired++
		}
	}
	sort.Slice(result.Grants, func(i, j int) bool {
		if result.Grants[i].ItemID == result.Grants[j].ItemID {
			return result.Grants[i].Count < result.Grants[j].Count
		}
		return result.Grants[i].ItemID < result.Grants[j].ItemID
	})
	sort.Slice(result.Failures, func(i, j int) bool {
		return result.Failures[i].MailID < result.Failures[j].MailID
	})
	return result
}

// ClaimAll claims every mail currently owned by recipient.
func (s *Store) ClaimAll(recipient string, now time.Time) BatchResult {
	ids := make([]string, 0, len(s.order))
	for _, id := range s.order {
		if s.mails[id].Recipient == recipient {
			ids = append(ids, id)
		}
	}
	sort.Strings(ids)
	return s.ClaimBatch(recipient, ids, now)
}

ClaimOne 把过期当成状态而不是错误,这是批量语义的关键。如果过期返回 error,调用方很容易在第一个过期邮件处中断整批;返回 ClaimExpired 状态,批量才能继续处理后面的邮件。Add 时递增版本,MarkRead 和成功领取也递增,红点缓存因此有明确的失效信号。

补偿逻辑在 compensation.go。它先校验键、收件人、原因和物品,再查补偿表;命中则返回原邮件 ID 并写一条重复审计,未命中则用 comp- 加键作为邮件 ID,落新邮件、记审计、递增版本。

// file: internal/mailclaim/compensation.go
// tier: L1
// verify: go test ./internal/mailclaim/
package mailclaim

import "time"

// IssueCompensation creates a compensation mail once per key. A replay returns
// the original mail ID and writes a duplicate entry to the audit log.
func (s *Store) IssueCompensation(c Compensation, at time.Time) (CompensationResult, error) {
	if c.Key == "" {
		return CompensationResult{}, ErrEmptyKey
	}
	if c.Recipient == "" {
		return CompensationResult{}, ErrEmptyID
	}
	if c.Reason == "" {
		return CompensationResult{}, ErrEmptyReason
	}
	if !validGrants(c.Grants) {
		return CompensationResult{}, ErrNoGrants
	}
	if id, ok := s.compensations[c.Key]; ok {
		s.audit = append(s.audit, AuditEntry{
			At: at, Action: "compensation", Key: c.Key,
			Recipient: c.Recipient, MailID: id, Duplicate: true,
		})
		return CompensationResult{MailID: id, Duplicate: true}, nil
	}

	mailID := "comp-" + c.Key
	if _, ok := s.mails[mailID]; ok {
		return CompensationResult{}, ErrCompensationID
	}
	s.mails[mailID] = &Mail{
		ID:        mailID,
		Recipient: c.Recipient,
		Title:     "补偿:" + c.Reason,
		Grants:    cloneGrants(c.Grants),
		ExpiresAt: c.ExpiresAt,
	}
	s.order = append(s.order, mailID)
	s.compensations[c.Key] = mailID
	s.version++
	s.audit = append(s.audit, AuditEntry{
		At: at, Action: "compensation", Key: c.Key,
		Recipient: c.Recipient, MailID: mailID,
	})
	return CompensationResult{MailID: mailID}, nil
}

func (s *Store) Audit() []AuditEntry {
	return append([]AuditEntry(nil), s.audit...)
}

func (s *Store) Version() uint64 {
	return s.version
}

这里有两个有意的选择。第一,重复补偿仍然返回成功,只是标记 Duplicate,这样调用方不需要把“重复”当成异常处理;真正需要告警的是重复率,而不是单次重复。第二,审计在重复路径上也会追加,且明确标记 Duplicate,方便事后统计哪个入口在重复调用。

红点在 reddot.go。它遍历同一份邮件表,分别计算未读、可领和即将过期;版本号来自 Store,任何写操作都会改变它。

// file: internal/mailclaim/reddot.go
// tier: L1
// verify: go test ./internal/mailclaim/
package mailclaim

import "time"

// RedDot derives every badge count from the same mail list. A second counter
// maintained by a template or a client is the bug, not the cache.
func (s *Store) RedDot(recipient string, now time.Time, soon time.Duration) RedDot {
	if soon < 0 {
		soon = 0
	}
	dot := RedDot{Version: s.version}
	deadline := now.Add(soon)
	for _, m := range s.mails {
		if m.Recipient != recipient {
			continue
		}
		if !m.Read {
			dot.Unread++
		}
		if m.Claimed || now.After(m.ExpiresAt) || len(m.Grants) == 0 {
			continue
		}
		dot.Claimable++
		if !m.ExpiresAt.After(deadline) {
			dot.ExpiringSoon++
		}
	}
	return dot
}

RedDot 没有自己的存储,也没有后台重算任务。它的正确性来自“和列表读同一份数据”,性能则由缓存和版本号解决。如果未来邮件量大到每次遍历都不可接受,可以加一层按收件人分片的物化视图,但物化视图必须由同一批写操作驱动,并且带版本,不能变成第二套真相。

失败模式与兜底

失败表现兜底
批量中一封失败就回滚全部玩家点了没反应逐封独立事务,部分成功显式返回
过期被当成系统错误整批在第一个过期邮件处中断过期作为状态返回,继续处理后续邮件
响应丢失后客户端重试重复发奖已领取返回 already,客户端按已到账处理
批量无上限长事务、大响应、重试成本高单次上限,超出要求分批
补偿用时间戳做键重跑或双击产生多封业务键 + 派生邮件 ID + 唯一索引
重复补偿不记录无法统计重复入口重复也写审计,标记 Duplicate
红点独立计数计数与列表漂移列表派生 + 版本号,缓存按版本失效
红点口径不一致客户端与服务端解释不同未读、可领、即将过期的定义写进接口契约

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/mailclaim
$ go vet ./internal/mailclaim/...
$ go test -count=1 ./internal/mailclaim/
ok  	gsarchlab/internal/mailclaim

本机退出码为 0,包内 7 个测试通过,其中 4 个是表驱动测试;gofmt -l 与 go vet 均无输出。测试覆盖部分成功、重放幂等、错误归属、补偿去重与审计、补偿参数校验、红点口径,以及版本号随写操作变化。

常见错误

第一,把批量领取写成一个大事务。一封邮件背包满导致整批回滚,玩家看到的是“一键领取没反应”,而不是“有一封领不了”。

第二,把过期当成错误。批量接口需要继续处理后面的邮件,过期应该是结果状态,不是中断条件。

第三,补偿键用时间戳或操作人。这两者都不能表达“同一个业务事实”,重跑和双击都会穿透。

第四,补偿修改原邮件。原邮件是事实记录,补偿应该是新邮件并保留来源 ID。

第五,红点单独维护计数器。任何一次漏更新都会让红点长期漂移,而列表才是唯一真相。

小结

批量领取的核心是承认部分成功:逐封原子、整批不做事务、结果显式分类、重放幂等。补偿的核心是业务键:同一个事故、同一个玩家、同一组物品只落一封,重复也要留审计。红点的核心是从列表派生:计数不是状态,版本号只负责让缓存失效。邮件系统到这里形成闭环:投递负责到达,附件负责归属,批量与补偿负责把资产交到玩家手里,红点负责让玩家知道。

下一节离开邮件,进入支付:渠道回调、发货幂等和订单状态机,面对的仍然是同一类问题——至少一次的通知,如何换来恰好一次的业务效果。

阅读导航:上一节:10.2 附件、过期与流拍结算 · 下一节:11.1 支付渠道与发货幂等 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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