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

14 阅读5分钟

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

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

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

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

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


1. 概念纠偏:防重 ≠ 幂等 🔄

很多同学喜欢把这俩混为一谈,其实差别大了去了:

  • 防重(Anti-Duplication)针对请求。这个请求我只收一次,第二次直接扔掉。不管你后面怎么样,我不处理。
  • 幂等(Idempotency)针对结果。你发我一百次同样的请求,我处理一百次,但结果副作用跟处理一次是一样的(钱只扣一次)。

举个例子

  • 防重:你重复提交入职申请,HR 系统直接提示“请勿重复提交”。
  • 幂等:你重复发起转账 100 元,系统只扣你 100 元,不会因为发了两次请求就扣 200。

2. 落地实战:防重方案(拒绝“连点怪”) 🚫

防重的核心在于:识别唯一请求

方案一:前端节流(第一道防线)

这是给小白看的,但必须做。按钮点击后置灰,或者 loading状态。

缺点:浏览器刷新、脚本模拟请求,直接绕过。

方案二:Redis SetNX(分布式锁思路)

这是最常用、最有效的手段。

核心逻辑:利用 Redis 的单线程特性,在接口处理前,先去占个坑。

Key 设计req:{userId}:{action}:{uniqueToken}

// 伪代码:防重拦截器
@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. 落地实战:幂等方案(结果不变) 🔑

防重是挡在门外,幂等是进门后的保险箱。

方案一:数据库唯一约束(最硬核)

这是金融级方案。

表设计

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

// 伪代码:更新订单状态
@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 存在,都会去处理。

-- 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 级别的防线

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

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