你有没有遇到过这种情况:用户点了”支付”按钮,页面卡了一下,用户又点了一次,结果账户被扣了两次钱;或者消息队列因为网络抖动重投了一条消息,订单被创建了两遍。这类问题在单体应用里偶尔出现,在分布式系统里却几乎是必然。它的根源只有一个——同一个操作被执行了多次,而系统默认每次执行都会产生一次副作用。要让系统在这个不确定的世界里保持正确,我们需要一种能力:幂等。
很多人把幂等当成一个”接口要支持重试”的技术细节,随手加个唯一索引就完事。但真正在生产里出过事故的人会明白,幂等是一套贯穿设计、存储与流程的体系。今天这篇文章,我想把幂等设计拆成三层来聊:如何定义清楚一次操作的身份、如何在存储层把身份变成约束、如何在流程层让重试变得安全。

第一层:先给每一次操作一个”身份证”
幂等的本质问题不是”如何不重复执行”,而是”如何判断这两个请求其实是同一个操作“。如果系统无法识别出”这是同一次点击”,那后面所有的去重逻辑都无从谈起。所以幂等设计的第一步,是给每一次业务操作分配一个稳定、唯一、可传递的身份标识。
最常见的做法是幂等键(Idempotency Key)。客户端在发起请求时生成一个全局唯一的 token(通常是 UUID),放在请求头或参数里。服务端收到后,先拿这个 key 去查”这次操作我处理过没有”,处理过就直接返回上次的结果,没处理过才真正执行。这个思路听起来简单,但有几个容易踩的坑:
第一,key 的生成位置很关键。如果由服务端生成,那么每次重试都会得到新的 key,去重就失效了。key 必须由发起方生成,并且在整个重试链条中保持不变——客户端 SDK 在第一次点击时生成一次,之后所有重试复用同一个 key。
第二,key 的粒度要和业务语义对齐。是一笔支付一个 key,还是一个订单一个 key,还是一个用户一次会话一个 key?粒度太粗会把本该分开的操作误判为重复,粒度太细又挡不住真正的重试。判断标准是:如果两次请求在业务上”应该产生且只产生一个结果”,它们就该共享同一个 key。
第三,没有 key 的场景要靠业务字段推导。很多老接口没有幂等键,这时可以用业务上有唯一性的字段组合做天然幂等键,比如”订单号 + 操作类型”,或者”用户ID + 日期 + 业务类型”。这不是最优解,但能在不改协议的前提下挡住大部分重复。
把身份定清楚了,幂等才有地基。否则后面无论加多少锁、建多少唯一索引,都只是在跟一个连自己都不认识的请求较劲。

第二层:让存储层成为最后的守门人
定义了幂等键之后,很多人会自然地想到”在业务代码里查一下有没有处理过”。但只靠应用层的查询判断是危险的——并发场景下,两个请求可能同时查询、同时发现”没处理过”,然后同时执行。这就是经典的 check-then-act 竞态。真正的守门人必须下沉到存储层,用数据库的原子性和唯一约束来保证”最多执行一次”。
最直接的办法是唯一索引。把幂等键作为一张”操作记录表”的唯一键,插入成功代表这是第一次处理,插入冲突(Duplicate Key)代表这是重复请求。这个动作是原子的,数据库替你挡住了并发。相比应用层先查后写,它简单且可靠,是幂等落地的首选方案。
但唯一索引也有它的边界。当一次操作要跨越多个数据库表、甚至多个服务时,单纯靠一张表无法覆盖全部副作用。这时常见的增强手段有两种。一种是记录状态机:把操作记录设计成带状态的(处理中 / 已成功 / 已失败),第一步用唯一的幂等键把记录”抢占”下来并置为处理中,后续步骤根据状态推进,重复请求看到”处理中”或”已成功”就直接返回,避免并发重复执行。
另一种是把幂等键写进业务表本身。比如订单表加一列 idempotency_key 并建唯一索引,这样即使服务重启、消息重投,数据库也会在写入的那一刻拒绝第二条相同记录。这种做法把幂等从”额外的一层”变成了数据模型的一部分,更靠近不可绕过的底线。
需要提醒的是,存储层的约束要和事务边界配合。如果一次操作涉及数据库写入和发送消息两个动作,仅仅保证数据库幂等是不够的——消息可能被重复发送。这时要么让消费端也具备幂等能力,要么引入本地消息表、事务消息等机制,把”写库”和”发消息”绑在一个可靠的流程里。幂等从来不是单点问题,而是端到端的。
第三层:在流程层把重试变成一种默认能力
前两层解决的是”重复请求来了怎么办”,第三层要解决的是”我们如何放心地重试”。一个成熟的系统不应该害怕重试,而应该把重试当作常态:网络会抖动、下游会超时、进程会崩溃,唯一能让我们在不确定中保持正确的手段,就是让每一次操作都可以被安全地重放。
要做到这一点,首先要在协议层把幂等语义显式表达出来。HTTP 里 GET、PUT、DELETE 天然是幂等的,POST 不是——所以在设计接口时,能用 PUT 表达”设置成某个状态”的就不要用 POST 表达”执行一次动作”。对于必须用 POST 的动作型接口,就约定带上幂等键,并在文档里写清楚”重复提交返回首次结果”,让调用方敢于重试。
其次,超时后的处理策略要慎重。很多事故不是发生在”重试”本身,而是发生在”我不确定上次成功没有,于是又发了一次”。正确的心智模型是:超时不等于失败,它等于”结果未知”。遇到未知结果,正确的动作是带着同一个幂等键重试,让服务端来告诉你结果,而不是换一个新请求从头再执行一遍。
最后,要把幂等能力做成可观测、可验证的。为幂等命中(重复请求被拦截)打点计数,为唯一键冲突记录日志,监控它们的趋势。当幂等命中的比例突然升高时,往往意味着上游在异常重试,或者客户端有 bug 在重复提交——这本身就是一条有价值的告警信号。幂等设计做到最后,不只是防护,更是一面照出系统健康状况的镜子。

结语
幂等这个词听起来很学术,但它解决的问题非常具体:在这个一定会重复、一定会抖动的世界里,让一次业务操作在结果上只发生一次。它不需要多么复杂的算法,却需要我们在三个层次上都想清楚——给操作一个明确无二的身份,让存储层用原子约束守住底线,在流程层把重试当作常态来设计。
比起事后追查”为什么扣了两次钱”,更值得投入的是在写第一行代码时就问自己一句:如果这个请求被重复执行一百次,系统的状态还对不对?能坦然回答这个问题的系统,才真正具备了在分布式世界里生存的资格。