Redis 分布式锁+接口幂等性使用+当下流行的限流方案「落地实操」+用户连续点击两下按钮的解决方案自用总结

针对用户连续点击注册按钮或脚本接口攻击的问题,核心解决方案是幂等性设计流量控制,以下是具体的答案和实现方案:

一、用户连续点击两下按钮的解决方案

1. 前端层面:防止重复提交(第一道防线)
  • 按钮置灰 :用户点击后,立即将注册按钮设为disabled(禁用)状态,直到接口返回结果(成功/失败)或超时后再恢复。
  • Loading状态:配合加载动画,阻断用户的连续操作意图。
  • 防抖/节流:使用JS防抖(Debounce)机制,例如设置300ms内只允许触发一次请求。
2. 后端层面:保证接口幂等性(核心保障)

仅靠前端不可靠(用户可绕过前端直接调用接口),后端必须处理。

  • 方案A:数据库唯一索引(最直接)
    • phone手机号字段建立唯一索引(Unique Key)
    • 当第二次插入时,数据库会抛出DuplicateKeyException(重复键异常)。
    • 如何区分"重复提交"和"真的已注册"?
      • 插入前先查询 :在userDao.insert(user)之前,先执行select * from user where phone = ?
      • 逻辑判断:如果查询发现用户已存在,直接返回"该手机号已注册";如果查询不存在,但插入时报错(高并发下的"幻读"),则捕获异常并返回"请勿重复提交"或"注册失败"。
  • 方案B:Token机制(标准幂等方案)
    • 进入注册页时,前端请求后端获取一个全局唯一Token(如UUID)。
    • 点击注册时,将Token随参数一起提交。
    • 后端利用Redis(SETNX命令)尝试将Token作为Key存入:
      • 存入成功:执行业务逻辑(插入数据库)。
      • 存入失败:说明请求已在处理,直接返回"重复提交"。

二、脚本接口攻击(一秒1000个请求)的解决方案

这属于高并发/恶意流量 问题,数据库扛不住,必须做限流(Rate Limiting)

1. 限流策略:怎么限?
  • 接口限流:限制该注册接口的总QPS(每秒查询率),例如设定阈值为100,超过直接返回"系统繁忙"。
  • IP限流:限制单个IP的请求频率(如1分钟内最多10次),防止单一IP暴力攻击。
  • 手机号限流:限制单个手机号在单位时间内的请求次数(如1分钟1次),防止恶意注册。
2. 技术实现
  • 本地限流:使用Guava RateLimiter(简单场景)。
  • 分布式限流:使用Redis + Lua脚本(高并发分布式场景)。
  • 专业组件:集成Sentinel或Hystrix等熔断限流框架。
3. 数据库扛不住的兜底方案
  • 异步化:注册请求不直接写库,而是写入MQ(消息队列,如RabbitMQ/Kafka),后台消费者慢慢消费入库,削峰填谷。
  • 缓存拦截:对于高频重复手机号,先在Redis中判断是否已注册,避免每次都查库。

总结

  • 连续点击:前端禁用 + 后端幂等(唯一索引/Token)。
  • 脚本攻击:IP/接口限流 + 消息队列异步削峰。

你核心想掌握Token+Redis SETNX实现接口幂等 的具体落地、Redis分布式锁的面试核心知识点 ,以及当下流行的限流方案(含代码/实操) ,而且要能直接落地应用,接下来我会把每个点拆成核心原理+实操步骤+代码实现+面试考点,全程贴合实际开发和面试场景,不用再记空方案。

先明确:SETNX不是完整的分布式锁,但它是分布式锁/幂等校验的核心基础,后面会先讲这个点,再按你的需求逐个落地。

一、先搞懂:Redis SETNX 是什么?和分布式锁的关系

1. SETNX 核心语法&含义

Redis 命令:SETNX key valueSET if Not Exists(不存在则设置)

  • 执行结果:key不存在 → 设置成功,返回1;key已存在 → 设置失败,返回0

  • 特性:原子性(Redis单命令原子性,不会出现并发下的判断+设置分离)

  • 过期时间:SETNX本身不会自动过期,必须配合EX/PX(秒/毫秒),否则异常时key会永久存在,变成死锁
    👉 实操推荐组合命令 (原子性,避免先SETNX再EX的两步操作):

    redis 复制代码
    # 核心:不存在则设置,且30秒后过期,一步完成(原子)
    SET key value NX EX 30

2. SETNX 是加锁吗?(面试必问)

SETNX是实现分布式锁的「核心动作」,但单独的SETNX不是完整的分布式锁

  • 用SETNX做「加锁」:把锁标识作为key,客户端唯一标识(如UUID)作为value,SETNX成功=加锁成功,失败=锁已被占用;
  • 单独SETNX的问题:如果加锁的客户端挂了,key不会自动删除,后续所有客户端都加不上锁(死锁);
  • 完整分布式锁:必须满足加锁(NX+EX)、解锁(Lua脚本)、续期(可选) 三个核心点,后面单独讲Redis分布式锁的面试核心。

3. 为什么幂等用SETNX?(和分布式锁的区别)

幂等校验的核心是**「同一请求只能执行一次」,分布式锁的核心是「同一资源同一时间只能被一个客户端操作」**,两者都用SETNX的原子性,但场景不同:

  • 幂等(如注册Token):key是唯一请求标识(Token),过期时间是请求处理超时时间(如5秒),只要SETNX失败,就判定为重复请求;
  • 分布式锁(如扣库存):key是资源标识(如stock_1001),过期时间是业务处理最大时间(如30秒),解锁需要验证value是自己的标识,避免误删别人的锁。

二、方案B:Token+Redis SETNX 实现接口幂等(注册防重复提交)「完整落地」

适用于注册/下单/支付 等所有需要防重复提交的场景,前后端配合+后端核心校验,能防前端绕过、高并发重复请求,步骤可直接搬去开发。

核心流程(一张图看懂)

复制代码
前端进入注册页 → 前端请求后端获取Token → 后端生成UUID-Token,Redis存Token(NX+EX5s)→ 前端携带Token提交注册 → 后端SETNX校验Token → 成功则执行业务,失败则返回重复提交

1. 技术栈准备(通用,SpringBoot为例)

  • 后端:SpringBoot + RedisTemplate(或Redisson)+ UUID

  • 前端:任意框架(Vue/React/原生),核心是"获取Token→提交带Token"

  • 依赖(SpringBoot):

    xml 复制代码
    <!-- Redis起步依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <!-- 连接池(可选,推荐) -->
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>

2. 后端具体实现(分3步,含完整代码)

步骤1:Redis配置(解决序列化问题,避免key/value乱码)
java 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        // key用字符串序列化
        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);
        // value用JSON序列化
        GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);
        template.afterPropertiesSet();
        return template;
    }
}
步骤2:提供「获取Token」接口(前端进入注册页时调用)

核心:生成UUID作为Token,存入Redis(NX+EX5秒,5秒是请求处理的合理超时),返回给前端。

java 复制代码
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("/register")
public class RegisterController {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;
    // Token的Redis前缀,方便后续清理
    private static final String TOKEN_PREFIX = "register:token:";
    // Token过期时间:5秒(可根据业务调整)
    private static final long TOKEN_EXPIRE = 5L;

    // 步骤1:前端获取注册Token
    @GetMapping("/getToken")
    public Result<String> getRegisterToken() {
        // 生成全局唯一Token(UUID去掉横杠)
        String token = UUID.randomUUID().toString().replace("-", "");
        // 存入Redis:SETNX+过期时间(原子操作)
        redisTemplate.opsForValue().setIfAbsent(TOKEN_PREFIX + token, "valid", TOKEN_EXPIRE, TimeUnit.SECONDS);
        // 返回Token给前端
        return Result.success(token);
    }

    // 步骤2:注册接口(带Token提交)
    @PostMapping("/doRegister")
    public Result<String> doRegister(@RequestBody RegisterDTO dto) {
        String token = dto.getToken();
        String phone = dto.getPhone();
        String password = dto.getPassword();

        // 1. 校验Token是否为空
        if (token == null || token.trim().isEmpty()) {
            return Result.fail("请获取注册令牌");
        }
        String redisKey = TOKEN_PREFIX + token;

        // 2. 核心:SETNX校验是否重复提交(原子性)
        Boolean isFirst = redisTemplate.opsForValue().setIfAbsent(redisKey, "used", TOKEN_EXPIRE, TimeUnit.SECONDS);
        // setIfAbsent返回false → Token已存在(重复提交)
        if (isFirst == null || !isFirst) {
            return Result.fail("请勿重复提交,请稍后再试");
        }

        // 3. 执行业务逻辑:注册(此处省略数据库操作,实际需加唯一索引兜底)
        try {
            // 示例:userService.register(phone, password);
            return Result.success("注册成功");
        } catch (Exception e) {
            // 异常时无需删除Token,让其自动过期即可
            return Result.fail("注册失败:" + e.getMessage());
        }
    }
}
步骤3:配套实体类(DTO+统一返回结果)
java 复制代码
// 注册请求DTO(前端提交的参数,含Token)
public class RegisterDTO {
    private String token; // 前端携带的Token
    private String phone; // 手机号
    private String password; // 密码
    // getter/setter省略
}

// 统一返回结果(通用)
public class Result<T> {
    private Integer code;
    private String msg;
    private T data;
    // 静态方法:success/fail 省略,按自己项目的统一返回写即可
}

3. 前端具体实现(核心逻辑,Vue为例)

vue 复制代码
<template>
  <div class="register">
    <input v-model="phone" placeholder="请输入手机号" />
    <input v-model="password" placeholder="请输入密码" type="password" />
    <button @click="doRegister" :disabled="loading">注册</button>
  </div>
</template>

<script>
import axios from 'axios'
export default {
  data() {
    return {
      phone: '',
      password: '',
      token: '', // 存储后端返回的Token
      loading: false // 按钮加载状态(前端兜底)
    }
  },
  // 进入页面时自动获取Token
  created() {
    this.getToken()
  },
  methods: {
    // 获取注册Token
    async getToken() {
      const res = await axios.get('/register/getToken')
      this.token = res.data.data
    },
    // 提交注册
    async doRegister() {
      // 前端兜底:按钮置灰+loading(第一道防线)
      this.loading = true
      try {
        await axios.post('/register/doRegister', {
          token: this.token,
          phone: this.phone,
          password: this.password
        })
        alert('注册成功')
      } catch (err) {
        alert(err.response.data.msg)
        // 失败后重新获取Token,避免下次提交用失效Token
        this.getToken()
      } finally {
        // 无论成功失败,恢复按钮
        this.loading = false
      }
    }
  }
}
</script>

4. 关键兜底:数据库唯一索引

后端幂等+前端兜底后,必须给手机号字段加唯一索引,防止极端高并发下Redis失效导致的重复插入:

sql 复制代码
-- 给user表的phone字段加唯一索引,命名为uk_user_phone
ALTER TABLE `user` ADD UNIQUE INDEX `uk_user_phone` (`phone`) COMMENT '手机号唯一索引,防止重复注册';

异常处理 :注册时捕获数据库的DuplicateKeyException,返回友好提示:

java 复制代码
// 在doRegister的try中添加
try {
    userService.register(phone, password);
    return Result.success("注册成功");
} catch (DuplicateKeyException e) {
    return Result.fail("该手机号已注册");
} catch (Exception e) {
    return Result.fail("注册失败:" + e.getMessage());
}

三、Redis分布式锁 「面试核心复习」(含考点+正确实现)

面试中Redis锁是高频必问,面试官会从「基本实现→问题→优化→成熟框架」层层追问,这里直接按面试答题逻辑梳理,记住**"核心三要素+避坑点+成熟方案"** 就能答满分。

1. 面试必背:分布式锁的核心要求

实现一个合格的分布式锁,必须满足5点:

  1. 互斥性:同一时间,只有一个客户端能获取锁;
  2. 原子性:加锁/解锁的操作必须是原子的,避免并发问题;
  3. 防死锁:锁必须有过期时间,防止客户端挂了锁不释放;
  4. 防误删:只能删除自己加的锁,不能删别人的锁;
  5. 可重入(可选):同一客户端获取锁后,再次请求能重入(如分布式服务中同一线程的多层调用)。

2. 初级实现:SETNX+EX(面试先讲这个,再讲问题)

redis 复制代码
# 加锁:key=锁标识,value=客户端唯一标识(UUID),NX=不存在则加,EX=30秒过期
SET lock:stock_1001 uuid123 NX EX 30
# 解锁:先判断value是不是自己的,再删除(⚠️ 两步操作,非原子,有问题)
GET lock:stock_1001 → 等于uuid123 → DEL lock:stock_1001

问题 :解锁的「判断+删除」是两步操作,高并发下会误删别人的锁(比如客户端A的锁快过期,业务没执行完,锁自动释放,客户端B加锁,此时A执行完,删除了B的锁)。

3. 正确实现:SETNX+EX + Lua脚本解锁(原子解锁,面试核心)

核心优化 :用Lua脚本 把「判断value+删除key」变成原子操作,解决误删问题。

(1)加锁命令(和初级一致,核心是value带客户端唯一标识)
redis 复制代码
SET lock:{资源名} {客户端唯一标识} NX EX {过期时间}
# 示例:扣库存锁,客户端标识=uuid+线程ID,过期30秒
SET lock:stock_1001 uuid123-1 Thread-1 NX EX 30
(2)解锁Lua脚本(Redis执行Lua脚本是原子的,必背)
lua 复制代码
-- 脚本参数:KEYS[1]=锁key,ARGV[1]=客户端唯一标识
if redis.call('get', KEYS[1]) == ARGV[1] then
    -- 是自己的锁,删除
    return redis.call('del', KEYS[1])
else
    -- 不是自己的锁,返回0
    return 0
end
(3)SpringBoot中实现Lua脚本解锁(实操代码)
java 复制代码
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Component
public class RedisLockUtil {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;
    // 解锁Lua脚本
    private static final String UNLOCK_LUA = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    // 初始化Lua脚本
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setScriptText(UNLOCK_LUA);
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    /**
     * 加锁
     * @param lockKey 锁key
     * @param expireTime 过期时间(秒)
     * @return 客户端唯一标识(解锁时需要)
     */
    public String lock(String lockKey, long expireTime) {
        String clientId = UUID.randomUUID().toString().replace("-", "") + "-" + Thread.currentThread().getId();
        Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, clientId, expireTime, TimeUnit.SECONDS);
        return success ? clientId : null;
    }

    /**
     * 解锁
     * @param lockKey 锁key
     * @param clientId 加锁时的客户端标识
     * @return true=解锁成功,false=解锁失败
     */
    public boolean unlock(String lockKey, String clientId) {
        Long result = redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(lockKey), clientId);
        return result != null && result > 0;
    }
}

4. 进阶问题:锁过期了,业务还没执行完?(面试追问)

问题 :设置30秒过期,但若业务执行需要40秒,锁会提前释放,导致并发问题。
解决方案:锁续期(看门狗机制)

  • 核心思路:获取锁后,启动一个后台定时线程,每隔「过期时间的1/3」(如30秒→10秒)检查一次,如果锁还存在(自己的),就刷新过期时间到30秒;

  • 成熟实现:不用自己写 ,直接用Redisson (Redis官方推荐的Java客户端),内置了看门狗(Watch Dog) 机制,自动续期。

  • 面试回答:实际开发中不重复造轮子,用Redisson的RLock,自带可重入、看门狗、原子加解锁,代码极简:

    java 复制代码
    // Redisson加锁示例(一行代码)
    RLock lock = redissonClient.getLock("lock:stock_1001");
    lock.lock(30, TimeUnit.SECONDS); // 加锁,30秒过期,看门狗自动续期
    try {
        // 执行业务逻辑
    } finally {
        lock.unlock(); // 解锁
    }

5. 面试总结:Redis分布式锁的答题思路

  1. 先讲分布式锁的核心要求(互斥、原子、防死锁、防误删);
  2. 再讲初级实现:SETNX+EX,指出解锁的非原子问题;
  3. 接着讲正确实现:SETNX+EX + Lua脚本原子解锁,解决误删;
  4. 最后讲进阶问题:锁过期续期→Redisson看门狗,实际开发用成熟框架,不自己写。

四、脚本攻击(高并发请求):当下流行的限流方案「落地实操」

脚本攻击(如1秒1000个请求)的核心是限流 ,但限流不是只说"限QPS/IP",而是要分场景(单机/分布式) 选方案,

当下流行的限流方案分3类:
本地限流(Guava)、
分布式限流(Redis+Lua)、
框架限流(Sentinel)

其中Sentinel是企业主流(阿里开源,轻量、易集成、功能全),Redis+Lua是分布式限流的基础(面试必问),Guava适合简单单机场景。

先明确限流的核心算法(面试先讲算法,再讲实现):

  1. 令牌桶算法 (主流):以固定速率生成令牌放入桶中,请求来临时取1个令牌,取到则处理,取不到则限流;支持突发流量(桶有容量),Guava/Sentinel都用这个;
  2. 漏桶算法 :请求先进入漏桶,漏桶以固定速率放水(处理请求),桶满则限流;适合平稳流量,防止突发流量冲击;
  3. 计数器算法 :简单粗暴,单位时间内计数,超过则限流;有临界问题 (如1分钟限100次,59秒和0秒各来100次,2秒内200次),一般不用纯计数器,而是滑动窗口计数器(优化临界问题)。

方案1:本地限流(Guava RateLimiter)→ 简单单机场景(如单实例服务)

1. 核心:基于令牌桶算法,一行代码实现限流,无需中间件

2. 实操步骤(SpringBoot)

(1)引入依赖
xml 复制代码
<!-- Guava 核心依赖 -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>32.1.3-jre</version>
</dependency>
(2)代码实现(注册接口限流,QPS=10)
java 复制代码
import com.google.common.util.concurrent.RateLimiter;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
@RequestMapping("/register")
public class RegisterController {
    // 核心:创建令牌桶,每秒生成10个令牌(QPS=10)
    private static final RateLimiter RATE_LIMITER = RateLimiter.create(10.0);

    // 注册接口限流
    @PostMapping("/doRegister")
    public Result<String> doRegister(@RequestBody RegisterDTO dto) {
        // 尝试获取1个令牌,无等待:获取不到直接返回限流
        if (!RATE_LIMITER.tryAcquire()) {
            return Result.fail("系统繁忙,请稍后再试(限流)");
        }
        // 以下是原有业务逻辑...
        return Result.success("注册成功");
    }
}

3. 关键方法

  • RateLimiter.create(10.0):每秒生成10个令牌;
  • tryAcquire():无等待,立即返回是否获取到令牌;
  • tryAcquire(1, 500, TimeUnit.MILLISECONDS):等待500毫秒,获取1个令牌,超时则返回false(适合允许轻微等待的场景)。

4. 缺点

  • 仅适用于单实例服务,多实例时各实例独立计数,总QPS会超过阈值(如2个实例,每个QPS=10,总QPS=20);
  • 无持久化,服务重启后限流规则重置。

方案2:分布式限流(Redis+Lua)→ 分布式多实例场景(面试必问)

1. 核心:基于滑动窗口计数器 (Lua脚本原子实现),支持接口限流/IP限流/手机号限流,多实例共享Redis计数,总QPS精准可控。

2. 实操:注册接口「IP限流+接口总QPS限流」(SpringBoot+Redis+Lua)

(1)核心Lua脚本(滑动窗口限流,通用)
lua 复制代码
-- 限流Lua脚本:KEYS[1]=限流key,ARGV[1]=窗口大小(毫秒),ARGV[2]=阈值,ARGV[3]=当前时间戳
-- 1. 删除窗口外的旧数据
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[3] - ARGV[1])
-- 2. 获取当前窗口内的请求数
local count = redis.call('ZCARD', KEYS[1])
-- 3. 判断是否超过阈值
if count >= tonumber(ARGV[2]) then
    return 0 -- 限流
else
    -- 4. 加入当前请求的时间戳
    redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3])
    -- 5. 设置key过期时间(窗口大小+1秒,避免冗余key)
    redis.call('EXPIRE', KEYS[1], ARGV[1]/1000 + 1)
    return 1 -- 放行
end
(2)后端实现(限流工具类+接口集成)
java 复制代码
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Collections;
import java.util.concurrent.TimeUnit;

@Component
public class RedisLimitUtil {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;
    // 限流Lua脚本
    private static final String LIMIT_LUA = "redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[3] - ARGV[1]) local count = redis.call('ZCARD', KEYS[1]) if count >= tonumber(ARGV[2]) then return 0 else redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3]) redis.call('EXPIRE', KEYS[1], ARGV[1]/1000 + 1) return 1 end";
    private static final DefaultRedisScript<Long> LIMIT_SCRIPT;

    static {
        LIMIT_SCRIPT = new DefaultRedisScript<>();
        LIMIT_SCRIPT.setScriptText(LIMIT_LUA);
        LIMIT_SCRIPT.setResultType(Long.class);
    }

    /**
     * 分布式限流
     * @param limitKey 限流key(接口/IP/手机号)
     * @param windowMs 窗口大小(毫秒,如60000=1分钟)
     * @param threshold 窗口内阈值(如10=1分钟最多10次)
     * @return true=放行,false=限流
     */
    public boolean limit(String limitKey, long windowMs, long threshold) {
        // 当前时间戳(毫秒)
        long currentTime = System.currentTimeMillis();
        // 执行Lua脚本
        Long result = redisTemplate.execute(
                LIMIT_SCRIPT,
                Collections.singletonList(limitKey),
                windowMs, threshold, currentTime
        );
        // result=1放行,0限流
        return result != null && result == 1;
    }

    // 封装:IP限流(简化调用)
    public boolean limitByIp(String ip, long windowMs, long threshold) {
        return limit("limit:ip:" + ip, windowMs, threshold);
    }

    // 封装:接口总QPS限流(简化调用)
    public boolean limitByApi(String api, long windowMs, long threshold) {
        return limit("limit:api:" + api, windowMs, threshold);
    }

    // 封装:手机号限流(简化调用)
    public boolean limitByPhone(String phone, long windowMs, long threshold) {
        return limit("limit:phone:" + phone, windowMs, threshold);
    }
}
(3)注册接口集成限流(IP+接口+手机号,三重防护)
java 复制代码
@RestController
@RequestMapping("/register")
public class RegisterController {
    @Resource
    private RedisLimitUtil redisLimitUtil;
    // 限流规则:1.接口总QPS=50(每秒50次) 2.单IP1分钟最多10次 3.单手机号1分钟最多1次
    private static final long API_QPS = 50;
    private static final long IP_WINDOW = 60 * 1000L;
    private static final long IP_THRESHOLD = 10L;
    private static final long PHONE_WINDOW = 60 * 1000L;
    private static final long PHONE_THRESHOLD = 1L;

    @PostMapping("/doRegister")
    public Result<String> doRegister(@RequestBody RegisterDTO dto, HttpServletRequest request) {
        String phone = dto.getPhone();
        // 1. 获取客户端IP(实际开发需处理反向代理,如Nginx的X-Real-IP)
        String ip = request.getRemoteAddr();
        // 2. 接口总QPS限流(窗口1000ms=1秒,阈值50)
        if (!redisLimitUtil.limitByApi("/register/doRegister", 1000L, API_QPS)) {
            return Result.fail("系统繁忙,接口限流,请稍后再试");
        }
        // 3. IP限流(1分钟最多10次)
        if (!redisLimitUtil.limitByIp(ip, IP_WINDOW, IP_THRESHOLD)) {
            return Result.fail("您的IP请求过于频繁,请1分钟后再试");
        }
        // 4. 手机号限流(1分钟最多1次)
        if (!redisLimitUtil.limitByPhone(phone, PHONE_WINDOW, PHONE_THRESHOLD)) {
            return Result.fail("该手机号请求过于频繁,请1分钟后再试");
        }

        // 后续:Token幂等校验 + 注册业务逻辑...
        return Result.success("注册成功");
    }
}

3. 关键说明

  • IP获取 :实际生产中如果有Nginx反向代理,需要从请求头X-Real-IPX-Forwarded-For获取真实IP,否则获取的是Nginx的IP;
  • 限流key设计 :用limit:类型:标识的前缀,方便后续监控和清理;
  • 原子性:Lua脚本保证「删旧数据+计数+加新数据」是原子操作,无并发问题。

方案3:框架限流(Sentinel)→ 企业主流(推荐落地)

1. 核心:阿里开源的流量治理框架 ,基于令牌桶算法,支持本地/分布式限流、熔断、降级、热点限流,可视化控制台配置规则,无需写Lua脚本,开发效率极高,是当下企业处理高并发的主流方案。

2. 核心优势

  • 开箱即用:SpringBoot一键集成,注解式开发;
  • 可视化配置:控制台直接配置限流规则(QPS/IP/热点参数),无需改代码重启;
  • 功能全面:除了限流,还能处理熔断(服务不可用时断开)、降级(非核心服务降级)、热点限流(如手机号作为热点参数限流);
  • 分布式支持:结合Nacos/Apollo实现规则持久化,多实例共享规则。

3. 实操:SpringBoot集成Sentinel(注册接口限流)

(1)引入依赖(SpringBoot 2.7+为例)
xml 复制代码
<!-- Sentinel SpringBoot起步依赖 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
    <version>2021.0.5.0</version>
</dependency>
<!-- Nacos依赖(可选,规则持久化用) -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    <version>2021.0.5.0</version>
</dependency>
(2)配置application.yml
yaml 复制代码
spring:
  cloud:
    sentinel:
      transport:
        dashboard: 127.0.0.1:8080 # Sentinel控制台地址(需单独启动)
        port: 8719 # 与控制台通信的端口
      web-context-unify: false # 关闭context统一,避免接口路径匹配问题
  application:
    name: register-service # 服务名,控制台会显示
(3)启动Sentinel控制台
(4)接口集成Sentinel(注解式,一行代码)
java 复制代码
import com.alibaba.csp.sentinel.annotation.SentinelResource;
import com.alibaba.csp.sentinel.slots.block.BlockException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/register")
public class RegisterController {
    // 核心:@SentinelResource 标记需要限流的接口,blockHandler指定限流降级方法
    @PostMapping("/doRegister")
    @SentinelResource(value = "doRegister", blockHandler = "registerBlockHandler")
    public Result<String> doRegister(@RequestBody RegisterDTO dto) {
        // 原有业务逻辑:Token幂等 + 注册...
        return Result.success("注册成功");
    }

    // 限流降级方法:参数和返回值必须和原方法一致,最后加BlockException参数
    public Result<String> registerBlockHandler(RegisterDTO dto, BlockException e) {
        return Result.fail("Sentinel限流:系统繁忙,请稍后再试");
    }
}
(5)可视化配置限流规则(关键,无需改代码)
  1. 启动SpringBoot服务,访问一次/register/doRegister接口(Sentinel懒加载,首次访问才会注册接口);
  2. 打开浏览器访问http://127.0.0.1:8080,输入账号密码sentinel/sentinel;
  3. 找到register-service服务,点击「流控规则」→「新增流控规则」;
  4. 配置规则(以注册接口为例):
    • 资源名:doRegister(和@SentinelResource的value一致);
    • 针对来源:default(默认,所有来源);
    • 阈值类型:QPS;
    • 单机阈值:50(单实例QPS=50);
    • 流控模式:直接;
    • 流控效果:快速失败(获取不到令牌直接返回);
  5. 点击保存,规则立即生效,无需重启服务。

4. 进阶:热点限流(针对手机号/IP限流,企业常用)

如果需要对接口的某个参数 (如手机号)限流,用Sentinel的热点限流

  1. 在@SentinelResource中添加@SentinelResource(value = "doRegister", blockHandler = "registerBlockHandler")
  2. 在控制台「热点规则」→「新增热点规则」;
  3. 配置:资源名=doRegister,参数索引=0(DTO的第一个参数是phone),单机阈值=1,窗口时长=60(秒);
  4. 保存后,单手机号1分钟最多1次请求,精准防恶意注册。

5. 生产落地:规则持久化

Sentinel默认规则存在内存中,服务重启后丢失,生产中需结合Nacos/Apollo实现规则持久化:

  • 配置Nacos地址到application.yml;
  • 引入Sentinel-Nacos依赖;
  • 控制台配置的规则会自动同步到Nacos,服务重启后从Nacos加载规则。

五、整体落地总结(注册接口防重复+防攻击)

把以上方案整合,形成注册接口的三层防护体系,从前端到后端,从幂等到限流,全方位解决连续点击和脚本攻击:

第一层:前端兜底(低成本,第一道防线)

  • 按钮点击后置灰+loading,直到接口返回;
  • 对手机号输入做格式校验,减少无效请求。

第二层:后端幂等(防重复提交,核心)

  • Token+Redis SETNX实现接口幂等;
  • 数据库手机号唯一索引,极端场景兜底。

第三层:多层限流(防脚本攻击,高并发防护)

  • 本地限流(Guava):单机场景兜底;
  • 分布式限流(Redis+Lua):多实例精准控总QPS,支持IP/手机号限流;
  • 框架限流(Sentinel):企业生产首选,可视化配置,支持流量治理全功能。

六、面试考点速记(Redis锁+限流)

Redis分布式锁

  1. 核心要求:互斥、原子、防死锁、防误删、可重入;
  2. 基础实现:SETNX+EX,问题是解锁非原子→误删;
  3. 正确实现:SETNX+EX + Lua脚本原子解锁;
  4. 进阶问题:锁过期续期→Redisson看门狗;
  5. 实际开发:用Redisson,不自己写锁。

限流

  1. 核心算法:令牌桶(主流,支持突发)、漏桶(平稳)、滑动窗口(优化计数器);
  2. 三种方案:
    • Guava RateLimiter:单机简单场景,令牌桶,一行代码;
    • Redis+Lua:分布式场景,滑动窗口,原子计数,面试必问;
    • Sentinel:企业主流,令牌桶,可视化配置,支持限流/熔断/降级/热点限流;
  3. 限流维度:接口总QPS、IP、手机号(热点参数)。

以上所有代码都可直接复制到SpringBoot项目中运行,规则配置也贴合实际开发,记住**"落地步骤+核心代码+面试考点"**,既能实际应用,也能应对面试。

相关推荐
NineData16 小时前
NineData智能数据管理平台新功能发布|2026年1-2月
数据库·sql·数据分析
回家路上绕了弯16 小时前
深入解析Agent Subagent架构:原理、协同逻辑与实战落地指南
分布式·后端
IvorySQL17 小时前
双星闪耀温哥华:IvorySQL 社区两项议题入选 PGConf.dev 2026
数据库·postgresql·开源
ma_king19 小时前
入门 java 和 数据库
java·数据库·后端
jiayou641 天前
KingbaseES 实战:审计追踪配置与运维实践
数据库
NineData1 天前
NineData 迁移评估功能正式上线
数据库·dba
雨中飘荡的记忆2 天前
大流量下库存扣减的数据库瓶颈:Redis分片缓存解决方案
java·redis·后端
NineData2 天前
数据库迁移总踩坑?用 NineData 迁移评估,提前识别所有兼容性风险
数据库·程序员·云计算
赵渝强老师2 天前
【赵渝强老师】PostgreSQL中表的碎片
数据库·postgresql
全栈老石2 天前
拆解低代码引擎核心:元数据驱动的"万能表"架构
数据库·低代码