在线联机原型全集:第 21 章 异步远征
异步远征(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. 关键系统目标
- 延迟任务可靠触发:支持 10 秒–72 小时的 TTL;重启不丢、顺序可弱化、至少一次投递。
- 幂等结算:任何结算 API 重复调用均不重复发放;对经济系统写入采用去重写模型。
- 可重试与对账:失败重试退避 + 结算事件入账至
settlement-ledger,支持离线对账修复。 - 可观测性:每个远征记录全链路 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, etaGET /expeditions/{id}:查询进度(剩余时间、事件列表)POST /expeditions/{id}/choose:中途事件选项提交POST /settlement(内部):带Idempotency-Keyheader(或 body),返回ledger_id- WebSocket
expedition.progress/expedition.settled:推送
9. 中途事件(可选玩法增益)
- 事件生成:基于路线权重表 + 玩家幸运值/装备加成。
- 交互:推送事件卡片(
title, options, expires_at),玩家 10 分钟内选择。 - 超时策略:默认分支(保守/风险)由路线定义。
- 安全:事件选择写入
expedition.version,防止并发重复提交。
10. 反作弊与风控
- 结算只接受 DelayQueue 到期触发路径;客户端不得直接调用结算。
expedition_id与player_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%提示可能的轮询/并发配置问题。
- 5 分钟内
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 天):监控告警 + 压测调优 + 运维脚本