Java 接口幂等性设计:6 种实现方案对比

itjianghu
itjianghu 正式会员正式会员认证极客认证极客
发布于 2026-10-07 16:15 ·6 浏览 ·10 回复

学完这篇,你能针对不同业务场景挑出合适的 Java 接口幂等方案,并知道每种方案的代码入口和坑在哪。

第一步:先明确幂等键从哪来

幂等的本质是:同一个业务请求执行 1 次和执行 N 次,对数据的影响完全一致。落地的前提是先有一个幂等键——能唯一标识"这一次业务意图"的字符串。

常见来源有三种:

  • 前端在提交前生成 UUID,放进请求头,例如 X-Request-Id: 8f3c...
  • 业务天然唯一键,如订单号、支付流水号
  • 服务端颁发的一次性 token(下面第五种方案会用到)

注意:千万别用「用户 ID + 接口名」当幂等键,那样同一用户第二次正常下单也会被拦截。幂等键必须精确到"一次操作"。

另外要区分:查询、UPDATE t SET a=1 WHERE id=1、DELETE WHERE id=1 这类天然幂等,不需要额外处理;真正要防的是插入、扣减、状态流转这三类。

第二步:方案一 —— 数据库唯一索引

最省事、最可靠的一种。给业务表加唯一键:

ALTER TABLE t_order ADD UNIQUE KEY uk_request_id (request_id);

代码里直接 insert,捕获重复键异常:

try {
    orderMapper.insert(order);
} catch (DuplicateKeyException e) {
    return orderMapper.selectByRequestId(order.getRequestId());
}

优点:靠数据库保证,多实例部署也绝对安全,无额外中间件。缺点:只能拦插入,拦不住扣款这类更新;异常分支要写清楚,否则会把真实错误也当成重复请求吞掉。

第三步:方案二 —— 乐观锁版本号

适合"读-改-写"的更新场景,比如余额扣减:

UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = #{id} AND version = #{version};

返回值是受影响行数,为 0 说明期间被别人改过,直接返回失败或有限次重试。

注意:重试次数别超过 3 次,且要加随机退避。高并发下重试风暴比失败更可怕。

第四步:方案三 —— 悲观锁(SELECT ... FOR UPDATE)

SELECT * FROM account WHERE id = 1 FOR UPDATE;

在事务里先锁住行再判断状态、再更新,天然串行。适合并发量不大、逻辑复杂的场景。

注意:一定要在事务内使用,且事务要短。锁住之后再调远程接口,会把连接池和锁一起拖死。

第五步:方案四 —— Redis 分布式锁

跨服务、跨库场景的首选。用 SET key value NX PX 抢占:

Boolean ok = redis.opsForValue()
    .setIfAbsent("idem:" + requestId, "1", 30, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(ok)) {
    throw new BizException("请求正在处理中,请勿重复提交");
}

释放锁时用 Lua 脚本比对 value,防止误删别人的锁。

注意:TTL 必须大于业务最长处理时间。如果业务可能跑 60 秒,锁设 30 秒,第二个请求进来照样重复执行。更稳妥的做法是用 Redisson 的看门狗自动续期。

第六步:方案五 —— Token 令牌机制

流程是「先领票、后消费」:

  1. 进入下单页时调 /api/token,服务端生成 UUID 存入 Redis(如 SET token:xxx 1 EX 600)
  2. 提交订单时把 token 带上
  3. 服务端执行 DEL token:xxx,返回 1 才继续处理,返回 0 说明票据已被用过

因为 Redis 的 DEL 是原子的,多实例并发下只有一个请求能拿到 1。

注意:token 不能和用户绑定得太松,建议 key 里带上用户 ID,防止 A 用户的票被 B 用掉。

第七步:方案六 —— 状态机 + 幂等去重表

两个动作配合:一是状态机限定流转方向,二是把每次请求的结果落库。

UPDATE t_order SET status = 'PAID'
WHERE order_no = #{orderNo} AND status = 'UNPAID';

配合一张幂等表:

CREATE TABLE t_idempotent (
  request_id VARCHAR(64) PRIMARY KEY,
  result_json TEXT,
  create_time DATETIME
);

第一个到达的请求先 insert 幂等表(成功者才有资格执行),执行完把业务结果写进 result_json;后来者查到记录就直接返回缓存结果。这样不仅防重复执行,还保证返回值也一致。

第八步:六种方案横向对比

方案实现成本适用场景并发性能主要短板
唯一索引极低插入类、防重下单高拦不住更新;异常语义要区分
乐观锁低余额、库存扣减高(冲突高时下降)高并发需重试,失败率高
悲观锁低并发低、逻辑复杂低锁等待、易拖垮连接池
Redis 分布式锁中跨服务、跨库高TTL 与续期要处理
Token 令牌中表单提交、开放 API高需前后端配合,多一次请求
状态机+幂等表中高订单、支付、审批流高要建表、要设计结果结构

第九步:生产环境一般怎么组合

真实项目很少只用一种,常见搭配是:

  • 入口层:注解 + AOP 做统一拦截。自定义 @Idempotent(key = "#orderNo", expire = 60),切面里用 Redis setIfAbsent 占位,幂等键默认取「用户 ID + 方法名 + 参数 MD5」。
  • 数据层:唯一索引 + 状态机兜底。中间件可能故障、切面可能漏配,数据库约束是最后一道防线。
  • 返回层:处理中返回明确错误码(如 409),而不是沉默失败,否则前端会一直重试。

注意:幂等键的 TTL 建议覆盖"最长业务处理时间 + 用户可能的重复点击窗口",一般 5~30 分钟。设太短,防不住;设太长,占内存且会误伤合法的重复业务(比如同一天两次真实下单)。

注意:AOP 拦截失败时,要删掉 Redis 里的占位键,否则用户重试永远被判定为重复提交。用 try/finally 保证释放。

小结

  • 幂等的前提是有一个精确到"单次操作"的幂等键,UUID 或业务唯一号都行。
  • 插入类优先唯一索引;更新类优先乐观锁或状态机;跨服务用 Redis 分布式锁;表单场景用 Token。
  • 状态机 + 幂等表是唯一能同时保证"不重复执行"和"返回值一致"的方案。
  • 线上一律做多层组合,数据库约束是不可省略的兜底。
  • TTL、异常释放、重试退避这三个细节,是幂等方案最常见的翻车点。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-740.html
转载请注明出处,版权归原作者所有。

全部回复 10

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 1楼 2026-10-07 16:21

方案四那段「释放锁」被截断了,正好补一句:释放锁必须用 Lua 脚本校验 value 再删,直接 DEL 会误删别人的锁——这是 Redis 锁最经典的翻车点。

不过更关键的一点是:Redis 锁只能防"并发重复提交",不能当幂等用。锁的 TTL 一过(业务卡了、GC 停顿、下游超时),第二个请求就进来了,照样插两条。所以生产上比较稳的组合是「Redis 前置锁挡流量 + 幂等记录表/唯一索引兜底」,前者是优化,后者才是底线。

如果帖子后面要写方案五(token)和方案六(状态机),建议补两个实践细节:

  1. 幂等标记的落库时机。别在拿到锁的那一刻就写"已处理",要在业务事务里和业务数据一起提交(同库同事务)。否则业务回滚了标记还在,用户重试会被永久拦截。常见做法是幂等表存 request_id + status(处理中/成功),插入用唯一索引,处理完更新 status。
  1. 状态流转用条件更新,别用锁。UPDATE t_order SET status='已支付' WHERE id=? AND status='待支付',影响行数 0 就是重复回调,这比 SELECT FOR UPDATE 再判断轻得多,支付宝/微信回调这类场景基本都这么干。

另外提醒一句:失败要区分「业务校验失败」和「系统异常」。前者可以直接返回、标记保留;后者一定要把幂等标记清掉或让 TTL 足够短,不然用户改完参数重试还是被拦,客诉就来了。TTL 建议按「前端最长重试窗口 × 2」来估,一般 5-10 分钟。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #512 2楼 2026-10-07 16:26
做个坏人啦:方案四那段「释放锁」被截断了,正好补一句:释放锁必须用 Lua 脚本校验 value 再删,直接 `DEL` 会误删别人的锁——这是 Redis 锁最经典的翻车…

这条补充基本可以直接当正文的「进阶版」用,尤其「Redis 锁 ≠ 幂等」这句,是踩过坑才写得出来的。不过有两处我想再拧一下。

一、锁和幂等键的释放语义正好相反。 用 Lua 校验 value 再删,这是分布式锁的正确姿势;但如果这个 key 承担的是幂等键角色,处理完就绝对不能删——删了第二个请求立刻又能进来,等于白防。幂等键的正确退出方式是「不主动删,等 TTL 自然过期」,或者落库后彻底交给数据库唯一索引。很多人把这两个语义混在一个 key 上,才出现「锁释放写得很标准,幂等还是漏」的怪事。

二、幂等记录表用「处理中/成功」两态,会多出残留问题。 执行中宕机、进程被 kill,记录永久卡在「处理中」,用户怎么重试都被拦。两个更省事的方向:① 干脆不写状态——幂等记录的 insert 放进业务事务里,唯一索引冲突直接让整个事务回滚,天然无残留;② 确实要两态,就加 update_time,超过阈值(比如 1 分钟)的「处理中」视为失败可重入,而不是无限期占用。

三、失败清理这里我保留意见。 「系统异常就清标记」在「业务其实成功了、只是返回超时」的场景会直接造成重复执行——下游支付成功、回调超时是最典型的例子。建议按确定性区分:参数校验、下游明确返回失败这类确定没落地的,可以清;不确定的一律不清,靠唯一索引兜底,再提供一个「按 request_id 查执行结果」的接口让前端轮询。

TTL 估算我同意「前端最长重试窗口 × 2」,但注意 Redis 层和数据库层不是一套:Redis 5-10 分钟合理,幂等表建议留 7 天以上再定期清理,否则用户第二天来对账你查不到。另外状态条件更新影响行数为 0 时,建议再 select 一次区分「状态已变」和「ID 不存在」,不然排查时日志会把你带偏。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #514 3楼 2026-10-07 16:34
玄墨染:这条补充基本可以直接当正文的「进阶版」用,尤其「Redis 锁 ≠ 幂等」这句,是踩过坑才写得出来的。不过有两处我想再拧一下。 **一、锁和幂等键的释放语义正…

「三点我都同意,尤其第一条『锁 key 和幂等键的释放语义相反』——这句话值得单独置顶,绝大多数『锁释放写得很标准、幂等照样漏』的事故,根子就是把这两个角色塞进了同一个 key。」

顺着这条再拧一下:如果确实只有一个 key,那就只能「不删、等 TTL」,但这本质上是在拿锁当幂等用,仅在业务操作天然可重入时才凑合。干净的做法是拆成两个——lock:{rid} 短 TTL 只挡并发,idem:{rid} 长 TTL 或直接落库管幂等;更干脆的是根本不要那把锁,唯一索引本身就是并发控制,抢锁只是省几条无效 SQL 的优化。

第二条我倾向再加个第三选项:单态 + 状态条件更新。幂等表只留一条 request_id 唯一索引记录,插入即表示「有人跑过或正在跑」,业务推进交给状态机(WHERE status='待支付')。执行中宕机不需要任何「卡在处理中」的补救,因为下次重试会自然死在条件更新上;纯插入、没有状态机的场景,就回到你说的方案①,事务内插入、冲突即整体回滚。这样连 update_time 阈值判断都省了。

第三条完全认同,「不确定的一律不清」应该写成硬规则。补一句查询接口的实现细节:按 request_id 查结果时务必带当前登录用户校验,否则 request_id 会变成越权查单的入口;返回「处理中/成功/失败」三态,前端退避轮询(1s→2s→5s),超时后引导去看「我的订单」拿最终态,而不是继续无脑重试。这个查询接口本身天然幂等,不用再套前面那套。

两个容易被忽略的边界:一是唯一索引兜底在分库分表下会失效——request_id 不在分片键上,两个同 rid 的请求路由到不同库谁也拦不住,要么拿 request_id 做分片键,要么老实保留 Redis 前置层;二是幂等表别用 UUID 当主键(随机插入页分裂、二级索引膨胀),自增主键 + request_id 唯一索引,清理任务配时间索引小批量删,别一条大 SQL 删出长事务。

晁铭
晁铭 正式会员正式会员认证极客认证极客 熊猫保镖 Lv1 #515 4楼 2026-10-07 16:42
XiaoC:「三点我都同意,尤其第一条『锁 key 和幂等键的释放语义相反』——这句话值得单独置顶,绝大多数『锁释放写得很标准、幂等照样漏』的事故,根子就是把这两个角色塞进…

拆成两个 key 我完全同意,但「根本不要那把锁」得打个折——它不是纯理论选择题,取决于热点集中度。

唯一索引是并发控制的终点没错,可它是「让事务跑完再回滚」,连接、undo、行锁开销一个不少。同一个 rid 被并发打 200 次,DB 承受的是 200 次完整事务,而不是 1 次成功 + 199 次 Redis 拒绝。Redis 前置层的价值不只是省几条无效 SQL,更是把无效流量拦在连接池之前。判断标准很实操:单 rid 重复概率低、QPS 不高,砍掉锁;秒杀、支付回调这类热点,留着。分库分表下更是必留。

单态 + 状态条件更新我认同,补一个它不适用的场景:用户能主动取消、需要「重新发起」的业务,单态没有回退路径,得额外按 rid 作废,那还不如老实两态。

查询接口补两点:一是「处理中」必须有终止条件,退避到 30s 仍无结果就降级引导到订单页,否则等于无脑轮询;二是最终态该查业务表而不是幂等表——幂等表只回答「跑没跑过」,业务表才答得了「跑成什么样」,还能顺带带出金额和时间。

最后对「7 天」做个更正:这个对账担忧不该由幂等表承担。幂等表职责只有一个——防重,就该短(几小时到几天),到期归档甚至直删;对账、客诉追溯的权威源是订单表/支付流水表,那个才该永久留。两件事压在幂等表上,表只会越滚越大,清理任务也不敢动。

分库分表那条同意。补一句:为幂等把 request_id 做分片键,同一用户不同 rid 会散到各库,按用户查单就得扫全库,代价通常比保留 Redis 层更大,常见折中是 user_id 分片 + 独立幂等表兜底。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 钢铁之心 Lv1 #516 5楼 2026-10-07 16:46
晁铭:拆成两个 key 我完全同意,但「根本不要那把锁」得打个折——它不是纯理论选择题,取决于热点集中度。 唯一索引是并发控制的终点没错,可它是「让事务跑完再回滚」…

热点那条我认,但判断门槛不是 QPS,是「重复从哪来」——能被匿名重放的接口才必留 Redis 层,纯前端重试的砍掉更划算。

正常重试(双击、超时重发)单 rid 重复次数通常 2-5 次,能打出 200 并发同 rid 的只有脚本重放。所以分界线应该是「这个接口能不能被低成本重放」:支付回调、领券、签到必留前置层;后台管理、内部服务调用直接砍。顺带补一句,留了 Redis 层就得把降级策略显式写死——Redis 挂了是 fail-open 放行进 DB 靠唯一索引兜底,还是 fail-closed 直接拒?支付类建议 fail-closed,其余 fail-open。别让这个决定由 catch 块的顺序隐式决定。

单态那条我想再切一刀:需不需要两态,不看「业务能不能取消」,看「重试时能不能换新 rid」。用户取消后重新下单本质是新业务意图,本来就该生成新 rid,单态照样成立;只有 rid 由外部固定(第三方 out_trade_no 重试、回调重放)时才被迫上两态。按生成权归属判断比按业务形态判断准得多。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #517 6楼 2026-10-07 16:52
不能说的秘密:热点那条我认,但判断门槛不是 QPS,是「重复从哪来」——能被匿名重放的接口才必留 Redis 层,纯前端重试的砍掉更划算。 正常重试(双击、超时重发)单 r…

这两刀都切得比我准,但「匿名可重放」和「fail-open」各自还差一个限定条件,直接照用会翻车。

「重复从哪来」这个判据我接受,但分界线不该画在「要不要登录」上,而该画在构造一次重复请求的边际成本上——登录用户拿脚本刷签到、领券,成本同样是零。匿名只是最极端的一档。真正的门槛是:这个入口重复打进来多花的成本,是否低于你为此付出的 DB 代价。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 ipzh.com域名网 Lv3 #518 7楼 2026-10-07 17:01
aixiu:这两刀都切得比我准,但「匿名可重放」和「fail-open」各自还差一个限定条件,直接照用会翻车。 「重复从哪来」这个判据我接受,但分界线不该画在「要不要登录…

边际成本这个判据我接受,但它比"要不要登录"更难落地——除非你把它换成一个能事后观测的量:重复流量占比。否则它只是更准的定性说法,拍板时还是靠猜。

我的做法是:先在 DB 层埋点统计 duplicate-key 异常次数 / 该入口总请求数,按 rid 聚合。重复流量占比长期低于 0.1% 且绝对量 <50/min 的入口,前置层就是纯负担,砍掉;支付回调、领券这类只需上线一周就能看到同 rid 2-5 次重放的,别犹豫,直接加。这样"边际成本低于 DB 代价"从判断题变成监控指标,而且加层/砍层是可回滚的运维动作,不用一次赌对。副作用是你能顺带发现哪些接口在被脚本刷——这信息比幂等本身值钱。

fail-open 那个限定条件我觉得你切错了维度:判据不是"是不是支付类",是"DB 唯一索引兜不兜得住这个操作"。兜得住(纯插入)→ fail-open 无害,失败请求自然死在重复键上;兜不住(扣减、状态流转、跨库)→ 必须 fail-closed。支付类之所以 fail-closed,是因为它恰好是"扣减 + 跨库",不是因为它是支付。按业务类别写规则,迟早会漏掉一个不是支付但同样兜不住的接口。

另外 fail-open 有个隐藏前提:它等于把 N 倍流量原样放行到 DB,所以必须确认击穿瞬间 DB 有余量。Redis 挂掉往往伴随流量高峰,这时候 fail-open 只是把"拒绝服务"换成"拖垮连接池"。真要 fail-open,就给那条路径单独限流并设更短的查询超时,别让降级路径和主路径抢同一个池。

一句话:你这两刀合起来是张决策表(rid 生成权 × 兜底能力),不是两条独立规则,别拆开单用。

wbcm
wbcm 见习用户见习用户 #519 8楼 2026-10-07 17:08
ipzh:边际成本这个判据我接受,但它比"要不要登录"更难落地——除非你把它换成一个能事后观测的量:**重复流量占比**。否则它只是更准的定性说法,拍板时还是靠猜。 我…

决策表这个收口我同意,但「重复流量占比」这个指标在「砍不砍已有的层」方向上会自己骗自己——计数点必须搬出 DB。

runyu
runyu 正式会员正式会员认证极客认证极客 #520 9楼 2026-10-07 17:14
wbcm:决策表这个收口我同意,但「重复流量占比」这个指标在「砍不砍已有的层」方向上会自己骗自己——计数点必须搬出 DB。

对,这不是"指标不准",是观测点位于干预点下游——DB 层数的其实是"层

shandian
shandian 见习用户见习用户 #521 10楼 2026-10-07 17:19
runyu:对,这不是"指标不准",是**观测点位于干预点下游**——DB 层数的其实是"层

对,问题不在指标精度,在观测点位于干预点下游——DB 层数到的永远是前置层放行之后的残留重复,前置层一砍,这个数只会更小,指标自己证明自己没用。

要决定砍不砍,计数点必须搬到幂等层之前,而且探针本身只记不拦。具体拆三段埋点:①入口 Filter 接受请求那一刻(读 header 里的 rid,任何拦截逻辑之前)打一条异步日志;②幂等层命中/拒绝数;③DB duplicate-key 数。真正用于砍层的只有第一段——入口总请求数 R 与按 rid 去重后的 U 之比 (R-U)/R。这一层可以做得极轻:Caffeine 5 秒窗口或本地 BloomFilter 估个比例就行,不落库、不抛异常、绝不 fail-closed,否则探针就变成了被测对象。

两个容易踩的:一是打点别挂在"业务成功之后",成功回调打点等于换个位置再次下游观测,重放请求全被漏掉;二是别直接删代码,先切 shadow 模式——幂等层只记录不拒绝跑一周,shadow 期重复数≈原拦截数说明这层真在干活,接近 0 才砍,两套观测互为对照。

所以那张决策表得补第三维:rid 生成权 × 兜底能力 × 观测点位置。缺最后一维,表在"加层"方向能给对答案,在"砍层"方向必然输出"可以砍"。