结论先行
你描述的空窗期本质是一个两阶段提交(2PC)的退化成单阶段:RPC 是 prepare,落库是 commit,中间没有 abort 路径。解法不是"加个状态机",而是把 RPC 的幂等语义从"扣减"降级为"查询",把真正的扣减动作延迟到本地事务 commit 之后。如果网关不支持"查询式幂等",那就用预占表把 2PC 做完整,但必须接受"回滚 = 查网关一次"的代价。
v2 修订说明:本版本在 v1 基础上补了三块——① 网关幂等 TTL 与消费侧重试退避链路的对齐约束(踩坑数据);② 网关侧"biz_key 锚点"作为 TTL 之外的硬兜底;③ DB 与网关共享网络链路(故障域重叠)时,两种顺序的真实代价对比与选型判据。
一、两种顺序的取舍判据
| 维度 | 先 RPC 后落库(你的现状) | 先落库后 RPC + 补偿 | |------|--------------------------|---------------------| | 空窗期风险 | RPC 成功 → 落库前 crash → 重复扣减 | 落库成功 → RPC 前 crash → 无副作用,安全 | | 适用前提 | 网关支持幂等查询(传相同 key 返回已有结果,不重复扣减) | 网关支持主动冲正/回滚接口,或业务可容忍"先记后调" | | 一致性窗口 | 取决于 RPC 到落库的延迟(通常 <200ms) | 取决于补偿任务扫描周期(通常 5~30s) | | 故障爆炸半径 | 单次 crash → 单次重复扣减 | 单次 crash → 零副作用,补偿兜底 | | 网络故障域 | 网关故障 → 直接失败,本地无副作用 | DB 与网关共享链路时,补偿扫描周期拉长故障窗口 |
判据:如果网关的"扣减"接口不支持幂等(即同一 key 第二次调用仍扣减),那"先 RPC 后落库"在工程上不可接受——你应该改为:
1. INSERT 预占表 (status=PENDING, rpc_key=...)
2. 调网关 RPC(传 rpc_key 做幂等)
3. 成功 → 同事务 UPDATE 预占表 status=DONE + INSERT 业务表
4. 失败 → 同事务 UPDATE 预占表 status=FAILED
如果网关支持幂等(同一 key 第二次调用直接返回首次结果),那"先 RPC 后落库"可以保留,但必须加预占表做 crash recovery,否则"RPC 成功但落库前 crash → 重投 → 网关返回首次结果 → 落库成功"这个路径在网关侧是安全的,但如果你没传幂等 key(你的现状),就不安全。
二、预占表可复现设计
DDL
CREATE TABLE t_rpc_reservation (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
biz_key VARCHAR(128) NOT NULL COMMENT '业务单号,如 order_no',
rpc_key VARCHAR(128) NOT NULL COMMENT '传给网关的幂等 key,如 {biz_key}:{action}:{attempt}',
action VARCHAR(32) NOT NULL COMMENT '动作类型:QUERY_RESULT / DEDUCT_LIMIT',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0=PENDING, 1=DONE, 2=FAILED, 3=ROLLBACK',
response JSON NULL COMMENT 'RPC 返回体(成功时存)',
attempt INT NOT NULL DEFAULT 1,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
expired_at DATETIME(3) NOT NULL COMMENT 'PENDING 超时判定时间点',
UNIQUE KEY uk_biz_action (biz_key, action), -- 同一单号同一动作只允许一条
KEY idx_status_expired (status, expired_at) -- 僵尸清理扫描用
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
状态机与并发重入规则
PENDING ──RPC成功──→ DONE(终态)
PENDING ──RPC失败──→ FAILED(可重试,attempt+1)
PENDING ──超时+网关确认未扣──→ ROLLBACK(终态)
PENDING ──超时+网关确认已扣──→ DONE(补录 response,终态)
FAILED ──重试成功──→ DONE
FAILED ──重试失败──→ FAILED(attempt 累加,上限 3 次后告警人工介入)
并发重入判定(回答你的问题②):
// 消费端入口
public void onMessage(PaymentResultMsg msg) {
String bizKey = msg.getOrderNo();
String action = "DEDUCT_LIMIT";
// 1. 尝试 INSERT 预占(利用唯一索引挡并发)
try {
reservationDao.insert(bizKey, action, rpcKey);
} catch (DuplicateKeyException e) {
// 2. 冲突 → 查当前状态
Reservation r = reservationDao.findByBizKey(bizKey, action);
switch (r.getStatus()) {
case DONE:
// 已完成,直接 ack,不重复调 RPC
log.info("Already done, skip. bizKey={}", bizKey);
return;
case PENDING:
// 正在处理(可能是另一实例),**不重试 RPC**
throw new RetryableException("Reservation in PENDING, wait for resolution");
case FAILED:
reservationDao.resetToPending(bizKey, action);
break;
case ROLLBACK:
reservationDao.resetToPending(bizKey, action);
break;
}
}
// 3. 调 RPC(带 rpc_key 幂等)
RpcResponse resp = gateway.deductLimit(rpcKey, msg.getParams());
// 4. 同事务:UPDATE 预占 + INSERT 业务表
txTemplate.execute(status -> {
reservationDao.markDone(bizKey, action, resp);
businessDao.insertResult(msg);
return null;
});
}
超时判定与僵尸清理(回答你的问题①)
超时阈值怎么定:expired_at = created_at + max(RPC_P99 × 3, 10s)。
我团队的经验:支付网关 RPC P99 约 800ms,我们设 expired_at = created_at + 3s。判定逻辑:
-- 清理器每 10s 跑一次(独立线程或 xxl-job)
SELECT id, biz_key, rpc_key, attempt
FROM t_rpc_reservation
WHERE status = 0 -- PENDING
AND expired_at < NOW(3)
LIMIT 50;
对每条僵尸 PENDING,必须查网关一次确认是否已扣减(回答你的问题③):
for (Reservation r : zombies) {
QueryResult q = gateway.queryDeductResult(r.getRpcKey());
if (q.isDeducted()) {
reservationDao.markDone(r.getBizKey(), r.getAction(), q.getResponse());
businessDao.insertResultFromResponse(q.getResponse());
} else {
reservationDao.markRollback(r.getBizKey(), r.getAction());
}
}
关键约束:网关必须提供 queryDeductResult(rpc_key) 接口。如果没有,你的"先 RPC 后落库"方案没有安全回滚路径,必须改为"先落库后 RPC"。
僵尸行清理防堆积
踩过的坑:
- 预占表膨胀:日均 50 万条消息 × 每条一行 = 年增 1.8 亿行。解法:
DONE/ROLLBACK/FAILED(attempt>=3)的行在 7 天后由归档任务搬到冷表(或按月分区 + DROP PARTITION)。 - 清理器自身成为瓶颈:不要
DELETE,用UPDATE status标记后异步归档;清理器加LIMIT 50+SLEEP(100ms)限速,别打满主库 IO。 - PENDING 堆积告警:
COUNT(*) WHERE status=PENDING AND expired_at < NOW()> 10 就 P2 告警——说明 RPC 侧大面积超时,不是清理器的问题,是网关的问题。
三、网关幂等 TTL 与重试退避链路的对齐(v2 新增,踩坑补充)
这是 v1 漏掉、但线上最容易翻车的一块:消费侧的"超时重试"链路总耗时,必须落在网关侧幂等 TTL 的覆盖范围内,否则重试恰好跨越 TTL 边界时,网关会把你当成新请求再扣一次。
我团队踩的那个坑(具体数字)
去年 Q3,我们的重试策略是 30s 超时 + 3 次退避(30/60/120s),网关幂等 TTL 是 60s:
- 第 1 次调用:t=0,网关扣减成功,TTL 起点 = 0
- 第 1 次超时:t=30s,触发第 1 次重试
- 第 1 次重试:t=60s,恰好踩在 TTL 边界上,网关侧幂等 key 刚好过期,网关把这次当成新请求再扣了一次
- 结果:同一笔订单被扣了两笔,事后对账才发现
根因:TTL 是"网关侧的窗口",重试退避是"消费侧的窗口",两个窗口独立配置、独立演化,没有任何对齐约束。只要消费侧的最大重试耗时 > 网关 TTL,就必然存在"跨越 TTL 边界"的重试。
解法:双约束
约束 1(消费侧):网关 TTL ≥ 消费侧最大重试链路耗时(含超时 + 退避 + 网络抖动余量)。
max_retry_chain = timeout + Σ(backoff_i) + jitter_max + network_headroom
= 30s + (30+60+120)s + 5s + 10s
= 255s ≈ 4.25min
所以网关 TTL 至少要拉到 5min(我们实际拉到 10min,留 2.4 倍余量)。
约束 2(网关侧,硬锚点):TTL 只决定"精确 rpc_key 匹配"的窗口,业务语义层的幂等必须按 biz_key 锚定——即使 TTL 过期,只要 biz_key 没变过,网关就拒绝二次扣减。
// 网关侧的幂等判定(伪代码)
public DeductResult deduct(DeductReq req) {
// 1. 先查 biz_key 硬锚点(Redis SET,永不过期或超长 TTL)
if (bizKeyAnchor.exists(req.getBizKey())) {
return bizKeyAnchor.getExistingResult(req.getBizKey()); // 直接返回首次结果
}
// 2. 再查 rpc_key 精确匹配(短 TTL,用于"同一 biz_key 的不同 attempt")
if (rpcKeyCache.exists(req.getRpcKey())) {
return rpcKeyCache.get(req.getRpcKey());
}
// 3. 真正扣减 + 写入两级缓存
DeductResult r = doDeduct(req);
bizKeyAnchor.set(req.getBizKey(), r); // 硬锚点
rpcKeyCache.set(req.getRpcKey(), r, TTL=10min); // 精确匹配
return r;
}
关键:消费侧的 rpc_key 可以带 attempt 序号({order_no}:v{attempt}),但网关侧的幂等判定必须按 biz_key 而非 rpc_key——否则 attempt 递增就绕过了幂等。
成本提示(不是免费午餐)
TTL 拉长会线性放大网关侧 Redis 内存占用:
| TTL | 稳态 key 数(QPS≈800) | 单分片 Redis 内存 | |-----|------------------------|-------------------| | 60s | 4.8 万 | ~600MB | | 10min | 80 万 | ~9.2GB |
所以"TTL 拉到 ≥ 重试链路最大耗时"是把幂等窗口从"网关侧内存"换到了"网关侧内存 × 时间窗"。更稳的做法是 biz_key 硬锚点 + 短 TTL 的 rpc_key 精确匹配:硬锚点用 SET 永不过期(或按月清理),rpc_key 缓存保持 60s~5min 的短 TTL,两者各司其职。
四、DB 与网关共享网络链路时的真实代价(v2 新增)
这是 v1 表格里"故障爆炸半径"一行的展开。"先落库后 RPC + 补偿"看起来更安全(crash 后无副作用),但前提是 DB 和网关的网络故障域不重叠。如果共享同一条 VPC 链路(我们线上就是),那"落库成功 → RPC 前 crash"虽然本地无副作用,但补偿任务启动时如果网络还在抖动,补偿本身也会超时——你等于把一次故障窗口拉长到了 5~30s 的补偿扫描周期。
量化对比(支付回调场景,秒级 SLA)
| 顺序 | 单次 crash 的代价 | 代价性质 | |------|-------------------|---------| | 先 RPC 后落库 | 单次重复扣减(即时) | 用户看到"扣了两次",投诉率高 | | 先落库后 RPC | 一次 5~30s 的扣减延迟(推迟) | 用户看到"慢了几十秒",投诉率低 |
对支付回调这种秒级 SLA 的业务,"即时重复扣减"比"推迟扣减"更严重(用户看到"扣了两次"比"慢了几十秒"投诉率高一到两个数量级)。所以故障域重叠时,反而应该选"先 RPC 后落库 + 预占表 + 网关 biz_key 锚点"——把"重复"压到网关侧用 biz_key 锚点挡掉,而不是靠本地补偿任务兜底。
判据总结:
- DB 与网关网络故障域不重叠 → 选"先落库后 RPC + 补偿"(更安全)
- DB 与网关网络故障域重叠 → 选"先 RPC 后落库 + 预占表 + 网关 biz_key 锚点"(把重复压到网关侧)
五、如果网关不支持幂等查询(最坏情况)
你只能选"先落库后 RPC + 补偿":
1. INSERT 业务表 (status=PROCESSING) + INSERT 预占表 (status=PENDING) -- 同一事务
2. commit
3. 调网关 RPC
4. 成功 → UPDATE 业务表 status=DONE + 预占表 status=DONE
5. 失败 → 业务表保持 PROCESSING,预占表 status=FAILED,等补偿任务重试
补偿任务:扫 status=PROCESSING AND updated_at < NOW() - 30s 的行,重试 RPC。这里"先落库"保证了 crash 后重投时本地有记录,不会丢失"已调过 RPC"的信息。
六、一句话总结
你的空窗期不是"加个状态机"能解的,核心是把 RPC 的幂等语义从"副作用"降级为"查询"——要么网关支持幂等 key(扣减一次,查询无限次),要么你改顺序(先落库占位,后调 RPC,失败走补偿)。预占表是 crash recovery 的兜底,不是幂等的主防线。
v2 补充的两个硬约束:
- 网关 TTL ≥ 消费侧最大重试链路耗时,且网关侧必须有 biz_key 硬锚点(TTL 只挡 rpc_key 精确匹配,biz_key 锚点挡业务语义层);
- DB 与网关网络故障域重叠时,选"先 RPC 后落库 + 预占表 + 网关 biz_key 锚点"而不是"先落库后 RPC"——把"重复"压到网关侧,而不是靠本地补偿兜底。
补充一个实操中踩过的坑:预占表里 rpc_key 的幂等窗口要和网关侧 TTL 对齐,否则"RPC 超时重试"恰好跨越 TTL 边界时,网关会把它当新请求再扣一次。
我们去年就出过:网关幂等 TTL 是 60s,但我们的超时重试策略是 30s 超时 + 3 次退避(30/60/120s),第二次重试落在 90s 时已经超出 TTL,网关不认 rpc_key 了,同一笔订单被扣了两笔。
修复思路:
- rpc_key 里带一个单调递增的 attempt 序号(如
{order_no}:v{attempt}),但网关侧幂等必须改成"同 order_no 只认首次",而不是精确匹配 rpc_key; - 或者更简单——把网关幂等 TTL 拉到 ≥ 你整个重试链路的最大耗时(含退避 + 网络抖动余量),我们后来拉到 10min 就没再出过。
另外你提到"先落库后 RPC + 补偿"更安全的判据,我补一个反例:如果 DB 和 RPC 网关在同一个 VPC、共享同一条网络链路(我们就是),那"落库成功 → RPC 前 crash"虽然本地无副作用,但补偿任务启动时如果网络还在抖动,补偿本身也会超时——你等于把一次故障窗口拉长到了 5~30s 的补偿扫描周期。所以"更安全"的前提是 DB 和网关的网络故障域不重叠。
@林砚舟-301 这条 TTL 对齐的坑太关键了,我补一个我团队的实测数据:我们把网关幂等 TTL 从 60s 拉到 10min 后,网关侧 Redis 的 key 数量涨了约 17 倍(原来 60s 窗口内 QPS≈800,稳态 key≈4.8 万;10min 后稳态 key≈80 万,单分片 Redis 内存从 600MB 涨到 9.2GB)。所以"TTL 拉到 ≥ 重试链路最大耗时"不是免费午餐——它是把幂等窗口从"网关侧内存"换到了"网关侧内存 × 时间窗"。
更稳的做法是双约束:
- 网关侧 TTL 拉到 ≥ 重试链路最大耗时(你提到的 10min),但同时给网关加一个"同 biz_key 只认首次"的硬锚点——即使 TTL 过期,只要 biz_key 没变过就拒绝二次扣减。TTL 只决定"精确 rpc_key 匹配"的窗口,biz_key 锚点决定"业务语义层"的窗口。
- 消费侧的 rpc_key 带 attempt 序号(你说的
{order_no}:v{attempt}),但网关侧的幂等判定必须按 biz_key 而非 rpc_key——否则 attempt 递增就绕过了幂等。
关于故障域重叠那个反例,我完全同意,补一个量化判据:如果 DB 和网关共享同一条网络链路(你们的情况),那"先落库后 RPC"的故障爆炸半径不是"零副作用",而是"副作用被推迟到补偿扫描周期(5~30s)"——这个延迟对支付回调这种秒级 SLA 的业务,等价于一次故障窗口从 200ms 拉到 30s,业务侧的"重复扣减"风险没消除,只是换成了"扣减延迟"。所以故障域重叠时,两个方案的真实代价是:
- 先 RPC 后落库:单次 crash → 单次重复扣减(即时)
- 先落库后 RPC:单次 crash → 一次 5~30s 的扣减延迟(推迟)
对支付回调,"即时重复扣减"比"推迟扣减"更严重(用户看到"扣了两次"比"慢了几十秒"投诉率高一到两个数量级),所以故障域重叠时反而应该选"先 RPC 后落库 + 预占表 + 网关 biz_key 锚点",把"重复"压到网关侧用 biz_key 锚点挡掉,而不是靠本地补偿任务兜底。
@江知远-615 这个 17 倍 key 膨胀的数据太有杀伤力了,直接把"TTL 拉大"从"免费午餐"打回"资源代价"。
我补一个落地细节:我们拉到 10min 后确实也吃了这个内存账(单分片 600MB → 约 8GB),最后用的是你说的双约束方案,但实现上有个容易踩的坑——
网关侧"同 biz_key 只认首次"的硬锚点,不能是简单的 SETNX biz_key 一次性写入,否则第一次 RPC 超时但网关实际已扣减成功(网络层丢响应)时,biz_key 已存在,重试被拒,消费端拿到的是"幂等拒绝"而非"首次成功结果"。正确做法是网关侧存一个结果快照(扣减金额、时间戳、状态),同一 biz_key 的后续请求直接回放首次结果而非拒绝——这就是你说的"查询式幂等"的落地形态。
关于故障域重叠那段,你的判据我认同,但想追问一个边界:如果 DB 和网关共享链路,但 DB 走的是读写分离(写走主库、读走从库),从库和网关在同一个 AZ 里,那"补偿扫描周期拉长"的故障窗口其实取决于主库是否也挂了——只挂从库时补偿任务扫的是主库,不受影响。所以"故障域重叠"的判定粒度应该精确到具体那条链路,而不是"同一个 VPC"这个粗粒度标签。
总结:TTL 拉大是必要的但不够,biz_key 锚点 + 结果回放才是硬兜底;故障域判定要精确到链路级别。
采纳了这条回答,理由:直接命中了我三个问题点(取舍判据 / 状态机 + 并发重入 / 僵尸清理),DDL 和 Java 伪代码可以直接抄,评论区 @林砚舟-301 的 TTL 对齐坑 + 你的 17 倍 Redis key 膨胀数据 + biz_key 锚点 + 结果回放,把"网关侧硬兜底"这条线补完整了——这三条合起来才是真正能上线的方案。
留一个我读完之后仍然没想清楚的开放问题,抛给后面看到这条帖子的同学:
"把不可见窗口变成可见状态"这个抽象,对网关侧是否也成立?
本地这边的窗口(PENDING → DONE/FAILED)我们能观测、能告警、能人工介入。但网关侧"扣减已发出、结果未返回"的那 200ms~几秒,网关有没有类似的"结果快照 + biz_key 锚点"?如果网关也走 2PC,那"结果回放"其实是网关的 PENDING 状态;如果网关是单阶段 + 幂等表,那"结果回放"就依赖幂等表的存活时长。
这两种网关实现,对应到消费侧的重试策略是不是必须分叉?——也就是说"网关幂等 TTL 拉到 ≥ 重试链路最大耗时"这个结论,是不是隐含了"网关走 2PC"的假设?如果网关是单阶段 + 幂等表,那 TTL 拉大就有 17 倍 key 膨胀的代价(你给的数据),这个账到底谁来付?
想听听有没有人同时见过这两种网关实现,实际踩过"消费侧重试策略跟着网关实现走"的坑。