微服务中请求的幂等方案
·
更新于 ·
giftia
幂等的意思是:同一个请求无论执行一次还是多次,产生的副作用都和只执行一次一样。
在微服务架构中,网络是不可靠的。客户端超时重试、消息队列重复投递、用户手抖连点两次按钮,都会导致同一笔业务操作被重复提交。如果不做幂等,扣款可能扣两次,订单可能创建两条——这比服务挂了还危险。
先记住三句话:
- 幂等不是性能优化,是正确性保障。 它保证在重试场景下业务结果唯一,不保证性能。
- 幂等键的设计决定了方案的可靠程度。 键选错了,整个幂等机制形同虚设。
- 没有万能方案,只有适合你场景的方案。 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_no、transfer_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 幂等键 | 数据库防重表 | 删除 Token | MQ 去重 |
|---|---|---|---|---|
| 核心机制 | SETNX + 过期 | 唯一索引 + INSERT | DEL 原子操作 | SetNX msgId |
| 性能 | 高 | 中 | 高 | 高 |
| 一致性 | 极低概率丢失 | 强(ACID) | 极低概率丢失 | 依赖消费端实现 |
| 幂等键来源 | 客户端 UUID | UUID 或业务流水号 | 服务端生成 | MQ 消息 ID |
| 复杂度 | 中 | 低 | 低 | 低 |
| 适合的操作 | 通用 | 金融/核心链路 | 一次性创建 | 异步消费 |
常见误区
- 用时间戳做幂等键。 同一毫秒可能并发多笔请求,时间戳不唯一。
- 缓存了结果却不设过期。 内存泄漏只是时间问题。
- 业务失败了不释放幂等键。 客户端重试会被一直拒绝,需要人工介入。
- 防重表只判冲突不判状态。 场景:请求 A 插入记录、开始处理;请求 B 查到记录已存在、直接返回——但此时 A 还没处理完,B 返回了个空。查记录时至少要判断
status = 1(成功)才返回已有结果。 - 把乐观锁当幂等方案用。 乐观锁解决的是并发写覆盖(两个不同操作改同一条数据),不是同一个操作的重复提交。想用版本号做幂等,需要额外配合
req_id判断是否同一请求。
总结
| 你的场景 | 推荐方案 |
|---|---|
| 通用 API 幂等(创建订单、发起支付) | Redis 幂等键 |
| 金融交易、核心链路,一致性不可妥协 | 数据库防重表 |
| 表单重复提交(注册、提交工单) | 删除 Token |
| 异步消费(MQ 消息处理) | MQ 去重(消费端 SetNX) |
复杂场景可以组合使用,比如核心金融链路用数据库防重表 + Redis 幂等键做两级防护,MQ 消费端再叠加 msgId 去重。
不管选哪种方案,最核心的一句话:幂等键要选对,约束要强(唯一索引或 SETNX),状态要清晰。