别让用户“剁手”把你搞破产:接口幂等性与防重的终极防线

别让用户"剁手"把你搞破产:接口幂等性与防重的终极防线 🛡️

各位"背锅侠"们,大家好。👋

咱们做后端的,最怕的不是代码写不出来,而是用户手太快。💸

尤其是做支付、下单、抽奖这类业务。用户因为网络卡顿,手指在屏幕上疯狂输出连点两下,结果你这边没防住,给他发了两份奖品,或者扣了两次钱。第二天财务找上门,你就准备收拾工位吧。🧳

今天咱们就聊聊怎么给用户这双"快手"戴上镣铐:接口幂等性请求防重


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 的预校验机制 🎫

这是很多大厂(如阿里、京东)用的方案。

  1. 申请 Token:在用户进入页面时,后端生成一个唯一 Token,存入 Redis,并返回给前端。

  2. 提交携带:用户提交请求时,带上这个 Token。

  3. 校验删除 :后端使用 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 级别的防线

毕竟,代码写得好,老板回家早。要是真因为没做幂等赔了钱,那可真是"一顿操作猛如虎,一看工资二千五"了。😂

大家如果有更好的方案,或者在生产环境中踩过什么"幂等"的大坑,欢迎在评论区交流,咱们一起填坑!👇

相关推荐
掘金者阿豪2 小时前
程序员必踩的一个坑:Codex 报错 Missing environment variable `OPENAI_API_KEY`,完整解决指南(附架构图)
后端
神奇小汤圆2 小时前
从分析 Claude Code 源码到自己写一个:AnyCoder,支持 DeepSeek/Qwen 等任意大模型的开源 AI 编程 Agent
后端
悟空码字2 小时前
别再重复造轮子了!SpringBoot对接第三方系统模板,拿来即用
java·spring boot·后端
程序员cxuan2 小时前
为什么 Claude 要求实名认证?
人工智能·后端·程序员
Lsk_Smion2 小时前
Hot100(开刷) 之 环形链表(II)-- 随机链表的复制 -- 翻转二叉树
java·后端·kotlin·力扣·hot100
神毓逍遥kang3 小时前
在nest.js中我想把Java的Sa-Token搬来
前端·后端
神奇小汤圆3 小时前
MySQL CPU飙到680%:一次「僵尸查询」引发的雪崩
后端
浪客川3 小时前
【百例RUST - 006】一文理解所有权和切片
开发语言·后端·rust
香香甜甜的辣椒炒肉3 小时前
Spring JDBC 万能模板
java·后端·spring