高并发场景下接口出问题,多数不是性能不够,而是幂等没做:用户重复点击产生两笔订单,消息重试导致重复扣款,超时重发造成库存多扣。这篇文章以Go微服务架构为背景,给出API接口幂等性的完整实现方案,以及限流、并发控制的工程做法,代码可直接套用。
为什么幂等是高并发系统的硬要求
分布式环境下,一次请求的失败重试不可避免:客户端超时重发、网关重试、消息队列at-least-once投递。只要重试存在,接口就必须保证”同一操作执行多次,结果与执行一次相同”。支付扣款、订单创建、库存扣减这类资金与资源相关的接口,不做幂等等于埋雷。
幂等令牌方案:创建类接口的标准做法
订单创建、表单提交这类非天然幂等的接口,用”先取令牌、提交带令牌、服务端核销”三步实现:
// 预取令牌:进入下单页时调用,令牌存入Redis并设过期
func (s *OrderService) IssueToken(ctx context.Context, userID int64) (string, error) {
token := uuid.NewString()
key := fmt.Sprintf("idem:token:%d:%s", userID, token)
if err := s.redis.Set(ctx, key, "1", 10*time.Minute).Err(); err != nil {
return "", err
}
return token, nil
}
// 提交时核销:用DEL的原子性保证令牌只能用一次
func (s *OrderService) SettleToken(ctx context.Context, userID int64, token string) error {
key := fmt.Sprintf("idem:token:%d:%s", userID, token)
n, err := s.redis.Del(ctx, key).Result()
if err != nil {
return err
}
if n == 0 {
return ErrDuplicateRequest // 令牌已被消费,判定重复提交
}
return nil
}
关键点:核销必须用DEL或Lua脚本一次完成”检查加删除”,先GET再DEL存在竞态窗口;令牌绑定用户维度,避免跨用户误判;业务失败时允许客户端换新令牌重试,或提供”核销回滚”接口。
数据库层兜底:唯一索引不可省略
Redis令牌是第一道防线,数据库唯一索引是最终防线。用业务键(用户ID加幂等键)建唯一约束,插入冲突即视为重复:
CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
idem_key VARCHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
UNIQUE KEY uk_idem (user_id, idem_key)
);
// Go侧捕获冲突错误,返回既有订单而非报错
err := db.Create(&order).Error
if errors.Is(err, gorm.ErrDuplicatedKey) {
return s.GetOrderByIdemKey(ctx, userID, idemKey) // 幂等返回原结果
}
两层防护的意义:Redis故障时唯一索引仍能挡住重复数据;反过来,Redis挡掉绝大部分重复流量,数据库不用频繁吃冲突异常。
查询与状态机接口:天然幂等的利用与改造
查询接口天然幂等,重点在写接口的状态语义。状态流转类接口(取消订单、审核通过)用条件更新实现幂等:
// 只允许 PENDING -> CANCELLED,重复调用影响行数为0
res := db.Model(&Order{}).
Where("id = ? AND status = ?", orderID, StatusPending).
Update("status", StatusCancelled)
if res.RowsAffected == 0 {
return ErrInvalidStateTransition // 已处理过或状态不符,幂等返回
}
转账、扣款类接口用”流水表加唯一键”:每笔操作记录流水(业务单号唯一),执行前先插流水,插成功才执行操作,重复请求插入失败直接返回原结果。
并发控制:单机限流与分布式互斥
幂等解决”重复”,限流解决”过量”。Go服务内用golang.org/x/time/rate做每实例限流,简单可靠:
var limiter = rate.NewLimiter(rate.Limit(1000), 2000) // 1000 QPS,突发2000
func RateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
w.Header().Set("Retry-After", "1")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
集群维度需要分布式限流,Redis加Lua实现滑动窗口是通用做法;同一资源的互斥(防止两个请求同时处理同一订单)用Redis SETNX加过期时间做分布式锁,或直接依赖数据库行锁(SELECT … FOR UPDATE)。热点商品库存扣减建议把扣减操作前置到Redis原子操作,异步落库,避免数据库行锁成为瓶颈:
// Lua保证"检查+扣减"原子性
local stock = redis.call('GET', KEYS[1])
if not stock or tonumber(stock) < tonumber(ARGV[1]) then
return 0
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
重试方的配合:退避与幂等键传递
服务端做完幂等,调用方也要守规矩:重试必须带指数退避(100ms、400ms、1.6s),避免雪崩式重试;重试时原样传递幂等键,不能每次重试生成新键;收到429按Retry-After等待。SDK里统一封装重试策略,业务代码不感知。
上线验证清单
幂等实现完必须验证:同一幂等键连续提交10次,业务结果只有一条记录;令牌核销后二次提交被拒;Redis停掉后重复请求仍被唯一索引拦截;压测下限流阈值附近无错误放大。幂等与并发控制不是可选优化,是高并发微服务的准入条件,两者都在,系统才经得住真实的重试与洪峰。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/go-wei-fu-wu-gao-bing-fa-she-ji-shi-zhan-api-jie-kou-mi/