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

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

跨渠道限购与游戏侧分布式事务落地

同一件限量皮肤同时挂在游戏内商城 / 官网 H5 / 米大师 iOS 内购 / 安卓渠道 SDK——四个入口一起卖,账号体系还各不相同,怎么做到"每玩家最多 1 件、总盘不超卖、支付-发货不错位"?
本篇是 game-biz 视角的应用剖面,把 分布式事务原理、秒杀削峰与统一库存、幂等设计、业务代理·支付·商城、分布式游戏存储 五份既有事实源绑到具体组件与事务链上,原理不复述。落地库假设为 tcaplus 类文档型 KV(单 key 排队 + 版本 CAS,无跨记录事务)——所有事务动作都翻译成"单记录多字段 CAS + 幂等重试"。

一句话结论

跨渠道限购 = 账号解析层归一 idem_key + 统一限购中心持总账 · 本地配额扛峰值 + TCC(单记录 status + version CAS) · 本地消息表压进 t_order 主记录 + 写时分片计数 + 全局索引扫描交叉对账;外部第三方(米大师)不进 2PC,所有一致性都在最终一致里收敛。

场景问题

面试真题常这么问:"一件限购 1 件的限量皮肤,游戏内商城和游戏外(官网 / iOS 米大师 / 安卓渠道 SDK)都在卖,你怎么保证每个玩家最多买 1 件、总量不超卖?" 三个字眼藏着三个坑:跨渠道、限购、同一玩家——每一个坑都足以让"抄一份 seckill 就完事"的方案翻车。

入口矩阵:同一件货,四种账号视图

入口账号体系支付通道与游戏内 uid 的关系
游戏内商城内部 uid(登录态直取)米大师 CGI天然一致
官网 H5 商城QQ / WX openid米大师 Web需账号绑定才能对上内部 uid
iOS 苹果内购Apple ID + 米大师订单号米大师 iOS SDK支付成功回调时才带 uid,订单先行、账号后绑
安卓渠道 SDK渠道 openid(应用宝 / 华为 / 小米 各不同)渠道 SDK 内计费 → 回调米大师需渠道 openid ↔ 内部 uid 映射表

为什么直接抄 seckill 会翻车

seckill 里的 bought:{item} SADD uid 假设"所有渠道共享同一 uid 空间"。真实业务里,官网 H5 拿到的是 QQ openid、iOS 拿到的是米大师订单号、安卓拿到的是渠道 openid——各自的 uid 根本对不上。同一玩家在官网买一份、又在游戏内再点一次,两处各自 SADD 的都是"首次",SISMEMBER 都返回 0,结果双双成功——跨渠道超卖。

一个真实资损画面

某周末上线一款 999 件限量皮肤,未做跨渠道打通。玩家 A:

  • 12:00 官网 H5(QQ openid=X)买了 1 件 → bought:{item} 里 SADD X;
  • 12:00:03 打开游戏,登录态是内部 uid=99881 → SADD 99881 也成功;
  • 12:00:05 又在 iOS 米大师商城下单,米大师订单号 M-2026 → 走的又是另一份 SADD;
  • 12:00:10 支付回调三路陆续到达 mallsvrd → 该玩家背包收到 3 份限定皮肤。

全服此类玩家 200+,超卖 400 件、资损几十万米、还得手动追回并公告。根因:跨渠道 idem_key 未归一,"同一玩家" 在系统眼里是三个陌生人。

事务视角:不是本地事务能救的

跨渠道下单-支付-发货这条链,涉及的组件:客户端 → mallsvrd(游戏内商城)→ paypxy(支付代理)→ 米大师(外部第三方,不受你控制)→ mallsvrd 发货。米大师是外部服务,不可能加入你的 XA 事务管理器——这就一举把同步 2PC 判了死刑。剩下的路只有 TCC / Saga / 本地消息表 / 最大努力通知 / 事务消息 这些最终一致方案,工程目标退化为一个更朴素的问题:"跨方链路只要收敛到一致,中间态被玩家看到几秒可接受"。

实现方案

思路分三段:账号解析层归一身份 → 统一限购中心 + 本地配额做正确性 + 性能 → TCC + 本地消息表 做支付-发货事务链。

账号解析层:把四种身份归一到一个 account_key

目标:idem_key 里不能出现"渠道自带的 openid",否则同一玩家跨渠道会看成两个人。

做法:在 mallsvrd 前置一个账号解析层(可以是独立小服务,也可以在 mallsvrd 入口做拦截):

account_key = resolve(channel, external_uid)
  → if 已绑定内部 uid: 返回 uid(例如 99881)
  → else: 返回临时锚 "temp:{channel}:{external_uid}"(例如 "temp:web:qq_X")
  • 已绑定入口(游戏内商城 / 官网已扫码绑 QQ 的玩家):直接取 t_account_bind 表里的内部 uid,account_key = uid。
  • 未绑定入口(iOS 首次下单、渠道 SDK 匿名下单):先用 temp:{channel}:{external_uid} 作锚点占额度;等支付回调带出真实 uid(米大师回调必然含内部 uid,因为发货必须落到具体玩家)后,触发合并:把 temp:... 记录合并到 uid=99881 名下。

幂等键由业务操作维度与账号一起哈希:

idem_key = sha1( activity_id || sku || purchase_scope || account_key )
purchase_scope = "per_account"   // 表示"每账号 1 件"

合并时的限购计数守恒

临时锚 temp:web:qq_X 与 内部 uid 99881 合并后,去重表里可能各有 1 条记录 → 合并后总计数 = 2,直接暴露超卖。守恒动作是:合并前先查两侧计数总和,若 > 每玩家上限,走两条路二选一——

  • 保守派:拒绝合并,玩家二次登录时提示"该账号已在其他入口购买过限购物品,本次订单需退款/补偿";
  • 激进派:允许合并 + 后台补偿式收回多余的物品(会有玩家投诉,需运营话术支持)。
    选哪条不是架构决策,是业务权衡——把它明确写进限购中心的合并策略配置,不要藏在代码里。

idem_key 落到去重表和限购中心的原子扣减都以它为唯一约束——同一个 account_key 只可能造出一个 idem_key。跨渠道超卖到此为止。

统一限购中心 + 各渠道本地配额

原理与边界详见 seckill · 统一库存(分段是性能、统一是正确性);本节只讲限购这一特定资源上的三点落地差异:

  • 各渠道持 quota:{channel}:{sku} 一段本地配额(例如四渠道各持 250 件的初始配额,总盘 999);扣减用 Lua 原子 + idem_key 去重。
  • 本地配额低于低水位 → 调 borrow.lua 向中心 total:{sku} 原子申领一批。
  • 限购的额外约束:限购中心额外维护 bought:{sku} → set of account_key。每次 Try 除了扣配额,还要 SADD account_key,若返回 0(该 account_key 已在集合里)→ 直接拒绝、返还本地配额。这是"每玩家 1 件"的正确性闸。
-- try_purchase.lua
-- KEYS[1] = quota:{channel}:{sku}   本地配额 key
-- KEYS[2] = bought:{sku}             全渠道去重集合
-- ARGV[1] = idem_key                 幂等键
-- ARGV[2] = account_key              归一后的账号
-- 返回:1 成功 / 0 售罄 / -1 已购(限购触发)
if redis.call('SISMEMBER', KEYS[2], ARGV[2]) == 1 then
  return -1
end
local q = tonumber(redis.call('GET', KEYS[1]) or '0')
if q <= 0 then return 0 end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[2])
return 1

限购 vs 秒杀总量的边界

quota:{channel}:{sku} 是性能分片(打散热点),bought:{sku} 是正确性总账(跨渠道去重)。前者可以出现"某渠道本地额度耗尽但玩家还没买"的临时不均,靠回源 borrow 再平衡;后者一旦 SADD 成功就是全局承诺,不允许被绕过。

离线对账(tcaplus 版):文档型 KV 没有 SELECT SUM/COUNT,也不能像 SQL 一次 count(*) 出全表统计——三份数据得换两种玩法交叉验证:

  • 实时对账(每分钟):在写入路径同步维护分片计数。给每个 sku 建一张 t_sku_stat Generic 表,key={sku}:{shard_id = hash(uid) % 32},字段 done_count;mallsvrd 每次 Confirm 成功、CAS 更新 t_purchase_try 之后追加一次 t_sku_stat 的 CAS +1。对账时读 32 条相加即为完成总数。分片是为了避免热 key:全服玩家买同一 sku 都撞一条计数记录会把这条 key 打满 QPS 上限。
  • 日终对账(每天):走全局索引分页扫描 t_order where sku=? AND status='done',一次 limit=3000,翻页到 offset+limit=10000 时切换 shard 键(如按 paid_at 小时桶)继续扫,直到扫完。

三方交叉:|Redis bought:{sku}| == sum(t_sku_stat[sku][0..31].done_count) == 全局索引扫出的 t_order 完成数。任一不等即告警,走人工补偿或后台脚本回补。

为什么必须"写入时同步维护计数" 而不是靠索引 count

tcaplus 全局索引异步准实时(<1s 但仍有秒级延迟),且单查询 ≤3000、limit+offset ≤10000 硬上限。用它做实时对账要么读到旧值、要么翻页翻不完。真正的实时指标必须在写入这条 t_purchase_try / t_order 记录的同时 CAS 增一条 t_sku_stat——这就是 KV 世界的"物化视图",多存一份聚合值换查询 O(1)。

TCC 三阶段:单条记录 + CAS 版本号跑完状态机

原理(Try/Confirm/Cancel 通用定义 + 空回滚 / 悬挂 / 幂等三坑)详见 distributed-transaction · TCC;本节讲具体落地到 tcaplus 这类文档型 KV 上要怎么写。

存储语义前提

落地库是 tcaplus 类分布式 KV——Generic 表一 Key 一记录、单 key 写在存储节点工作线程内排队 + 记录版本号 CAS 校验、没有跨记录事务、没有 SUM/COUNT 聚合。所以整套状态机被压缩成 "一条 t_purchase_try 记录的字段变迁",隔离粒度就是这条记录的 version。跨记录的原子由第 4 节的本地消息表兜底、由第 5 节的对账兜底。

Try(mallsvrd):

  1. 归一 account_key、生成 idem_key;
  2. 走 try_purchase.lua 预扣本地配额 + SADD bought(Redis 层拒绝就短路返回,t_purchase_try 不落);
  3. 对 tcaplus t_purchase_try 执行 InsertRecord,key = idem_key,value = {uid, sku, account_key, status:"trying", expire_at:now+15min}:
    • 返回 SUCCESS → 记录写入,version=1;
    • 返回 KEY_EXISTS → 说明是 Try 重试,GetRecord 读回旧记录按 status 幂等处理(trying 直接返回成功;done/cancelled 见 Confirm/Cancel 分支);
  4. 交给 paypxy 去调米大师下单。

Confirm(paypxy → mallsvrd):米大师支付回调(带订单号、金额、uid)到达 paypxy → paypxy 走本地消息表(下一节讲)→ 消费者投递到 mallsvrd → mallsvrd 幂等发货:

rec = GetRecord(t_purchase_try, key=idem_key)    # 若 NOT_FOUND → 空回滚,见下
switch rec.status:
  case "trying":
    rec.status   = "done"
    rec.done_at  = now
    ReplaceRecord(rec, cas_version=rec.version)  # 版本不符 → 已被别人改过,重读判状态
    发货:对 t_bag 记录做 CAS 增(items[sku] += 1)
  case "done":
    return 幂等成功                              # 直接回放
  case "cancelled":
    return 悬挂兜底(拒绝 Confirm、通知财务退款)

Cancel(定时器 / 支付失败回调):

  • 触发一:定时器扫全局索引 t_purchase_try where status='trying' AND expire_at < now,逐条 GetRecord → CAS Replace 成 status='cancelled' + cancelled_at=now;同时 Redis 侧 INCR quota + SREM bought account_key。
  • 触发二:米大师主动回调 "支付失败/退款" → paypxy 转 mallsvrd Cancel 同上。

三坑落地(都用 GetRecord/Replace 的 CAS 语义解):

坑场景tcaplus 侧防御动作
空回滚Try 网络超时未真扣,事务管理器直接 Cancelmallsvrd 对 t_purchase_try GetRecord(idem_key) 返回 RECORD_NOT_FOUND → 记一条"空回滚"审计、直接返回成功;不动 Redis 配额、不做写入
悬挂Cancel 先到(超时),迟到 Try 才到Try 里 InsertRecord 返回 KEY_EXISTS,读回一看 status='cancelled' → 拒绝 Try;若 Redis Lua 已经先扣了配额,触发一次"配额回补"(INCR quota + SREM bought),保证 Redis 与 tcaplus 两侧最终一致
幂等Confirm / Cancel 均可能重试t_purchase_try 用 idem_key 做主键 → Generic 表 key 天然唯一;每次 Replace 带 cas_version,重复到达要么因 status 已推进被幂等回放,要么因 version 不符报错重试

tcaplus 不是 SQL 的三点具体差别

  1. 没有 BEGIN/COMMIT:这里的 "状态推进" 不是一个跨行事务,而是一次 ReplaceRecord(cas_version) 的原子行为——工作线程内单 key 排队 + 版本校验,语义等价于单行乐观锁。跨记录的"顺带更新"(如同时改 t_purchase_try 和 t_bag)不能 期望原子;由本地消息表 + 消费幂等把它们串成最终一致。
  2. 没有 SELECT ... FOR UPDATE:并发裁决靠 CAS,冲突方一定是报错回来的,不会阻塞。所以任何 Replace 都要写"读回来重判" 的重试路径,别当成必成功的语句。
  3. key 冲突 = 唯一索引:InsertRecord 返回 KEY_EXISTS 是 tcaplus 版的 "duplicate key",用它替代 SQL 的唯一索引兜底即可。

本地消息表:让"支付成功 ⟺ 发货消息一定发出"

原理详见 distributed-transaction · 本地消息表。SQL 版本里靠 BEGIN; UPDATE t_order; INSERT t_pending_msg; COMMIT; 一次事务把两件事绑死;tcaplus 没有跨记录事务,得换一种落地。生产上按"字段能不能扩" 分成方案 A / B。

方案 A(首选):把消息状态压进 t_order 主记录

paypxy 的 t_order 加两个字段 dispatch_state / dispatch_payload,回调时一次 CAS Replace 完成两件事——因为 tcaplus 单记录多字段更新天然原子:

# paypxy 收到米大师回调
rec = GetRecord(t_order, key=order_id)          # 假设读到 version=3
rec.status           = "paid"
rec.paid_at          = now
rec.dispatch_state   = "pending"                # ← "待发货消息" 就是这一列
rec.dispatch_payload = {idem_key, uid, sku, amount, ...}
ReplaceRecord(rec, cas_version=3)               # 一次原子写,version→4

只写一条记录 = 天然原子。"付款事实入库" 与 "发货消息已登记" 从此同生共死。

relayer 协程走全局索引扫 dispatch_state='pending':

rows = QueryByIndex(t_order,
                    where: dispatch_state='pending' AND paid_at < now-3s,
                    limit: 200)
for r in rows:
    投 MQ → mallsvrd 幂等消费成功 →
    r.dispatch_state = "delivered"
    ReplaceRecord(r, cas_version=r.version)     # CAS 防重复回写覆盖新状态

三个 tcaplus 特性引入的细节:

  • 全局索引 <1s 异步同步:paid_at 加 3 秒延迟过滤,防"刚写完还没进索引就被扫过"造成 relayer 漏投。
  • 单查询 ≤3000 条、limit+offset ≤10000:大促期一个 sku 挂几万单 pending,得用分片键分段扫(如按 paid_at 小时桶 或 hash(order_id)%N)分批 QueryByIndex,别指望一把翻完。
  • 回写必须带 cas_version:投递成功回写 delivered 时,若中途另一路 relayer 又拉起同一条重投,version 不匹配就会失败——这正是我们想要的,别把 CAS 冲突当异常吞掉,改成"重读判 dispatch_state 是否已 delivered,是则跳过"。

方案 B:独立 t_pending_msg 表 + 主表反查兜底

如果 t_order 字段不方便扩(结构冻结、上下游依赖),退而拆两条记录:

# 步骤 1:CAS 更新 t_order → paid
rec = GetRecord(t_order, key=order_id); rec.status="paid"; rec.paid_at=now
ReplaceRecord(rec, cas_version=rec.version)

# 步骤 2:InsertRecord t_pending_msg
InsertRecord(t_pending_msg, key=idem_key, value={
  payload:{uid, sku, ...}, status:"pending", created_at:now
})

这两步不是原子的——中间 paypxy 崩溃会漏一条消息。补救靠 relayer 双向扫描:

  • 主扫描:t_pending_msg where status='pending' → 投 MQ → 回写 delivered。
  • 兜底扫描:t_order where status='paid' AND paid_at < now-30s → 对每条按 idem_key GetRecord(t_pending_msg),RECORD_NOT_FOUND 就补插一条 pending。这是"消息漏写"的自愈路径。

方案 A 干净、少一次 RPC,方案 B 结构解耦但要多写一份兜底扫描。生产上能扩字段就走 A。

为什么不用 tcaplus List 表当消息队列

List 表(同 key 多条按列表存)看起来天生适合当 pending 队列,但没有独立记录 TTL、没有全局索引、不能按 status 反查——运维语义弱于 Generic + 全局索引,除非 MQ 完全不可用,不建议用 List 表兜"消息投递"这类关键路径。

mallsvrd 侧的幂等消费

以 idem_key 为消息去重键。消费者收到消息后:

try_rec = GetRecord(t_purchase_try, key=idem_key)   # Confirm 阶段一定要读一次
if try_rec.status == "done": return 幂等成功         # 已发过货,回放
if try_rec.status == "cancelled": return 悬挂拒绝    # 交财务退款
# status == "trying" → 正常发货:CAS 推 try_rec → done + CAS 增 t_bag

幂等的根基是 t_purchase_try 这条记录的 status 字段,不是消息本身的去重表——因为 tcaplus 单记录 CAS 是最便宜的原子原语,把幂等收敛在业务主记录上比另建 dedup 表更合适。

端到端事务链:一张时序图讲完六动作

失败与补偿分支

为什么这么做

  • 为什么账号解析层是这套方案的第一块基石:跨渠道超卖的根因永远不是"扣减不原子",而是"你以为的两个人其实是一个人"。把身份归一显式做成一层,跨渠道限购才有可能;反过来,账号解析写得越晚(比如塞在发货前)越危险——一旦支付都过了才发现同人重复下单,除了退款没别的招。

  • 为什么用 TCC 把限购额度做成两阶段而不是"下单即扣":从玩家点"购买"到米大师回调,中间要过外部第三方,链路秒级到分级都有可能。"下单即扣"要么扣早了、玩家取消支付形成大量脏额度(配额被占死、玩家看到"售罄"却其实没卖),要么扣晚了、发货时才发现别人抢走了。Try 预扣 + Confirm 转实扣 + Cancel 释放恰好把这段"我预留但未完成"的中间态显式化,配额既不会白占也不会漏扣。

  • 为什么用本地消息表兜发货而不是让 mallsvrd 同步等米大师回调:米大师是外部系统,你不能强迫它同步回调,也不能保证它一定回调(可能超时、可能网络分区)。把"付款成功"这个事实一旦落到 paypxy 本地库,就把 dispatch_state='pending' 与 t_order 主字段做一次 CAS 原子写(方案 A),剩下的投递靠"至少一次 + 消费幂等" 拉到最终一致。这是 distributed-transaction 里那句"用一次本地原子写撬动跨服务最终一致"的教科书应用——tcaplus 没有 SQL 事务,但单记录 CAS 就是它的原子原语。

  • 为什么必须有离线对账:所有最终一致方案都建立在"重试到成功"上,但重试次数是有限的、消息也会真丢。对账是最后一道防线——把 |bought 集合| / sum(t_sku_stat 分片计数) / 全局索引扫出的 t_order 完成数 三份数据周期性比对,任何不等就告警补偿。没有对账,最终一致就只是"看起来一致"。

  • 为什么单记录 CAS + 状态字段能替代 SQL 事务:TCC 的三阶段本质上是"一个业务资源在时间上的三种状态",天然映射到一条 t_purchase_try 记录的 status 字段变迁上——trying → done 或 trying → cancelled 都是单记录单字段更新,不跨记录、不跨表,正好命中 tcaplus 单 key 排队 + version CAS 的强项。跨记录的部分(如 t_purchase_try done 之后要顺带更新 t_bag)交给"消费幂等 + 状态字段" 收敛,一次 Confirm 消息触发的所有下游写都靠 idem_key 反复回放到成功为止。这是把 SQL 事务的"跨行原子" 翻译成 KV 世界的"多次单行 CAS + 幂等重试" 的标准姿势。

存储层选型:tcaplus 与 SQL 落地的三点具体差别

同一套 TCC + 本地消息表 + 对账思路,在 SQL 与 tcaplus 这类文档型 KV 上落地方式很不一样。逐点对照:

维度SQL 版做法tcaplus 版做法差别原因
多表原子写BEGIN; UPDATE t_order; INSERT t_pending_msg; COMMIT;首选把 dispatch_state/dispatch_payload 压进 t_order 主记录一次 CAS 写;实在拆不开就双写 + relayer 反查兜底tcaplus 没有跨记录事务,只有单 key 排队 + version CAS——所以设计时先想"能不能塞进一条记录"
唯一约束防重复唯一索引 (UNIQUE(idem_key))Generic 表 key 天生唯一,InsertRecord 返回 KEY_EXISTS = duplicate单 Key 一记录的存储模型本身就是唯一约束
状态推进的并发裁决UPDATE ... WHERE status='trying' + 隔离级别GetRecord 读到 version → 修改字段 → ReplaceRecord(cas_version),冲突方报错回来不是阻塞单机 SQL 靠锁/MVCC,tcaplus 靠工作线程内单 key 排队 + 版本 CAS
对账聚合SELECT SUM/COUNT ... WHERE status='done'写入时同步维护分片计数记录 t_sku_stat:{sku}:{shard} + 日终走全局索引 3000 条分页扫描交叉验证tcaplus 无 SUM/COUNT,且全局索引 <1s 异步、单查 ≤3000 / limit+offset ≤10000
索引查询SELECT ... WHERE 任意字段主键部分匹配走本地索引;其他字段走全局索引 SQL(限制多)全局索引单独同步进程,能力弱于关系库索引

心法:设计任何一个跨渠道限购的字段/表结构时,反复问自己两个问题——"这个原子性能不能收在一条记录里"、"这个聚合能不能在写入时同步维护"。如果两个都能,就走 tcaplus 单记录 CAS + 物化计数;如果收不进来,就退化成"多条记录 + 消费幂等 + 反查兜底"的最终一致,绝不指望"事务"这个原语。

为什么别的选择不行

  • 同步 2PC / XA 跨米大师:米大师是外部第三方,不可能加入你的 XA 事务管理器;即便对方愿意,跨公网的 Prepare 阶段锁资源会把双方吞吐都拖死。同步 2PC 三宗罪详见 distributed-transaction,此处直接判死。

  • 纯 Saga 补偿链:Saga 无隔离性——已提交的本地事务对外可见。放到限购里就是:Try 扣配额已 commit → 玩家在活动页看到"售罄"→ 你在后台补偿了 → 玩家隔几秒又看到"可购买",反复横跳,客服投诉爆表。TCC 的 Try 阶段扣的是冻结状态(status='trying'),对外可以设计成"占用但不显示售罄",中间态可控。

  • Seata AT 模式一把梭:AT 依赖对每条 SQL 生成回滚镜像,只能覆盖你自己控制的 DB——米大师是外部服务、渠道 SDK 更是黑盒,AT 到这里退化后与手写 TCC 无异,还多一层 Seata Server 单点。不如直接手写 TCC。

  • 各渠道各自去重、事后合并:等于允许一段时间内的"跨渠道超卖",事后补偿式回收(拉黑、清库存、公告道歉)。玩家已经拿到限定皮肤了,你再去背包里删——PR 稿都得写一份,且大概率会有玩家在社交平台发帖引发舆情。这条路只在"极小额限购、不允许中断售卖窗口"时才可能被接受,游戏侧限购几乎从不适用。

  • 只在 mallsvrd 加一把分布式锁串行化下单:把所有渠道的下单全串到一把锁上,正确性有了,但吞吐掉到个位数 TPS,秒杀级峰值直接崩。锁不是限购的正解,正确性的正解是 Redis Lua 原子扣减 + tcaplus Generic 表 key 唯一 + bought 去重集合——三层各就各位、都在存储引擎自身的原子模型里,不需要另加锁。

沉淀结论

一句话结论

跨渠道限购 = 账号解析层归一 idem_key + 统一限购中心持总账 + 本地配额扛峰值 + TCC 预扣 + 本地消息表兜发货 + 离线对账清零头;米大师作为外部方不进 2PC,一切一致性都在最终一致里收敛。

面试速答清单:

  • 单机 ACID 到跨米大师失效;跨方链路只能选 BASE 最终一致。
  • 同步 2PC 三宗罪(阻塞 / 单点 / 分区不一致)+ 外部方不可控 → 生产用 TCC + 本地消息表 组合。
  • 跨渠道超卖的根因是账号视角不一,靠账号解析层归一 account_key 消除。
  • 统一限购中心持总账 + 本地配额扛峰值:分片=性能、统一=正确性。
  • TCC 三坑(空回滚 / 悬挂 / 幂等)都靠 t_purchase_try 的 status + version CAS 判断 + Generic 表 key 天然唯一兜底。
  • 落地库是 tcaplus 类文档 KV:没有跨记录事务,靠单 key 排队 + version CAS;消息状态压进 t_order 主记录一次 CAS 写;对账靠"写时同步维护分片计数"+ 全局索引分页扫描。
  • 兜底三件套:幂等 · 重试退避 · 离线对账。

面试口播(60 秒版)

微服务两阶段提交生产几乎不用,因为同步阻塞、协调者单点、分区不一致三大硬伤;在游戏支付-发货链路里,米大师是外部第三方,同步 2PC 更没戏,我们退化成 TCC 预扣 + 本地消息表 + 离线对账 组合。
同一件限购物品跨"游戏内 / 官网 H5 / iOS 米大师 / 安卓渠道"多入口售卖,第一件事是账号解析层——把 QQ / WX openid、米大师订单号、渠道 openid 归一到游戏内主 uid,才敢做 idem_key。
扣减层用统一限购中心持总账 · 各渠道本地配额扛峰值,售罄回源借还。事务层 Try 预扣配额 + SADD bought 挡限购,同时 InsertRecord t_purchase_try status=trying;米大师回调到 paypxy 后把 dispatch_state=pending 压进 t_order 主记录一次 CAS 写(tcaplus 单记录 CAS 就是这里的原子原语),relayer 扫索引投 MQ 到 mallsvrd 幂等发货。
三个坑一并处理:空回滚(GetRecord 返回 NOT_FOUND,直接成功)、悬挂(Cancel 抢在 Try 前 CAS 成 cancelled,迟到 Try Insert 返回 KEY_EXISTS 读回拒绝)、幂等(Generic 表 key 唯一 + ReplaceRecord 带 cas_version)。
最后一道防线是离线对账:|Redis bought 集合| == sum(t_sku_stat 分片计数) == 全局索引扫的 t_order 完成数,不等就告警补偿。

记忆口诀

  • 身份:账号解析层归一 / QQ WX 米大师 渠道 openid 通归一到游戏 uid
  • 扣减:本地配额扛峰值 / 统一中心持总账 / 分片性能 · 统一正确 / bought 集合防限购
  • 事务:Try 预扣 · Confirm 实扣 · Cancel 释放 / 单记录 status + version CAS 替 SQL 事务 / 本地消息表压进 t_order 主记录
  • 三坑:空回滚 GetRecord NOT_FOUND 直接成功 / 悬挂 InsertRecord KEY_EXISTS 读 cancelled 拒绝 / 幂等 Generic key 唯一 + cas_version 兜底
  • 兜底:至少一次投递 + 消费幂等 / 对账靠写时分片计数 + 索引扫描 / 米大师外部方 不进 2PC

相关专题:分布式事务原理 · 秒杀削峰与统一库存 · 幂等设计 · 业务代理·支付·商城 · 分布式游戏存储

内容来源

  • 作者在 mallsvrd(游戏内商城)/ paypxy(支付代理)/ platpxy(业务代理)支付-发货链路的落地经验(腾讯游戏后台 2019–2024)
  • Seata TCC 官方文档(Try/Confirm/Cancel 三接口 + 空回滚 / 悬挂 / 幂等)
  • 米大师商户接入规范(内部文档,不外链):CGI 下单、回调签名、订单查询 API、退款流程
  • tcaplus 类文档型 KV 的使用约束(Generic 表 key 唯一、单 key 排队 + version CAS、无跨记录事务、全局索引 <1s 异步且单查 ≤3000 / limit+offset ≤10000)取自本站 /game-infra/distributed-kv
  • 本站单一事实源交叉引用:/common/distributed-transaction、/game-infra/seckill、/game-infra/distributed-kv、/game-biz/idempotency-design、/game-biz/business-proxy
  • 抽取时间:2026-08-15 / tcaplus 落地重写:2026-08-17
最近更新: 2026/9/10 11:38
Prev
业务幂等性设计
Next
Redis 房间推荐列表