什么是幂等性
什么是幂等性 Idempotency
幂等性是一个在分布式系统中非常重要的概念。
本文主要分为以下几个部分
- 背景 (Context): 为什么分布式系统中会出现“重复请求”?
- 定义 (Definition): 什么是幂等性?
- HTTP 协议 (Protocols): 哪些操作天生是幂等的?
- 解决方案 (Implementation): 如何在业务中设计幂等接口?
- Kafka 消费者(Consumer)端如何保证消息处理的“幂等性”?
1. 核心定义 (Core Definition)
一句话解释: 一个操作,无论执行多少次,产生的影响(Side Effect)和执行一次是一样的。
在数学中,幂等性表示为:
$f(f(x)) = f(x)$
在计算机工程中,这意味着:
用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
生活中的例子:
- 幂等: 电梯按钮。你按一次“上”,电梯会来;你疯狂按 10 次“上”,电梯还是只会来一次,不会来 10 部电梯。
- 非幂等: 你的银行卡扣款。如果刷一次扣 100 元,刷 10 次扣 1000 元,这就是非幂等(如果系统没做保护的话)。
2. 为什么需要幂等性? (The "Why")
在单机系统中,函数调用很少失败。但在分布式系统 Distributed Systems中,通信是不可靠的。
最典型的场景是 “超时重试” (Timeout & Retry):
- 客户端发起支付请求。
- 服务端处理成功,扣了款。
- 但是,网络抖动,服务端返回“成功”的响应包在路上丢了。
- 客户端因为没有收到响应,以为失败了,触发了重试机制 (Retry Mechanism),再次发送了一模一样的支付请求。
如果接口没有幂等性,系统就会再次扣款,导致严重的生产事故(Double Charge)。
3. HTTP 方法与幂等性 (HTTP Context)
在设计 REST API 时,不同的 HTTP Method 对幂等性的要求不同:
| HTTP Method | 幂等性 (Idempotent) | 描述 | | --------------- | ----------------------- | ------------------------------------------------------------------------------------------------------- | | GET | ✅ 是 | 读取资源。读 1 次和读 10 次,数据本身不会变。 | | PUT | ✅ 是 | 更新资源(全量)。把 ID=1 的名字改为 "Tom"。无论改多少次,最后结果都是 "Tom"。 | | DELETE | ✅ 是 | 删除资源。删 1 次文件没了;再删 10 次,文件还是“没了”的状态(虽然可能返回 404,但服务器资源状态未变)。 | | POST | ❌ 否 | 新增资源。POST 1 次创建一笔订单;POST 10 次可能创建 10 笔不同的订单。 |
所以常见的问题是“如何把 POST 接口做成幂等的?”
4. 常见的实现方案 (Implementation Patterns)
如果不加控制,POST 是不幂等的。我们需要通过技术手段强制它幂等。以下是三种最通用的方案:
A. 数据库唯一主键/索引 (Database Unique Constraint)
最简单粗暴的方法。
- 原理: 利用数据库的
UNIQUE KEY约束。 - 做法: 比如在插入订单时,将“用户 ID + 时间戳”或者“前端生成的 UUID”作为唯一索引。
- 结果: 第二次重复请求插入时,数据库会抛出
DuplicateKeyException,代码捕获这个异常并直接返回“成功”即可。
B. Token 机制 (Token Based - 常用)
适用于防止用户重复提交表单。
- 获取 Token: 客户端在操作前,先调后端接口拿一个唯一的 Token。
- 提交请求: 客户端带上这个 Token 发起请求。
- 后端校验: 后端收到请求,去 Redis 里查这个 Token 是否存在。
- 如果存在:删除 Token,执行业务逻辑。
- 如果不存在(已被删过):说明是重复请求,直接报错或返回之前的操作结果。
- (注意:这里涉及“先删 Token 还是先执行业务”的原子性问题,通常使用 Redis 的 Lua 脚本保证原子性)
C. 状态机幂等 (State Machine)
适用于订单状态流转。
- 业务逻辑: 订单状态流转必须是有序的,例如
Unpaid->Paid。 - SQL 写法:
UPDATE orders SET status = 'Paid' WHERE id = 123 AND status = 'Unpaid'; - 结果: 如果第一次更新成功了,状态变成了
Paid。重试的第二次请求再来执行这条 SQL,因为条件status = 'Unpaid'不满足,更新行数为 0,从而天然保证了幂等。
Kafka 消费者(Consumer)端如何保证消息处理的“幂等性”?
主要包括以下几个方面
- 核心矛盾 (The Conflict): 为什么 Kafka 无法自动做到“只发一次”?(Kafka 的投递语义)
- 问题根源 (Root Causes): 重复消息具体是在哪一步产生的?
- 解决方案 (Solutions): 我们作为开发者,如何在 Consumer 端手动实现幂等?
1. 核心矛盾:Kafka 的“至少一次”承诺
在分布式消息队列中,有三种投递语义(Delivery Semantics):
- At Most Once (最多一次): 消息可能会丢,但绝不重复。
- At Least Once (至少一次): 消息绝不会丢,但可能会重复。(Kafka 的默认行为)
- Exactly Once (精确一次): 消息既不丢也不重复。(很难实现,性能开销大,通常需要配合事务)
结论:
因为 Kafka 为了保证数据不丢失,默认保证的是 At Least Once。这意味着,消费者代码必须能够承受处理重复消息的情况。幂等性是消费者的责任,而不是 Kafka Broker 的责任。
2. 重复消息是怎么产生的? (Root Causes)
在 Kafka 中,重复通常发生在两个位置:
A. 生产者端 (Producer) - 发送失败重试
- 场景: Producer 发送消息给 Broker,Broker 存好了,回传 ACK。
- 故障: 网络抖动,ACK 丢了。
- 结果: Producer 以为发送失败,触发自动重试(Retry),于是 Broker 里收到了两条一样的消息。
- (注:Kafka 0.11+ 引入了
enable.idempotence=true配置可以解决这个问题)
B. 消费者端 (Consumer) - 提交 Offset 失败 (最常见)
- 场景: Consumer 拉取了一批消息 -> 业务逻辑处理完毕(比如写了数据库) -> 准备提交 Offset。
- 故障: 就在提交 Offset 之前,Consumer 宕机了,或者发生了 Rebalance(重平衡)。
- 结果: 新的 Consumer 接手后,读取到的 Offset 还是旧的,于是把刚才那批已经处理过的消息又拉了一遍。
3. Consumer 端幂等落地方案
既然 Consumer 收到重复消息是不可避免的,我们需要在业务逻辑层建立防线。
方案一:基于 Redis 的去重表 (Deduplication Table)
最通用的方案,适用于高并发场景。
- 前置条件: 每条消息体里必须有一个全局唯一 ID (Business Key / Message Key),例如
order_id或uuid。 - 流程:
- Consumer 收到消息,解析出 ID。
- 去 Redis 查一下:
SETNX key value(Set if Not Exists)。 - 如果返回 True (写入成功): 说明没处理过 -> 执行业务逻辑 -> (可选) 设置过期时间。
- 如果返回 False (写入失败): 说明 Key 已存在 -> 直接丢弃消息或记录日志。
这里有一个边缘情况 (Edge Case)。如果 Redis 写成功了,但业务逻辑挂了怎么办?
- 优化: 把 Redis 的操作放在业务逻辑成功之后?不行,那样防不住并发。
- 正解: 这是一个典型的分布式事务问题。通常为了简单,我们接受“业务执行完再写 Redis 标记”,或者使用数据库的事务表方案(方案二)。
方案二:数据库唯一索引 + 事务 (DB Transaction - 最稳健)
如果你的业务逻辑主要是写数据库,这是最强一致性的方案。
- 原理: 创建一张
processed_messages表,将 Message Key 设为唯一索引。 - 流程(在一个事务中):
BEGIN; -- 1. 尝试插入去重表 INSERT INTO processed_messages (msg_id, created_at) VALUES ('msg_123', NOW()); -- 2. 如果上面报错 DuplicateKeyException,直接回滚,视为重复消费 -- 3. 如果成功,执行真正的业务更新 UPDATE user_balance SET amount = amount - 100 WHERE uid = 1; COMMIT; - 优点: 数据库事务保证了“记录状态”和“业务操作”原子性绑定,不会出现 Redis 方案中两边不一致的情况。
方案三:乐观锁 (Optimistic Locking)
适用于更新数据的场景。
- 原理: 消息中携带数据的“版本号” (Version)。
UPDATE product_stock SET count = count - 1, version = version + 1 WHERE id = 100 AND version = <消息里的版本号>; - 结果: 如果该版本号已经被消费过(版本号已升),这条 SQL 影响行数为 0,天然幂等。