为什么你的接口总被重复请求坑死?一次说清幂等性设计

2026-07-10 1 0

为什么你的接口总被重复请求坑死?一次说清幂等性设计

想象一下这个场景:凌晨两点,你被手机的报警电话吵醒,监控显示某用户的余额莫名少了五万块。你一边揉眼睛一边翻日志,发现是支付回调被重复触发了三次——用户网络不好,点击了三次“支付”,你的接口完美地执行了三次扣款。

恭喜你,你即将体验什么叫“人在睡袋睡,锅从天上来”。

幂等性是什么?为什么你必须关心?

幂等性(Idempotency)听起来像个学术名词,其实说人话就是:同一个请求你执行一百次,结果跟执行一次一模一样

注意这里说的是“结果一样”,不是“返回一样”。你调一次 DELETE /orders/123,删掉了订单,返回200;你再调一次,还是返回200——返回码一样,但第一次和第二次产生的副作用可完全不同(第二次什么都没删)。所以真正的幂等是:多次执行不会改变资源状态

那什么时候需要幂等?

  • 网络不稳定场景:支付回调、第三方 webhook、弱网环境下的重试
  • 用户手抖场景:按钮连击、订单提交、表单重复提交
  • 系统故障场景:服务超时重试、消息队列重复消费、分库分表数据合并

简单说,只要你的请求可能因为任何原因被执行多次,你就得考虑幂等。不然迟早有一天,财务会拿着账单找你喝茶。

那些年我们踩过的幂等性坑

坑一:扣款扣成负数

这是最经典的场景。用户余额100块,支付请求来了,扣100——正常。但网络超时了,前端重试,又扣100。你以为金额不足会拦截?不好意思,你的代码是:

// 经典的“优雅”扣款代码
def deduct_balance(user_id, amount):
    user = get_user(user_id)
    if user.balance >= amount:
        user.balance -= amount
        save_user(user)
        return True
    return False

问题在哪?没有锁,没有原子性。并发情况下两个请求同时读到 balance=100,都进了 if 判断,都扣了——用户白嫖一百块。

坑二:订单创建了八百遍

用户下单,点击了“提交”,网慢,没反应,再点,还慢,再点。终于有一天你发现某个用户有847笔待支付订单,全是同一时间创建的,就因为你的下单接口没有做幂等。

坑三:消息消费了三百遍

你用 MQ 处理异步任务,消费端代码写得挺好看:

while True:
    msg = queue.get()
    process(msg)
    msg.ack()

但如果 process 执行成功了,ack 失败了——恭喜你,下次服务重启,这条消息会被重新消费。你的优惠券被重复发放了,你的库存被重复扣减了,你的短信被重复发送了——用户收到八百条“您的订单已发货”,然后打客服电话骂你。

正确的幂等设计姿势

方案一:唯一 token 机制(最适合前端重试场景)

思路很简单:客户端在请求前先申请一个唯一的 token,后端把这个 token 和业务 ID 绑定,后续相同 token 的请求直接拦截。

# 后端示例(Python)
idempotency_store = {}  # 生产环境用 Redis

def require_idempotency_token(token: str, biz_id: str):
    """检查并存储幂等 token"""
    key = f"idem:{biz_id}:{token}"
    if redis.exists(key):
        raise DuplicateRequestError("请求已处理")
    redis.setex(key, 86400, "1")  # 24小时过期

def create_order(token: str, user_id: str, items: list):
    require_idempotency_token(token, f"order:{user_id}")
    # 正常业务逻辑
    order = Order.create(user_id=user_id, items=items)
    return order

前端只需要在提交时生成一个 UUID 放到 header 里就行了:

fetch("/api/orders", {
  method: "POST",
  headers: {
    "X-Idempotency-Token": crypto.randomUUID()
  },
  body: JSON.stringify({ items: [...] })
})

方案二:数据库唯一索引(最适合防重复插入)

对于订单创建这种场景,最简单粗暴的方式就是加唯一索引:

-- 给订单表加上业务唯一键
ALTER TABLE orders ADD UNIQUE INDEX idx_idem_key (idempotency_key);

-- 插入时直接处理
INSERT INTO orders (idempotency_key, user_id, amount)
VALUES ("order_abc123", 1001, 199)
ON DUPLICATE KEY UPDATE id=id;  -- 重复时啥也不做

有人会说:“我直接查一下有没有这条记录再插入不就行了?”不行,典型的“check-then-act”竞态条件。两个请求同时查,都说没有,都去插入,数据库里就多了两条。

方案三:分布式锁(适合高并发扣款场景)

对于余额扣减这种强一致性场景,数据库行锁是最好的选择:

# 使用数据库事务和行锁
def deduct_balance(user_id: int, amount: int) -> bool:
    with db.transaction():
        # SELECT FOR UPDATE 加行锁
        user = db.query(
            "SELECT balance FROM users WHERE id = %s FOR UPDATE",
            user_id
        ).fetchone()
        
        if user["balance"] < amount:
            return False
        
        db.execute(
            "UPDATE users SET balance = balance - %s WHERE id = %s",
            amount, user_id
        )
    return True

注意:FOR UPDATE 必须要在事务里用,而且锁定顺序要一致(如果同时锁多个用户,要按 ID 排序后依次锁定),不然会死锁。

方案四:消息队列的 exactly-once 语义

MQ 的重复消费问题,标准解法是消费前先查“处理记录表”:

def consume_message(msg):
    msg_id = msg.message_id
    
    # 幂等检查
    if redis.exists(f"processed:{msg_id}"):
        return  # 跳过已处理的消息
    
    # 业务处理
    process(msg)
    
    # 标记已处理(这里用原子操作保证不重复标记)
    redis.set(f"processed:{msg_id}", "1", ex=86400)
    msg.ack()

有人问:process 执行成功了,但 redis.set 之后、msg.ack 之前服务崩了怎么办?答案是——重试时会重复消费,但因为有 redis 检查,不会重复处理。代价是可能出现少量重复消费(用户收到两条短信),这个要看你业务的容忍度。

幂等性设计的几个原则

最后总结几条实战经验:

  1. 幂等性要设计,不要事后补救。写接口之前就想清楚这个操作是否需要幂等,是的话怎么做。
  2. 优先用唯一索引。数据库唯一索引是世界上最可靠的幂等保障,能用索引解决的别用代码逻辑。
  3. token 要足够随机。UUID 够了,别用 user_id+timestamp 这种容易被猜到的组合。
  4. 幂等存储要有过期时间。订单的幂等 token 保留7天就够了,没必要永久保留浪费内存。
  5. 读接口一般不需要幂等。GET 请求天然幂等(不改变状态),但别把读操作写成读完了还顺便更新了什么。

写在最后

幂等性这个话题,说简单也简单,说复杂也复杂。简单在于概念就一句话——“多次执行结果一样”;复杂在于不同业务场景要不同的实现方式,没有银弹。

但有一点是确定的:那些不在设计阶段考虑幂等性的系统,最后都会在某个深夜付出代价。可能是赔钱,可能是背锅,可能是被用户在社交媒体上挂出来。

所以,下次写接口之前,先问自己一句:这个操作,需要幂等吗?

如果答案是 yes,那恭喜你,今天这篇文章没白看。

相关文章

API设计血泪史:三次重写教会我的那些事
SQL优化:从30秒到30毫秒,我踩过的那些坑
Go的GC:那些教科书永远不会教你的骚操作
sync.WaitGroup:我以为我懂它,直到写出了生产事故
还在为部署AI工具秃头?来找小龙虾,39块搞定一切!
连接池泄漏:那个让我凌晨三点写检讨的bug,比失恋还难受

发布评论