为什么你的接口总被重复请求坑死?一次说清幂等性设计
想象一下这个场景:凌晨两点,你被手机的报警电话吵醒,监控显示某用户的余额莫名少了五万块。你一边揉眼睛一边翻日志,发现是支付回调被重复触发了三次——用户网络不好,点击了三次“支付”,你的接口完美地执行了三次扣款。
恭喜你,你即将体验什么叫“人在睡袋睡,锅从天上来”。
幂等性是什么?为什么你必须关心?
幂等性(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 检查,不会重复处理。代价是可能出现少量重复消费(用户收到两条短信),这个要看你业务的容忍度。
幂等性设计的几个原则
最后总结几条实战经验:
- 幂等性要设计,不要事后补救。写接口之前就想清楚这个操作是否需要幂等,是的话怎么做。
- 优先用唯一索引。数据库唯一索引是世界上最可靠的幂等保障,能用索引解决的别用代码逻辑。
- token 要足够随机。UUID 够了,别用 user_id+timestamp 这种容易被猜到的组合。
- 幂等存储要有过期时间。订单的幂等 token 保留7天就够了,没必要永久保留浪费内存。
- 读接口一般不需要幂等。GET 请求天然幂等(不改变状态),但别把读操作写成读完了还顺便更新了什么。
写在最后
幂等性这个话题,说简单也简单,说复杂也复杂。简单在于概念就一句话——“多次执行结果一样”;复杂在于不同业务场景要不同的实现方式,没有银弹。
但有一点是确定的:那些不在设计阶段考虑幂等性的系统,最后都会在某个深夜付出代价。可能是赔钱,可能是背锅,可能是被用户在社交媒体上挂出来。
所以,下次写接口之前,先问自己一句:这个操作,需要幂等吗?
如果答案是 yes,那恭喜你,今天这篇文章没白看。