我的下单接口被同一个请求重复调用,结果生成了两笔一模一样的订单,我一开始以为是前端 bug,最后才明白重试在分布式里根本无法避免、而我没做幂等的深度复盘

我的下单接口,用户点一次就该下一单,可线上时不时冒出重复订单:同样的商品金额生成了两笔三笔。我第一反应甩锅前端"重复调用了",让前端加防抖,可问题依然。复盘完整链路才痛悟:根子不在前端,而在我没做幂等。分布式里重复请求根本无法避免——网络超时重试(后端其实成功了只是响应没回来)、用户重复点、网关自动重试、MQ 至少一次投递,而后端根本分不清"新订单"还是"重试"。这篇从重复不可避免、必须幂等讲起,到幂等键+唯一约束+状态机的正解、哪些操作要幂等/幂等键怎么设计、exactly-once 真相(至少一次+幂等),以及那句最戳心的——分布式要为重复和失败而设计,别消灭重复要容忍重复。

我的下单接口被同一个请求重复调用,结果生成了两笔一模一样的订单,我一开始以为是前端 bug,最后才明白重试在分布式里根本无法避免、而我没做幂等的深度复盘

这是一个让我对"分布式里的重复"刻骨铭心的故事。我写了一个下单的接口:用户点击"提交订单",前端调用我的接口,我就在数据库里,创建一条订单记录。逻辑清清楚楚。在我朴素的认知里,用户点一次,就下一单;前端调一次接口,我就创建一条订单——一一对应,天经地义。

可线上,却时不时冒出"重复订单"的投诉:用户明明只想下一单,系统里却生成了两笔、甚至三笔一模一样的订单(同样的商品、金额、时间)。我第一反应,是甩锅给前端:"肯定是前端的 bug,把接口重复调用了!是不是按钮没防重复点击?是不是请求发了两次?"我让前端去排查、去加防抖。可加了之后,问题依然时有发生。直到我沉下心,去复盘那些重复订单产生的完整链路,才痛彻地明白,根子根本不在前端,而在我自己:问题的核心,是我没有给这个下单接口做"幂等";而我更深层的错误,是天真地以为"一个请求只会被处理一次"——可在分布式系统里,"同一个请求被重复发送、从而被重复处理",是根本无法避免的常态!它的来源,五花八门:网络超时重试(前端发了请求,但因为网络慢,没等到响应,它以为失败了,就自动重试了一次——可其实,我后端第一次已经处理成功、只是响应在回传路上慢了/丢了);用户重复操作(用户没看到反馈,手快又点了一次);网关/代理的重试(很多网络组件,在超时时会自动重发请求);消息队列的"至少一次(at-least-once)"投递(如果下单是消费消息触发的,MQ 为了保证不丢消息,会可能重复投递同一条)。而我的接口,面对这些"重复来的同一个请求",每一次都老老实实地、又创建了一条新订单——于是,重复订单,就这么产生了。我一直以为"重复调用"是个该被前端消灭的"异常",可在分布式的世界里,它是一个必然会发生、且无法从源头根除的现实;而我作为服务端,唯一能做、也必须做的,是让我的接口"幂等"——即:同一个请求,无论被处理多少次,产生的效果,都和处理一次完全相同。我那个会重复创建订单的接口,恰恰不幂等——它把每一次重复,都当成了一次新的请求。

故障现场:不幂等的接口,重试一次就多一笔订单

我把这个"重复订单"的现场,用代码和链路摊开给你看:

// ✗ 灾难: 不幂等的下单接口
@PostMapping("/order")
public Order createOrder(@RequestBody OrderRequest req) {
    // 直接创建订单 —— 同一个请求被调几次, 就创建几条!
    Order order = new Order(req.getUserId(), req.getItems(), req.getAmount());
    orderRepository.save(order);     // ✗ 每次调用都 insert 一条新订单
    return order;
}

// 重复订单是怎么产生的(分布式里, 重试无法避免):
//   场景1: 网络超时重试
//     前端发请求 → 后端"已成功创建订单" → 但响应在回传路上慢了/丢了
//     → 前端没收到响应, 以为失败 → 自动重试 → 后端又创建一条! (重复)
//   场景2: 用户重复点击 / 网关自动重试 / MQ 至少一次投递
//     → 同一个"下单意图", 变成了多个到达后端的请求 → 创建多条订单。

// 关键: 后端无法区分"这是新订单"还是"这是上一个请求的重试"!
//   因为它们长得一模一样(同一个用户、同样的商品金额)。
//   → 不幂等的接口, 把每一次重复, 都当成一次新请求处理 → 重复订单。

// 根因: 我假设"一个请求只被处理一次", 但分布式里重复请求不可避免;
//   而我的接口不幂等——同一请求处理 N 次, 就产生 N 笔订单。
//   "防住前端重复点击" 治标不治本——重试的来源太多, 根本防不全!

看着这条链路,我才算真正理解了这些"重复订单"的根源。问题的核心,有两层。第一层,是我那个下单接口,不幂等——它每被调用一次,就无条件地创建一条新订单。第二层,也是更根本的,是我天真地假设了"一个请求,只会被处理一次";可在分布式系统里,"同一个请求被重复发送、从而被重复处理",是一个根本无法避免的常态而这种"重复",来源五花八门、防不胜防:网络超时重试(前端发了请求,后端其实已经处理成功,但响应在回传途中慢了或丢了,前端没收到,便以为失败、自动重试);用户重复操作(没看到反馈,手快又点了一下);网关/代理的自动重试(很多网络组件在超时时会自动重发);消息队列的"至少一次(at-least-once)"投递(MQ 为了保证不丢消息,会可能重复投递同一条)。而最关键、也最致命的一点是:后端,根本无法区分,一个到达的请求,究竟是"一笔全新的订单",还是"上一个请求的重试"!因为它们,长得一模一样(同一个用户、同样的商品和金额)。于是,我那个不幂等的接口,就把每一次重复,都当成了一次全新的请求来处理,老老实实地,又创建了一条新订单。这也解释了,为什么"让前端防重复点击"治标不治本:因为重试的来源太多了——除了用户点击,还有网络、网关、MQ……你根本防不全。你堵住了前端这一个源头,重试还会从网络、从网关、从消息队列,源源不断地来。归根结底:我一直把"重复调用",当成一个该被消灭的异常;可在分布式的世界里,它是一个必然会发生、且无法从源头根除的现实。而我作为服务端,唯一能做、也必须做的,是让我的接口"幂等"——也就是:同一个请求,无论被处理多少次,所产生的效果,都和只处理一次,完全相同。我那个会重复创建订单的接口,恰恰不幂等;而正解,不是去徒劳地"消灭重复",而是让我的接口,能从容地容忍重复

第一件事:搞懂分布式里"重复不可避免",必须幂等

定位到根源,我必须把"为什么重复不可避免"和"什么是幂等",彻底想清楚:

分布式里重复请求不可避免, 服务端必须做"幂等"

# 为什么"重复请求"在分布式里不可避免?
#   - 网络不可靠: 请求/响应可能丢、可能慢 → 调用方"超时重试"
#     (而它无法知道: 是真失败了, 还是"成功了但响应没回来"?)
#   - 调用方重试: 客户端、网关、SDK、RPC 框架, 超时常会自动重试。
#   - 用户重复操作: 没反馈就再点一次。
#   - 消息投递: MQ 多用"至少一次", 会重复投递。
#   → 结论: 你的接口, 一定会收到"重复的请求"。这是常态, 不是异常。

# 一个深刻的事实: 调用方无法区分"失败"和"成功但没收到响应"
#   - 它超时了 → 它只能选择: 重试(可能导致重复) 或 不重试(可能丢失)。
#   - 多数选"重试"(宁可重复, 不可丢失) → 所以重复必然发生。

# 什么是"幂等(idempotent)"?
#   - 一个操作, 执行一次 和 执行多次(同样的输入), 产生的"效果"相同。
#   - 例: "把状态设为已支付"是幂等的(设几次都是已支付);
#         "余额 +100"不是幂等的(执行几次就加几次)!
#   - 天然幂等: 查询(GET)、删除(DELETE 同一个)、覆盖式更新(SET)。
#   - 天然不幂等: 创建(每次新建)、累加(每次加)、追加。

# 服务端的责任: 让"需要幂等"的接口, 真正幂等
#   - 即使收到 N 个重复请求, 也只产生"一次"的效果(只创建一笔订单)。
#   - 这样, 无论调用方怎么重试, 都不会出问题——重试就变"安全"了。

# 核心: 别想着"消灭重复"(你消灭不了), 而要让接口"容忍重复"(做幂等)。
#   幂等, 是分布式系统里, 应对"不可避免的重复"的根本武器。

想清楚之后,我对"分布式接口设计",有了一个根本性的认识转变。首先要接受一个现实:在分布式系统里,"重复请求"是不可避免的常态,而不是异常。它的来源众多:网络不可靠(请求或响应可能丢、可能慢,导致调用方超时重试)、调用方重试(客户端、网关、RPC 框架在超时时常会自动重试)、用户重复操作消息队列的"至少一次"投递——所以,你的接口,一定会收到重复的请求而这背后,有一个深刻而无奈的事实:调用方,根本无法区分"真的失败了"和"成功了、只是响应没回来";它超时之后,只能二选一——重试(可能导致重复)或不重试(可能导致丢失);而大多数场景,会选择"重试"(因为宁可重复,也不可丢失)——所以,重复,必然发生那么,什么是"幂等"?一个操作,执行一次执行多次(同样的输入),所产生的效果相同,就是幂等的。比如,"把状态设为已支付"是幂等的(设几次都是已支付);而"余额 +100"不是幂等的(执行几次就加几次)。天然幂等的有:查询、删除同一个、覆盖式更新;天然幂等的有:创建(每次新建一条)、累加、追加——而我那个"创建订单"的接口,恰恰是天然不幂等的。由此,服务端的责任就清晰了:要让那些"需要幂等"的接口,真正做到幂等——即使收到 N 个重复请求,也只产生"一次"的效果(只创建一笔订单);这样一来,无论调用方怎么重试,都不会出问题——重试,就变得"安全"了归根结底,我领悟到一个分布式系统里至关重要的设计原则:别想着去"消灭重复"(你根本消灭不了,来源太多),而要让你的接口,能够"容忍重复"(把它做成幂等的)。幂等,正是分布式系统里,应对那"不可避免的重复"的、最根本的武器——这,是我用一堆重复订单,补上的、关于分布式设计的、最重要的一课。

第二件事:正解——用幂等键 + 唯一约束做幂等

搞懂了根因——"重复不可避免、接口不幂等"——正解就清晰了:给每个请求,带上一个唯一的"幂等键(idempotency key)";服务端用这个键去重:第一次处理就记下这个键,后续带着同一个键的重复请求,就识别出来、不再重复处理(直接返回上次的结果)。再配合数据库的唯一约束,做最后一道兜底。

// 正解1: 幂等键 + 去重(核心方案)
@PostMapping("/order")
public Order createOrder(@RequestBody OrderRequest req,
                         @RequestHeader("Idempotency-Key") String idemKey) {
    // idemKey: 由"客户端生成"的唯一键(如 UUID), 同一次下单意图用同一个键,
    //          重试时带"相同"的键。

    // 1. 尝试占用这个幂等键(原子操作, 如 Redis SETNX, 或 DB 唯一索引)
    boolean firstTime = redis.setIfAbsent("idem:" + idemKey, "1", 24, HOURS);
    if (!firstTime) {
        // 这个键已处理过 → 是重复请求! 直接返回上次的结果, 不再创建
        return getExistingResult(idemKey);   // ✓ 幂等: 重复请求不产生新效果
    }
    // 2. 第一次: 正常处理, 并把结果和 idemKey 关联存起来
    Order order = doCreateOrder(req, idemKey);
    return order;
}

// 正解2: 数据库唯一约束(最可靠的兜底)
//   给订单表加一个唯一索引(如 idempotency_key 列, 或业务唯一键)
//   ALTER TABLE orders ADD UNIQUE KEY uk_idem (idempotency_key);
//   → 重复请求 insert 时, 唯一约束会让第二次"插入失败"(冲突),
//     你 catch 这个冲突, 返回已存在的订单 → 即使并发也不会重复!
//   (唯一约束是数据库层的强保证, 是幂等最可靠的最后一道防线)

// 正解3: 业务状态机(适合状态流转类操作)
//   "支付"操作: 先查订单状态, 已是"已支付"就直接返回成功, 不重复扣款。
//   if (order.status == PAID) return success;   // 幂等: 重复支付不重复扣

// 三者常配合: 幂等键去重(挡大部分) + 唯一约束(兜底防并发) + 状态判断。

// 核心: 用"唯一键"识别"同一个请求", 处理过就不再处理(返回旧结果)。
//   让接口对"重复", 天然免疫——这就是幂等。

这套正解,核心是给请求一个"身份证(幂等键)",让服务端能认出"这是同一个请求",从而对重复的它,不再重复处理正解1(幂等键去重,核心方案):让客户端,为每一次"下单意图",生成一个唯一的幂等键(如 UUID),并在请求头里带上;重试时,带的是同一个键。服务端收到请求,先原子地"占用"这个键(如用 RedisSETNX):如果占用成功(第一次),就正常处理;如果占用失败(说明这个键已经处理过了,是重复请求),就直接返回上次的结果,不再创建新订单——这样,重复请求就不会产生新的效果,接口就幂等了。正解2(数据库唯一约束,最可靠的兜底):给订单表,加一个唯一索引(比如在 idempotency_key 列、或某个业务唯一键上);这样,重复请求去 insert 时,唯一约束会让第二次插入直接失败(冲突),你 catch 这个冲突、返回那条已存在的订单即可——即使在并发的情况下,唯一约束也能从数据库层,强保证不会产生重复,这是幂等最可靠的最后一道防线。正解3(业务状态机,适合状态流转类操作):比如"支付"操作,可以先查订单状态,如果已经是"已支付"了,就直接返回成功、不再重复扣款这三者,通常配合使用:幂等键去重(挡住绝大部分重复)+ 唯一约束(兜底,防住并发下的漏网之鱼)+ 状态判断。归根结底:用一个"唯一键",去识别"同一个请求",只要这个请求处理过了,就不再处理(直接返回旧结果)——让接口,对"重复",天然免疫。这,就是幂等。我那次的错误,正是没给请求一个身份、也就没法认出重复;而正解,就是给它一个身份,让重复无所遁形。

下面这张图,对比了"不幂等"和"用幂等键"两条路径:

这张图的对比很清楚:左边红色那条,接口不幂等、无脑创建,重复请求一来又创建一条,生成重复订单;右边绿色那条,接口幂等、请求带幂等键,先用键去重——没处理过才创建并记下键,已处理过就直接返回上次结果、不再创建,所以无论重复多少次,都只有一笔订单。两条路的根本分野,在于你有没有用一个唯一键,让接口能认出并容忍重复。

第三件事:哪些操作要幂等、幂等键怎么设计

填平了下单这个坑,我系统梳理了一遍:哪些操作必须做幂等,以及幂等键该怎么设计:

哪些操作要幂等 & 幂等键怎么设计

# 哪些操作"必须"做幂等?(凡是"写"且"重复会出问题"的)
#   - 创建类: 下单、注册、发券 → 重复会多创建。
#   - 扣减类: 支付、扣库存、扣余额 → 重复会多扣(最严重!)。
#   - 发送类: 发短信、发通知、发消息 → 重复会骚扰用户。
#   - 消息消费: MQ 至少一次投递 → 消费逻辑必须幂等。
#   → 凡是"有副作用、且重复执行后果不一样"的写操作, 都要做幂等。
#   (纯查询 GET 天然幂等, 不用特殊处理)

# 幂等键怎么来?(关键: 同一"业务意图"对应同一个键)
#   方案A: 客户端生成(推荐): 客户端为一次操作生成 UUID, 重试时复用同一个。
#          (需要客户端配合, 在请求头/体里带 Idempotency-Key)
#   方案B: 用业务唯一性: 用"天然唯一"的业务字段做键。
#          如"同一用户+同一订单+同一时间窗"、"订单号"、"消息ID"。
#   方案C: 服务端发token: 下单前先调一个接口领一个"防重token", 提交时带上。

# 幂等键的存储与去重:
#   - Redis SETNX(快, 但要处理过期、和"处理中"状态)。
#   - DB 唯一约束(最可靠, 抗并发, 是兜底)。
#   - 去重表(记录已处理的键 + 结果, 重复时返回旧结果)。

# 几个细节坑:
#   - "处理中"也要占位: 别让两个并发的重复请求都通过了"没处理过"判断。
#     (用 DB 唯一约束, 或 Redis 原子占位 + 状态)
#   - 要能"返回上次的结果": 重复请求不只是"不处理", 还要返回和第一次一样的响应。
#   - 幂等键要有合理的"有效期"(别永久存, 也别太短)。

# 核心: "写且重复会出问题"的操作, 都要幂等; 用"同一意图对应同一键"去识别重复。

这一梳理,让我对"幂等"的应用,有了体系化的认识。首先,哪些操作必须做幂等?——凡是""且"重复执行后果不一样"的操作:创建类(下单、注册、发券——重复会多创建)、扣减类(支付、扣库存、扣余额——重复会多扣,后果最严重)、发送类(发短信、发通知——重复会骚扰用户)、消息消费(MQ 至少一次投递,消费逻辑必须幂等);而纯查询(GET)天然幂等,不用特殊处理。其次,幂等键怎么来?——关键是"同一个业务意图,要对应同一个键":方案A(客户端生成,推荐):客户端为一次操作生成 UUID,重试时复用同一个;方案B(用业务唯一性):用"天然唯一"的业务字段做键(如订单号、消息 ID、或"同一用户+同一时间窗"的组合);方案C(服务端发 token):提交前先领一个"防重 token",提交时带上。然后,是存储与去重的手段:Redis SETNX(快,但要处理过期和"处理中"状态)、DB 唯一约束(最可靠、抗并发,作兜底)、去重表(记录已处理的键和结果,重复时返回旧结果)。最后,有几个容易踩的细节坑:"处理中"也要占位(别让两个并发的重复请求,都通过了"没处理过"的判断——要用 DB 唯一约束、或 Redis 原子占位);要能"返回上次的结果"(幂等不只是"不重复处理",还要让重复请求拿到和第一次一样的响应);幂等键要有合理的"有效期"。归根结底:凡是"写、且重复会出问题"的操作,都要做幂等;并用"同一意图对应同一键"的方式,去识别和拦截重复。把这个意识刻进每一个写接口的设计里,重复请求,就再也威胁不到你的数据一致性了。

第四件事:幂等的几种实现,以及"exactly-once"的真相

这次踩坑,把我引向了一个更深的话题:幂等有哪几种实现、各有什么取舍?以及那个传说中的"精确一次(exactly-once)",到底是怎么回事?我把它们梳理清楚了:

幂等的几种实现 & exactly-once 的真相

# 幂等的几种实现手段(由轻到重):
# 1. 业务状态判断: 操作前查状态, 已是目标态就直接返回。
#    (如已支付就不再扣) 简单, 但要小心并发(查和改之间的竞态)。
# 2. 唯一约束(DB unique key): 让数据库挡住重复插入。
#    最可靠、抗并发, 是兜底; 缺点是只防"插入"类。
# 3. 幂等键 + 去重表/缓存: 记录已处理的键(+结果), 重复就返回旧结果。
#    通用、灵活; 要处理"处理中"状态、过期、并发占位。
# 4. 乐观锁(版本号/CAS): 更新时带版本号, 防重复更新覆盖。
#    适合"更新"类的幂等。

# 关于"exactly-once(精确一次)"的真相:
#   - 很多人追求"消息/请求恰好被处理一次", 但在分布式里,
#     端到端的"精确一次投递"是极难、甚至理论上做不到的。
#   - 现实的做法是: "at-least-once(至少一次)投递" + "幂等消费"
#     = 效果上的 "exactly-once(精确一次)"。
#   - 即: 允许重复投递/重试(保证不丢), 但消费方幂等(保证不重复生效)。
#   → "不丢" 靠重试; "不重" 靠幂等。两者结合, 才得到"恰好一次"的效果。

# 所以, 幂等不是可选项, 是分布式可靠性的"刚需":
#   - 你要保证"不丢" → 就得"重试" → 就会"重复" → 就必须"幂等"。
#   - 幂等, 是"允许重试"的前提; 没有幂等, 你都不敢重试(怕重复)。

# 核心: 分布式里追求的"精确一次", 是靠"至少一次 + 幂等"实现的。
#   幂等, 让"重试"变安全; 而重试, 是分布式容错的基础。
#   所以幂等, 是整个分布式可靠性大厦的一块基石。

这一深挖,让我对"幂等"在分布式体系里的地位,有了更深的理解。幂等的实现,有几种手段,由轻到重:业务状态判断(操作前查状态,已是目标态就直接返回——简单,但要小心查和改之间的并发竞态);唯一约束(让数据库挡住重复插入——最可靠、抗并发,作兜底,但只防"插入"类);幂等键 + 去重表/缓存(记录已处理的键和结果,重复就返回旧结果——通用灵活,但要处理"处理中"状态、过期、并发占位);乐观锁(版本号/CAS)(更新时带版本号,防重复更新覆盖——适合"更新"类)。而关于那个传说中的"exactly-once(精确一次)",有一个重要的真相:很多人追求"消息/请求恰好被处理一次",但在分布式里,端到端的"精确一次投递",是极难、甚至理论上做不到的。现实中、也是业界公认的做法是:"at-least-once(至少一次)投递" + "幂等消费" = 效果上的 "exactly-once(精确一次)"——即:允许重复投递和重试(从而保证"不丢"),但让消费方幂等(从而保证"不重复生效");"不丢"靠重试,"不重"靠幂等,两者结合,才得到了"恰好一次"的效果由此,我才真正意识到:幂等,不是一个可选项,而是分布式可靠性的"刚需":你要保证"不丢",就得"重试";一重试,就会"重复";要容忍重复,就必须"幂等"。幂等,正是"允许重试"的前提——没有幂等,你都不敢重试(怕产生重复)。归根结底:分布式里苦苦追求的"精确一次",其实,是靠"至少一次 + 幂等"实现的;幂等,让"重试"变得安全;而重试,又是分布式容错的基础。所以,幂等,是整个分布式可靠性大厦的一块基石——它远不只是为了防一笔重复订单,而是支撑起整个系统"能重试、可容错"的根本。把幂等的几种实现,整理成一张表:

实现手段 适合 优点 注意
业务状态判断 状态流转(如支付) 简单 查改之间有竞态
DB 唯一约束 插入类(下单) 最可靠、抗并发 只防插入,作兜底
幂等键+去重表 通用 灵活、能返回旧结果 处理中/过期/并发占位
乐观锁/CAS 更新类 防重复更新覆盖 需版本号

第五件事:分布式设计,要"为重复和失败而设计"

这次踩坑,在认知层面给了我最大的纠偏——它让我建立起了分布式系统的核心设计观。我把这层反思,沉淀了下来:

认知纠偏: 分布式设计, 要"为重复和失败而设计"

# 我的误解(错误的):
#   我用"单机、确定性"的思维写分布式接口——假设"一个请求只被处理一次"、
#   "调用要么成功要么失败、不会有中间态"。然后把重复甩锅给前端。
#   → 我用单机的"理想假设", 去面对分布式的"残酷现实"。

# 真相: 分布式系统里, 这些"理想假设"全都不成立
#   - 请求会重复(重试/网络/MQ)。
#   - 调用会有"不确定的中间态"(超时了, 不知道成没成功)。
#   - 网络会分区、节点会宕机、消息会乱序/重复/丢。
#   → 你不能假设"一切正常", 要假设"什么都可能出岔子"。

# 分布式的核心设计观: "为失败而设计(Design for Failure)"
#   - 假设重复会发生 → 接口做"幂等"(本文)。
#   - 假设调用会失败 → 做超时、重试、熔断、降级。
#   - 假设消息会重复 → 消费做幂等。
#   - 假设节点会挂 → 做冗余、故障转移。
#   → 不是"祈祷不出问题", 而是"出了问题也能正确/优雅地处理"。

# 一个关键的思维转变:
#   ✗ 命令式假设: "我让它做一次, 它就只做一次"(在分布式里是幻想)。
#   ✓ 防御式设计: "它可能被做很多次/可能失败, 我要让结果依然正确"。
#     → 让系统"天然能容忍重复和失败", 而不是依赖"一切恰好正常"。

核心: 分布式里, 重复和失败是常态。要"为重复和失败而设计"——
  做幂等(容忍重复)、做容错(容忍失败), 让系统在混乱中依然正确。

这层反思,是这次踩坑给我最高维度的收获。复盘我的误解,根源是:我用"单机、确定性"的思维,去写一个分布式的接口——我假设"一个请求只会被处理一次"、"调用要么成功要么失败、不会有中间态",然后,把出现的重复,甩锅给前端。我,用单机的"理想假设",去面对了分布式的"残酷现实"可真相是:在分布式系统里,这些"理想假设",全都不成立:请求会重复(重试/网络/MQ);调用会有"不确定的中间态"(超时了,你根本不知道它到底成没成功);网络会分区、节点会宕机、消息会乱序/重复/丢失——你不能假设"一切正常",而要假设"什么都可能出岔子"由此,我领悟到分布式系统的核心设计观——"为失败而设计(Design for Failure)":假设重复会发生,接口就做"幂等"(本文);假设调用会失败,就做超时、重试、熔断、降级;假设消息会重复,消费就做幂等;假设节点会挂,就做冗余、故障转移——不是"祈祷不出问题",而是"出了问题,也能正确、优雅地处理"而这,本质上是一个关键的思维转变:命令式的假设("我让它做一次,它就只做一次"——这在分布式里,是一种幻想),转向防御式的设计("它可能被做很多次、可能失败,而我要让结果依然正确")——也就是,让系统"天然能容忍重复和失败",而不是依赖"一切恰好正常"归根结底:在分布式的世界里,重复和失败,是常态;要"为重复和失败而设计"——做幂等(容忍重复)、做容错(容忍失败),让你的系统,在混乱中,依然能保持正确。我那堆重复订单,正是为我那份"单机式的天真假设",交的学费;而从此,"为失败而设计",成了我做每一个分布式功能时,刻在骨子里的前提。把"理想假设"和"为失败而设计"两种心态对比成一张表:

维度 理想假设(踩坑) 为失败而设计(成熟)
对重复请求 假设只处理一次 假设会重复,做幂等
对调用 非成即败 有不确定中间态,要容错
出了重复 甩锅给前端 服务端幂等兜住
设计基调 祈祷一切正常 假设什么都可能出岔子
系统在混乱中 数据错乱 依然正确

一套"这个接口要不要做幂等"的决策流程

把这次踩坑的全部教训,我浓缩成了一张"一个接口要不要做幂等、怎么做"的决策图,贴在了团队的架构规范里:

这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:设计接口先问是不是写操作——纯查询天然幂等;写操作再问重复执行后果是否一样——一样的(覆盖式 SET、或已是目标态)天然幂等或加状态判断即可,不一样的(创建、扣减、发送)就必须做幂等!实现上:插入类用 DB 唯一约束兜底,通用场景用客户端幂等键 + 去重表/Redis 占位,状态流转类用状态机判断 + 乐观锁;最终都让重复请求不产生新效果、返回旧结果这条"先判断要不要幂等、再选实现"的决策链,现在是我们团队设计每一个写接口时的准则。

我立下的几条幂等与分布式规矩

这次"重复订单"的踩坑,让我把幂等和分布式设计的注意事项,认真地立成了几条规矩:

  1. 接受"重复请求不可避免"。重试/网络/网关/MQ 都会带来重复,别想消灭它,要容忍它。
  2. 写且重复有害的操作必须做幂等。创建、扣减、发送、消息消费——一律做幂等。
  3. 用幂等键识别重复。客户端生成唯一键、重试复用同一个;服务端用它去重、返回旧结果。
  4. DB 唯一约束兜底。它是抗并发、最可靠的最后一道防线,尤其防插入类重复。
  5. 处理"处理中"和并发。原子占位,别让两个并发重复请求都通过判断;要能返回上次结果。
  6. 幂等是重试的前提。"至少一次 + 幂等 = 精确一次效果";有幂等才敢重试、才能容错。
  7. 为失败和重复而设计。分布式里假设什么都可能出岔子,让系统天然容忍重复和失败。

写在最后

这次"我的下单接口生成了重复订单、我却一直甩锅给前端"的经历,是我在分布式架构路上,一次很典型、也很受用的成长。它教给我的,远不止"接口要做幂等"这一条具体的技术经验,更是一种分布式系统的根本设计观——要"为重复和失败而设计"。我那堆重复订单,根源就在于,我带着单机时代"一个请求只处理一次"的天真假设,去写一个分布式接口;却不知道,在分布式的世界里,重复请求是不可避免的常态,而我作为服务端,唯一能做的,不是徒劳地去"消灭重复",而是让我的接口,能够从容地"容忍重复"

所以,当你在分布式系统里设计任何一个写接口时,请别再假设"它只会被乖乖地调用一次",而要坦然地承认:"它一定会被重复调用、它的调用一定会有失败和不确定";然后,为这个现实,做好设计——该幂等的就做幂等(容忍重复)、该容错的就做容错(容忍失败)。就像那个下单接口,你只要给它加上一个幂等键、一道唯一约束,就绝不会再因为一次网络重试、一次用户手快,而生成出一堆让用户投诉的重复订单。从"假设一切恰好正常"的单机思维,到"假设什么都可能出岔子"的分布式思维,从徒劳地"消灭异常"到从容地"容忍异常",是从一个"会写接口"的开发,走向一个"能驾驭分布式复杂性"的架构师,必经的修炼。愿你设计的每一个接口,都既扛得住重复、也容得下失败;也愿你我,在分布式的混乱与不确定中,始终用"为失败而设计"的智慧,守住系统的正确。共勉。

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

我让大模型返回 JSON,平时一直解析得好好的,直到某次它在 JSON 外面裹了一段解释文字,我的 JSON.parse 当场崩了、整个功能瘫痪的深度复盘

2026-6-1 23:20:51

技术教程

我以为 LINQ 查询在定义那一行就执行完了,结果它每次遍历都重新查一遍数据库,还因为延迟执行读到了中途变化后的数据的深度复盘

2026-6-1 23:33:51

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