API上游失败防误扣费:幂等设计、状态机、重试与对账实战
我故意构造了 3 类上游失败怎样避免 API 失败后误扣费先交代背景我做的是一个收费 API 网关用户调用我们接口时我们会先去上游计费系统完成额度扣减再返回业务结果。听起来很简单但线上跑了一段时间后发现偶尔会有用户投诉“明明一次请求怎么扣了两次钱”。后来我把上游计费系统完全 mock 掉故意构造了 3 类上游失败场景把所有坑都踩了一遍这才把“误扣费”问题彻底解决掉。这篇文章不是教科书是我自己的实操复盘。核心围绕 API 失败时的 4 个关键点展开幂等设计、状态机、重试策略、对账兜底。适合后端开发、支付系统维护同学看尤其是正在做计费、订单、支付这类“钱相关”服务的同行建议收藏慢慢对照。1. 为什么要防“失败后误扣费”1.1 一个真实事故先说我们线上出过的幺蛾子。某天凌晨用户 A 调用我们接口因为网络抖动我们发给上游计费系统的请求超时了。代码里写的逻辑很简单超时抛异常然后直接返回“系统繁忙”。但问题是上游计费系统其实已经收到了请求并且扣款成功了。用户 A 看我们接口失败了又重试了一次结果第二次扣款也成功了。等于用户为一次业务操作付了两次钱。这种事故最恶心的地方在于你本地看到的失败和上游实际的成功是不一致的。但用户只看最终账单他不会管你内部是超时还是什么。所以每次出问题第一反应都是先查自己的日志可日志里明明白白写着“请求超时”你甚至不知道这是该退钱还是该补发业务结果。这就是典型的“分布式系统三态”问题成功、失败、未知。未知状态才是误扣费的根源。1.2 误扣费的本质不确定状态很多人以为 API 调用的结果只有成功和失败两种但实际上只要网络存在就一定有第三种请求发出去了但响应没回来。可能是超时可能是连接断开可能是对端处理完但回包丢了。你站在调用方视角只能判断“我没拿到成功响应”但你没法判断上游到底有没有做动作。在这种情况下你如果直接把这次调用标记为失败然后允许用户重试就可能造成重复扣费。你如果标记为成功又可能白白给用户发业务资源。所以我们需要一套机制把“未知状态”显式地建模出来而不是二选一。这里有两条铁律我反复踩坑后总结的凡是涉及扣费的操作必须保证幂等。凡是拿不到明确结果的操作必须进入“待确认”状态并通过主动查询解决而不是直接判死。后面我构造的三类失败就是专门用来测试这两条铁律有没有被执行到位。2. 我构造的三类上游失败为了把问题讲清楚我专门写了一个 mock 上游服务模拟计费系统的各种异常行为。我故意构造的三类上游失败分别是请求超时、上游返回异常状态码、上游返回成功但业务数据异常。下面逐个说。2.1 第一类请求超时这一类最容易构造也让很多人栽跟头。我在 mock 服务里加了一个/charge接口收到请求后不返回任何响应直接挂死。我设置的超时时间为 3 秒所以每次调用都会触发Timeout异常。我本以为这很简单实际测下来发现两个问题第一如果超时后直接返回失败会让用户重试从而造成重复扣款。原因很简单上游可能已经扣款成功了只是响应没回来。用户重试的第一笔订单 ID 又是新的上游无法识别这是同一个操作。第二如果超时后不返回失败而是等待更长的时间那么接口响应会变得极慢影响用户体验。而且就算你等 30 秒你依然不知道上游到底成功没有。等待不能解决问题只能增加不确定性。正确的做法是把这次计费操作标记为UNKNOWN同时记录下发起请求时生成的request_id然后立刻给用户一个非明确的结果比如“处理中”再用后台任务去上游查询这笔订单的真实状态。2.2 第二类上游返回异常状态码第二类我模拟的是上游明确告诉你“我出错了”。比如返回 HTTP 500或者返回 429 限流甚至返回 503。这看起来比超时好处理因为至少拿到了一个“失败”信号但这里同样有坑。第一次测试时我在调用方拿到 500 后直接抛异常然后让用户重试结果出现了和超时一样的问题——重复扣费。为什么因为上游虽然返回了 500但可能在返回之前已经把扣费操作提交了只是生成响应的时候出了问题。这种情况在真实系统里经常发生比如数据库事务提交成功但 HTTP 响应失败。后来我调整了策略只有当上游返回明确的“请求未处理”类错误码时才允许安全重试。比如 HTTP 400、404、或者是业务错误码里写着“订单不存在”“参数错误”这种说明上游根本没有创建订单重试是安全的。但 500、503、429 这类服务端错误必须当成 UNKNOWN 处理。我还特别测试了 429 限流如果上游因为限流拒绝了你但你的重试请求带着同样的request_id上游是应该返回“这个单已经处理过了别再扣了”而不是继续给你新扣一次。所以限流场景反而最能验证幂等做得怎么样。2.3 第三类返回成功但业务数据异常这是我最想强调的一类失败因为它最隐蔽。我构造的 mock 场景是上游返回 HTTP 200内容也是标准 JSON但里面的订单号和请求时发送的不一样。换句话说上游给了一个莫名其妙的成功响应但这个响应并不是针对我这笔请求的。为什么会出现这种鬼情况真实环境里可能是上游的网关路由错了也可能是返回了缓存数据还可能是有人恶意构造回包。总之你把“200 合法 JSON”当成成功那就惨了你给用户发业务结果但上游扣的是另一笔钱而且你这边的订单状态永远对不上账。这个场景下单单靠 HTTP 状态码判断成功是没有意义的。必须校验响应内容里的业务字段比如订单号、金额、用户 ID 等必须和我们发出去的一致。我当时的校验规则是回包里的order_id必须等于本地上一次生成的订单号。回包里的amount必须等于本次请求的金额。如果回包里带有上游的系统单号这个单号会被记录下来后续对账要用。只要任何一个字段不匹配就视为失败并且进入 UNKNOWN 状态需要人工或对账系统介入。绝对不能在业务数据异常时强行判成功。3. 怎样设计一套防误扣费机制三类失败构造完之后我总结出一套完整的防误扣费机制。核心是四层幂等、状态机、重试与补偿、对账兜底。这一节我一个个拆开讲。3.1 幂等性是第一道防线幂等性的意思是同一个操作执行一次和执行多次最终结果是一样的。放在扣费场景里就是同一笔业务请求不管重试多少次上游都只能扣一次钱。实现幂等最常见的方式是request_id也叫幂等键。我每次请求上游时都会生成一个全局唯一的request_id并把它作为请求参数传给上游。上游拿到这个 ID 后先去查自己的幂等表如果发现已经处理过就不再扣费直接返回第一次处理的结果。这里有几个操作细节request_id必须全局唯一。我使用的是 UUID但要注意不能用简单的随机数必须是高强度的否则在并发场景下可能碰撞。request_id必须在发往上游之前就生成并且存储到本地业务订单表里。不能在上游返回错后再生成因为那会儿你可能已经丢失最原始的请求信息。幂等表要设置合理的过期时间。比如我们的业务是 24 小时内不会出现相同请求那就把幂等记录保留 7 天。过期时间太短会导致重试请求在上游变成新单太长又会占存储。我还用一个场景验证了幂等的作用我故意把同一个request_id连续发送 10 次上游只在第一次真正扣费后 9 次全部返回第一次的扣费结果本地账单一分不多扣。这是我们防误扣费的根基。3.2 请求状态的有限状态机有了幂等键还不够因为你还要在本地记录这次请求处于什么阶段。我把订单状态设计成一个有限状态机包含这几个状态状态含义关键处理PENDING刚创建还没发起上游调用超时未处理则关闭PROCESSING已发起上游调用等待结果超时或异常转入 UNKNOWNSUCCEEDED上游确认扣费成功业务发放完成最终状态不做变更FAILED上游明确拒绝且未产生费用可安全重试或直接关闭UNKNOWN上游结果无法确认必须启动主动查询核心是UNKNOWN状态。以前很多人的状态机只有四个状态没有 UNKNOWN导致超时后直接变成 FAILED用户一重试就重复扣费。我的做法是网络超时、读到 500/503、响应内容校验失败全部进入UNKNOWN。进入UNKNOWN后立刻启动一个异步的“状态确认”任务向上游查询这张订单的真实状态。查询接口本身也要幂等并且要带我们原来的request_id。如果查询结果显示“扣费成功”那我们把本地状态更新为SUCCEEDED然后继续发业务结果如果显示“没有这笔订单”则更新为FAILED用户可以安全重试。这里有一个非常重要的点永远不要从PROCESSING直接跳到SUCCEEDED或FAILED除非你已经从上游拿到了业务层面合法的确认。其他任何情况都先转到UNKNOWN。3.3 重试与补偿的正确姿势重试这件事很多人理解成“失败后隔几秒再发一次”但这里其实有严格的讲究。先要区分两种重试场景一种是服务端返回明确的可重试错误比如 400 “订单不存在”此时可以马上重试因为上游没产生扣费另一种是超时或 5xx 这类需要查状态的错误此时不能盲重重试正确姿势是先查状态再决定要不要重发。在实际代码里我实现了三种重试策略即时重试针对上游返回REQUEST_NOT_PROCESSED之类的明确信号最多重试 2 次每次间隔 200ms。退避重试针对上游返回 429 或 503使用指数退避比如 1s、2s、4s、8s最多 5 次。每次重试都带着同一个request_id。补偿重试如果状态确认时发现上游扣款成功但本地业务没有成功比如我们自己的数据库挂了那就不能重试扣费而是要做“补偿”——把业务结果补发出去或者把扣费回滚掉。补偿操作是一个反向操作比如退款。但退款也有风险如果退款接口超时你会不会退两次钱所以退款接口也必须幂等退款请求也要带一个独立的refund_request_id。我自己测试时发现很多人做重试时忘了加“重试次数上限”导致上游一直收到请求最后把人家的接口打爆。一定记得在配置中心里给重试次数设上限并且要对重试日志做监控。3.4 对账与人工介入兜底就算你幂等做了、状态机完美、重试合理依然可能出现极端情况上游数据库回滚了但我们没感知或者上游的幂等表正好过期了恰好又来了一个相同request_id。这种小概率事件靠实时链路永远防不住所以必须有离线对账兜底。我搭的对账逻辑很简单每天早上 3 点跑一个定时任务拉取上游前一天的扣费流水跟我们本地订单表做比对。比对规则上游有扣费记录但本地没有对应订单 → 可能是我们漏记了需要查日志补录。本地有订单标记为SUCCEEDED但上游没有对应流水 → 可能是假成功需要触发退款。本地订单是UNKNOWN但上游看到扣费成功 → 需要把这笔单修正为SUCCEEDED并补业务发放。对账任务跑完会生成差异列表直接推送到企业微信告警群。处理人对差异进行“人工确认”后系统才允许关闭问题。对账不是可选项而是必须项。就算你前面所有实时机制都没问题对账依然能发现一些“你以为没问题”的问题。有一次对账就抓出来一个极低概率的 bug上游在处理请求时把request_id给它插入到数据库时发生了主键冲突但幂等表居然返回成功了。这种坑只有对账才能暴露出来。4. 实操故障注入测试全流程理论说了再多不如亲手测试一遍。这一节我把整个故障注入测试流程写出来包括 mock 服务的搭建、每个场景的预期与实测结果以及我踩过的坑。4.1 用 Mock 服务模拟上游为了让测试可控我用 Python 写了一个简单的 Flask mock 服务模拟上游计费系统。核心代码压在这里from flask import Flask, request, jsonify import time, uuid, random app Flask(__name__) # 模拟幂等表 processed {} app.route(/charge, methods[POST]) def charge(): req request.get_json() request_id req.get(request_id) fail_type req.get(fail_type, ) # 第一类直接挂死超时由调用方控制 if fail_type timeout: time.sleep(30) return jsonify({ok: False}), 200 # 第二类返回 500 if fail_type 500: return jsonify({error: server error}), 500 # 第二类返回 429 if fail_type 429: return jsonify({error: rate limit}), 429 # 第三类返回成功但订单号不对 if fail_type bad_order: return jsonify({ ok: True, order_id: fake-wrong-order-id, status: success }), 200 # 正常处理如果 request_id 已存在直接返回第一次结果 if request_id in processed: return jsonify(processed[request_id]), 200 result { ok: True, order_id: req.get(order_id), status: success, fee: req.get(amount) } processed[request_id] result return jsonify(result), 200 if __name__ __main__: app.run(host0.0.0.0, port8080)说明一下fail_type是我测试时用来控制 mock 行为的字段。真实环境里不需要这个字段但为了让故障注入可配置我在请求参数里加了它。调用方这边我用一个简单的 Python 客户端发起请求模拟我们网关的逻辑。核心逻辑是每次请求生成request_id和order_id记录到本地表再调 mock 上游。4.2 每个失败场景的预期与实测结果我针对每个场景都写了测试用例并且在测试前先写好预期结果再跑测试看实际结果。下面是完整对照表场景预期行为实测结果结论超时本地状态变为 UNKNOWN启动查询不直接判失败第一次测试直接超时判失败后续重试扣了两笔必须引入状态机确认返回 500本地状态变 UNKNOWN先查状态再决定重试第一次测试直接重试造成重复扣费500 不可安全重试返回 429按退避重试且每次带同一 request_id第二次重试后上游返回第一次结果无重复扣费幂等配合限流可解决返回成功但订单号错误校验响应中的 order_id不一致则判 UNKNOWN第一次测试直接当成功导致账实不符必须做业务字段校验正常成功SUCCEEDED且幂等保证后续重放不重复扣费连续重放 10 次只扣一次符合预期从表格里可以看出我最开始的设计存在很多漏洞超时一律算失败、500 直接重试、响应 200 就当成功。这些都是导致误扣费的典型根源。测试进行到一半时我还发现一个隐藏问题mock 服务里我用time.sleep(30)模拟超时但 Flask 默认的线程池很小同时发 10 个超时请求直接卡死所有线程导致后续正常请求也排队超时。这个现象提醒我线上如果上游真有大量慢请求必须把调用方线程池、连接池调大并且给上游请求设置独立的超时时间避免慢请求拖垮整个服务。4.3 测试中踩过的坑以下几件事是我从这次故障注入测试里实打实踩出来的每一条都是代码和线上的教训。第一个坑幂等键存 Redis 时没设过期时间导致 Redis 内存暴涨。后来设置了 7 天 TTL但对账任务需要访问历史数据又发现 7 天后幂等信息没了。最终方案是幂等表用数据库存储同时加一个 Redis 缓存加速查询缓存 TTL 设为 24 小时数据库记录永久保留。第二个坑状态机里漏掉了UNKNOWN状态。最开始我只有PROCESSING、SUCCESS、FAILED超时后直接FAILED然后允许用户重试。后来把UNKNOWN加进来并且给UNKNOWN状态的订单做了 30 秒后的自动查询任务才算真正闭环。第三个坑对账时发现上游返回的order_id有时候会带前导空格导致字符串比较失败。后来我统一在入库前做 trim所有订单号、金额都会做规范化。第四个坑也是最容易忽略的补偿操作没有幂等保护。我写退款接口时只做了简单的“这张订单已退款”校验但如果是并发请求两个退款请求同时过来可能都会通过校验导致退两次款。后来我在退款表里也加了refund_request_id用数据库唯一索引保证同一条退款请求只能成功一次。这些坑让我明白一个道理复杂系统的可靠性不是靠某个人拍脑袋能设计出来的必须通过反复的故障注入测试把每个环节的真实反应看一遍才能找到漏洞。5. 常见问题排查与避坑指南最后这部分我整理一下在实际运营中频繁遇到的线上问题以及排查思路。这部分内容更像一个速查表遇到类似问题时可以对照着处理。5.1 出现重复扣费怎么办如果用户已经反馈重复扣费第一时间要做的是从订单库里查出这笔业务关联的所有扣费记录按request_id分组。看是否有两条记录的request_id不同但业务单号相同。这种情况可能是用户手动重试时生成了新request_id而我们的状态机没有正确标记UNKNOWN导致用户能多次重试。如果确认是重复扣费先暂停用户的该项操作然后在后台把多扣的钱退回。从流程上讲退款也要走幂等流程。而且退款之前应该先修复根本问题比如把状态机的 UNKNOWN 补上或者把超时后的重试逻辑改成先查状态。5.2 回调丢失怎么办我们这里说的“回调”是指上游在扣费完成后主动通知我们。如果回调丢失最直接的现象是用户已经扣了钱但我们的订单一直停留在PROCESSING或UNKNOWN业务结果没发出去。解决办法是双向确认不能只依赖回调。我们设计的补偿机制是所有UNKNOWN且超过 30 秒的订单由一个定时任务主动向上游发查询。查询带上原始request_id和业务单号。只要上游能查到这笔订单我们就能把状态修正为SUCCEEDED并补发结果。这里要特别注意查询接口的并发度定时任务一次可能拉起几千个待查询订单需要做批量查询不能每个订单都同步串行去请求。否则一个上游慢响应就会卡住整个队列。我后来用了多线程 信号量控制并发实测下来 2000 个待确认订单能在 10 秒内全部查完。5.3 幂等键选错字段的后果很多人图省事把幂等键设置成“订单号 金额”结果发现同一个用户在同一订单上修改金额后重试就产生新的幂等键导致重复扣费。幂等键必须是一个与业务内容无关的全局唯一标识最好的做法是直接用 UUID或者使用数据库中订单表的主键。如果非要和业务关联可以用“用户 ID 操作类型 业务单号”这样稳定的组合但绝对不要包含金额、数量这类可能变化的信息。还有一个容易犯的错误幂等键在重试时被重新生成。我在测试时就故意模拟了这种情况第一次请求超时我重新生成了一个request_id去重试结果上游把它当成两笔独立订单处理。后来我强制要求一个业务请求从创建到结束request_id始终不变。如果需要在日志里区分每次物理请求可以用另一个attempt_id但那只是日志追踪用不能作为幂等依据。实际上我还在代码里加了一个切面每次准备调用上游前都会从当前订单上下文里取request_id如果为空则报错。这样能从框架层面避免“重试时重新生成幂等键”这种低级错误。最后再分享一个小技巧我用这套机制跑了好几个月最深刻的体会是防误扣费不能只靠某一段代码一定要把幂等、状态机、重试、对账当成一个整体来设计。测试时不要只测正常流程一定要故意去构造那些让你难受的异常场景。最后分享一个操作上的小技巧把所有进入UNKNOWN状态的请求都记录到一个独立的日志表并加上当前完整的上下文快照。这样一旦出问题你不需要去翻普通日志拼凑信息直接从这张表里就能还原出“用户当时发了什么、我们调了谁、上游返回了什么”。对账修复时效率能提升不少。我在实际处理线上事故时每次都是靠这张表快速定位到问题根源的。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →