
针对用户连续点击注册按钮或脚本接口攻击的问题,核心解决方案是幂等性设计 与流量控制,以下是具体的答案和实现方案:
一、用户连续点击两下按钮的解决方案
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 value → SET 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点:
- 互斥性:同一时间,只有一个客户端能获取锁;
- 原子性:加锁/解锁的操作必须是原子的,避免并发问题;
- 防死锁:锁必须有过期时间,防止客户端挂了锁不释放;
- 防误删:只能删除自己加的锁,不能删别人的锁;
- 可重入(可选):同一客户端获取锁后,再次请求能重入(如分布式服务中同一线程的多层调用)。
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分布式锁的答题思路
- 先讲分布式锁的核心要求(互斥、原子、防死锁、防误删);
- 再讲初级实现:SETNX+EX,指出解锁的非原子问题;
- 接着讲正确实现:SETNX+EX + Lua脚本原子解锁,解决误删;
- 最后讲进阶问题:锁过期续期→Redisson看门狗,实际开发用成熟框架,不自己写。
四、脚本攻击(高并发请求):当下流行的限流方案「落地实操」
脚本攻击(如1秒1000个请求)的核心是限流 ,但限流不是只说"限QPS/IP",而是要分场景(单机/分布式) 选方案,
当下流行的限流方案分3类:
本地限流(Guava)、
分布式限流(Redis+Lua)、
框架限流(Sentinel) ,
其中Sentinel是企业主流(阿里开源,轻量、易集成、功能全),Redis+Lua是分布式限流的基础(面试必问),Guava适合简单单机场景。
先明确限流的核心算法(面试先讲算法,再讲实现):
- 令牌桶算法 (主流):以固定速率生成令牌放入桶中,请求来临时取1个令牌,取到则处理,取不到则限流;支持突发流量(桶有容量),Guava/Sentinel都用这个;
- 漏桶算法 :请求先进入漏桶,漏桶以固定速率放水(处理请求),桶满则限流;适合平稳流量,防止突发流量冲击;
- 计数器算法 :简单粗暴,单位时间内计数,超过则限流;有临界问题 (如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-IP或X-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控制台
-
命令启动(默认端口8080,账号密码sentinel/sentinel):
bashjava -jar sentinel-dashboard-1.8.6.jar
(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)可视化配置限流规则(关键,无需改代码)
- 启动SpringBoot服务,访问一次
/register/doRegister接口(Sentinel懒加载,首次访问才会注册接口); - 打开浏览器访问
http://127.0.0.1:8080,输入账号密码sentinel/sentinel; - 找到
register-service服务,点击「流控规则」→「新增流控规则」; - 配置规则(以注册接口为例):
- 资源名:
doRegister(和@SentinelResource的value一致); - 针对来源:default(默认,所有来源);
- 阈值类型:QPS;
- 单机阈值:50(单实例QPS=50);
- 流控模式:直接;
- 流控效果:快速失败(获取不到令牌直接返回);
- 资源名:
- 点击保存,规则立即生效,无需重启服务。
4. 进阶:热点限流(针对手机号/IP限流,企业常用)
如果需要对接口的某个参数 (如手机号)限流,用Sentinel的热点限流:
- 在@SentinelResource中添加
@SentinelResource(value = "doRegister", blockHandler = "registerBlockHandler"); - 在控制台「热点规则」→「新增热点规则」;
- 配置:资源名=doRegister,参数索引=0(DTO的第一个参数是phone),单机阈值=1,窗口时长=60(秒);
- 保存后,单手机号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分布式锁
- 核心要求:互斥、原子、防死锁、防误删、可重入;
- 基础实现:SETNX+EX,问题是解锁非原子→误删;
- 正确实现:SETNX+EX + Lua脚本原子解锁;
- 进阶问题:锁过期续期→Redisson看门狗;
- 实际开发:用Redisson,不自己写锁。
限流
- 核心算法:令牌桶(主流,支持突发)、漏桶(平稳)、滑动窗口(优化计数器);
- 三种方案:
- Guava RateLimiter:单机简单场景,令牌桶,一行代码;
- Redis+Lua:分布式场景,滑动窗口,原子计数,面试必问;
- Sentinel:企业主流,令牌桶,可视化配置,支持限流/熔断/降级/热点限流;
- 限流维度:接口总QPS、IP、手机号(热点参数)。
以上所有代码都可直接复制到SpringBoot项目中运行,规则配置也贴合实际开发,记住**"落地步骤+核心代码+面试考点"**,既能实际应用,也能应对面试。