我的下单接口偶尔会给同一个用户生成两笔一模一样的订单、甚至重复扣款,我对着这些诡异的重复数据排查了大半天才真正理解幂等性的复盘
这是一个让我对"分布式系统"敬畏起来的故事。我有一个下单接口,逻辑很直白:接收用户请求 → 扣减库存 → 创建订单 → 扣款。它绝大多数时候都工作得很好。可线上偶尔会冒出一些诡异的脏数据:同一个用户,在几乎同一时刻,生成了两笔内容一模一样的订单;更严重的几次,是同一笔消费,被扣了两次款。客服收到投诉,我一查数据库,两条订单除了订单号,其余字段(用户、商品、金额、时间)几乎完全相同,像是被复制了一份。
我一开始怀疑是前端有 bug、用户手抖点了两次。可深入排查日志后,我发现真相远比这复杂,也补上了我对分布式系统一个最核心的认知漏洞:这些重复,大多不是用户点两次造成的,而是"重试"造成的。场景五花八门:其一,用户点了下单,请求已经到了服务端、也处理成功了,但返回响应时,网络抖动、超时了;前端没收到成功响应,以为失败了,于是自动重试,又发了一次——服务端就处理了两次。其二,我的服务调用支付/库存等下游时,下游其实成功了,但响应超时,我的服务以为失败,发起了重试。其三,如果中间过了消息队列,MQ 为了保证"消息不丢",采用的是"至少投递一次(at-least-once)"语义,同一条消息,可能被投递、消费多次。而所有这些场景,都指向同一个我从未真正重视过的本质:在分布式系统里,"一次请求/消息,被处理多次",不是异常,而是必然会发生的常态——因为网络是不可靠的,而为了对抗这种不可靠,重试,是几乎所有环节的默认自我保护机制;它带来的代价,就是"重复"。而我的下单接口,是一个非幂等(non-idempotent)的操作:它被执行一次和执行两次,产生的结果,是不同的(执行两次就生成两笔订单、扣两次款);把一个非幂等的操作,暴露在一个"必然会重复请求"的环境里,出乱子,只是迟早的事。我这才痛彻地明白:"幂等性(idempotency)"——即"一个操作,无论执行一次还是多次,产生的结果都相同"——不是一个锦上添花的高级特性,而是分布式系统里,任何"会改变数据的关键写操作",都必须具备的基本生存能力。不设计幂等,就等于默认网络永远可靠、请求永不重复,而这,在分布式世界里,是一个致命的天真假设。
故障现场:非幂等的写操作,遇上重试就重复执行
我把这个"重复下单"的现场,用代码摊开给你看:
// ✗ 灾难: 非幂等的下单, 重试一次就多生成一笔订单/多扣一次款
public Order createOrder(OrderRequest req) {
// ✗ 没有任何"防重复"机制, 来一次请求就实打实执行一次
deductStock(req.getItemId(), req.getQty()); // 扣库存
Order order = new Order(req);
orderDao.insert(order); // ✗ 每次都 insert 一条新订单
pay(req.getUserId(), req.getAmount()); // ✗ 每次都扣一次款
return order;
}
// 重复怎么发生(都不是"用户故意点两次"):
// 场景1: 服务端处理成功了, 但返回响应时网络超时
// → 前端没收到成功 → 自动重试 → 服务端处理第二次 → 两笔订单。
// 场景2: 调下游(支付)成功了, 但下游响应超时
// → 本服务以为失败 → 重试 → 扣两次款。
// 场景3: 走了 MQ, at-least-once 语义 → 同一消息被消费多次 → 重复执行。
// 本质:
// - 网络不可靠 → 重试是默认的自我保护 → "重复请求"是分布式常态。
// - createOrder 是"非幂等"操作: 执行1次 vs 2次 结果不同(多一笔订单)。
// - 非幂等操作 + 必然会重复的环境 = 迟早出重复数据。
// 现象: 数据库里两条订单, 除订单号外字段几乎全同, 时间相差几百毫秒。
// 根因: 关键写操作没做幂等, 遇到网络重试/重复投递时被重复执行。
看着这段代码,我才算彻底想明白了这些重复数据的根源。问题的核心,是我的 createOrder 是一个非幂等的写操作——它没有任何"防重复"机制,来一次请求,就实打实地执行一次:扣一次库存、插一条订单、扣一次款。而重复,几乎都不是"用户故意点两次",而是重试:场景一,服务端处理成功了,但返回响应时网络超时,前端没收到成功、自动重试,服务端就处理了第二次;场景二,调下游支付成功了但响应超时,本服务以为失败、重试,扣了两次款;场景三,走了 MQ,at-least-once 语义下同一消息被消费多次。这一切的本质是:网络不可靠 → 重试是默认的自我保护 → "重复请求"是分布式系统的常态;而把一个非幂等的操作(执行 1 次和 2 次结果不同),放进一个必然会重复的环境,就迟早会出重复数据。归根结底:关键写操作没做幂等,遇到网络重试/重复投递时被重复执行,这,就是重复下单、重复扣款的根源。
第一件事:搞懂幂等性与分布式的"重复"常态
定位到根源,我必须把"幂等性"和"为什么会重复"这件事,从根上彻底搞清楚:
幂等性: 一个操作执行一次和执行多次, 产生的结果相同
# 为什么分布式系统里"重复"是常态?
# - 网络不可靠: 请求/响应都可能丢、可能超时。
# - 超时的歧义: "超时" ≠ "失败"! 可能"对方已成功, 只是响应没回来"。
# - 为对抗不可靠 → 各层默认重试(前端重试、RPC重试、MQ重投)。
# - 结果: 同一个操作, 必然有概率被请求/执行多次。
# 哪些操作天然幂等? 哪些不是?
# - 天然幂等: 查询(GET)、把状态设为固定值(set status=PAID)、删除(delete by id)。
# - 非幂等: 插入新记录(每次多一条)、累加(balance += 100, 每次多扣)、
# "创建订单""扣款""发短信"等"产生新副作用"的操作。
# HTTP 方法的幂等约定(设计参考):
# - GET/PUT/DELETE 应设计成幂等; POST 通常非幂等(创建)。
# 幂等的核心思路: 让系统能"识别出这是重复请求, 并直接返回上次的结果"
# - 需要一个"唯一标识"来判断"这是不是同一个操作"。
# - 第一次执行并记录; 后续重复请求识别到 → 不再执行, 返回已有结果。
# 关键认知: 别假设"请求只来一次"。
# - 任何会改数据的关键写操作, 都要设计成"重复执行也安全"。
# - "超时不代表失败" 是分布式第一课。
# 核心: 网络不可靠+重试使"重复"成为分布式常态; 非幂等写操作必然出事;
# 关键写操作必须做幂等(靠唯一标识识别重复并返回上次结果)。
原理终于清晰了。什么是幂等性?——一个操作,执行一次和执行多次,产生的结果相同。为什么分布式系统里"重复"是常态?因为网络不可靠(请求/响应都可能丢、超时);而最致命的,是"超时"的歧义——超时 ≠ 失败!它很可能是"对方其实已经成功了,只是响应没回来";为了对抗这种不可靠,各层都默认重试(前端、RPC、MQ),结果就是同一个操作,必然有概率被执行多次。哪些操作天然幂等?查询、把状态设为固定值(set status=PAID)、按 id 删除,是天然幂等的;而插入新记录、累加(balance += 100)、创建订单、扣款、发短信等"产生新副作用"的操作,是非幂等的。(HTTP 也有约定:GET/PUT/DELETE 应幂等,POST 通常非幂等。)那幂等的核心思路是什么?让系统能"识别出这是重复请求,并直接返回上次的结果"——这需要一个"唯一标识"来判断"是不是同一个操作":第一次执行并记录,后续重复请求识别到,就不再执行、直接返回已有结果。由此,我刻下一个关键认知:别假设"请求只来一次";任何会改数据的关键写操作,都要设计成"重复执行也安全";"超时不代表失败",是分布式第一课。归根结底:网络不可靠 + 重试,使"重复"成为分布式常态;非幂等写操作必然出事;关键写操作必须做幂等(靠唯一标识识别重复并返回上次结果)。
第二件事:正解——用幂等键 + 唯一约束去重
搞懂了原理,正解就清晰了:给每个请求一个全局唯一的"幂等键",用数据库的唯一约束做"同一个键只能成功一次"的硬保证。
// ✓ 正解: 客户端带幂等键 + 服务端用唯一约束去重
// 1. 客户端生成一个全局唯一的幂等键(如 UUID), 同一笔操作重试时复用同一个键
// POST /order Header: Idempotency-Key: 7f3a... (重试时这个键不变!)
// 2. 服务端: 幂等键落库, 用唯一索引保证"同一个键只能插入成功一次"
// 建表: CREATE TABLE idempotent (key VARCHAR PRIMARY KEY, result TEXT, status INT);
public Order createOrder(OrderRequest req, String idempotencyKey) {
// ✓ 先尝试占位: 唯一约束保证并发/重试下只有一个能成功
try {
idempotentDao.insert(idempotencyKey, "PROCESSING"); // 主键=幂等键
} catch (DuplicateKeyException e) {
// ✓ 插入失败 = 这个键已存在 = 重复请求!
Record r = idempotentDao.get(idempotencyKey);
if (r.status == DONE) return r.result; // ✓ 已完成 → 直接返回上次结果
else throw new RetryLaterException(); // 上次还在处理中 → 让客户端稍后重试
}
// ✓ 走到这里说明是"第一次", 正常执行业务
Order order = doCreateOrder(req);
idempotentDao.update(idempotencyKey, DONE, order); // ✓ 记录结果
return order;
}
// 为什么用唯一约束?
// - 它是数据库层面的"原子去重", 天然扛并发(两个并发请求只有一个 insert 成功)。
// - 比"先查再插"可靠: 先查再插之间有缝隙, 并发下两个都查不到、都插入 → 仍重复!
// 核心: 客户端带不变的幂等键(重试复用), 服务端用唯一约束原子去重,
// 重复请求识别到就返回上次结果, 而不是再执行一遍。
修复的方案,既简洁又可靠。第一步,客户端为每一笔操作,生成一个全局唯一的"幂等键"(如 UUID),并放在请求头里;关键是——重试时,复用同一个键!(这样服务端才能识别出"这是同一笔操作的重试")。第二步,服务端把幂等键落库,并用数据库的唯一约束(主键/唯一索引)做去重:处理前,先尝试用幂等键"占位"insert——如果 insert 成功,说明是"第一次",正常执行业务、再记录结果;如果 insert 抛唯一键冲突,说明这个键已存在、是重复请求,这时直接返回上次的结果(若上次还在处理中,就让客户端稍后重试)。这里有个关键设计:为什么用唯一约束、而不是"先查有没有、再决定插不插"?因为"先查再插"之间有时间缝隙,并发下两个请求可能都查不到、于是都去插入,照样重复!而唯一约束是数据库层面的原子去重,天然扛并发——两个并发请求,只有一个能 insert 成功。归根结底:客户端带不变的幂等键(重试复用),服务端用唯一约束原子去重,重复请求识别到就返回上次结果,而不是再执行一遍。
第三件事:不同场景的幂等实现方式
幂等键+唯一约束是通法,但不同场景还有更轻量或更合适的实现,我也一并梳理了:
幂等的几种实现: 按场景选最合适的
# 1. 唯一约束去重(最通用, 推荐):
# - 幂等键 / 业务唯一键(如"用户+商品+秒级时间"或订单号)做唯一索引。
# - insert 冲突即视为重复 → 返回已有结果。
# - 适合: 创建类操作(下单、注册、支付单)。
# 2. 状态机流转(适合有状态的对象):
# - 订单: 待支付 → 已支付 → 已发货, 用 CAS 更新:
# UPDATE order SET status='PAID' WHERE id=? AND status='UNPAID'
# - 重复执行: 第二次 WHERE 条件不满足(已是PAID), 影响行数=0, 不重复扣。
# - 适合: 支付、发货等"状态推进"类操作。
# 3. token / 防重令牌(适合防表单重复提交):
# - 进页面先领一个一次性 token, 提交时带上。
# - 服务端校验并删除 token; 重复提交时 token 已不存在 → 拒绝。
# - 适合: 前端表单防重复点击。
# 4. 去重表 + 唯一键(适合 MQ 消费幂等):
# - 消费消息前, 用 messageId 查/插去重表。
# - 已存在 → 说明消费过 → 直接 ack 跳过。
# - 适合: MQ at-least-once 下的消费端去重。
# 选型要点:
# - 创建类 → 唯一约束; 状态推进 → 状态机CAS;
# - 表单防重 → token; MQ消费 → 去重表。
# - 幂等键怎么来: 客户端生成(UUID) 或 用业务天然唯一键。
# 核心: 按场景选幂等实现 —— 创建用唯一约束、状态推进用状态机CAS、
# 表单用token、MQ消费用去重表; 本质都是"识别重复 + 不重复执行"。
幂等的"工具箱"就此打开。方式一,唯一约束去重(最通用):用幂等键或业务唯一键(如"用户+商品+秒级时间"、或订单号)建唯一索引,insert 冲突即视为重复,适合下单、注册、支付单等创建类操作。方式二,状态机流转(适合有状态对象):用 CAS 更新——UPDATE order SET status='PAID' WHERE id=? AND status='UNPAID',重复执行时第二次 WHERE 条件已不满足、影响行数为 0,不会重复扣,适合支付、发货等"状态推进"操作。方式三,token 防重令牌:进页面先领一个一次性 token,提交时带上,服务端校验并删除,重复提交时 token 已不存在、被拒绝,适合前端表单防重复点击。方式四,去重表(适合 MQ 消费):消费前用 messageId 查/插去重表,已存在就说明消费过、直接 ack 跳过,适合 MQ at-least-once 下的消费端去重。归根结底:按场景选幂等实现——创建用唯一约束、状态推进用状态机 CAS、表单用 token、MQ 消费用去重表;但它们的本质,都是同一个:"识别出重复 + 不重复执行"。
下面这张图,是这次"重复下单"的成因与幂等解法:
第四件事:几种幂等方案的横向对比
修复时我把几种幂等实现,按"适用场景、并发安全、复杂度"几个维度横向比了一遍,方便对号入座。
| 方案 | 核心机制 | 最适合的场景 | 注意点 |
|---|---|---|---|
| 幂等键 + 唯一约束 | insert 冲突即去重 | 下单/注册/支付单等创建 | 键要全局唯一, 重试需复用同一键 |
| 状态机 CAS | WHERE 限定旧状态 | 支付/发货等状态推进 | 看影响行数判断是否真的改了 |
| 防重 token | 一次性令牌, 用后即焚 | 前端表单防重复提交 | token 要有有效期 |
| 去重表(messageId) | 消费前查/插去重表 | MQ at-least-once 消费 | 去重记录要定期清理 |
| 分布式锁 | 同一键串行执行 | 需严格串行的临界操作 | 有性能损耗, 锁要设超时防死锁 |
把它们排在一起,选择就清楚了。最通用、最该优先考虑的,是幂等键 + 唯一约束——它简单、可靠、靠数据库天然扛并发,适合绝大多数创建类操作;状态推进(支付、发货)用状态机 CAS 最自然(靠 WHERE 限定旧状态 + 看影响行数);前端表单防重用 token;MQ 消费去重用去重表。至于分布式锁:它能让同一键串行执行,适合需要严格串行的临界操作,但它有性能损耗、锁还要设超时防死锁,能用唯一约束/CAS 解决的,就别动用分布式锁(杀鸡用牛刀)。这些方案的共性,值得反复咀嚼:它们没有一个是"阻止重复请求到来"(那做不到),而全都是"让重复请求到来后,不产生重复的副作用"——这,正是幂等设计的精髓:不与"重复"对抗,而是坦然接纳重复、并让它变得无害。
第五件事:幂等设计里那些容易踩的坑
做幂等,看着简单,但魔鬼在细节。我把实现幂等时容易自以为做对、其实有漏洞的坑,系统排查了一遍。
| 容易踩的坑 | 问题 | 正确做法 |
|---|---|---|
| 用"先查再插"判重 | 查和插之间有缝隙, 并发下仍重复 | 用唯一约束让数据库原子去重 |
| 重试时幂等键变了 | 每次新键, 服务端认不出是重复 | 同一操作的重试必须复用同一键 |
| 幂等记录和业务不在一个事务 | 业务成功但记录失败(或反之), 状态不一致 | 放同一本地事务, 或用状态标记+补偿 |
| 只防了入口, 没防下游调用 | 对下游的重试仍重复扣款 | 调下游也要传幂等键, 下游也做幂等 |
| 去重表/键无限增长 | 越积越多拖垮存储 | 设 TTL 或定期归档清理 |
| "处理中"状态没处理好 | 第一次还没完, 第二次直接返回空 | 区分 处理中/已完成, 处理中让其重试 |
这张表,让我对"幂等"的认识,从"加个唯一键就行"深化到了"一个需要严谨设计的完整链路"。最隐蔽、也最致命的坑,是"先查再插"判重:它看起来能防重复,可查询和插入之间的那道缝隙,在并发下会被两个请求同时钻过去,照样产生重复——必须用数据库唯一约束这种原子手段。其余的坑也都很真实:重试时幂等键变了(每次新键,服务端根本认不出重复);幂等记录和业务不在一个事务(导致状态不一致);只防了入口、没防对下游的调用(对下游重试照样重复扣款,所以调下游也要传幂等键、下游也要做幂等);去重表无限增长(要设 TTL/定期清理);以及"处理中"状态没处理好(第一次还没完成,第二次重复请求直接返回了空结果)。它们共同的启示是:幂等不是一个"点",而是一条"链"——从客户端生成键、到入口去重、到事务一致、再到下游传递,任何一环的疏漏,都会让整个幂等防线形同虚设。做幂等,要有"端到端"的全链路视角。
第六件事:写一个关键接口时,我现在会怎么决策
现在,每当我准备写一个"会改数据"的关键接口,脑子里都会过一遍这张决策图——核心就一句:假设它一定会被重复调用。
这张图的灵魂,是一个前置假设:我现在写任何会改数据的接口,都默认假设"它一定会被重复调用",而不是"它应该只来一次"。第一问:这个操作天然幂等吗?——像"设为固定值""按 id 删除"这种,本身就安全,无需额外处理;而创建、累加、扣款这种,必须做幂等设计。然后按操作类型选方案:创建类用幂等键+唯一约束、状态推进用状态机 CAS、MQ 消费用去重表、表单提交用 token。再贯穿两条链路要求:幂等记录要和业务在同一事务、调下游也要传递幂等键。最后,也是我以前最缺的一步:用并发 + 重试的测试去验证——同一请求并发/重复执行多次,结果必须一致,才算真的做对了,而不是"我以为做了幂等"。
我立下的几条规矩
这场"重复下单、重复扣款"的事故,换来了我做分布式系统时,刻进骨子里的几条铁律:
- 默认假设请求会重复。网络不可靠+重试,让"重复"成为常态;任何会改数据的关键写操作,都要设计成"重复执行也安全"。
- 超时不代表失败。这是分布式第一课;对方可能已经成功了,只是响应没回来——所以重试前必须考虑幂等。
- 幂等优先用唯一约束,别用"先查再插"。查插之间有缝隙,并发下仍重复;唯一约束是数据库级的原子去重。
- 重试必须复用同一个幂等键。键一变,服务端就认不出是重复,幂等彻底失效。
- 按场景选方案。创建→唯一约束,状态推进→CAS,MQ→去重表,表单→token,严格串行才上分布式锁。
- 幂等是端到端的链路。记录与业务同事务、调下游也传键、去重表设TTL——任何一环漏了都白搭。
- 幂等要用并发+重试测试验证。同请求多次执行结果一致才算数,别停留在"我以为做了"。
附:用并发测试验证幂等是否真的生效
幂等做没做对,不能靠"看代码觉得没问题",要靠并发+重试的测试逼它现形。下面这段,把"怎么测幂等"具体写了出来:
// ✓ 用并发测试验证幂等: 同一个幂等键, 多线程同时打, 只能成功创建一笔
@Test
public void test_idempotent_under_concurrency() throws Exception {
String sameKey = "idem-key-fixed-123"; // ✓ 关键: 所有线程用同一个幂等键
OrderRequest req = sampleRequest();
int threads = 20;
ExecutorService pool = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
List> futures = new ArrayList<>();
for (int i = 0; i < threads; i++) {
futures.add(pool.submit(() -> {
start.await(); // ✓ 所有线程卡在同一起跑线, 制造真并发
return createOrder(req, sameKey); // 同一个键, 并发调用
}));
}
start.countDown(); // 同时放行
// ✓ 收集所有返回的订单
Set orderIds = new HashSet<>();
for (Future f : futures) orderIds.add(f.get().getId());
// ✓ 断言1: 所有请求返回的是同一笔订单(幂等返回上次结果)
assertEquals(1, orderIds.size());
// ✓ 断言2: 数据库里只有一笔订单(没有重复创建)
assertEquals(1, orderDao.countByRequest(req));
// ✓ 断言3: 只扣了一次款
assertEquals(req.getAmount(), getTotalCharged(req.getUserId()));
}
// 还要测"串行重试"场景:
// 连续用同一个键调 5 次, 同样断言: 1 笔订单、1 次扣款、5 次都返回同一结果。
// 核心: 幂等必须用"同键并发 + 同键重试"测试验证 ——
// 断言"只创建一笔、只扣一次款、多次返回同一结果", 而不是只看代码觉得对。
这段测试,是我现在所有"关键写接口"上线前的必过关卡。它的精髓,是用 20 个线程、卡在同一条起跑线上、用同一个幂等键、同时发起调用,去人为制造出最严苛的并发;然后,用三条断言把幂等钉死:所有请求返回的是同一笔订单、数据库里只有一笔订单、款只扣了一次。除了并发,还要测串行重试:用同一个键连续调 5 次,同样断言"1 笔订单、1 次扣款、5 次都返回同一结果"。这套测试的价值,在于它把"幂等"这个抽象的承诺,变成了可被机器反复验证的、铁一般的事实:幂等做没做对,不是靠"读代码觉得应该没问题"来判断的——并发的世界里,"觉得对"和"真的对"之间,隔着无数个你想不到的时序;唯有用并发和重试,亲手把它逼到极限、再用断言确认它依然正确,你才能真正放心地,把它交给那个会无情地、反复地重试你的分布式世界。
写在最后
回头看,这场由"接口不幂等"引发的、重复下单重复扣款的事故,真正教给我的,是一个比"幂等性"本身更根本的世界观转变:从单机时代"我调用,它执行,要么成功要么失败,干净利落"的确定性世界,迈入分布式时代"消息会丢、会重、会乱序,超时是常态,'不知道成没成功'是日常"的不确定性世界。我过去写代码的所有直觉,都建立在那个美好而脆弱的单机假设之上:网络永远通、调用必有明确结果、一次请求只处理一次。可一旦跨越了网络、走进了分布式,这些假设,统统不再成立——而幂等性,正是我们为了在这个充满了"重复"与"不确定"的新世界里活下去,所必须建立的第一道、也是最基本的一道防线。所以,做分布式系统,最重要的心态转变是:从"祈祷不出错"转向"假设一定会出错(会重复、会超时、会失败),并提前设计好:当它出错时,系统依然能保持正确"。真正健壮的分布式系统,从来不是建立在"网络可靠"的幻想之上,而是建立在"网络一定不可靠"的清醒预判,和对每一种异常的周全应对之上。把"它一定会被重复调用"当成写每个关键接口时的默认前提——这,是我用一次"重复扣款"的崩溃,换来的、关于分布式架构最朴素、也最深刻的领悟。如果这篇复盘,能让你在下一次写下一个写接口之前,多问一句"它被调用两次,会怎样",那我对着那些重复数据熬的这大半天,就值了。
—— 别看了 · 2026