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

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

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

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

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

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


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

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

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

相关推荐
万少6 小时前
Vibe Coding不停歇,移动端 TRAE SOLO 让你用手机也能编程啦
前端·javascript·后端
Rust研习社7 小时前
为什么 Rust 没有空指针?
开发语言·后端·rust
皮皮林5517 小时前
全网最全的 Jenkins + Maven + Git 自动化部署指南!
后端
舒一笑7 小时前
用几十行代码搞定 Chat 接口透明转发:跨环境轻量级网关实战
后端·程序员·架构
铁皮饭盒8 小时前
成为AI全栈 - 第3课:路由 RESTful Elysia 状态码 设计规范
前端·后端·全栈
我叫黑大帅8 小时前
如何通过 Python 实现招聘平台自动投递
后端·python·面试
狼爷9 小时前
短视频播放量(Views)计数系统实现方案:高并发、不丢数的工业级实践
后端·架构
苍何10 小时前
我用 Tabbit 浏览器搭了一套内容创作全自动流水线,太香了!
后端
苍何10 小时前
全网首测,TRAE SOLO 的 AI 麦克风!
后端
IT_陈寒10 小时前
Redis这个内存杀手,差点让我们运维半夜追杀我
前端·人工智能·后端