别让用户"剁手"把你搞破产:接口幂等性与防重的终极防线 🛡️
各位"背锅侠"们,大家好。👋
咱们做后端的,最怕的不是代码写不出来,而是用户手太快。💸
尤其是做支付、下单、抽奖这类业务。用户因为网络卡顿,手指在屏幕上疯狂输出连点两下,结果你这边没防住,给他发了两份奖品,或者扣了两次钱。第二天财务找上门,你就准备收拾工位吧。🧳
今天咱们就聊聊怎么给用户这双"快手"戴上镣铐:接口幂等性 与请求防重。
1. 概念纠偏:防重 ≠ 幂等 🔄
很多同学喜欢把这俩混为一谈,其实差别大了去了:
- 防重(Anti-Duplication) :针对请求。这个请求我只收一次,第二次直接扔掉。不管你后面怎么样,我不处理。
- 幂等(Idempotency) :针对结果 。你发我一百次同样的请求,我处理一百次,但结果 和副作用跟处理一次是一样的(钱只扣一次)。
举个例子:
- 防重:你重复提交入职申请,HR 系统直接提示"请勿重复提交"。
- 幂等:你重复发起转账 100 元,系统只扣你 100 元,不会因为发了两次请求就扣 200。
2. 落地实战:防重方案(拒绝"连点怪") 🚫
防重的核心在于:识别唯一请求。
方案一:前端节流(第一道防线)
这是给小白看的,但必须做。按钮点击后置灰,或者 loading状态。
缺点:浏览器刷新、脚本模拟请求,直接绕过。
方案二:Redis SetNX(分布式锁思路)
这是最常用、最有效的手段。
核心逻辑:利用 Redis 的单线程特性,在接口处理前,先去占个坑。
Key 设计 :req:{userId}:{action}:{uniqueToken}
typescript
// 伪代码:防重拦截器
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 1. 从 Header 或 Token 中获取用户唯一标识
String userId = request.getHeader("X-User-Id");
// 2. 获取客户端生成的唯一请求 ID (非常重要!)
String requestId = request.getHeader("X-Request-Id");
// 如果没有,说明是非法请求或爬虫,直接拦截
if (StringUtils.isBlank(requestId)) {
throw new RuntimeException("缺少请求指纹!🚨");
}
String key = "req:lock:" + userId + ":" + requestId;
// 3. SetNX:如果 Key 不存在才设置,存在则返回 false
Boolean success = redisTemplate.opsForValue().setIfAbsent(key, "1", 5, TimeUnit.SECONDS);
if (Boolean.FALSE.equals(success)) {
// 请求重复了,直接返回,别进 Controller 了
throw new RuntimeException("您点得太快了,慢点儿~ 🐢");
}
return true;
}
}
专家提示 :X-Request-Id最好由前端生成(UUID),这样能覆盖用户在不同设备上的重复操作。
3. 落地实战:幂等方案(结果不变) 🔑
防重是挡在门外,幂等是进门后的保险箱。
方案一:数据库唯一约束(最硬核)
这是金融级方案。
表设计:
sql
CREATE TABLE `order` (
`id` bigint NOT NULL,
`order_no` varchar(64) NOT NULL,
`biz_id` varchar(64) NOT NULL COMMENT '业务唯一ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_id` (`biz_id`) -- 核心在这里!
);
逻辑:
业务方传入一个 biz_id(比如支付流水号)。即使请求过来两次,数据库会因为唯一键冲突插入失败。你的代码捕获 DuplicateKeyException,直接返回"操作成功"即可。
方案二:状态机幂等(适合订单流转)
订单状态从 CREATED-> PAID。
typescript
// 伪代码:更新订单状态
@Transactional
public void payOrder(String orderNo) {
int rows = orderMapper.updateStatus(
"PAID",
orderNo,
Arrays.asList("CREATED") // 只有 CREATED 状态才能更新
);
if (rows == 0) {
// 说明要么订单不存在,要么已经不是 CREATED 了(已经付过款了)
// 此时直接返回成功,这就是幂等
log.info("订单已处理或状态异常,直接返回成功");
return;
}
// 扣减库存...
}
核心 :UPDATE table SET status = 'NEW' WHERE status = 'OLD' AND id = ?。利用 SQL 的行锁保证原子性。
4. 高级玩法:基于 Token 的预校验机制 🎫
这是很多大厂(如阿里、京东)用的方案。
-
申请 Token:在用户进入页面时,后端生成一个唯一 Token,存入 Redis,并返回给前端。
-
提交携带:用户提交请求时,带上这个 Token。
-
校验删除 :后端使用 Lua 脚本 原子性地检查并删除 Redis 中的 Token。
- 删成功 = 第一次请求,放行。
- 删失败 = 重复请求,拦截。
为什么用 Lua?
因为"查"和"删"必须是原子操作,否则在高并发下,两个线程同时查到 Token 存在,都会去处理。
vbnet
-- Lua 脚本:原子校验
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
5. 总结:资深工程师的 CheckList ✅
为了体现你的专业度,下次评审时拿出这张表:
| 场景 | 推荐方案 | 核心原理 | 适用业务 |
|---|---|---|---|
| 表单提交/点赞 | Redis SetNX | 抢占锁,过期失效 | 防止用户连点 |
| 支付/转账 | 唯一索引 (UK) | 数据库层的最终裁决 | 金融交易,绝对防重 |
| 订单状态变更 | 乐观锁/状态机 | Where status = ? |
订单流转 |
| 复杂流程 | Token 机制 | 一次性验证码 | 秒杀、高并发写 |
最后的碎碎念 💭
千万不要依赖前端! 永远不要相信用户的网络环境。哪怕前端做了防重,后端也必须有一道 Redis 或 DB 级别的防线。
毕竟,代码写得好,老板回家早。要是真因为没做幂等赔了钱,那可真是"一顿操作猛如虎,一看工资二千五"了。😂
大家如果有更好的方案,或者在生产环境中踩过什么"幂等"的大坑,欢迎在评论区交流,咱们一起填坑!👇