distributed system

什么是幂等性

#idempotency#Kafka#HTTP#Redis

什么是幂等性 Idempotency

幂等性是一个在分布式系统中非常重要的概念。

本文主要分为以下几个部分

  1. 背景 (Context): 为什么分布式系统中会出现“重复请求”?
  2. 定义 (Definition): 什么是幂等性?
  3. HTTP 协议 (Protocols): 哪些操作天生是幂等的?
  4. 解决方案 (Implementation): 如何在业务中设计幂等接口?
  5. Kafka 消费者(Consumer)端如何保证消息处理的“幂等性”?

1. 核心定义 (Core Definition)

一句话解释: 一个操作,无论执行多少次,产生的影响(Side Effect)和执行一次是一样的。

在数学中,幂等性表示为:

$f(f(x)) = f(x)$

在计算机工程中,这意味着:

用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。

生活中的例子:

  • 幂等: 电梯按钮。你按一次“上”,电梯会来;你疯狂按 10 次“上”,电梯还是只会来一次,不会来 10 部电梯。
  • 非幂等: 你的银行卡扣款。如果刷一次扣 100 元,刷 10 次扣 1000 元,这就是非幂等(如果系统没做保护的话)。

2. 为什么需要幂等性? (The "Why")

在单机系统中,函数调用很少失败。但在分布式系统 Distributed Systems中,通信是不可靠的。

最典型的场景是 “超时重试” (Timeout & Retry)

  1. 客户端发起支付请求。
  2. 服务端处理成功,扣了款。
  3. 但是,网络抖动,服务端返回“成功”的响应包在路上丢了。
  4. 客户端因为没有收到响应,以为失败了,触发了重试机制 (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 - 常用)

适用于防止用户重复提交表单。

  1. 获取 Token: 客户端在操作前,先调后端接口拿一个唯一的 Token。
  2. 提交请求: 客户端带上这个 Token 发起请求。
  3. 后端校验: 后端收到请求,去 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)端如何保证消息处理的“幂等性”?

主要包括以下几个方面

  1. 核心矛盾 (The Conflict): 为什么 Kafka 无法自动做到“只发一次”?(Kafka 的投递语义)
  2. 问题根源 (Root Causes): 重复消息具体是在哪一步产生的?
  3. 解决方案 (Solutions): 我们作为开发者,如何在 Consumer 端手动实现幂等?

1. 核心矛盾:Kafka 的“至少一次”承诺

在分布式消息队列中,有三种投递语义(Delivery Semantics):

  1. At Most Once (最多一次): 消息可能会丢,但绝不重复。
  2. At Least Once (至少一次): 消息绝不会丢,但可能会重复。(Kafka 的默认行为
  3. 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_iduuid
  • 流程:
    1. Consumer 收到消息,解析出 ID。
    2. 去 Redis 查一下:SETNX key value (Set if Not Exists)。
    3. 如果返回 True (写入成功): 说明没处理过 -> 执行业务逻辑 -> (可选) 设置过期时间。
    4. 如果返回 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,天然幂等。