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

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

业务幂等性设计

幂等键 + 去重 / CAS / 状态机——让"发奖、扣费、支付回调"重复来多少次,效果都只算一次。

一句话结论

用幂等键锁定同一请求,靠去重/CAS/状态机让重复处理只生效一次。

场景问题

只要链路里有钱、有道具,"同一个请求被处理两次"就是资损。而重复请求在分布式系统里不是异常,是常态:

  • 网络重试:客户端超时了,但服务端其实已经处理,客户端重发;
  • 消息重投:MQ 保证的是"至少一次"(at-least-once),消费者收到重复消息很正常;
  • 用户重复点击:领奖按钮连点三下,或断网后刷新页面重提;
  • 超时补偿:支付回调 5 分钟没收到 ack,支付平台按退避策略重推 15 次。

一个真实的资损画面

支付回调进来发道具,代码是"先查订单没发过 → 发货 → 标记已发"。回调重投两次几乎同时到达,两个协程都查到"没发过",于是各发一份。玩家白嫖一份限定皮肤,对账时才发现少收了钱。根因:先查后写之间有并发窗口,查和写不是原子的。

所以每一个"改变余额 / 库存 / 道具"的写操作,都必须问一句:它被调两次,结果一样吗? 若不一样,就要给它加幂等。

实现方案

第一步:设计幂等键

幂等的前提是能识别"这是同一个请求"。幂等键要满足同一业务操作稳定唯一、不同操作必然不同:

场景幂等键来源
支付回调发货订单号 order_id下单时生成,回调透传
活动领奖(actID, uid, tier) 业务唯一键业务维度天然唯一
通用写接口请求 ID request_id客户端生成 UUID,重试时保持不变
扣费交易流水号 txn_id下单时下发,全链路透传

打个比方:幂等键就像快递取件码——同一个包裹只对应同一串码,你到柜前扫一次码,柜门开、包裹取走、码就作废;接下来无论是快递员再扫、你自己再来一次、还是别人拿相同的码来碰运气,柜子都只会告诉你"该包裹已被取走",绝不会凭空多吐出一份。类比失效边界:取件码的作废是服务端的柜机强校验,玩家/快递员本地记不记都不影响;如果幂等键只写在客户端本地缓存里做"重复点击拦截",一旦用户换设备、清缓存、卸载重装,客户端就以为是首次请求,本地拦截彻底失效——所以幂等键必须由发起方生成、但由服务端持久化并校验,不能把最后一道关设在能被玩家清空的地方。

幂等键必须由发起方生成并在重试中保持不变

如果幂等键是服务端每次收到请求才现生成的,那重试就成了"新请求",幂等失效。客户端重试必须带上同一个 request_id。

方案一:唯一索引防重(最硬的底线)

给去重表的幂等键加数据库唯一索引,插入冲突即代表重复。这是并发窗口的最终兜底——因为唯一约束由数据库在存储层原子保证:

CREATE TABLE grant_dedup (
    idem_key    VARCHAR(128) NOT NULL,
    uid         BIGINT       NOT NULL,
    result      JSON,
    created_at  BIGINT       NOT NULL,
    UNIQUE KEY uk_idem (idem_key)   -- 幂等键唯一约束,重复插入直接报错
);
func GrantOnce(db *sql.DB, idemKey string, uid uint64, pack *Pack) (*Result, error) {
    tx, _ := db.Begin()
    defer tx.Rollback()

    // 唯一索引:并发下只有一个事务插入成功,其余拿到 Duplicate 错误
    _, err := tx.Exec(
        "INSERT INTO grant_dedup(idem_key, uid, created_at) VALUES(?,?,?)",
        idemKey, uid, time.Now().Unix())
    if isDuplicateKey(err) {
        // 已处理过:回放既有结果,不重复发货
        return loadResult(db, idemKey)
    }
    if err != nil {
        return nil, err
    }

    res, err := sendGoods(tx, uid, pack) // 与去重记录同一事务,一起提交
    if err != nil {
        return nil, err // 回滚:去重记录也一并撤销,允许重试
    }
    _, _ = tx.Exec("UPDATE grant_dedup SET result=? WHERE idem_key=?", res.JSON(), idemKey)
    return res, tx.Commit()
}

方案二:Redis SETNX 去重令牌(快速拦截)

在打到 DB 前,用 Redis SET key val NX EX 抢一个去重令牌,抢不到就是重复请求,直接短路。这是高并发下的第一道快门,减轻 DB 压力:

// SET idem:{key} 1 NX EX 600 —— 只有第一个请求能设置成功
ok, err := rdb.SetNX(ctx, "idem:"+idemKey, "1", 10*time.Minute).Result()
if err != nil {
    return handleWithDB(idemKey) // Redis 故障降级到 DB 唯一索引兜底
}
if !ok {
    return ErrDuplicate // 已有令牌,重复请求,直接拦
}

SETNX 不能单独用

Redis 令牌可能因超时、宕机、TTL 到期而"漏放",它只是性能优化的快门,不是正确性保证。正确性最终要靠 DB 唯一索引兜底。两者叠加:Redis 挡住大部分重复,唯一索引兜住漏网的并发。

方案三:状态机 + CAS 只允许合法流转

对"订单"这类有生命周期的实体,用状态机约束,配合 CAS(compare-and-swap) 保证状态流转的原子性——只有满足"当前是旧状态"才能改成新状态:

-- CAS:带上预期旧状态做条件更新,affected_rows=0 即流转失败(已被改过)
UPDATE orders SET status='shipped', ship_at=?
 WHERE order_id=? AND status='paid';
aff, _ := res.RowsAffected()
if aff == 0 {
    // 状态不是 'paid':要么已发货(重复回调),要么已取消(非法流转)
    cur := queryStatus(orderID)
    if cur == "shipped" {
        return OK // 重复回调,幂等返回成功,避免上游无限重试
    }
    return ErrIllegalTransition // 已取消等非法流转,拒绝
}

CAS 把"查 + 改"压成一条原子语句,彻底消除了先查后写的并发窗口。

令牌桶式幂等:先申请 token 再提交

对表单/下单类操作,可以让客户端先向服务端申请一个一次性 token,提交时带上,服务端校验并消费 token(消费也是 CAS/SETNX)。token 只能消费一次,重复提交因 token 已消费而被拒。这把"防重"前置到提交之前,比事后去重体验更好。

发奖 / 扣费 / 支付回调的统一范式

三者本质相同,都是"带幂等键的写"。把上面几招组合成一条防线:

为什么这么做

为什么至少一次 + 幂等 = 恰好一次?
分布式系统里,"精确一次投递"(exactly-once delivery)在网络层几乎做不到——你无法区分"对方没收到"和"对方收到了但 ack 丢了"。工程上的解法是退而求其次:投递层做至少一次(重试直到成功),处理层做幂等(重复处理无副作用)。两者叠加,最终效果上等价于恰好一次(effectively-once)。幂等是把"投递的不确定性"在业务侧收口的关键。

为什么要多层叠加,不能只用一招?

  • 只用 Redis SETNX:Redis 故障 / TTL 过期会漏,不保证正确性;
  • 只用唯一索引:每个重复请求都打到 DB,高并发下压力大;
  • 只用状态机 CAS:需要实体本身有状态,纯"新增型"操作(如发一次性奖励)没有可流转的旧状态。

所以:SETNX 做性能快门,唯一索引做正确性底线,状态机 CAS 做流转约束,各司其职、互相兜底。

为什么别的选择不行

只靠前端防重(按钮置灰 / 防抖)不行。 前端能挡住"用户手抖连点",但挡不住网络重试、消息重投、超时补偿这些服务端侧的重复来源。前端防重是体验优化,绝不是正确性保证——攻击者甚至可以绕过前端直接打接口。

"先查后写"不加原子保证不行。 if 没处理过 { 处理 } 在并发下,两个请求可能同时通过判断,各处理一次。查和写之间的窗口是资损的温床。必须靠唯一索引/CAS/分布式锁把这个窗口消除。

给整个接口加分布式锁"图省事"代价太大。 锁能保证串行,但把并发能力压没了,锁本身还要处理超时、误删、续期。有唯一索引 / CAS 这种存储层原子能力时,优先用它们——它们既保证正确性又不牺牲并发。锁只在没有天然唯一键、又必须串行的复杂场景才用。

沉淀结论

  • 每个改变余额/库存/道具的写,都要先自问"调两次结果一样吗",不一样就加幂等。
  • 幂等键由发起方生成、重试中保持不变,这是一切的前提。
  • 组合防线:Redis SETNX 快门 + DB 唯一索引底线 + 状态机 CAS 流转,三者互补。
  • 重复请求要幂等返回成功(回放既有结果),别返回失败——否则上游会无限重试放大压力。
  • 至少一次投递 + 处理幂等 = 效果恰好一次,这是分布式一致性的工程正解。
  • 前端防重只是体验层,正确性必须落在服务端存储层的原子约束上。

与商城支付实战的呼应

本域《业务代理 · 支付 · 商城》里支付代理的"幂等四道闸"(未找到订单 / 已终止 / 重复发货返回成功 / 正常)正是这套范式的落地:重复发货返回成功对应本文"重复请求幂等返回成功、避免上游无限重试";商城服务发货前查 received_list、订单优先读 cache miss 再回查 DB,对应"去重表 + 先查快路径"。

记忆口诀

  • 幂等键:发起方生成 / 重试不变 / 同操作唯一
  • 三道防线:Redis SETNX 快门 / DB 唯一索引底线 / 状态机 CAS 流转
  • 恰好一次:至少一次投递 + 处理幂等 = 效果恰好一次
  • 重复请求:回放既有结果 / 幂等返回成功 / 别返回失败

内容来源

综合整理自分布式幂等性设计的通用实践与游戏支付/发奖链路的落地经验;具体案例呼应本域《业务代理 · 支付 · 商城》中支付代理幂等四道闸与商城服务重复发货处理的实战总结。

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

  1. 幂等键为什么必须由发起方生成、并在重试中保持不变?如果服务端每次现生成会怎样?
参考答案

幂等的前提是能识别"同一个请求"。若服务端每次收到才生成键,重试就成了新请求,键不同,幂等失效、重复处理照样发生。所以客户端重试必须带同一个 request_id/txn_id。

  1. "先查后写"(if 没处理过 { 处理 })在并发下为什么会资损?
参考答案

查和写不是原子的,中间有并发窗口。两个请求可能同时通过"没处理过"判断,各处理一次(各发一份奖)。必须靠唯一索引/CAS 把查改压成一步,消除窗口。

  1. 对比 Redis SETNX 去重令牌 与 DB 唯一索引:各自的定位是什么?为什么要叠加?
参考答案

SETNX 是性能快门:高并发下先拦大部分重复、减轻 DB 压力,但会因超时/宕机/TTL 到期而漏放,不保证正确性。唯一索引是正确性底线:由 DB 存储层原子保证,兜住漏网并发,但每个重复都打到 DB。故叠加:快门挡量、索引兜底。

  1. 为什么"至少一次投递 + 处理幂等"约等于"恰好一次"?为什么不直接做精确一次投递?
参考答案

网络层无法区分"对方没收到"和"收到但 ack 丢了",精确一次投递几乎做不到。工程解法:投递层做至少一次(重试到成功),处理层做幂等(重复无副作用),两者叠加达到效果恰好一次(effectively-once)。

  1. 状态机 + CAS 适用什么场景?纯"新增型"操作(如发一次性奖励)为什么不能只靠它?
参考答案

CAS 适用有生命周期的实体(如订单),靠"当前是旧状态才能改"实现原子流转、天然幂等。但纯新增操作没有可流转的旧状态,无从 CAS,需靠唯一索引去重兜底。

最近更新: 2026/9/10 11:38
Prev
赛事玩法(Bracket / 瑞士轮 / BO 系列)
Next
跨渠道限购与游戏侧分布式事务落地