接口幂等性设计完全指南:从一次"用户被扣了两次款"看懂前端置灰按钮为什么挡不住重复请求

2023 年我做一个电商的下单接口用户在前端点提交订单后端创建一笔订单扣一次款这件事我没多想就有了方案接口收到请求插入一条订单记录调支付返回成功第一版我做得很顺手为了防止用户手抖点两下我还特意在前端做了处理点击之后按钮立刻置灰本地点几下订单一笔一笔干净利落我心里很笃定重复提交嘛前端把按钮一灰用户想点第二下也点不了可等真实用户用起来一串问题冒了出来第一种最先把我打懵还是有用户创建了两笔一模一样的订单第二种最难缠有用户被扣了两次款是前端请求超时后自动重试了一次而后端其实第一次就已经成功了第三种最头疼我后来加了插入前先查一下这笔订单存不存在可高并发下还是出现了重复第四种最莫名其妙我有个发优惠券的逻辑走了消息队列队列偶尔重复投递了一条消息我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为重复请求是个边角问题用户手抖点两下那是前端该管的事可这个认知是错的重复请求根本不是用户手抖这一个来源它是分布式系统里无法消除的常态本文从头梳理为什么前端置灰按钮挡不住重复请求幂等性到底是什么怎么用唯一键和数据库约束做幂等幂等令牌怎么用查一下再插入为什么在并发下不行以及一些把它做扎实要避开的工程坑

2023 年我做一个电商的下单接口——用户在前端点"提交订单",后端创建一笔订单、扣一次款。这件事我没多想,就有了方案:接口收到请求,插入一条订单记录,调支付,返回成功。第一版我做得很顺手——前端一个提交按钮,后端一个接口,插订单、扣款、返回。为了防止用户手抖点两下,我还特意在前端做了处理:点击之后按钮立刻置灰。本地点几下,订单一笔一笔干净利落,我心里很笃定:重复提交嘛,前端把按钮一灰,用户想点第二下也点不了,这下单接口稳了。可等真实用户用起来,一串问题冒了出来。第一种最先把我打懵:还是有用户创建了两笔一模一样的订单——同样的商品、同样的金额、前后差了一两秒。第二种最难缠:有用户被扣了两次款,后台一查,是前端请求超时后自动重试了一次,而后端其实第一次就已经成功了。第三种最头疼:我后来加了"插入前先查一下这笔订单存不存在",可高并发下还是出现了重复——两个请求几乎同时查,都说"不存在",然后都插了。第四种最莫名其妙:我有个发优惠券的逻辑走了消息队列,队列偶尔重复投递了一条消息,用户的账户里就凭空多了一张券。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为重复请求是个边角问题——用户手抖点两下,那是前端该管的事,前端把按钮一置灰就解决了;接口本身,只要逻辑写对,收到一个请求处理一个请求,就万事大吉。可这个认知是错的。重复请求,根本不是"用户手抖"这一个来源,它是分布式系统里无法消除的常态:用户会重复点、前端会超时重试、网关会重试、消息队列会重复投递。你的接口,只要它不能保证"同一个操作执行一次和执行多次的效果完全一样",它就一定会在某个时刻出问题。这个"执行多次等于执行一次"的能力,有个名字,叫幂等性。要把写接口做扎实,根上要明白:幂等性不是一个可选的优化,它是任何一个会产生副作用的接口的必备能力。本文从头梳理:为什么"前端置灰按钮"挡不住重复请求,幂等性到底是什么,怎么用唯一键和数据库约束做幂等,幂等令牌怎么用,"查一下再插入"为什么在并发下不行,以及一些把它做扎实要避开的工程坑。

问题背景

先把"重复请求"这件事说清楚。一个客户端发出的请求,到达服务端、被处理,这个过程里任何一个环节出点状况,都可能导致同一个"业务意图",变成多个实际请求。这不是异常,这是常态。

错误认知是:一个请求对应一次处理,重复是偶发的、是前端的锅。真相是:在一条"客户端—网络—网关—服务端"的链路上,重复请求是被结构性地制造出来的。把这一点摊开,第一版的几类问题就都能解释了:

  • 重复创建订单:用户在网络慢的时候,以为没点上,又点了一下,两个请求都到了后端。
  • 超时重试导致重复扣款:前端发请求后等超时了,它不知道后端到底成没成功,只能重试;而后端其实成功了。
  • 查一下再插入还是重复:并发下,两个请求的"查"都发生在对方的"插"之前,于是都判断"不存在"。
  • 消息队列重复投递:消息队列为了保证消息不丢,采用"至少投递一次"语义,代价就是同一条消息可能被投递多次。

所以让写接口做对,核心不是"消灭重复请求"——你消灭不了,而是让接口具备幂等性:无论同一个操作被执行多少次,系统的最终状态和执行一次完全一样。下面六节,就从第一版"前端置灰就够了"的想当然讲起。

一、为什么"前端置灰按钮"挡不住重复请求

第一版的防重思路,全压在了前端那个"点击后置灰"上。这个处理本身没坏处,它确实能挡住一部分情况——用户在同一个页面上手快点了两下。但它的问题是,它只挡住了重复请求众多来源里的一个。重复请求这件事,远不止"用户手抖"这一种成因。

# 反面教材:防重全靠前端置灰,后端接口毫无防护

# 前端:点击后按钮置灰(伪代码示意)
#   submitBtn.onclick = () => {
#       submitBtn.disabled = true;   // 以为这样就防住了
#       postOrder(orderData);
#   }

# 后端:收到请求就插,完全没考虑"这个请求是不是重复的"
def create_order(user_id, items, amount):
    order_id = db.insert("orders", {
        "user_id": user_id,
        "items": items,
        "amount": amount,
        "status": "created",
    })
    charge(user_id, amount)        # 扣款
    return {"order_id": order_id}

# 后端默认了:每个进来的请求,都是一个独立的、新的业务意图
# 可这个默认是错的

把重复请求的来源摊开看,你会发现前端置灰只是杯水车薪。其一,前端超时重试:前端发出请求,等了几秒没等到响应,它无法判断是"请求没发到"还是"响应没回来",为了不丢失用户的操作,它通常会重试——这一重试,后端就收到了第二个请求,而置灰按钮对此毫无作用。其二,网关或负载均衡的重试:很多基础设施层为了高可用,自带请求重试机制。其三,消息队列的重复投递:主流消息队列为了不丢消息,提供的是"至少一次"语义,重复是设计内的。其四,用户换个标签页、刷新页面再提交。这些来源,没有一个是前端那个置灰按钮能管到的。

这一节要建立的认知是:重复请求不是一个"意外",而是分布式系统的一个"固有属性"——只要你的请求要穿过网络,只要链路上任何一环存在超时和重试,重复就是注定会发生的。第一版的根本错误,是把"防重"定位成了一个前端的、UI 层面的小修小补。但防重根本不是 UI 问题,它是一个后端的、数据正确性层面的核心问题。前端的置灰按钮,顶多算"减少重复发生的概率",它永远做不到"杜绝"。真正能兜底的,只能是后端接口自己——接口必须假设"我收到的任何一个请求,都可能是一个我已经处理过的请求的重复",并在这个假设下,依然保证结果正确。这个"无论收到几次,结果都对"的能力,就是幂等性。把防重的责任,从前端那个不可靠的按钮,移交给后端接口自身的幂等设计,是做对这件事的起点。

二、幂等性到底是什么:执行多次等于执行一次

幂等性这个词,听起来抽象,但它的定义朴素得很:一个操作,执行一次,和执行很多次,对系统造成的最终影响,是完全相同的。注意,它说的是"最终影响相同",不是"每次都成功"——重复执行时,后面几次可能"什么也没做",但这正是幂等:它们没有造成额外的影响。

理解幂等,最好的办法是看哪些操作天然幂等、哪些天然不幂等。"把账户余额设置为 100"——执行一次余额是 100,执行十次还是 100,天然幂等。"给账户余额增加 100"——执行一次加 100,执行十次加 1000,天然不幂等。"删除 id 为 7 的记录"——执行一次记录没了,再执行还是没了,幂等。"插入一条新记录"——执行几次就多几条,不幂等。

# 直观感受:同样是"改数据",有的天然幂等,有的不

# 幂等:把状态设置成一个确定的值
def set_status_paid(order_id):
    # 执行 1 次:status 变成 paid
    # 执行 N 次:status 还是 paid,没有任何额外影响
    db.execute("UPDATE orders SET status = 'paid' WHERE id = %s",
               order_id)

# 不幂等:在原值的基础上累加
def add_balance(user_id, delta):
    # 执行 1 次:加了 delta
    # 执行 N 次:加了 N 倍的 delta —— 这就是重复扣款的来源
    db.execute("UPDATE accounts SET balance = balance + %s "
               "WHERE id = %s", delta, user_id)

# 不幂等:每次都新增一行
def insert_order(data):
    # 执行 N 次:数据库里多出 N 条订单
    db.insert("orders", data)

这里有个关键认知:HTTP 的方法,本身对幂等性有约定。GET(查询)、PUT(整体替换)、DELETE(删除)在语义上被设计为幂等的;而 POST(通常用于"创建")在语义上是不幂等的。第一版的下单接口,正是一个 POST——它做的是"创建订单"这种天然不幂等的操作。所以,我们要做的"幂等设计",针对的恰恰就是这些天然不幂等的操作:用一层额外的机制,把一个本不幂等的操作,改造成幂等的。

这一节的认知是:幂等性不是一个操作"有没有"的天生属性,而是一个你需要主动去"赋予"的工程属性——对那些天然不幂等的操作,你的任务是给它套上一层机制,让它表现得像幂等的一样。"设置状态"这类操作幂等,是它运气好,天生如此;"创建订单""增加余额"这类操作不幂等,你不能因此就说"那它没救了"。你要做的,是识别出"这个操作天然不幂等",然后判断"它会不会被重复调用"(只要它有副作用、又走网络,答案就是会),最后给它设计幂等。后面三节,讲的就是这个"设计"具体怎么做——用唯一键、用幂等令牌、用数据库的原子性。幂等不是查出来的属性,是做出来的能力。

三、用唯一键和数据库约束做幂等

把一个不幂等的操作改造成幂等,最扎实、最常用的一招,是"业务唯一键 + 数据库唯一约束"。思路是这样的:对每一个业务操作,找出一个能唯一标识它的键——注意,是标识"这个业务操作",不是数据库自动生成的那个自增 ID。比如下单,可以约定"同一个用户、同一批商品、同一个下单时间窗口"算同一笔;更常见的是,让客户端为每一次"下单意图"生成一个唯一的标识符。

-- 在订单表上,给业务唯一键加一个唯一索引

ALTER TABLE orders
  ADD COLUMN biz_key VARCHAR(64) NOT NULL,
  ADD UNIQUE INDEX uniq_biz_key (biz_key);

-- biz_key 是"这一次下单意图"的唯一标识,
-- 由客户端生成、随请求带上来。
-- 有了这个唯一索引,数据库会强制:
-- 同一个 biz_key,最多只能有一行 —— 这是数据库级别的硬保证

有了这个唯一索引,接口的逻辑就清晰了:插入订单时,带上 biz_key。如果这是第一次请求,插入成功;如果是重复请求,biz_key 撞上唯一索引,数据库会直接拒绝这次插入,抛出一个"唯一键冲突"的错误。你的代码捕获这个错误,就知道"这是个重复请求",于是不再重复创建,而是把第一次创建的那笔订单查出来返回。

# 用唯一键 + 唯一索引冲突,把"创建订单"改造成幂等

from db_errors import UniqueViolation

def create_order_idempotent(biz_key, user_id, items, amount):
    try:
        # 第一次请求:biz_key 不冲突,正常插入
        order_id = db.insert("orders", {
            "biz_key": biz_key,
            "user_id": user_id,
            "items": items,
            "amount": amount,
            "status": "created",
        })
        return {"order_id": order_id, "repeated": False}
    except UniqueViolation:
        # 重复请求:biz_key 撞上唯一索引,插入被数据库拒绝
        # 不再重复创建,而是把已存在的那笔订单查出来返回
        existing = db.query_one(
            "SELECT * FROM orders WHERE biz_key = %s", biz_key)
        return {"order_id": existing["id"], "repeated": True}

这个方案的精髓,在于它把"判断重复"这件事,交给了数据库的唯一约束。为什么这很重要?因为唯一约束的检查,是数据库在写入时原子地完成的——它不存在"先检查、再写入"那个可被并发钻空子的时间缝隙。无论多少个带相同 biz_key 的请求并发涌进来,数据库能且只能让其中一个插入成功,其余的一律冲突。这是一道无法被并发绕过的硬防线。

这一节的认知是:做幂等,要尽量把"判重"这个动作,下沉到数据库的约束层,而不是放在你自己的应用代码里用"if 判断"来做。原因下一节(第五节)会展开讲,这里先记住结论:应用代码里的"先查再写",在并发下是不可靠的;而数据库的唯一约束,是并发安全的。这个方案还有一个常被忽略的好处——它是"自带补偿"的:就算你的应用代码因为某种原因漏判了、真的发起了两次插入,数据库的唯一索引也会在最后一刻把第二次拦下来。它是一道兜底防线,哪怕你上层的逻辑有疏漏,数据的唯一性依然由数据库死死守住。把幂等的最终保证,建立在数据库的约束这种"硬"机制上,而不是应用逻辑这种"软"判断上,你的幂等才真正可靠。

四、幂等令牌:让客户端带一个一次性的请求标识

上一节有个没说透的点:那个 biz_key,到底从哪来?如果用"用户 + 商品 + 时间窗口"去拼,会很别扭——时间窗口多长算同一笔?这就引出了更通用、更干净的方案:幂等令牌(idempotency key)。它的做法是,让客户端在发起一次有副作用的操作前,先生成(或向服务端申请)一个全局唯一的令牌,然后把这个令牌随请求带上。同一次"业务意图"的所有重试,都带同一个令牌。

方案一,是客户端自己生成令牌(比如一个 UUID)。用户每打开一次下单页面,前端就生成一个新的 UUID;用户在这个页面上无论点几次提交、前端重试几次,带的都是这同一个 UUID。服务端则维护一张"幂等记录表",每处理一个令牌就记下来。

# 幂等令牌方案:服务端维护一张幂等记录表

def handle_with_idempotency_key(idem_key, do_business):
    # 先尝试"占坑":把这个令牌插入幂等表
    try:
        db.insert("idempotency_records", {
            "idem_key": idem_key,
            "status": "processing",
            "created_at": now(),
        })
    except UniqueViolation:
        # 令牌已存在,说明是重复请求
        record = db.query_one(
            "SELECT * FROM idempotency_records WHERE idem_key = %s",
            idem_key)
        if record["status"] == "done":
            # 首次请求已处理完,直接返回当时存下的结果
            return json.loads(record["result"])
        else:
            # 首次请求还在处理中,告诉客户端稍后重试
            raise RequestInProgress()

    # 占坑成功,说明是首次请求,执行真正的业务
    result = do_business()
    db.execute(
        "UPDATE idempotency_records SET status = 'done', result = %s "
        "WHERE idem_key = %s", json.dumps(result), idem_key)
    return result

方案二,是服务端预先发放令牌。客户端进入下单页面时,先调一个接口向服务端"领"一个令牌,服务端生成并记下它;客户端提交订单时带上。这种方式服务端对令牌的生命周期掌控更强。用 Redis 也能简洁地实现令牌的"一次性"——用一个原子操作,谁能把令牌从 Redis 里删掉,谁就算抢到了首次处理权。

# 用 Redis 的原子删除,保证令牌只被消费一次

def consume_token(token):
    # DEL 返回被删除的 key 数量:
    #   返回 1 -> 这次调用成功删掉了 token,是首次请求,放行
    #   返回 0 -> token 早被删了(或不存在),是重复请求,拦截
    deleted = redis.delete(token)
    return deleted == 1

def create_order_with_token(token, order_data):
    if not consume_token(token):
        # 令牌已被消费,判定为重复请求
        raise DuplicateRequest("请勿重复提交")
    # 抢到令牌,执行下单
    return do_create_order(order_data)

这一节的认知是:幂等令牌的本质,是给"一次业务意图"发一张身份证——让服务端有能力识别出"这些先后到达的请求,其实是同一件事"。幂等的核心难点,从来都是"识别重复"。第三节用业务字段拼唯一键,有时候很别扭,因为业务字段不一定天然能唯一地框定一次意图。而幂等令牌,是把这个难题直接交给客户端解决:你最清楚"用户的一次操作意图"是什么,那就由你来为它生成一个唯一标识。客户端在"意图产生的那一刻"打下一个唯一的标记,之后无论这个意图因为重试、手抖变成多少个网络请求,它们都带着同一个标记,服务端凭这个标记一眼就能认出"老熟人"。这个方案干净、通用,几乎适用于一切需要幂等的写操作。把"识别重复"这件事,前移到"意图产生的源头"去做,是幂等设计里最优雅的一招。

把一个写请求进来后,如何判定并处理它的完整流程画出来,就是下面这张图:

[mermaid]
flowchart TD
A[写请求带幂等令牌到达] --> B{令牌在幂等表里吗}
B -->|不在| C[占坑 标记为处理中]
C --> D[执行真正的业务逻辑]
D --> E[保存结果 标记为已完成]
E --> F[返回本次结果]
B -->|在 且已完成| G[直接返回首次保存的结果]
B -->|在 但处理中| H[返回处理中 让客户端稍后重试]

五、"查一下再插入"为什么在并发下不行

第一版后来有个改进——第三种问题里提到的"插入前先查一下存不存在"。这个改进看起来很合理,符合直觉,但它在并发下是不可靠的。这一节专门把它讲透,因为这是幂等设计里最经典、也最容易踩的一个坑。

这种"先查,根据查的结果再决定写不写"的模式,有个专门的名字,叫"检查-然后-执行"(check-then-act)。它的致命缺陷在于:"检查"和"执行"是分开的两步,这两步之间存在一个时间缝隙。在并发场景下,完全可能出现:请求 A 检查(不存在)、请求 B 检查(也不存在)、请求 A 执行插入、请求 B 执行插入——两个请求的"检查"都赶在了对方"执行"之前,于是双双判断"不存在",然后双双插入。

# 反面教材:check-then-act,在并发下会漏

def create_order_buggy(biz_key, order_data):
    # 第 1 步:检查
    existing = db.query_one(
        "SELECT id FROM orders WHERE biz_key = %s", biz_key)

    # ↑↑↑ 就在这一行和下一行之间,另一个并发请求
    #     可能也执行完了它的"检查",也得到了"不存在"

    if existing:
        return existing["id"]      # 看着像防住了重复

    # 第 2 步:执行
    # 但并发的两个请求会双双走到这里,双双插入
    return db.insert("orders", {**order_data, "biz_key": biz_key})

# 平时单请求测,这段代码完美工作;
# 一旦两个请求并发,"检查"和"执行"的缝隙就被钻穿了

要堵住这个缝隙,办法只有一个方向:让"检查"和"执行"变成一个不可分割的原子操作,中间不留任何缝隙。第三节那个"唯一索引"就是最佳实践——它把判重和写入合并成了数据库的一次原子写入。除此之外,还可以用数据库锁:在事务里用 SELECT ... FOR UPDATE 把相关的行/范围锁住,或者直接用 INSERT 的冲突处理语法。

-- 正解一:让数据库在一条语句里原子地完成判重和写入
-- MySQL:INSERT ... ON DUPLICATE KEY UPDATE

INSERT INTO orders (biz_key, user_id, amount, status)
VALUES ('key-abc-123', 7, 99.00, 'created')
ON DUPLICATE KEY UPDATE id = id;
-- biz_key 冲突时,这条语句不会报错,也不会重复插入,
-- 只是执行一个无意义的 id = id。判重和写入,一步完成。

-- 正解二:PostgreSQL 的 ON CONFLICT DO NOTHING
INSERT INTO orders (biz_key, user_id, amount, status)
VALUES ('key-abc-123', 7, 99.00, 'created')
ON CONFLICT (biz_key) DO NOTHING;

这一节的认知是:在并发的世界里,"分两步做的事"和"一步做完的事",安全性有着天壤之别——任何"先检查、再根据检查结果行动"的代码,只要这两步不是原子的,并发就一定能从那条缝里钻进来。这不仅仅是幂等设计的坑,它是所有并发编程的通病。第一版"查一下再插入"之所以错,不是判断逻辑写错了,而是它天真地以为"我查完到我写,这中间不会有别人插手"——这个假设在单线程下成立,在并发下立刻崩塌。解决之道,永远是想办法把那"两步"压缩成"一步":要么靠数据库唯一约束(写入时原子判重),要么靠锁(把缝隙锁住不让别人进),要么靠数据库提供的原子语句。当你在代码里写下"先 SELECT 看看,再决定要不要 INSERT/UPDATE"时,警报就该拉响——问自己一句:并发的另一个请求,会不会正好挤在我这两步中间?能对这条缝隙保持警觉,你才能写出在真实并发下不出错的幂等接口。

六、把幂等做扎实,要避开的工程坑

前面五节讲清了幂等的原理和几种实现。但要在生产里真正用好,还有几个坑得专门讲。第一个,是幂等记录的存储和过期。你那张"幂等记录表"或 Redis 里的令牌,会随着请求量持续增长,不能让它无限膨胀。但过期时间也不能设得太短——要确保它至少覆盖"客户端所有可能的重试周期"。如果令牌只存 1 分钟,而客户端在第 2 分钟才重试,那这个令牌早没了,重复请求就漏过去了。

# 坑一:幂等记录要设过期,但过期窗口必须覆盖重试周期

# 用 Redis 存令牌时,过期时间要 >= 客户端最大重试间隔
def save_idempotency_token(token, ttl_seconds=86400):
    # 设成 24 小时:足够覆盖绝大多数重试场景,
    # 又不会让 Redis 无限堆积
    redis.setex(token, ttl_seconds, "1")

# 数据库里的幂等表,则用定时任务清理过期记录
def cleanup_idempotency_records():
    db.execute(
        "DELETE FROM idempotency_records "
        "WHERE created_at < %s", days_ago(7))
    # 保留 7 天:留足排查问题的时间,也控制了表的大小

第二个坑,是重复请求要返回"正确的结果",而不是粗暴地报个错。一个重复请求,本质上是客户端"没收到上次的响应"。如果你对重复请求直接返回"请勿重复提交"的错误,客户端反而会困惑——它会以为这次操作失败了。正确的做法,是识别出重复后,把首次请求当时的处理结果原样返回给它。这就是为什么第四节的幂等表里要存 result 字段。

# 坑二:对重复请求,要返回首次的结果,而不是报错

def handle_request(idem_key, do_business):
    record = db.query_one(
        "SELECT * FROM idempotency_records WHERE idem_key = %s",
        idem_key)
    if record and record["status"] == "done":
        # 重复请求:别返回错误,返回首次存下的那个成功结果
        # 客户端拿到的,和它"本该"在第一次拿到的,一模一样
        return json.loads(record["result"])
    # ……首次请求的处理逻辑……

还有几个坑值得点一下。其一,要区分"重复请求"和"并发请求":前者是同一个意图先后到达,后者可能是首次请求还在处理、重试就来了——所以幂等表里要有"处理中"这个中间状态,对撞上"处理中"的请求,应让它稍后重试,而不是当成已完成。其二,幂等的粒度要想清楚:是整个接口幂等,还是接口里某一步幂等?一个接口如果包含"创建订单 + 扣款 + 发券"多步,要保证的是整体幂等,不能创建订单幂等了、扣款却没有。其三,幂等键的生成要真正唯一,客户端用 UUID 这类碰撞概率极低的方案,别用"时间戳"这种会撞的。下面把几种幂等方案集中对照一下:

几种幂等实现方案对照

  方案              判重依据              适用场景
  --------------------------------------------------------------
  业务唯一键        业务字段拼成的键      字段能天然唯一框定一次操作
  幂等令牌          客户端生成的 UUID     通用 推荐 大多数写接口
  唯一索引冲突      数据库唯一约束        判重的最终兜底 并发安全
  ON DUPLICATE     数据库原子语句        判重写入一步完成
  Redis 原子操作    SETNX / DEL 的返回    高并发 令牌一次性消费

  原则:判重靠数据库约束兜底,识别意图靠客户端令牌,
        永远不要用应用层的"先查再写"来防并发。

这一节这几个坑,串起来是同一个意思:幂等不是"加一个唯一索引"就完事的开关,它是一个要从客户端发起、到服务端处理、到数据存储全链路一起考虑的设计。幂等记录要管理生命周期,重复请求要给出体面的结果,要分清重复与并发,要想清楚幂等的粒度,要保证幂等键真的唯一。这些点没有一个在"那条唯一索引"上,它们散落在整个请求处理的链路里。幂等真正要对抗的,是分布式系统那种"什么都可能发生两次"的不确定性——而对抗不确定性,从来不是靠一个点上的技巧,而是靠一套贯穿全链路的、处处都假设"这可能是重复的"的设计。把幂等当成接口的一种基本素质,在设计每一个有副作用的接口时就把它考虑进去,而不是等出了重复扣款的事故再回头打补丁——这样你的系统,才能在真实世界的重试和抖动里,始终保持数据的正确。

关键概念速查

概念 说明
幂等性 同一操作执行一次和执行多次,系统最终状态完全相同
重复请求 同一业务意图因重试手抖等变成的多个实际请求,是常态
至少一次投递 消息队列为不丢消息采用的语义,代价是消息可能重复
业务唯一键 能唯一标识一次业务操作的键,用于判重
唯一索引 数据库强制某列不重复,写入时原子判重,并发安全
幂等令牌 客户端为一次意图生成的唯一标识,所有重试都带它
check-then-act 先检查再执行的模式,两步非原子,并发下会漏
ON DUPLICATE KEY 数据库在一条语句里原子完成判重与写入
处理中状态 幂等记录的中间态,用于区分重复请求与并发请求
幂等粒度 多步操作要保证整体幂等,而非只让其中一步幂等

避坑清单

  1. 不要把防重寄托在前端置灰按钮:它只挡用户手抖,挡不住重试和重投。
  2. 不要假设一个请求只会到达一次:超时重试、网关重试、队列重投都会制造重复。
  3. 不要以为逻辑正确就够了:有副作用又走网络的接口,必须主动设计幂等性。
  4. 不要用应用层的"先查再插入"防并发:check-then-act 两步非原子,并发会钻空子。
  5. 不要忽视数据库唯一约束:它是写入时原子判重,是幂等最可靠的兜底防线。
  6. 不要用易碰撞的值做幂等键:用 UUID 这类,别用时间戳这种会撞的。
  7. 不要对重复请求直接报错:要返回首次处理的结果,客户端才不会以为失败了。
  8. 不要混淆重复请求和并发请求:幂等记录要有处理中状态来区分两者。
  9. 不要让幂等记录无限膨胀或过早过期:过期窗口要覆盖客户端最大重试周期。
  10. 不要只让多步操作里的一步幂等:要保证整个接口作为一个整体是幂等的。

总结

回头看第一版那个"前端置灰就够了"的下单接口,它的错误很典型。它不在某一行代码,而在一个对重复请求的根本误解:以为重复是用户手抖造成的偶发小事,前端把按钮一灰就解决了。真相是,重复请求是分布式系统的固有属性——超时重试、网关重试、消息队列重投,链路上处处在制造重复。前端那个按钮,顶多减少概率,杜绝不了。能真正兜住的,只有后端接口自身的幂等性:无论同一个操作被执行多少次,结果都和执行一次完全一样。

而把幂等做对,工程量并不小。它不是加一个唯一索引那么简单,而是要理解幂等是一种需要主动赋予的能力,要为业务操作设计一个能识别意图的幂等键,要把判重下沉到数据库的原子约束而不是应用层的先查再写,要处理好幂等记录的生命周期,要对重复请求返回首次结果,要分清重复与并发、想清楚幂等的粒度。一套真正可靠的幂等方案,是这些环节一个不少地拼起来的。

这件事其实很像在银行柜台办一笔转账。你把填好的转账单递给柜员,这就是一个请求。设想一下,如果柜台没有任何防重机制:你递了单子,柜员办理时系统卡了一下,你没听到"办好了"的回执,心里没底,又填了一张一模一样的单子递过去——结果转账被执行了两次,钱出去了两笔。银行真实的做法是什么?每一张转账单上,都有一个唯一的流水号(这就是幂等令牌)。柜员每办一笔,先拿流水号去系统里登记;等你因为没收到回执、又递来一张单子时,柜员一看流水号,系统里已经有了——他不会再办一次,而是直接告诉你"这笔已经办好了,这是您的回执"(返回首次的结果)。你要的,本来就只是那个回执而已。幂等设计,做的就是给系统装上这套"认流水号"的机制:认出哪些请求其实是同一笔,该办的办,办过的,把上次的结果再给你看一遍。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你自己测,一个请求点一下、等它返回、再点下一个,所有请求都是串行的、一次性的,重复扣款、并发漏判,一个都不会出现——你会觉得"收一个请求处理一个请求"这套天衣无缝。真正会把幂等的缺口撑开的,是上线后的真实环境:成千上万的用户在网络忽快忽慢的情况下提交,前端在超时时默默重试,网关在抖动时默默重试,消息队列按"至少一次"忠实地偶尔重投——那个"查和写之间的缝隙""第一次成功了但响应没回去"的场景,会被真实流量精准地、反复地踩中,重复订单和重复扣款就涌进了工单系统。所以如果你正在写一个会改数据的接口,别等用户拿着两笔一样的扣款记录来投诉,才回头给接口打补丁。在写下这个接口的第一行代码时就问自己:它会被重复调用吗(只要有副作用又走网络,答案就是会)、我用什么键识别重复、我的判重是不是并发安全的——把"处理请求"和"处理可能重复的请求"当成两件必须分别设计的事,这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

LLM 幻觉缓解完全指南:从一次"模型一本正经编了个不存在的制度条款"看懂喂资料为什么挡不住瞎编

2026-5-22 20:56:14

技术教程

LLM 应用评估完全指南:从一次"改了提示词修好一个 case 结果碰坏一片"看懂为什么肉眼看例子不算测试

2026-5-22 21:12:09

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索