3. 幂等性原理:幂等的数学定义、HTTP 幂等性、业务幂等性设计
聊到 Exactly Once,绕不开的一个核心概念就是幂等性。说白了,幂等性就是「同一个操作,无论执行多少次,结果都一样」。我见过不少团队在实现消息不丢不重时,把大量精力花在去重表、分布式锁上,结果发现最根本的问题——接口本身不幂等——反而被忽略了。
今天我们就从数学定义开始,一路聊到 HTTP 协议里的幂等性,最后落到业务代码里怎么设计。嗯,这部分内容,我个人觉得是整门课最值得反复琢磨的。
3.1 幂等的数学定义
先看数学上的定义。幂等(Idempotent)这个概念最早来自抽象代数。对于一个二元运算 f,如果满足:
f(f(x)) = f(x)
那我们就说 f 是幂等的。举个例子,取值 abs(x),你算一次是 5,算一百次还是 5。这就是幂等。
放到计算机系统里,这个定义稍微扩展了一下。我们通常说:一个操作如果被执行多次,产生的副作用与执行一次相同,那它就是幂等的。
核心要点:幂等性关注的是「副作用」,不是「返回值」。同一个幂等接口,每次返回的结果可能不同(比如返回当前时间),但只要它不改变系统状态,就算幂等。
我记得有一次在评审支付接口设计时,团队争论了很久「查询接口算不算幂等」。其实很简单:查询不改数据,天然幂等。真正要小心的是那些「看起来只查一次,实际上偷偷写了日志」的接口。
3.2 HTTP 幂等性
HTTP 协议对幂等性有明确的定义。RFC 7231 里把 HTTP 方法分成了几类。我整理了一张表,方便你对照:
| HTTP 方法 | 是否幂等 | 说明 |
|---|---|---|
| GET | 是 | 只读,不改变资源状态 |
| HEAD | 是 | 同 GET,只返回响应头 |
| OPTIONS | 是 | 查询服务器支持的选项 |
| DELETE | 是 | 删除一次和删除多次,结果一样 |
| PUT | 是 | 全量替换,多次执行结果一致 |
| PATCH | 否 | 部分更新,多次执行可能不同 |
| POST | 否 | 创建资源,每次可能产生新资源 |
这里有个容易踩的坑:DELETE 是幂等的,但实现不当就不幂等了。你想想看,第一次 DELETE 返回 200,第二次返回 404,从客户端角度看,两次响应不一样。但 HTTP 协议定义的幂等性,看的是「服务器端资源的状态」,不是响应码。第一次删掉了资源,第二次资源已经不存在了——资源状态没变,所以 DELETE 依然是幂等的。
我曾经踩过的坑:在设计 RESTful API 时,把 DELETE 接口实现成了「先检查是否存在,不存在则返回 404」。这本身没问题,但如果在 DELETE 里加了「删除成功发送通知」的逻辑,那第一次和第二次的副作用就不一样了。幂等性要求副作用一致,通知只能发一次。
再来说说 PUT 和 POST 的区别。PUT 是全量替换,你传什么,资源就变成什么。多次 PUT 同一个请求体,结果完全一样。POST 则不同,它通常用来创建资源,每次调用都会产生一个新 ID。所以 POST 天然不幂等。
那 PATCH 呢?PATCH 是部分更新,理论上可以设计成幂等的。比如「将字段 A 设置为 5」,执行多次结果一样。但如果是「将字段 A 增加 1」,那就不幂等了。我个人习惯:能用 PUT 就别用 PATCH,除非你明确知道自己在做什么。
3.3 业务幂等性设计
HTTP 协议层面的幂等性只是基础。真正难的是业务幂等性——在分布式系统里,同一个请求可能因为网络重试、消息重复消费等原因,被后端处理多次。这时候,接口本身就算声明了幂等,业务数据也可能出问题。
业务幂等性设计,核心就一句话:让重复请求不产生重复的副作用。具体怎么做?我总结了四种常见方案。
3.3.1 全局唯一 ID + 去重表
这是最直接的做法。每次请求带一个全局唯一的业务 ID(比如订单号、流水号),服务端在处理前先查去重表。如果 ID 已经存在,直接返回上次的结果;如果不存在,才执行真正的业务逻辑。
// 伪代码示例
public Response handleRequest(Request req) {
String idempotentKey = req.getIdempotentKey();
// 1. 查去重表
IdempotentRecord record = idempotentDAO.getByKey(idempotentKey);
if (record != null) {
// 已处理过,直接返回
return record.getResponse();
}
// 2. 执行业务逻辑
Response resp = doBusiness(req);
// 3. 写入去重表
idempotentDAO.save(idempotentKey, resp);
return resp;
}
这里有个细节:去重表的写入和业务逻辑必须在同一个事务里,否则可能出现「业务成功、去重记录没写」的情况。我在项目中就遇到过这种问题,后来改用事务消息或者本地消息表来解决。
3.3.2 乐观锁 / 版本号
适合更新场景。每次更新时带上当前版本号,数据库在更新时检查版本号是否匹配。如果版本号变了,说明已经被其他请求更新过,当前请求直接忽略。
UPDATE account
SET balance = balance - 100, version = version + 1
WHERE id = 123 AND version = 5
这个 SQL 本身就是幂等的——执行一次扣 100,执行两次因为版本号不匹配,第二次不会生效。嗯,这里要注意:乐观锁依赖数据库的行锁,在高并发下可能会有大量重试。我一般建议在冲突概率低于 5% 的场景下使用。
3.3.3 状态机
很多业务场景有明确的状态流转。比如订单状态:待支付 → 已支付 → 已发货 → 已完成。每个状态只能向前流转,不能回退。利用这个特性,我们可以实现天然的幂等性。
我的经验:状态机配合幂等性设计,效果非常好。比如支付回调,不管来几次,只要订单状态已经是「已支付」,就直接返回成功。不需要额外的去重逻辑,状态本身就是最好的去重依据。
3.3.4 机制
适用于防止表单重复提交。服务端在渲染页面时生成一个 ,存到 Redis 里并设置过期时间。客户端提交时带上这个 ,服务端先检查 是否存在。存在则删除 并执行业务;不存在则说明是重复提交,直接拒绝。
// 获取
String = UUID.randomUUID().toString();
redis.setex(":" + , 300, "1");
// 提交时校验
String = request.getParameter("");
Boolean exists = redis.del(":" + );
if (exists == null || !exists) {
// 重复提交或 过期
return "请勿重复提交";
}
// 执行业务...
这里用 redis.del 而不是 redis.get,是因为 DEL 是原子操作,可以防止并发问题。你想想看,如果用 GET 再判断,两个请求可能同时读到 存在,然后都去执行业务——那就破功了。
3.4 幂等性设计的核心原则
说了这么多,最后总结几条我这些年摸爬滚打总结出来的原则:
- 接口设计时就考虑幂等,不要等出了问题再补。补丁式的幂等方案往往漏洞百出。
- 幂等 Key 要全局唯一,并且要能抗重试。UUID 是最简单的选择,业务流水号也可以。
- 去重和业务逻辑要在一个事务里,否则会出现「半幂等」状态。
- 不要过度设计。读接口天然幂等,不需要额外加锁。只有写接口才需要考虑。
- 幂等性不是银弹。它解决的是重复请求的问题,解决不了数据一致性的全部问题。后面我们会聊到分布式事务和补偿机制,那才是终极方案。
下面这张图,是我对幂等性设计的一个整体梳理。你可以把它当作一个决策树来用:
最后说一句:幂等性设计没有银弹。不同的业务场景,适合的方案不一样。我个人习惯是:创建操作用去重表,更新操作用乐观锁,有状态流转的用状态机。 机制更多是前端防重复提交,后端不能完全依赖它。
嗯,这一章的内容就到这里。幂等性是 Exactly Once 的基石,理解透了,后面聊分布式事务和消息可靠性时,你会轻松很多。