从重复扣款说起:接口幂等性的四种实现与取舍
从重复扣款说起:接口幂等性的四种实现与取舍
上个月处理了一个客诉:用户点击"支付"按钮后页面卡住,下意识又点了一下,结果银行卡被扣了两次款。技术上说,这就是一个典型的幂等性缺失问题——同一个操作执行一次和执行多次,结果应该一样,但我们的支付接口没做到。
排查修复的过程里,我把项目里几种幂等方案都过了一遍,把实现方式和适用场景整理成这篇。
为什么会有重复请求
先把重复请求的来源列全,方案才有针对性:
- 用户行为:双击按钮、页面卡住后刷新重试
- 网络重试:客户端请求超时后自动重发(很多 HTTP 库默认开启)
- 服务重试:网关/负载均衡的超时重试,或者消息队列的重复投递
- 架构问题:上游服务故障转移时把请求转发到另一个实例
前三条是"同一个人带着同一个意图发了两次请求",第四条是基础设施行为。幂等设计必须同时覆盖这四条,缺一条就是线上事故的种子。
方案一:数据库唯一约束(兜底方案)
最简单也最可靠的兜底:给业务键加唯一索引。
-- 防重表:以"业务类型 + 业务单号"为唯一键
CREATE TABLE idempotent_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_type VARCHAR(32) NOT NULL,
biz_no VARCHAR(64) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_biz (biz_type, biz_no)
);
处理请求前先插入防重记录,插入失败(唯一键冲突)说明是重复请求,直接返回上一次的结果。
try:
db.execute("INSERT INTO idempotent_record (biz_type, biz_no) VALUES (%s, %s)",
"PAYMENT", order_no)
except DuplicateKeyError:
return build_previous_response(order_no) # 返回首次处理的结果
优点:强一致,数据库层面绝对防重。
缺点:每笔业务多一次写;防重记录本身要考虑清理策略;响应结果的存储需要额外设计(否则重复请求只能返回"已处理",无法返回原结果)。
适合做最后防线,而不是唯一手段。
方案二:Token 机制(前端配合)
针对用户双击/重试的场景,标准解法是"先领令牌,提交时核销":
1. 进入支付页时:GET /api/token → 返回 token=T1(存 Redis,5 分钟过期)
2. 提交支付时:POST /api/pay?token=T1
3. 服务端:DEL token 原子删除,删成功才执行业务,删不到说明重复提交
def pay(token, order_no, amount):
# Lua 保证"判断 + 删除"原子性
ok = redis.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then "
" return redis.call('del', KEYS[1]) "
"else return 0 end", 1, "pay_token:" + token)
if not ok:
raise DuplicateSubmitError()
do_payment(order_no, amount)
优点:精准拦截"同一个页面会话的重复提交",用户体验好(第二次点击直接提示"请勿重复提交")。
缺点:必须前端配合改造;token 有时效,页面停留太久会失效;对服务间重试无效——服务 A 调服务 B 的重试不会带 token。
适合作为用户交互层的第一道防线。
方案三:业务键 + Redis 的快速幂等
服务间调用的重试,用业务键做幂等更直接:
def transfer(request_id, from_acc, to_acc, amount):
# request_id 由调用方生成并保证同一次业务重试时不变
key = f"idem:transfer:{request_id}"
ok = redis.set(key, "processing", nx=True, px=10 * 60 * 1000)
if not ok:
# 可能是重试:查一下上次的处理结果
return get_transfer_result(request_id)
try:
result = do_transfer(from_acc, to_acc, amount)
redis.setex(key, 24 * 3600, json.dumps({"status": "done", "result": result}))
return result
except Exception:
redis.delete(key) # 失败要释放,允许调用方重试
raise
优点:覆盖服务间重试;处理结果可以缓存下来,重试时直接返回原结果(真正的幂等语义)。
缺点:Redis 与数据库之间存在时间窗口——set key 成功但业务没执行完时服务崩溃,key 还在,重试会被误判为重复。需要配合方案一做兜底,或者把"标记"放业务事务里一并提交。
这是最容易做错的一种:很多人直接 setnx 完就不管了,没考虑"标记成功但业务失败"的中间态。
方案四:状态机(业务天然幂等)
支付、订单这类有明确状态流转的业务,最优雅的方案是把状态机做成乐观锁:
UPDATE payment SET status = 'PAID', paid_at = NOW()
WHERE order_no = %s AND status = 'PENDING';
受影响的行数为 1 → 本次执行生效;为 0 → 状态已经不是 PENDING(可能已支付、已取消),直接查当前状态返回。无论请求来多少次,数据库状态只会向前流转一次,天然幂等,且不需要额外的存储。
优点:无额外组件、强一致、和业务状态天然绑定。
缺点:只适用于状态流转清晰的场景;复杂业务的状态机维护成本高。
实际组合:四层防线
我们最终的方案是组合拳,按请求的流向分层:
用户端(方案二 Token)→ 网关(方案三 request_id)→ 业务层(方案四 状态机)→ 数据库(方案一 唯一约束兜底)
每一层拦截的重复来源不同,互为补充。全部打穿的代价是:重复请求最终卡在唯一约束上,业务一致性有保障。
三个容易忽略的细节
细节一:幂等键的选择。 用"请求参数的 hash"做幂等键是错的——参数里只要带个时间戳就失效了。幂等键必须是业务语义上的唯一标识:支付用"订单号",转账用"request_id"(调用方在业务事务中生成并持久化)。
细节二:返回结果的幂等。 重复请求不能返回报错就算完——调用方拿到报错会继续重试,雪崩。正确做法是把首次处理的结果存下来(setex key 86400 result),重复请求返回和首次一致的结果。
细节三:失败要释放标记。 方案三的代码里,业务抛异常时必须 delete 掉幂等标记,否则用户重试一次永远进不来,只能等 key 过期。判断"哪些异常算业务失败可重试"要小心:扣款成功后通知下游失败的异常,不能释放标记。
小结
| 方案 | 拦截对象 | 强度 | 成本 |
|---|---|---|---|
| Token 机制 | 用户重复点击 | 中 | 前端改造 |
| request_id + Redis | 服务间重试 | 中 | Redis 依赖、中间态处理 |
| 状态机乐观锁 | 所有来源 | 强 | 业务改造 |
| 数据库唯一约束 | 所有来源(兜底) | 最强 | 写入放大 |
没有银弹,按业务形态组合使用。最起码的一条底线:涉及钱的接口,数据库唯一约束必须有——前面几层全漏了,它也能把事故挡在最后一毫米。
- 点赞
- 收藏
- 关注作者
评论(0)