giftiaのblog

微服务中请求的幂等方案

· 更新于 · giftia

幂等的意思是:同一个请求无论执行一次还是多次,产生的副作用都和只执行一次一样。

在微服务架构中,网络是不可靠的。客户端超时重试、消息队列重复投递、用户手抖连点两次按钮,都会导致同一笔业务操作被重复提交。如果不做幂等,扣款可能扣两次,订单可能创建两条——这比服务挂了还危险。

先记住三句话

  1. 幂等不是性能优化,是正确性保障。 它保证在重试场景下业务结果唯一,不保证性能。
  2. 幂等键的设计决定了方案的可靠程度。 键选错了,整个幂等机制形同虚设。
  3. 没有万能方案,只有适合你场景的方案。 Redis 快但有丢失风险,数据库稳但性能有限,删 Token 简单但只适合一次性的场景。

方案一:Redis 幂等键

原理

在请求到达业务层之前,用唯一请求 ID(通常是客户端生成的 UUID)去 Redis 里查一次:

  • 键不存在 → 执行业务,执行完后 SET key 1 EX 300
  • 键已存在 → 说明是重复请求,直接返回已有的结果或拒绝
func (s *OrderService) CreateOrder(ctx context.Context, reqId string, req CreateOrderReq) (*Order, error) {
    // 1. 幂等检查
    key := fmt.Sprintf("idempotent:order:%s", reqId)
    ok, err := s.redis.SetNX(ctx, key, "processing", 5*time.Minute).Result()
    if err != nil {
        return nil, err
    }
    if !ok {
        // 重复请求,直接返回已有结果
        result, _ := s.redis.Get(ctx, key+":result").Result()
        return s.parseOrderResult(result)
    }

    // 2. 执行业务
    order, err := s.create(ctx, req)
    if err != nil {
        // 业务失败要释放锁,允许重试
        s.redis.Del(ctx, key)
        return nil, err
    }

    // 3. 缓存结果
    result, _ := json.Marshal(order)
    s.redis.Set(ctx, key+":result", result, 5*time.Minute)
    return order, nil
}

关键细节

  • SETNX 而非先查后设,避免并发下的竞态。SETNX 是原子操作。
  • 必须设过期时间,否则死 key 会撑爆内存。过期时长要大于单次请求的最长处理时间。
  • 业务失败要删除幂等键,否则客户端想重试都没机会。也可以用状态机:processing → success/failed
  • 值里存处理状态,而不是只存 1。这样并发请求进来时可以根据状态做更精细的判断。

适用场景

  • 请求量大的读写服务
  • 能接受极低概率的 Redis 数据丢失(可配合持久化策略降低风险)
  • 幂等键由客户端生成(UUID),不依赖业务数据

方案二:数据库防重表

原理

在数据库里建一张专门的防重表,利用数据库的唯一索引 + INSERT 来拦截重复请求。

防重键可以是两类:

  • 客户端 UUID / Request-Id:客户端每次请求生成一个唯一 ID,放到 Header 或请求体里传给服务端
  • 业务流水号:如 order_notransfer_id,业务本身就全局唯一

无论哪种,机制都一样——往唯一索引上插一条记录,冲突了就是重复请求。

CREATE TABLE idempotent_request (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    source      VARCHAR(32)  NOT NULL COMMENT '请求来源(接口名或服务名)',
    req_id      VARCHAR(64)  NOT NULL COMMENT '幂等键:客户端 UUID 或业务流水号',
    status      TINYINT      NOT NULL DEFAULT 0 COMMENT '0-处理中 1-成功 2-失败',
    resp_body   TEXT         COMMENT '处理结果',
    created_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_source_req (source, req_id)
);
func (s *TransferService) Transfer(ctx context.Context, reqId string, amount int64) error {
    // 1. 插入防重记录(reqId 可以是客户端 UUID,也可以是业务流水号)
    record := &IdempotentRecord{
        Source: "transfer",
        ReqID:  reqId,
        Status: 0, // 处理中
    }
    err := s.db.Create(record).Error
    if err != nil {
        if isDuplicateKeyErr(err) {
            // 唯一键冲突 → 重复请求,查已有结果返回
            return s.handleDuplicate(ctx, reqId)
        }
        return err
    }

    // 2. 执行业务
    err = s.doTransfer(ctx, reqId, amount)
    if err != nil {
        s.db.Model(record).Update("status", 2) // 标记失败
        return err
    }

    // 3. 标记成功
    s.db.Model(record).Update("status", 1)
    return nil
}

关键细节

  • 利用数据库唯一索引保证原子性INSERT 冲突就是天然的幂等判断。
  • 一定要有状态机(处理中 / 成功 / 失败),否则并发时会误判。
  • 失败的记录是删除还是标记失败? 推荐标记失败。删除后同样的 req_id 又能插入,可能导致业务被重复执行。
  • UUID vs 业务流水号怎么选?
    • 客户端传 UUID:通用性好,客户端重试时用同一个 UUID,不需要从任何地方获取流水号。适合没有天然业务流水号的接口(如新建订单前的预检)。
    • 业务流水号:无需客户端额外生成,下游服务自己能推导。适合流水号本身就全局唯一的场景(如银行转账的 transfer_id)。
    • 两者可以结合:即使有业务流水号,也可以让客户端附带 X-Request-Id 作为防重键,业务流水号只做业务关联。
  • 定时清理历史记录,避免单表过大影响写入性能。

适用场景

  • 金融交易、转账等对一致性要求极高的场景
  • 请求量适中,能承受数据库的写入压力
  • 幂等键可以是客户端 UUID,也可以是业务流水号,灵活适配

方案三:删除 Token

原理

在需要幂等的操作之前,服务端先生成一个一次性 Token 发给客户端。客户端提交请求时携带 Token,服务端先原子删除这个 Token:

  • 删除成功 → Token 有效,执行业务
  • 删除失败(Token 不存在)→ 重复请求,拒绝
func (s *PaymentService) Pay(ctx context.Context, token string, req PayReq) error {
    // 原子删除 Token
    deleted, err := s.redis.Del(ctx, "token:"+token).Result()
    if err != nil {
        return err
    }
    if deleted == 0 {
        return errors.New("重复请求或 Token 无效")
    }

    // Token 有效,执行业务
    return s.doPay(ctx, req)
}

Token 的获取接口:

func (s *PaymentService) GetToken(ctx context.Context, orderId string) (string, error) {
    token := uuid.New().String()
    key := fmt.Sprintf("token:%s", token)
    s.redis.Set(ctx, key, orderId, 10*time.Minute)
    return token, nil
}

关键细节

  • DEL 是原子操作,天然保证一次有效
  • Token 必须有过期时间,防止客户端拿到 Token 后不提交、Token 永久残留。
  • 适合创建型操作(下单、支付),不适合更新型操作。更新操作每次都需要新的 Token 不现实。
  • Token 不存业务结果,适用于不需要"返回已有结果"的场景。

适用场景

  • 表单重复提交(支付、注册、提交工单)
  • 需要"先申请、后操作"两步走流程的场景
  • 操作频率低、每次都需要新 Token 成本可控

方案四:消息队列去重

原理

在异步场景中,消息队列本身提供去重能力。主流 MQ 的实现方式:

  • Kafka:开启 enable.idempotence,Producer 给每条消息分配 PID + Sequence Number,Broker 按 PID + Seq 去重。
  • RocketMQ:消费端业务代码自己用 msgId 做去重(RocketMQ 不保证 Exactly Once,但 msgId 全局唯一)。
  • RabbitMQ:Publisher Confirm + 消费端手动 ACK,配合 Redis 存已处理的消息 ID。
// RocketMQ 消费端去重示例
func (h *OrderConsumer) Consume(ctx context.Context, msgs []*primitive.MessageExt) error {
    for _, msg := range msgs {
        key := fmt.Sprintf("mq:dedup:%s", msg.MsgId)
        ok, _ := h.redis.SetNX(ctx, key, "1", 24*time.Hour).Result()
        if !ok {
            // 已处理过,直接确认避免重复
            continue
        }
        if err := h.process(ctx, msg); err != nil {
            return err // 消费失败,MQ 会重投,SetNX 保证不会重复处理
        }
    }
    return nil
}

关键细节

  • 消费端自己兜底。即使 MQ 宣称 Exactly Once,消费端也要做去重——网络分区、Rebalance 都可能导致重复投递。
  • 去重 key 的过期时间要大于消息的最大重试窗口,否则超时后重试的消息会被当成新消息。
  • 如果消费端是批量处理,逐条 SetNX 而非整批一个 key,否则一批里部分成功部分失败无法精准去重。

适用场景

  • 异步解耦的业务链路(下单 → 发券 → 通知)
  • 需要保证消费端 Exactly Once 的场景
  • 配合事务消息实现"本地事务 + MQ"的最终一致性

方案对比

维度Redis 幂等键数据库防重表删除 TokenMQ 去重
核心机制SETNX + 过期唯一索引 + INSERTDEL 原子操作SetNX msgId
性能
一致性极低概率丢失强(ACID)极低概率丢失依赖消费端实现
幂等键来源客户端 UUIDUUID 或业务流水号服务端生成MQ 消息 ID
复杂度
适合的操作通用金融/核心链路一次性创建异步消费

常见误区

  1. 用时间戳做幂等键。 同一毫秒可能并发多笔请求,时间戳不唯一。
  2. 缓存了结果却不设过期。 内存泄漏只是时间问题。
  3. 业务失败了不释放幂等键。 客户端重试会被一直拒绝,需要人工介入。
  4. 防重表只判冲突不判状态。 场景:请求 A 插入记录、开始处理;请求 B 查到记录已存在、直接返回——但此时 A 还没处理完,B 返回了个空。查记录时至少要判断 status = 1(成功) 才返回已有结果。
  5. 把乐观锁当幂等方案用。 乐观锁解决的是并发写覆盖(两个不同操作改同一条数据),不是同一个操作的重复提交。想用版本号做幂等,需要额外配合 req_id 判断是否同一请求。

总结

你的场景推荐方案
通用 API 幂等(创建订单、发起支付)Redis 幂等键
金融交易、核心链路,一致性不可妥协数据库防重表
表单重复提交(注册、提交工单)删除 Token
异步消费(MQ 消息处理)MQ 去重(消费端 SetNX)

复杂场景可以组合使用,比如核心金融链路用数据库防重表 + Redis 幂等键做两级防护,MQ 消费端再叠加 msgId 去重。

不管选哪种方案,最核心的一句话:幂等键要选对,约束要强(唯一索引或 SETNX),状态要清晰。