从重复扣款说起:接口幂等性的四种实现与取舍

举报
yd_293030668 发表于 2026/11/04 14:24:20 2026/11/04
【摘要】 从重复扣款说起:接口幂等性的四种实现与取舍上个月处理了一个客诉:用户点击"支付"按钮后页面卡住,下意识又点了一下,结果银行卡被扣了两次款。技术上说,这就是一个典型的幂等性缺失问题——同一个操作执行一次和执行多次,结果应该一样,但我们的支付接口没做到。排查修复的过程里,我把项目里几种幂等方案都过了一遍,把实现方式和适用场景整理成这篇。 为什么会有重复请求先把重复请求的来源列全,方案才有针对性...

从重复扣款说起:接口幂等性的四种实现与取舍

上个月处理了一个客诉:用户点击"支付"按钮后页面卡住,下意识又点了一下,结果银行卡被扣了两次款。技术上说,这就是一个典型的幂等性缺失问题——同一个操作执行一次和执行多次,结果应该一样,但我们的支付接口没做到。

排查修复的过程里,我把项目里几种幂等方案都过了一遍,把实现方式和适用场景整理成这篇。

为什么会有重复请求

先把重复请求的来源列全,方案才有针对性:

  1. 用户行为:双击按钮、页面卡住后刷新重试
  2. 网络重试:客户端请求超时后自动重发(很多 HTTP 库默认开启)
  3. 服务重试:网关/负载均衡的超时重试,或者消息队列的重复投递
  4. 架构问题:上游服务故障转移时把请求转发到另一个实例

前三条是"同一个人带着同一个意图发了两次请求",第四条是基础设施行为。幂等设计必须同时覆盖这四条,缺一条就是线上事故的种子。

方案一:数据库唯一约束(兜底方案)

最简单也最可靠的兜底:给业务键加唯一索引。

-- 防重表:以"业务类型 + 业务单号"为唯一键
CREATE TABLE idempotent_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    biz_type VARCHAR(32) NOT NULL,
    biz_no VARCHAR(64) NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_biz (biz_type, biz_no)
);

处理请求前先插入防重记录,插入失败(唯一键冲突)说明是重复请求,直接返回上一次的结果。

try:
    db.execute("INSERT INTO idempotent_record (biz_type, biz_no) VALUES (%s, %s)",
               "PAYMENT", order_no)
except DuplicateKeyError:
    return build_previous_response(order_no)  # 返回首次处理的结果

优点:强一致,数据库层面绝对防重。
缺点:每笔业务多一次写;防重记录本身要考虑清理策略;响应结果的存储需要额外设计(否则重复请求只能返回"已处理",无法返回原结果)。

适合做最后防线,而不是唯一手段。

方案二:Token 机制(前端配合)

针对用户双击/重试的场景,标准解法是"先领令牌,提交时核销":

1. 进入支付页时:GET /api/token  → 返回 token=T1(存 Redis,5 分钟过期)
2. 提交支付时:POST /api/pay?token=T1
3. 服务端:DEL token 原子删除,删成功才执行业务,删不到说明重复提交
def pay(token, order_no, amount):
    # Lua 保证"判断 + 删除"原子性
    ok = redis.eval(
        "if redis.call('get', KEYS[1]) == ARGV[1] then "
        "  return redis.call('del', KEYS[1]) "
        "else return 0 end", 1, "pay_token:" + token)
    if not ok:
        raise DuplicateSubmitError()
    do_payment(order_no, amount)

优点:精准拦截"同一个页面会话的重复提交",用户体验好(第二次点击直接提示"请勿重复提交")。
缺点:必须前端配合改造;token 有时效,页面停留太久会失效;对服务间重试无效——服务 A 调服务 B 的重试不会带 token。

适合作为用户交互层的第一道防线。

方案三:业务键 + Redis 的快速幂等

服务间调用的重试,用业务键做幂等更直接:

def transfer(request_id, from_acc, to_acc, amount):
    # request_id 由调用方生成并保证同一次业务重试时不变
    key = f"idem:transfer:{request_id}"
    ok = redis.set(key, "processing", nx=True, px=10 * 60 * 1000)
    if not ok:
        # 可能是重试:查一下上次的处理结果
        return get_transfer_result(request_id)
    try:
        result = do_transfer(from_acc, to_acc, amount)
        redis.setex(key, 24 * 3600, json.dumps({"status": "done", "result": result}))
        return result
    except Exception:
        redis.delete(key)   # 失败要释放,允许调用方重试
        raise

优点:覆盖服务间重试;处理结果可以缓存下来,重试时直接返回原结果(真正的幂等语义)。
缺点:Redis 与数据库之间存在时间窗口——set key 成功但业务没执行完时服务崩溃,key 还在,重试会被误判为重复。需要配合方案一做兜底,或者把"标记"放业务事务里一并提交。

这是最容易做错的一种:很多人直接 setnx 完就不管了,没考虑"标记成功但业务失败"的中间态。

方案四:状态机(业务天然幂等)

支付、订单这类有明确状态流转的业务,最优雅的方案是把状态机做成乐观锁:

UPDATE payment SET status = 'PAID', paid_at = NOW()
WHERE order_no = %s AND status = 'PENDING';

受影响的行数为 1 → 本次执行生效;为 0 → 状态已经不是 PENDING(可能已支付、已取消),直接查当前状态返回。无论请求来多少次,数据库状态只会向前流转一次,天然幂等,且不需要额外的存储。

优点:无额外组件、强一致、和业务状态天然绑定。
缺点:只适用于状态流转清晰的场景;复杂业务的状态机维护成本高。

实际组合:四层防线

我们最终的方案是组合拳,按请求的流向分层:

用户端(方案二 Token)→ 网关(方案三 request_id)→ 业务层(方案四 状态机)→ 数据库(方案一 唯一约束兜底)

每一层拦截的重复来源不同,互为补充。全部打穿的代价是:重复请求最终卡在唯一约束上,业务一致性有保障。

三个容易忽略的细节

细节一:幂等键的选择。 用"请求参数的 hash"做幂等键是错的——参数里只要带个时间戳就失效了。幂等键必须是业务语义上的唯一标识:支付用"订单号",转账用"request_id"(调用方在业务事务中生成并持久化)。

细节二:返回结果的幂等。 重复请求不能返回报错就算完——调用方拿到报错会继续重试,雪崩。正确做法是把首次处理的结果存下来(setex key 86400 result),重复请求返回和首次一致的结果。

细节三:失败要释放标记。 方案三的代码里,业务抛异常时必须 delete 掉幂等标记,否则用户重试一次永远进不来,只能等 key 过期。判断"哪些异常算业务失败可重试"要小心:扣款成功后通知下游失败的异常,不能释放标记。

小结

方案 拦截对象 强度 成本
Token 机制 用户重复点击 中 前端改造
request_id + Redis 服务间重试 中 Redis 依赖、中间态处理
状态机乐观锁 所有来源 强 业务改造
数据库唯一约束 所有来源(兜底) 最强 写入放大

没有银弹,按业务形态组合使用。最起码的一条底线:涉及钱的接口,数据库唯一约束必须有——前面几层全漏了,它也能把事故挡在最后一毫米。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。