简介:幂等性是分布式系统保证数据一致性的基础能力。本文从幂等的概念本质与重复请求的典型来源出发,系统梳理入口层 Token 校验、业务层唯一标识与分布式锁、数据层唯一索引、消息消费去重四层防护方案,并结合关键代码示例与选型对比,帮助读者构建可落地的幂等设计体系,守住数据一致性底线。
幂等(Idempotency)源自数学概念,指函数满足 f(f(x)) = f(x),即重复执行与执行一次的效果相同。引入软件工程后,其工程含义可以概括为:同一操作被执行一次与被执行多次,对系统状态的影响以及对外返回的结果保持一致。
落到接口设计的语境中:调用方可能因为各种原因发出多次语义相同的请求(重复点击、超时重试、消息重投),服务端必须保证这些请求中只有一次真正产生业务效果——只创建一笔订单、只扣一次款——并且重复请求的响应与首次请求一致或语义等价。
有两个关键点常被误解,需要特别强调:
从协议语义看,HTTP 的 GET、PUT、DELETE 天然幂等,POST 则不幂等。而实际业务中的核心写操作(下单、支付、核销)绝大多数属于“非天然幂等”操作,必须依靠显式设计来获得幂等能力。
在动手设计之前,先弄清幂等问题的全部来源,才能避免防护盲区:
结论很明确:在分布式环境下,重复请求无法被彻底消除,幂等必须作为系统性的设计目标,而不是寄希望于“重试很少发生”。
任何单一手段都存在盲区。成熟的方案是分层设防、逐级兜底:入口层拦误操作,业务层拦重试与并发,数据层以强约束做最终兜底,消息消费场景则单独建立去重机制。下面逐层展开。
工作流程:
为什么必须原子? 如果把“读取 token”和“删除 token”拆成两步执行,两个并发请求可能同时通过读取校验、随后都被放行,防护失效。而缓存服务端执行脚本期间不会插入其他命令,把校验与删除放进同一段脚本即可消除竞态窗口:
-- 伪代码:原子校验并消费 tokenif cache_get(tokenKey) == userId thencache_del(tokenKey) -- 消费后立即删除,保证一次性return 1 -- 校验通过,放行endreturn 0 -- token 不存在或已被使用
优点:实现简单、性能开销小,能有效防止用户误操作导致的重复点击。
局限:token 与具体业务内容没有绑定,无法识别“换了新 token 的同一业务重试”,也挡不住恶意刷接口。因此它只能作为第一道防线,不能独立承担幂等的正确性。
设计核心:不依赖 token,而是用业务本身构造唯一键做幂等控制。例如规则“同一用户 5 秒内只能对同一场次发起一次购买请求”,其键的构成 = 用户维度 + 业务维度(+ 时间窗口)。
典型实现是“写入即成功”的原子占位(set-if-absent 语义):写入成功说明是首次请求,写入失败说明存在进行中的相同请求:
// 伪代码String lockKey = "order:lock:" + userId + ":" + sessionId;// 原子指令:键不存在时才写入,并设置 5 秒过期Boolean locked = cacheClient.setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);if (!locked) {throw new BizException("正在下单中,请勿重复提交");}try {doCreateOrder(req); // 执行下单核心逻辑} finally {cacheClient.delete(lockKey); // 无论成败,最终释放}
实践要点:
局限:分布式锁本质是“降低重复处理概率”,当业务耗时超过锁的过期时间时仍可能被穿透,因此同样需要数据层兜底。
无论上游如何拦截,最终落库这一步必须由强约束保证。数据库唯一索引由存储引擎强制实施,不依赖任何应用层判断,是所有手段中可靠性最高的一层。
订单表设计示例:
CREATE TABLE t_order (id BIGINT PRIMARY KEY,order_no VARCHAR(64) NOT NULL,user_id BIGINT NOT NULL,request_id VARCHAR(64) NOT NULL,UNIQUE (order_no), -- 订单号唯一UNIQUE (user_id, request_id) -- 核心幂等约束:同一用户同一请求仅允许一条);
幂等插入逻辑:
// 伪代码:即使重复消费、重复重试,数据库层也会拒绝try {orderMapper.insert(order);} catch (DuplicateKeyException e) { // 唯一键冲突异常(不同框架类名略有差异)log.warn("重复请求,返回已有订单");// 关键:不抛错退出,而是查出已有订单返回,保证重复请求的响应与首次一致return orderMapper.selectByUserIdAndRequestId(userId, requestId);}
两个关键点:
补充:对于状态流转类场景(如订单支付),条件更新(状态机)是同属数据层的常用幂等手段:
UPDATE t_order SET status = 'PAID'WHERE order_no = ? AND status = 'UNPAID';
影响行数为 0 即说明该订单已被处理,直接返回成功即可。乐观锁版本号方案原理相同:更新时带上版本条件,重复请求自然匹配失败。
消息队列“至少一次”的投递语义决定了消费端幂等是标配能力,而非可选项。实现要点:
// 伪代码public void onMessage(Message msg) {String bizKey = msg.getBusinessKey(); // 业务唯一键,如 request_idString consumedKey = "mq:consumed:" + bizKey;// 1. 消费前占位:24 小时内视为已消费Boolean isNew = cacheClient.setIfAbsent(consumedKey, "1", 24, TimeUnit.HOURS);if (!isNew) {log.warn("消息已消费,跳过:{}", bizKey);return;}// 2. 执行业务,数据库唯一索引兜底try {createOrder(msg.getBody());} catch (DuplicateKeyException e) {log.warn("数据库幂等拦截:{}", bizKey); // 订单已存在,属重复消息} catch (Exception e) {cacheClient.delete(consumedKey); // 业务失败,删除占位以允许重试throw e;}}
| 防护层 | 防护对象 | 可靠性 | 主要局限 |
|---|---|---|---|
| 入口层 Token | 用户误操作重复点击 | 中 | 无法防网络重试与恶意调用 |
| 业务层唯一标识 + 锁 | 网络重试、并发提交 | 中高 | 锁过期等极端情况可能穿透 |
| 数据层唯一索引 | 所有最终落库的重复 | 最高 | 仅覆盖落库动作,需配合冲突处理返回已有结果 |
| 消息消费去重 | 消息重复投递 | 中高 | 占位与业务执行非原子,需数据库兜底 |
选型建议:
幂等设计的本质可以浓缩为一句话:用稳定唯一的标识标记每一次业务意图,让重复请求收敛到同一个结果。
实践中不必纠结“哪一种方案最好”,而应按照“入口层拦误操作 → 业务层拦重试与并发 → 数据层唯一索引最终兜底 → 消息层单独去重”的思路分层设防;同时牢记两点:重复请求要返回首次结果而非报错,正确性永远交给强约束而非概率性手段。把幂等当作分布式系统的默认设计项,而不是事后补丁,才能在重试、故障与并发成为常态的环境下,守住数据一致性的底线。