Go微服务高并发设计实战:API接口幂等性与并发控制方案

高并发场景下接口出问题,多数不是性能不够,而是幂等没做:用户重复点击产生两笔订单,消息重试导致重复扣款,超时重发造成库存多扣。这篇文章以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/

(0)
小编小编
上一篇 21小时前
下一篇 21小时前

相关推荐