在线联机原型全集:第 21 章 异步远征(Async Expedition)——延迟任务 / 幂等结算原型

第 21 章:异步远征(Async Expedition)——延迟任务 / 幂等结算原型,介绍了一个简单的异步回合制游戏原型,包括延迟任务调度、可重试与去重、一次提交多端回执、最终一致性与幂等结算等功能。

异步远征(Async Expedition)——延迟任务 / 幂等结算原型

  • 类别:异步回合制 + 计时玩法 + 经济结算
  • 目标:验证基于 DelayQueue 的跨分钟/跨小时任务调度、可重试与去重、一次提交多端回执、最终一致性与幂等结算。
  • 原型代号proto-021-async-expedition
  • 依赖模块proto-010-city-slg-mini
  • 推荐语言栈:Go / Java(Quarkus / Spring)/ Rust
  • 协议栈:HTTP + WebSocket(进度推送)+ DelayQueue(Redis/Kafka/RabbitMQ/Quartz/Chronos 任一)
  • 服务边界expedition-svc(远征域)与 settlement-svc(结算域)解耦,通过事件总线对账

1. 核心玩法描述

玩家派出小队进行“远征”,需要一定时长(如 2h)。发起后立即扣除体力/门票并锁定出征成员与背包格。到期触发延迟任务执行结算:计算掉落、经验、耐久、可能的意外事件(受伤/失败),返还奖励并解锁小队。过程可插入中途事件(分支决策,限时 10 分钟响应),超时则按默认分支自动结算。


2. 关键系统目标

  1. 延迟任务可靠触发:支持 10 秒–72 小时的 TTL;重启不丢、顺序可弱化、至少一次投递。
  2. 幂等结算:任何结算 API 重复调用均不重复发放;对经济系统写入采用去重写模型。
  3. 可重试与对账:失败重试退避 + 结算事件入账至 settlement-ledger,支持离线对账修复。
  4. 可观测性:每个远征记录全链路 ID(trace_id),暴露指标:延迟分布、重试率、重复投递率、幂等拦截率、账实差异。

3. 领域建模(简要)

3.1 实体

  • Expedition:远征单(id, player_id, squad, route_id, start_at, eta, state)
  • ExpeditionRoute:路线(掉落表、时长、风险)
  • ExpeditionEvent:中途事件(type, options, deadline)
  • SettlementLedger:结算账(ledger_id, expedition_id, idempotency_key, delta, status)

3.2 状态机

stateDiagram-v2
[*] --> Draft
Draft --> Running: StartExpedition
Running --> PendingEvent: MidEventSpawn
PendingEvent --> Running: PlayerChoose / TimeoutDefault
Running --> Due: ETA reached (DelayQueue)
Due --> Settling: Pop task
Settling --> Settled: Apply ledger OK
Settling --> Compensating: Apply failed -> Retry/Manual
Compensating --> Settled: Fix succeeded
Settled --> [*]

4. 时序与流程

4.1 发起远征

sequenceDiagram
actor P as Player
participant G as GameGateway
participant E as expedition-svc
participant Q as DelayQueue
P->>G: POST /expeditions {route_id, squad}
G->>E: CreateExpedition & Reserve resources
E->>Q: enqueue(delay=ETA-start_at, payload={expedition_id})
E-->>G: expedition_id, eta
G-->>P: 成功,展示倒计时

4.2 到期结算(延迟任务触发)

sequenceDiagram
participant W as delay-worker
participant E as expedition-svc
participant S as settlement-svc
participant L as ledger-store
W->>E: handleDue(expedition_id, delivery_id)
E->>S: POST /settlement {expedition_id, idempotency_key}
S->>L: UPSERT ledger (unique idempotency_key)
S-->>E: settlement result (once)
E-->>W: ack delivery
E-->>P: WebSocket 推送奖励

5. DelayQueue 设计

5.1 选型策略

  • Redis ZSet:易用、轻量;需要轮询扫描 & 时钟漂移保护;吞吐中等。
  • Kafka 延迟层(基于定时层/定时轮):高吞吐、分区扩展;复杂度较高。
  • RabbitMQ 延迟插件/死信队列:语义清晰;需要集群 HA。
  • Quartz/TimeWheel(服务内):开发快;需要持久化/主备选主。

原型建议:先用 Redis ZSet(单服 < 5w 并发远征足够),键:expedition:due:zset,score=eta,value={expedition_id, delivery_uuid}。扫描步长 100–500,间隔 1–2 秒,支持压力递增。

5.2 交付语义

  • 至少一次投递:可能重复投递,要求结算幂等。
  • 去重键idempotency_key = hash(expedition_id + eta + route_version)
  • 可见性超时:消费开始先标记“inflight”,失败回滚回 ZSet,score=now+retry_backoff。

5.3 重试与退避

  • 指数退避:base=5s, max=30m, jitter=±20%
  • 最大重试次数:N=12(约 6 小时上限),超限转人工补偿队列。

6. 幂等结算设计

6.1 账本模型(一次写、处处读)

  • settlement-ledger 表以 idempotency_key 唯一索引,UPSERT。
  • 入账成功返回 200 OK + ledger_id。重复请求返回 200 OK + same ledger_id(命中幂等)。
  • 针对“物品发放、货币变更、经验增加”统一写 delta(JSONB/Protobuf),由经济核心原子应用并记录版本号。

6.2 经济系统写入

  • 采用幂等指令apply_delta(player_id, ledger_id, delta)
  • ledger_id 为去重项,如已应用则直接返回上次快照。
  • 失败必须 不可见(事务回滚),禁止“半成功”。

6.3 典型边界

  • 重复投递:同一 idempotency_key 二次调用 -> 命中 UPSERT,零副作用。
  • 时钟漂移:服务本地 ETA <= now + skew_guard(2s) 才执行;其余延迟再次入队。
  • 消息乱序:以 expedition.state 守卫:非 Due/Settling 状态拒绝结算。

7. 数据结构(示例)

7.1 SQL(MySQL)

CREATE TABLE expedition (
  id BIGINT PRIMARY KEY,
  player_id BIGINT NOT NULL,
  route_id BIGINT NOT NULL,
  squad JSON NOT NULL,
  start_at DATETIME(3) NOT NULL,
  eta DATETIME(3) NOT NULL,
  state TINYINT NOT NULL,        -- 0 Draft,1 Running,2 Due,3 Settling,4 Settled,5 Compensating
  version INT NOT NULL DEFAULT 0, -- 乐观锁
  created_at DATETIME(3), updated_at DATETIME(3), INDEX (player_id), INDEX (eta)
);

CREATE TABLE settlement_ledger (
  ledger_id BIGINT PRIMARY KEY,
  expedition_id BIGINT NOT NULL,
  idempotency_key CHAR(64) NOT NULL UNIQUE,
  delta JSON NOT NULL,
  status TINYINT NOT NULL,        -- 0 Pending,1 Applied,2 Failed
  applied_at DATETIME(3),
  created_at DATETIME(3)
);

7.2 Go 结构

type Expedition struct {
  ID        int64
  PlayerID  int64
  RouteID   int64
  Squad     []Unit
  StartAt   time.Time
  ETA       time.Time
  State     State
  Version   int
}

type SettlementRequest struct {
  ExpeditionID   int64
  IdemKey        string // hash(expedition_id+eta+route_version)
  Delta          EconomyDelta
}

8. API 设计(节选)

  • POST /expeditions:发起;请求校验小队可用、资源充足;响应 expedition_id, eta
  • GET /expeditions/{id}:查询进度(剩余时间、事件列表)
  • POST /expeditions/{id}/choose:中途事件选项提交
  • POST /settlement(内部):带 Idempotency-Key header(或 body),返回 ledger_id
  • WebSocket expedition.progress / expedition.settled:推送

9. 中途事件(可选玩法增益)

  • 事件生成:基于路线权重表 + 玩家幸运值/装备加成。
  • 交互:推送事件卡片(title, options, expires_at),玩家 10 分钟内选择。
  • 超时策略:默认分支(保守/风险)由路线定义。
  • 安全:事件选择写入 expedition.version,防止并发重复提交。

10. 反作弊与风控

  • 结算只接受 DelayQueue 到期触发路径;客户端不得直接调用结算。
  • expedition_idplayer_id 绑定校验;签名校验路由参数。
  • 掉落使用可审计 RNG(seed = route_id + player_id + start_at)。
  • 频次限制:每玩家并发远征上限(如 3)+ 路线冷却。
  • 经济发放二次校验:大额奖励触发风控阈值,转人工审核或拆分发放。

11. 可观测性与告警

  • 指标:due_latency_bucket(ETA→开始结算延迟)、retry_count, dup_delivery_rate, idempotency_hit_rate, apply_fail_rate

  • 日志:expedition_id, idem_key, delivery_uuid, retry_no, trace_id

  • 告警:

    • 5 分钟内 apply_fail_rate > 0.5%
    • due_latency_p95 > 3s
    • dup_delivery_rate > 1% 提示可能的轮询/并发配置问题。

12. 失败演练与恢复

  • Chaos 注入:随机 1% 结算阶段返回 500,验证退避与幂等。
  • 恢复脚本:根据 settlement_ledger.status=0/2 重放未应用账目。
  • 对账:每日 02:00 生成“账-库存”差异报表,若不一致自动列入补偿队列。

13. 压测与容量规划(基线)

  • 并发远征:100k 活跃;ETA 均匀分布
  • 扫描器:每秒扫描 500–2000 条,到期触发率 < 20%
  • 目标:due_latency_p95 <= 2s,重复投递率 <= 0.5%,重试总体 < 2%
  • Redis:ZSet QPS 5–10k 级可支撑;超过则考虑分片或切换 Kafka 定时层

14. 代码骨架(Go,核心逻辑示例)

14.1 延迟扫描器

func pollDue(ctx context.Context, rdb *redis.Client, batch int) {
  now := time.Now().Add(2 * time.Second) // skew guard
  vals, _ := rdb.ZRangeByScoreWithScores(ctx, "expedition:due:zset",
    &redis.ZRangeBy{Min: "0", Max: strconv.FormatInt(now.UnixMilli(), 10), Offset: 0, Count: int64(batch)}).Result()

  for _, z := range vals {
    payload := decode(z.Member.(string))
    if tryMarkInflight(ctx, payload) {
      go handleDue(ctx, payload) // 并发处理
    }
  }
}

14.2 幂等结算(伪码)

func settle(ctx context.Context, req SettlementRequest) (ledgerID int64, err error) {
  // 1) UPSERT ledger by idem_key
  ld, created := ledgerRepo.UpsertByIdemKey(ctx, req.IdemKey, req.ExpeditionID, req.Delta)
  if !created && ld.Status == Applied {
    return ld.ID, nil // 命中幂等
  }
  // 2) Apply delta atomically
  if err := economy.ApplyDelta(ctx, req.PlayerID, ld.ID, req.Delta); err != nil {
    ledgerRepo.MarkFailed(ctx, ld.ID)
    return 0, err
  }
  ledgerRepo.MarkApplied(ctx, ld.ID, time.Now())
  return ld.ID, nil
}

15. 测试用例(要点)

  • 发起→到期→结算全链路(正常)
  • 重复结算请求(相同 idempotency_key)→ 仅一次发放
  • 经济系统 500→重试退避,最终成功
  • 中途事件超时→默认分支
  • 高并发 10k 到期同秒→p95 due latency < 2s
  • 账-库存一致性校验通过;注入 1% 失败后能全部修复

16. 运维与配置

  • 扫描批次、间隔、并发处理数可动态配置(热加载)。
  • 灰度:先对 5% 路线启用 DelayQueue,观测指标稳定后全量。
  • 备份:expedition, ledger 每日快照;关键表 binlog 归档 7 天。
  • 降级:DelayQueue 故障时,按 ETA 本地补偿扫描器启动“紧急模式”。

17. 经济与数值建议

  • 远征时长与奖励成指数或次线性关系,避免短时刷子或长时通胀。
  • 引入“队伍负重、耐久、风险”三要素,形成可配置平衡面。
  • 通过路线冷却与门票限制控制产出速率;大额掉落采用保底 + 小概率暴击。

18. 里程碑

  • M1(2 天):ZSet DelayQueue + 最小远征流转(无事件)
  • M2(3 天):幂等结算 + 账本对账 + Chaos 注入
  • M3(3 天):中途事件 + WebSocket 推送
  • M4(2 天):监控告警 + 压测调优 + 运维脚本

继续阅读

探索更多技术文章

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

全部文章 返回首页