支付级接口幂等性设计:防重放与防抖
支付级接口幂等性设计:防重放与防抖
Section titled “支付级接口幂等性设计:防重放与防抖”在微服务环境中,有一个墨菲定律:只要你的接口允许重试,它就一定会被重试。
原因有很多:
- 用户在地铁里网络不好,点击“提交订单”后没反应,于是他疯狂连点了 5 次。
- 前端的 Nginx 或者是你们自己配置的 Feign/Ribbon 客户端,在遇到网络超时(Read Timeout)时,触发了内部的自动重试机制。
如果你的接口不是**幂等(Idempotent)**的,即“执行一次和执行一百次产生的影响不同”,那么这 5 次请求打过来,用户就会被扣掉 5 笔钱,系统直接面临严重的资损客诉。
1. 哪些操作天然幂等?哪些极其危险?
Section titled “1. 哪些操作天然幂等?哪些极其危险?”在动手做架构前,先盘点你的 CRUD:
SELECT(读):天然幂等。查一次和查一万次,数据库没变化。DELETE(删):天然幂等(通常情况下)。删一次记录没了,再删一万次还是没有。UPDATE(改):- 绝对值更新(幂等):
UPDATE user SET age = 18 WHERE id = 1。执行无数次结果一样。 - 相对值更新(极度危险):
UPDATE user SET balance = balance - 100 WHERE id = 1。重试多少次就扣多少钱!
- 绝对值更新(幂等):
INSERT(增):极度危险。每次执行都会多出一条数据。
2. 方案一:数据库唯一索引(兜底神器)
Section titled “2. 方案一:数据库唯一索引(兜底神器)”这是最简单、最粗暴、但也是最可靠的一道物理防线。
特别适合用于防范 INSERT 导致的重复数据。
场景:用户创建订单。
我们在数据库 orders 表中,除了主键 ID,再建一个唯一约束索引(Unique Index),字段比如是 order_serial_no(由前端或者上游系统在发起请求前生成并传过来的唯一流水号)。
ALTER TABLE orders ADD UNIQUE INDEX udx_serial_no (order_serial_no);当并发的 5 个插入请求同时到达时,虽然它们都通过了业务校验,但在落盘的一瞬间,MySQL 会依靠底层的 B+ 树锁机制,让第一个请求成功,让后面 4 个请求直接爆出 DuplicateKeyException(主键冲突)。
你在 Java 代码里捕获这个异常,友好地返回:“订单已在处理中,请勿重复提交”。
3. 方案二:Token 机制(Redis 防抖)
Section titled “3. 方案二:Token 机制(Redis 防抖)”唯一索引只能保落库,如果你的接口不仅有落库,还要调别人的外部接口(比如发邮件、调银行通道),那你必须在请求刚进系统时就把它们拦住。
这就是经典的 Token 防重放机制(PRG 模式变种)。
- 申请 Token:用户进入提交页面前(或页面加载时),前端先调用后端 API 获取一个防重 Token(UUID),后端将其存入 Redis(设置 5 分钟过期)。
- 携带 Token 提交:用户点击提交按钮,带着业务数据和这个 Token 一起发往后端。
- Redis 拦截:后端收到请求后,立刻去 Redis 里执行一段原子的 Lua 脚本或者利用 Redis 单线程特性执行删除:
// 使用 Redis 的 delete 操作,只有真正删除了存在的 key 才返回 trueBoolean isSuccess = redisTemplate.delete(token);if (!isSuccess) {throw new BusinessException("请勿重复点击提交!");}
解析:5 个并发请求带着同一个 Token 到来,只有冲在最前面的那个请求能成功把 Token 从 Redis 里删掉并拿到 true,后续 4 个请求在删的时候发现 Token 已经没了,直接被拦截在 Controller 门外。
4. 方案三:状态机 + 乐观锁(高并发更新)
Section titled “4. 方案三:状态机 + 乐观锁(高并发更新)”这招专门用来对付前面提到的极度危险的相对值 UPDATE。
在电商和支付链路里,订单或账户必定有一个“状态(Status)”或者“版本号(Version)”。
业务场景:我们要把订单状态从 UNPAID(未支付)改为 PAID(已支付)。
愚蠢的写法:
-- 没有任何防备,谁来都能改UPDATE orders SET status = 'PAID' WHERE order_id = 123;幂等的写法(状态机): 我们在 SQL 层面带上它的前置状态,强行做 CAS(Compare And Swap)校验:
UPDATE ordersSET status = 'PAID', update_time = NOW()-- 核心灵魂:必须带上前置状态WHERE order_id = 123 AND status = 'UNPAID';当 3 个回调请求同时到达时。第一个请求成功将 UNPAID 改为了 PAID(受影响行数为 1)。
紧接着第二和第三个请求执行同样的 SQL 时,因为此时数据库里该订单的状态已经是 PAID 了,不再满足 status = 'UNPAID',SQL 依然会执行成功,但受影响行数返回为 0。
你在 MyBatis 层一查,如果是 0,直接忽略或返回“支付已处理”,完美防止了重复修改或状态被逆向倒转。
一个 2026 年的高可用交易系统,幂等性绝对不是靠前端写一句 button.disabled = true 就能糊弄过去的。
- 对于前端的页面重复提交:用 Token(Redis 删除防抖) 在 Controller 层拦住。
- 对于底层的数据并发更新:用 状态机/乐观锁(带条件 UPDATE) 做并发控制。
- 最终数据的物理落盘兜底:用 MySQL 唯一索引 守住最后的底线。