4. 幂等性设计原则:请求去重、Idempotency Key 设计、责任划分

这章我们聊聊幂等性落地的三个核心支柱。说白了就是三件事:怎么识别重复请求、用什么东西来标记、谁该管哪一段。

我刚开始搞 Secrets Manager 接口的时候,以为幂等就是加个去重表就完事了。结果上线第一天就被打脸——客户端重试机制和 Server 端去重逻辑打架,导致同一把密钥被创建了两次。嗯,那次之后我才真正理解了这三件事必须拆开设计,再拼回一个闭环。

4.1 请求去重:不是“存一下”就完了

请求去重的本质是:同一幂等键,同一业务结果,只执行一次。但实现上有几个坑。

4.1.1 去重时机与状态机

我习惯把每个幂等请求的生命周期拆成三步:

  1. 收到请求:检查幂等键是否已存在。若存在且已成功,直接返回缓存结果;若存在且处理中,返回“正在处理”或等待;若不存在,写一条“处理中”记录。
  2. 执行业务:在事务内更新幂等记录状态为“成功”,同时保存结果。
  3. 返回响应:下游重试时再次命中步骤1,直接返回之前的结果。

看个简化版的实现思路:

// 伪代码:幂等去重中间件
func IdempotencyMiddleware(r *http.Request) {
    idempotencyKey := r.Header.Get("Idempotency-Key")
    if idempotencyKey == "" {
        // 非幂等请求放行
        return
    }
    record, err := db.GetIdempotencyRecord(idempotencyKey)
    if err == nil {
        switch record.Status {
        case "SUCCESS":
            // 直接返回已缓存的响应
            writeResponse(r, record.Response)
            return
        case "IN_FLIGHT":
            // 返回 409 或等待
            writeError(r, 409, "Request already in progress")
            return
        }
    }
    // 插入 IN_FLIGHT 记录,配合唯一索引防并发
    err = db.InsertIdempotencyRecord(idempotencyKey, "IN_FLIGHT")
    if err != nil { // 唯一索引冲突说明并发写入
        writeError(r, 409, "Duplicate request")
        return
    }
    // 进入业务逻辑...
}
注意:幂等记录的状态必须从“处理中”转换到“成功”或“失败”。如果一直处于“处理中”,重试的客户端会永远卡住。我曾经犯过这个错,结果客户端把超时时间设成了 30 秒,服务端处理用了 5 秒,但状态没及时推进——重试全堵死了。

4.2 唯一标识符(Idempotency Key)设计

Idempotency Key 就是整个机制的“钥匙”。钥匙丢了或者重复了,整个体系就崩了。

4.2.1 谁来生成?

这是个关键选择。我习惯把决策权交给客户端。为什么?

  • 客户端最容易保证全局唯一(配上 UUID v4 或 ULID)。
  • 服务端如果自己生成,重试时客户端拿不到同一个 Key,幂等性就失去了意义。
  • 客户端可以自主控制重试策略(超时、重试次数、间隔)。

4.2.2 格式与存储

维度 推荐做法 不推荐做法
格式 UUID v4 / ULID(128位随机) 时间戳+自增ID(容易冲突且可预测)
长度 36字符(含连字符) 过长或过短
传输方式 HTTP Header: Idempotency-Key 放在 Body 里(中间件解析困难)
存储类型 Redis + TTL 或 数据库唯一索引 只存内存不持久化(服务重启全丢失)
一个小建议:如果使用了 ULID,它天然带时间排序属性,可以用来做过期清理的索引。我在项目中用 Redis 存,TTL 设置成 24 小时,配合后台定时扫库清理。

4.3 服务端与客户端责任划分

这个划分如果模糊,你和同事就会互相甩锅。我画过一张边界图,你看一下:

幂等性责任边界 客户端 · 生成 Idempotency-Key (UUID) · 将 Key 放入请求 Header · 处理超时后重试(原 Key) · 根据响应状态确定是否重试 · 幂等 Key 的生成规则保证不重复 服务端 · 校验 Key 格式与唯一性 · 实现幂等记录存储(Redis/DB) · 处理中状态与并发控制 · 成功/失败状态的原子推进 · 过期清理与数据一致性 客户端负责生成和重试,服务端负责校验和去重,双方互不越界。 超时场景下客户端必须使用同一个 Key 重试,服务端必须返回相同结果。

你看这张图,左边客户端只负责生成 Key、发请求、处理超时重试;右边服务端只负责校验、存储、状态机、过期清理。中间那条线就是契约——客户端保证 Key 全局唯一,服务端保证 Key 幂等。

3.3.1 典型边界场景

我曾遇到过两个典型纠纷:

  • 场景一:客户端没有传 Key。服务端是直接拒绝,还是自动生成一个?我建议直接拒绝并返回 400。因为服务端自动生成的 Key,客户端重试时根本拿不到,幂等性等于没做。
  • 场景二:客户端重试时服务端返回 5xx。服务端该不该把幂等记录设为失败?我的做法是:如果业务还没执行(比如网络超时),不更新记录;如果业务已执行但响应发送失败,那记录已经标记为成功,重试直接返回缓存结果。这需要业务层做“幂等写入”保证。

核心原则总结

  1. 幂等 Key 由客户端生成,服务端只消费和存储。
  2. 服务端必须用唯一索引或 Redis 原子操作防止并发。
  3. 幂等记录状态机必须闭环:处理中 → 成功/失败。
  4. 服务端返回的响应必须与第一次成功执行的结果完全一致(包括状态码和响应体)。
  5. 幂等记录的 TTL 应覆盖最长的客户端重试窗口 + 时钟偏差。