笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 游戏业务实现(6 年)

    • 游戏业务实现
    • 多模板游戏活动框架
    • 赛事玩法(Bracket / 瑞士轮 / BO 系列)
    • 业务幂等性设计
    • 跨渠道限购与游戏侧分布式事务落地
    • Redis 房间推荐列表
    • 排行榜 / 榜单
    • 抽卡 / 掉落与保底
    • 邮件 / 发奖补偿
    • 匹配 / 段位(MMR)
    • 游戏反作弊
    • 游戏经济与成长
    • 游戏与互联网后台的本质差异
    • 业务代理 · 支付 · 商城

邮件 / 发奖补偿

个人邮件 vs 全服邮件 + 附件领取幂等 + 发货补偿——游戏里"发奖"的统一收口,也是补偿一切异常的最后一道兜底。

一句话结论

全服读扩散省写入,附件领取靠 CAS 幂等防重领,邮件是发奖兜底的最终落点。

场景问题

邮件是游戏里最不起眼却最关键的基础设施:几乎所有异步发奖都走邮件——活动奖励、赛季结算、客服补偿、版本补偿、离线奖励。它的难点全在"发奖"这两个字:

  • 全服邮件的量级:给全服 500 万人发一封"停机补偿邮件",如果给每人插一行,就是 500 万行写入 + 500 万份附件——写爆库。
  • 附件领取幂等:邮件附件(道具)只能领一次。玩家狂点领取、断线重连重领、多端同时领,都不能领出两份。
  • 发货可能失败:领取时背包满了、道具服超时——附件不能就这么没了,要能安全重试补领。
  • 过期清理:邮件无限堆积会拖垮存储和拉取性能,要有过期策略,但带未领附件的邮件不能随便删。
  • 补偿场景本身:当某次发奖流程失败(如抽卡发货挂了),最终的兜底往往就是"补发一封邮件"——邮件系统是补偿链路的终点。

核心矛盾:全服邮件要省写入(不能人手一份),个人附件又要精确到人地防重领。这两个诉求把邮件拆成了两套模型。

实现方案

一:个人邮件 vs 全服邮件(写扩散 vs 读扩散)

维度个人邮件全服/多人邮件
典型来源客服补偿、定向发奖、好友赠送停机补偿、版本福利、活动全服奖
存储模型写扩散:直接写到该玩家邮箱读扩散:只存一份"公告邮件",玩家上线时按条件生成个人领取记录
写入量O(收件人数),但收件人少O(1) 写一份,避免 500 万行
领取状态存在个人邮件行上存在玩家侧的领取记录(uid + mail_id 唯一)

全服邮件带领取条件(如"2026-07-01 前注册"),玩家拉邮件时动态判断可见性,领取动作才落一行个人记录——把 500 万次写摊薄到"只有真来领的人才写"。

打个比方:游戏邮件像小区信箱 + 到付快递——信箱里的信放着不会消失,你什么时候有空回家开箱都行(离线消息可堆积、拉取幂等);如果邮件带到付快递(附件道具),必须"签收 + 输入取件码"才算真正拿到手(CAS 领取幂等),任何时候中途出错快递照旧存在快递柜、可以安全重签。群发通知(全服停机补偿)像贴在电梯口的公告 + 广播地址,看到公告的人各自决定来不来领;而发奖领取是点对点的"签收凭证",每人一张、绝不重复。类比失效边界:现实信箱只往里塞、不往外撤,发出去的信不可能收回;游戏邮件常常要运营主动撤回错发(配置写错、道具发多),需要额外的补偿逻辑:"未读才可撤"(还没被玩家看到就整体撤销)、"已领取的不可撤"(防止把玩家已入袋的道具追回来引发纠纷),以及针对"已读未领"的中间态给出统一处置策略——这三个状态是纸质信箱完全没有的补丁面。

二:附件领取的幂等状态机

领取是"改变道具"的写操作,必须幂等。用状态机 + 领取记录唯一键:

未读(UNREAD) ──读──▶ 已读(READ) ──领取──▶ 已领(CLAIMED) ──▶ 可删除
                                  │
                     领取失败(发货异常) ──▶ 停在 READ,可安全重试
func ClaimAttachment(ctx context.Context, uid, mailID uint64) error {
    // 1. CAS 抢占领取权:只有从"未领"翻到"领取中"成功的那个请求能继续
    ok, err := repo.CAS(ctx, uid, mailID, StateUnclaimed, StateClaiming)
    if err != nil { return err }
    if !ok { return ErrAlreadyClaimedOrClaiming } // 重复领取直接挡在这里

    // 2. 原子发货到背包(自身也带幂等键 mailID)
    if err := bag.AddItems(ctx, uid, mail.Attachments, idemKey(uid, mailID)); err != nil {
        // 3. 发货失败:回滚到 Unclaimed,附件还在,可重试补领
        repo.CAS(ctx, uid, mailID, StateClaiming, StateUnclaimed)
        return err
    }
    // 4. 发货成功才落终态
    return repo.Set(ctx, uid, mailID, StateClaimed)
}
  • CAS 抢占:Unclaimed → Claiming 的 CAS 保证并发/重复领取只有一个能过,其余被挡。
  • 发货失败回滚:失败退回 Unclaimed,附件不丢,玩家可重试——这就是"补偿"的本质。
  • 发货本身带幂等键:idemKey(uid, mailID),即使"发货成功但状态没落终态"(中间崩溃),重试也不会重复发(背包侧幂等挡住)。

三:发奖补偿——邮件作为兜底终点

游戏里发奖有两条路:直接发到背包(在线、背包有位)和发邮件(离线、背包满、跨服异步)。当直接发货失败或不确定,统一降级为发邮件:

func GrantReward(ctx context.Context, uid uint64, items []Item, idem string) error {
    if err := bag.AddItems(ctx, uid, items, idem); err != nil {
        if isRetryable(err) || errors.Is(err, ErrBagFull) {
            // 背包满/在线发货失败 → 降级发邮件,附件挂邮件上,玩家自己来领
            return mail.SendWithAttachment(ctx, uid, items, idem) // 同一 idem 防重
        }
        return err
    }
    return nil
}

邮件是发奖链路的"最终一致性"落点

在线直发追求即时体验,但只要有任何不确定(离线、背包满、跨服超时),就落邮件——邮件把"必须送达"和"即时送达"解耦。玩家总能在邮箱找到该得的奖,这是发奖不丢的最终保证。补偿邮件复用发奖的同一幂等键,避免"直发失败又发邮件"变成发两份。

四:过期与清理

  • 已领 / 已读无附件:过期(如 30 天)直接物理删除或归档。
  • 带未领附件:延长保留期或到期前推送提醒,绝不能连附件一起删——那是资损。
  • 拉取分页:邮箱按时间倒序分页拉,避免一次性拉几百封;未读数单独用计数器维护,不必拉全量。

为什么这么做

为什么全服邮件用读扩散?
写扩散给全服每人插一行,500 万行写入会瞬间打爆 DB,且绝大多数玩家可能根本不上线来领——白写。读扩散只存一份公告 + 领取条件,把写入从"O(全服人数)"降到"O(实际领取人数)",且发送动作 O(1) 瞬间完成。代价是拉取时要动态判可见性,但读可以靠缓存扛。

为什么领取要 CAS + 状态机,而不是"查一下领没领"?
"先查未领 → 发货 → 标记已领"之间有并发窗口,两个并发请求都查到未领就各发一份(和 幂等性 里那个资损画面同源)。CAS 把"判断 + 占位"合成一个原子操作,从存储层杜绝并发双领。

为什么发货失败要回滚到 Unclaimed 而不是标记失败?
邮件的核心承诺是"附件迟早领得到"。发货失败若标记成终态,附件就永久卡死;退回 Unclaimed,附件还挂在邮件上,玩家下次能重领——这让邮件天然具备补偿/重试能力,成为发奖兜底。

为什么别的选择不行

方案为什么不行
全服邮件写扩散(人手一份)500 万行写入打爆库,且多数人不领 = 白写
领取用"查了再改"查改非原子,并发/重复领取发出多份附件,资损
发货失败标记终态附件永久卡死领不出,玩家投诉资损
不带幂等键补发邮件直发失败又发邮件 → 玩家领两份
过期一刀切物理删删掉带未领附件的邮件 = 直接吞掉玩家的奖,资损
拉邮件不分页老玩家几百封邮件一次拉爆,拉取慢、包体大

全服邮件的可见性坑

读扩散靠"领取条件"过滤可见性,条件写错(如时区、注册时间边界、区服 ID)会导致该发的人看不到、不该发的人领到奖。上线前务必用几个边界账号验证可见性规则;补偿邮件尤其敏感,多发/漏发都是运营事故。

沉淀结论

  • 两套模型:个人邮件写扩散(收件人少、直接落行);全服邮件读扩散(只存一份 + 领取条件,领取才落个人记录),把写入摊薄到实际领取人数。
  • 领取幂等靠 CAS + 状态机:Unclaimed → Claiming 原子抢占防并发双领;发货失败回滚到 Unclaimed 保证附件不丢、可补领。
  • 发货带幂等键:即使状态没落终态,重试也不重复发(背包侧幂等兜底)。
  • 邮件是发奖的最终一致性落点:任何不确定(离线/背包满/跨服超时)都降级为邮件,用同一幂等键避免发两份。
  • 过期清理:无附件邮件可删;带未领附件的绝不能删;拉取分页 + 未读计数器。

与其他专题的关系

  • 领取/发货的幂等与状态机 → 业务幂等性设计(同源的"先查后写"资损画面)。
  • 原子发货/扣减 → 业务代理 · 支付 · 商城 的 Deposit::ExchangeProps。
  • 抽卡、活动、赛季榜发奖失败时,兜底出口都是邮件补发 → 抽卡/掉落与保底、排行榜/榜单、多模板游戏活动框架。

记忆口诀

  • 两套模型:个人写扩散(收件人少/直接落行) / 全服读扩散(只存一份/带领取条件/领取才落记录)
  • 领取幂等:CAS 抢占 / Unclaimed→Claiming / 失败回滚保附件不丢
  • 发奖兜底:离线·背包满·跨服超时 → 降级发邮件 / 同一幂等键防两份
  • 过期清理:无附件可删 / 带未领附件绝不删 / 分页 + 未读计数器

内容来源

综合整理自游戏邮件系统与发奖补偿链路的实现经验;读扩散/写扩散取舍、附件领取状态机与"邮件作为发奖最终一致性落点"呼应本域 业务幂等性设计 与 业务代理 · 支付 · 商城。

自测:合上资料能说清楚吗?

Q1:全服停机补偿要发给 500 万人,为什么不给每人插一行?该怎么发?

参考答案

写扩散给全服每人插一行会产生 500 万行写入打爆 DB,且多数人不上线领 = 白写。改用读扩散:只存一份公告 + 领取条件(发送 O(1)),玩家上线时动态判可见性,领取才落一行个人记录,写入摊薄到实际领取人数。

Q2:玩家狂点/断线重连/多端同时领同一封附件,如何保证只领一份?

参考答案

用 CAS + 状态机。领取时对 Unclaimed → Claiming 做 CAS 原子抢占,只有一个请求能翻状态成功,其余被挡。把"判断+占位"合成一个原子操作,从存储层杜绝并发双领;发货本身再带 idemKey(uid,mailID) 二次兜底。

Q3:领取时背包满/道具服超时导致发货失败,附件该怎么办?

参考答案

回滚到 Unclaimed,而非标记失败终态。附件仍挂在邮件上,玩家可重试补领——这就是邮件的"补偿"本质。若标记成终态,附件会永久卡死领不出,造成资损投诉。

Q4:对比"个人邮件写扩散"与"全服邮件读扩散",各自的写入量、领取状态存哪、适用场景?

参考答案

写扩散(个人邮件):直接写玩家邮箱,写入 O(收件人数),领取状态存邮件行上,适合客服补偿/定向发奖等收件人少场景。读扩散(全服邮件):只存一份 + 领取条件,发送 O(1),领取状态存 uid+mail_id 个人记录,适合停机/版本福利等全服海量场景。

Q5:邮件过期清理为什么不能"一刀切物理删"?

参考答案

无附件的已读/已领邮件可按期(如 30 天)删除,但带未领附件的邮件绝不能删——删掉等于直接吞掉玩家的奖,是资损。应延长保留期或到期前推送提醒。

最近更新: 2026/9/10 11:38
Prev
抽卡 / 掉落与保底
Next
匹配 / 段位(MMR)