一键领取不是循环调用单封领取
《裂隙战场》赛季结束后,玩家邮箱里可能同时躺着排名奖励、参与奖励、段位保护卡和举报回执;《群岛纪元》维护补偿一次发三封;《泡泡工坊》的日常奖励每天一封。玩家不想逐封点,客户端提供一个“一键领取”。服务端最直接的做法是循环调用单封领取,把每一封的结果拼成一个数组返回。这个做法能跑,但很快会遇到三个问题。
第一,部分成功怎么表达。十封邮件里三封过期、一封已被另一个请求领过、六封成功,客户端需要知道每一类的数量,而不是一个笼统的“成功”或“失败”。第二,事务边界在哪里。批量领取不可能把十封邮件放进一个事务:其中一封因为背包满失败,不应该回滚已经成功的六封,否则玩家会看到“点了没反应”。第三,重复请求怎么办。玩家手抖点两次,或者网关重试,第二次请求不能重复发奖。
所以批量领取需要一个显式的结果模型:逐封原子,整批不做事务;部分成功是正常结果,不是异常。这句话是 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 支付渠道与发货幂等 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。