在分布式系统中,网络超时、用户重复点击、消息重投递等问题难以避免,导致同一个请求可能被多次执行。对于支付、订单创建等关键操作,重复执行会造成资金损失或数据错乱。幂等性设计正是为了解决这一问题:无论请求执行多少次,对系统状态的影响都和执行一次相同。
为什么需要幂等性?
以下场景极易引发重复请求:
- 支付回调:支付平台因未收到成功响应而重复通知。
- 用户重试:客户端超时后自动重发,或用户手动刷新页面。
- 消息队列:至少一次投递语义下,消息可能被多次消费。
如果没有幂等控制,轻则产生重复订单,重则导致重复扣款。
通用实现方案
1. 唯一标识 + 去重表
为每个请求生成全局唯一的业务标识(如订单号、请求 ID),在处理前先查询去重表。若已存在则直接返回成功,否则执行业务并记录标识。
CREATE TABLE idempotent_record (
idempotent_key VARCHAR(64) PRIMARY KEY,
status TINYINT NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
处理流程:
- 尝试插入
idempotent_key,若主键冲突则说明已处理过。 - 若插入成功,执行业务逻辑。
- 业务完成后更新记录状态(可选)。
注意:去重表需与业务操作在同一个事务中,或使用分布式事务保证一致性。
2. 数据库唯一约束
利用数据库的唯一索引防止重复数据。例如订单表的 order_no 唯一,重复插入会抛出异常,捕获后视为已处理。
3. 状态机
对于有状态流转的业务(如订单:待支付 → 已支付 → 已完成),只允许特定状态下的操作。例如支付回调时,仅当订单状态为“待支付”才更新为“已支付”,否则直接返回成功。
// 伪代码示例
int updated = orderMapper.updateStatus(orderId, "待支付", "已支付");
if (updated == 0) {
// 已经处理过,直接返回成功
return SUCCESS;
}
4. 分布式锁
在并发场景下,使用 Redis 等实现分布式锁,确保同一请求串行执行。但锁只能防止并发重复,不能防止重试导致的重复,需结合唯一标识使用。
支付场景实战
以微信支付回调为例:
- 支付平台发送回调通知,携带
out_trade_no(商户订单号)。 - 商户系统收到通知后,先查询本地订单状态。
- 若订单已是“已支付”,直接返回成功,避免重复处理。
- 若订单为“待支付”,则更新订单状态并记录支付流水,整个过程需保证原子性。
关键点:
- 回调接口必须支持幂等,因为支付平台会多次通知直到收到成功响应。
- 返回给支付平台的响应必须明确,避免因响应模糊导致更多重试。
消息重复消费
消息队列(如 Kafka、RabbitMQ)通常保证至少一次投递,消费者需自行实现幂等。常用方法:
- 为每条消息分配唯一
message_id,消费前检查是否已处理。 - 将消息 ID 存入数据库或 Redis,处理成功后标记。
- 结合业务状态判断,例如更新订单状态时使用条件更新。
# 伪代码:Redis 去重
def consume(message):
if redis.setnx(f"msg:{message.id}", 1):
process(message)
else:
# 已处理,直接确认
ack(message)
总结
幂等性设计没有银弹,需根据业务特点选择合适方案:
- 唯一标识 + 去重表:通用性强,适合大多数场景。
- 数据库唯一约束:简单可靠,但需处理异常。
- 状态机:适合有状态流转的业务。
- 分布式锁:解决并发问题,但需注意锁粒度。
无论哪种方案,核心都是为每个请求赋予唯一身份,并确保重复请求不会改变业务状态。在支付、订单等关键路径上,幂等性是系统稳定性的基石。