幂等 ID / 版本号 / ACK 机制

一句话速记

幂等:相同操作执行多次,结果与执行一次相同。实现方式:幂等 ID(请求携带唯一 ID,服务端去重)+ 数据库唯一键(INSERT IGNORE)+ 状态机(只有合法状态转换才执行);版本号:乐观锁,每次更新 WHERE version=old 防并发覆盖;ACK 机制:确认应答保证”至少一次”投递,配合幂等保证”Effectively Once”。

通俗解释

为什么需要幂等

分布式系统中,调用方无法确认接收方是否收到请求:
  请求已收到且处理成功,但响应丢失 → 调用方重试 → 重复处理
  请求超时(网络抖动)→ 调用方重试 → 可能处理两次

幂等确保:重复调用 = 调用一次
  创建订单 × 3 → 只创建 1 个订单
  扣款 × 3 → 只扣 1 次

关键细节

1)幂等 ID(Idempotency Key)

标准模式

客户端:生成 UUID(或雪花 ID)作为请求的幂等 ID
  → 每次新业务操作生成新的幂等 ID
  → 重试时复用同一个幂等 ID

服务端:
  1. 查询幂等 ID 是否已存在(Redis 或 DB)
  2. 不存在 → 处理请求,存储幂等 ID + 结果
  3. 已存在 → 直接返回之前的结果(不重复处理)
// HTTP 接口的幂等实现
@PostMapping("/orders")
public OrderResponse createOrder(
    @RequestHeader("Idempotency-Key") String idempotencyKey,
    @RequestBody CreateOrderRequest request
) {
    // 1. 查询幂等缓存
    String cachedResult = redis.get("idempotent:" + idempotencyKey);
    if (cachedResult != null) {
        return JSON.parseObject(cachedResult, OrderResponse.class);  // 直接返回历史结果
    }
    
    // 2. 防并发重复(SETNX 原子占位)
    boolean locked = redis.setnx("idempotent:lock:" + idempotencyKey, "1");
    if (!locked) {
        // 另一个请求正在处理,等待后重查
        Thread.sleep(100);
        return getResultOrWait(idempotencyKey);
    }
    
    try {
        // 3. 执行业务
        OrderResponse response = orderService.createOrder(request);
        
        // 4. 存储结果(TTL 设置为业务允许的重复窗口,如 24h)
        redis.setex("idempotent:" + idempotencyKey, 86400, JSON.toJSONString(response));
        return response;
    } finally {
        redis.delete("idempotent:lock:" + idempotencyKey);
    }
}

幂等 ID 的存储选择

Redis(推荐):
  SETNX + EX → 原子性 + 自动过期(避免永久占用内存)
  集群模式下用 Lua 脚本保证原子
  TTL = 业务重复时间窗口(通常 24h ~ 7d)

数据库唯一键:
  idempotency_key 字段 + UNIQUE INDEX
  INSERT IGNORE INTO idempotent_log(key, result) VALUES(?, ?)
  → 重复插入直接忽略
  → 适合需要持久化幂等记录的场景(对账)

2)数据库层面的幂等

INSERT 幂等

-- 方法 1:INSERT IGNORE(忽略重复键)
INSERT IGNORE INTO orders (order_id, user_id, amount)
VALUES ('ORDER-123', 1001, 100.00);
-- 重复执行无效果(主键或唯一键冲突时忽略)
 
-- 方法 2:INSERT ... ON DUPLICATE KEY UPDATE
INSERT INTO order_status (order_id, status)
VALUES ('ORDER-123', 'PAID')
ON DUPLICATE KEY UPDATE status='PAID';
-- 幂等更新
 
-- 方法 3:先 SELECT 后 INSERT(非原子,需应用层处理重复)

UPDATE 幂等(状态机)

-- 只允许合法状态转换,天然幂等
UPDATE orders
SET status = 'PAID'
WHERE order_id = 'ORDER-123'
  AND status = 'CREATED';  -- 只有 CREATED 才能转为 PAID
-- 执行多次:第一次成功,后续 status 已是 PAID,条件不满足,更新行数=0(忽略)

3)版本号(乐观锁)

数据库乐观锁

-- 读取时获取 version
SELECT id, balance, version FROM accounts WHERE id = 1001;
-- 假设 version=5,balance=1000
 
-- 扣款:只有 version 匹配才执行
UPDATE accounts
SET balance = balance - 100,
    version = version + 1
WHERE id = 1001 AND version = 5;
-- 成功(affected_rows=1):version 变为 6
-- 失败(affected_rows=0):另一个事务已更新,需重试
 
-- 重试策略:读最新数据 → 重新校验 → 再次 UPDATE

版本号的设计

单调递增整数(最常用):
  每次成功更新 +1
  简单,判断方式:WHERE version=old_version

时间戳(慎用):
  WHERE updated_at=old_timestamp
  问题:时钟回拨可能导致 version 倒退(不安全)

Sequence 号:
  结合分布式 ID 生成器,保证全局单调递增
  适合多节点并发写同一记录

ES 的 _seq_no + _primary_term:
  见 [[04-数据库与中间件/Elasticsearch/Version 冲突与乐观并发控制]]

4)ACK 机制(消息队列场景)

三种投递语义

At-Most-Once(最多一次,可能丢失):
  发送后不等 ACK,失败不重试
  用途:日志、监控(丢了无所谓)

At-Least-Once(至少一次,可能重复):
  必须收到 ACK 才认为成功,超时重试
  → 消费端可能收到重复消息
  → 需要消费端幂等处理
  → Kafka 默认,大多数业务 MQ

Exactly-Once(恰好一次):
  At-Least-Once + 消费幂等 = "Effectively Once"(工程层面)
  Kafka Transactions + Flink 可以实现真正 Exactly-Once(流处理)

消费端 ACK 的实现

// Kafka 手动 ACK(At-Least-Once)
@KafkaListener(topics = "payments")
public void handlePayment(ConsumerRecord<String, String> record,
                           Acknowledgment ack) {
    try {
        processPayment(record.value());  // 业务处理(幂等)
        ack.acknowledge();               // 手动提交 offset(处理成功才 ACK)
    } catch (Exception e) {
        // 不 ACK → 下次重新消费
        log.error("Process failed, will retry: {}", record.key(), e);
    }
}

ACK 超时与重试

RPC 调用 ACK:
  客户端发送请求 → 等待响应(ACK)
  超时 → 重试(幂等 ID 保证重复请求不重复处理)
  
  重试策略:
    固定间隔:每 1s 重试一次(简单但可能雪崩)
    指数退避:1s, 2s, 4s, 8s...(推荐)
    Jitter(随机抖动):在指数退避基础上加随机量(防止惊群效应)

5)中心到边缘节点的下发与最终一致

场景:中心服务更新配置 → 下发到边缘节点(可能有 1000 个节点)

下发模式:
  Push(推送):中心主动推,节点 ACK,无 ACK 则重试
  Pull(拉取):节点定时拉取,中心维护版本号
  
版本号设计:
  配置版本:config_version(单调递增)
  节点启动/定期:GET /config?since_version=N → 返回最新版本和内容
  
最终一致保证:
  节点启动时全量拉取最新配置
  运行时按版本号增量拉取(减少数据传输)
  中心记录每个节点的 ack_version,超时未 ACK 主动重推
  
补偿机制:
  中心定时扫描 ack_version < latest_version 的节点
  主动 Push(重试)

延伸追问

  • Q:幂等 ID 的 TTL 到期后,重复请求还会重复处理吗? → 会。TTL 的选择基于业务的”重复时间窗口”——超过这个时间的重复请求认为是新请求。例如:支付幂等 ID TTL=24h,说明 24h 后的相同幂等 ID 会被重新处理。业务上需要保证在 24h 内不会有超出原因的重复调用。
  • Q:高并发下,SETNX 去重有并发问题吗? → Redis 单线程处理命令,SET key value NX EX ttl 是原子操作,天然无并发问题。问题在于:如果业务处理失败后要删除幂等 key(允许重试),需要注意用 Lua 脚本原子地”检查 key 是否是自己设的再删除”,避免误删他人的 key。
  • Q:乐观锁的 CAS 和 synchronized 怎么选? → 并发冲突少(大多数情况无竞争)→ 乐观锁(CAS);并发冲突多(竞争激烈,CAS 重试次数多,CPU 浪费)→ 悲观锁(synchronized/分布式锁)。数据库场景:低并发 + 写操作少 → 乐观锁(version 字段);高并发秒杀 → Redis 预扣(减少 DB 压力)+ 悲观锁。

我的记法

  • 幂等 ID:请求带唯一 ID,服务端 Redis SETNX 去重,TTL=重复窗口
  • 数据库幂等:INSERT IGNORE + 唯一键;UPDATE WHERE status=合法前置状态
  • 版本号(乐观锁):WHERE version=old,affected_rows=0 则重试
  • ACK 语义:At-Most-Once / At-Least-Once(+幂等=Effectively Once)
  • 指数退避 + Jitter 防雪崩重试
  • 一句话:「幂等 = 请求带 ID + 服务端去重,版本号 = 更新时加 WHERE version 条件」

状态

  • 已背速记
  • 能写幂等 ID 的 Redis SETNX 代码
  • 能写乐观锁的 UPDATE WHERE version=N