SpringBoot实现短信验证码接口防刷的完整方案
核心目标:在高并发场景下,构建分层防护体系,既保障接口安全,又保证响应速度,同时控制短信成本。
一、问题背景与挑战
1.1 为什么需要防刷?
短信验证码接口是攻击者的高价值目标,主要原因:
- 直接利益驱动:刷 单、薅羊毛、恶意注册
- 成本敏感:短信成本按条计费,刷量会导致成本激增
- 业务影响:真实用户体验下降,品牌受损
1.2 典型攻击场景
| 攻击类型 | 攻击手段 | 防护难点 |
|---|---|---|
| 暴力刷量 | 单IP高频请求不同手机号 | 识别IP伪装 |
| 代理IP池 | 使用代理IP切换发送 | 设备指纹识别 |
| 打码平台 | 人工+自动化脚本绕过图形验证 | 行为分析 |
| 重放攻击 | 拦截请求重复发送 | 签名校验 |
| 分布式攻击 | 多节点协同攻击 | 全局限流 |
1.3 防护目标
- 安全性:拦截90%以上的恶意请求
- 性能:P99响应时间 < 100ms
- 可用性:99.99%可用性,保证真实用户不受影响
- 成本:短信成本控制在合理范围
二、整体架构设计
2.1 分层防护体系
┌─────────────────────────────────────────────────────────────┐
│ CDN/WAF层 │
│ - DDoS防护 - IP黑名单 - 地域封禁 │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ 网关层(Nginx/API Gateway) │
│ - 全局限流 - IP封禁 - 协议过滤 │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ 应用层(后端服务) │
│ - 签名校验 - 图形验证 - 业务限流 │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ 缓存层(Redis Cluster) │
│ - 计数器存储 - 黑名单缓存 - 分布式锁 │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ 数据层(MySQL/消息队列) │
│ - 记录日志 - 异步发送短信 - 持久化数据 │
└─────────────────────────────────────────────────────────────┘2.2 防护层级说明
| 防护层 | 核心能力 | 技术手段 | 响应时间 |
|---|---|---|---|
| CDN/WAF | 拦截已知攻击 | IP信誉库、攻击特征匹配 | < 10ms |
| 网关层 | 全局流量控制 | 限流算法、IP封禁 | < 5ms |
| 应用层 | 业务逻辑验证 | 签名、验证码、限流 | 10-50ms |
| 缓存层 | 高速状态存储 | Redis计数器、分布式锁 | < 10ms |
| 数据层 | 数据持久化 | 异步写入、消息队列 | 不阻塞 |
三、核心防护策略详解
3.1 前端防护层
3.1.1 按钮倒计时
// 前端实现示例
let countdown = 60;
let timer = null;
function sendSmsCode(phone) {
// 倒计时中禁用
if (countdown < 60) {
showToast('请等待倒计时结束');
return;
}
// 发送请求
$.ajax({
url: '/api/sms/send',
method: 'POST',
data: { phone: phone },
success: function(res) {
if (res.code === 0) {
startCountdown();
}
}
});
}
function startCountdown() {
const btn = $('#send-sms-btn');
btn.prop('disabled', true);
timer = setInterval(() => {
countdown--;
btn.text(`${countdown}秒后重试`);
if (countdown <= 0) {
clearInterval(timer);
btn.prop('disabled', false);
btn.text('发送验证码');
countdown = 60;
}
}, 1000);
}注意事项:
- 倒计时结束后必须重新获取验证码token
- 防止用户修改页面代码绕过
- 前端限制不能替代后端验证
3.1.2 图形验证码
实现逻辑:
- 用户点击"发送验证码"
- 弹出图形验证码(滑块/点选/旋转)
- 验证通过后获得临时token
- 携带token请求短信接口
// 图形验证码生成(使用Google Guava)
public class CaptchaService {
/**
* 生成滑块验证码
*/
public CaptchaVO generateSliderCaptcha(String sessionId) {
// 1. 生成滑块和背景图
SliderImageResult result = sliderCaptchaGenerator.generate();
// 2. 存储验证信息到Redis,5分钟过期
String captchaKey = RedisKeyConstant.SLIDER_CAPTCHA + sessionId;
CaptchaInfo captchaInfo = new CaptchaInfo(
result.getX(),
result.getY(),
System.currentTimeMillis()
);
// 使用Hash结构存储,支持部分字段查询
redisTemplate.opsForHash().putAll(captchaKey, captchaInfo.toMap());
redisTemplate.expire(captchaKey, 5, TimeUnit.MINUTES);
// 3. 返回前端信息(不含真实坐标)
return CaptchaVO.builder()
.sessionId(sessionId)
.backgroundImage(result.getBackgroundImage())
.sliderImage(result.getSliderImage())
.token(UUID.randomUUID().toString()) // 临时token
.build();
}
/**
* 验证滑块位置
*/
public boolean verifySliderCaptcha(String sessionId, String token,
int userX, int userY) {
String captchaKey = RedisKeyConstant.SLIDER_CAPTCHA + sessionId;
// 获取存储的验证信息
Map<Object, Object> captchaData = redisTemplate.opsForHash()
.entries(captchaKey);
if (captchaData.isEmpty()) {
return false;
}
int realX = (int) captchaData.get("x");
int realY = (int) captchaData.get("y");
// 允许误差范围:X轴±5像素,Y轴±10像素
boolean isValid = Math.abs(userX - realX) <= 5 &&
Math.abs(userY - realY) <= 10;
// 验证通过后删除验证码
if (isValid) {
redisTemplate.delete(captchaKey);
}
return isValid;
}
}
高级防护:
- 轨迹分析:记录鼠标移动轨迹,判断是否真人操作
- 时间检测:验证响应时间 < 1秒判定为脚本
- 设备指纹:生成设备唯一标识,防止绕过
3.2 网关层防护
3.2.1 Nginx限流配置
# 限流配置:定义限流区域
limit_req_zone $binary_remote_addr zone=sms_limit:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=sms_conn:10m;
server {
listen 80;
location /api/sms/ {
# 请求限流:每秒5个请求,允许突发10个
limit_req zone=sms_limit burst=10 nodelay;
# 连接数限制:同一IP最多20个并发连接
limit_conn sms_conn 20;
# 限流返回状态码
limit_req_status 429;
# 限流后返回JSON响应
error_page 429 = @rate_limited;
proxy_pass http://backend;
}
location @rate_limited {
default_type application/json;
return 429 '{"code": 429, "msg": "请求过于频繁,请稍后再试"}';
}
}参数说明:
zone=sms_limit:10m:限流区域名称,10MB内存(可存约16万个IP状态)rate=5r/s:每秒允许5个请求burst=10:允许突发10个请求(缓冲区大小)nodelay:超过burst立即拒绝,不延迟处理
3.2.2 IP黑名单动态管理
/**
* IP黑名单管理服务
*/
@Service
public class IpBlacklistService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String BLACKLIST_KEY = "security:ip:blacklist";
private static final long EXPIRE_HOURS = 24;
/**
* 检查IP是否在黑名单中
*/
public boolean isBlacklisted(String ip) {
return Boolean.TRUE.equals(
redisTemplate.opsForSet().isMember(BLACKLIST_KEY, ip)
);
}
/**
* 添加IP到黑名单
*/
public void addToBlacklist(String ip, String reason) {
redisTemplate.opsForSet().add(BLACKLIST_KEY, ip);
// 记录封禁原因和封禁时间
String infoKey = BLACKLIST_KEY + ":info:" + ip;
Map<String, String> info = new HashMap<>();
info.put("reason", reason);
info.put("bannedAt", String.valueOf(System.currentTimeMillis()));
redisTemplate.opsForHash().putAll(infoKey, info);
redisTemplate.expire(infoKey, EXPIRE_HOURS, TimeUnit.HOURS);
// 同时更新主黑名单的过期时间
redisTemplate.expire(BLACKLIST_KEY, EXPIRE_HOURS, TimeUnit.HOURS);
}
/**
* 从黑名单移除IP
*/
public void removeFromBlacklist(String ip) {
redisTemplate.opsForSet().remove(BLACKLIST_KEY, ip);
String infoKey = BLACKLIST_KEY + ":info:" + ip;
redisTemplate.delete(infoKey);
}
/**
* 获取黑名单信息
*/
public BlacklistInfo getBlacklistInfo(String ip) {
String infoKey = BLACKLIST_KEY + ":info:" + ip;
Map<Object, Object> data = redisTemplate.opsForHash()
.entries(infoKey);
if (data.isEmpty()) {
return null;
}
return BlacklistInfo.builder()
.ip(ip)
.reason((String) data.get("reason"))
.bannedAt(Long.parseLong((String) data.get("bannedAt")))
.build();
}
}
3.3 应用层防护
3.3.1 签名校验机制
签名流程:
- 前端按字典序排序所有参数
- 拼接密钥和时间戳
- 计算MD5/SHA256签名
- 将签名放在请求头中
/**
* 签名验证工具类
*/
@Component
public class SignatureValidator {
// 签名密钥(实际应放在配置中心)
@Value("${sms.sign.secret}")
private String signSecret;
// 签名有效期(5分钟)
private static final long SIGN_EXPIRE_TIME = 5 * 60 * 1000;
/**
* 生成签名(供前端参考)
*/
public static String generateSignature(Map<String, String> params,
String secret, long timestamp) {
// 1. 按字典序排序参数
TreeMap<String, String> sortedParams = new TreeMap<>(params);
// 2. 拼接字符串
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
if (entry.getValue() != null && !entry.getValue().isEmpty()) {
sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
}
}
// 3. 添加密钥和时间戳
sb.append("timestamp=").append(timestamp).append("&");
sb.append("secret=").append(secret);
// 4. 计算MD5
return DigestUtils.md5Hex(sb.toString());
}
/**
* 验证签名
*/
public boolean validateSignature(HttpServletRequest request,
Map<String, String> params) {
// 1. 获取请求头中的签名和时间戳
String signature = request.getHeader("X-Signature");
String timestampStr = request.getHeader("X-Timestamp");
if (StringUtils.isEmpty(signature) || StringUtils.isEmpty(timestampStr)) {
return false;
}
try {
long timestamp = Long.parseLong(timestampStr);
// 2. 检查时间戳是否过期
if (System.currentTimeMillis() - timestamp > SIGN_EXPIRE_TIME) {
return false;
}
// 3. 计算签名
String expectedSignature = generateSignature(params, signSecret, timestamp);
// 4. 比对签名(使用防时序攻击的比较方法)
return MessageDigest.isEqual(
signature.getBytes(StandardCharsets.UTF_8),
expectedSignature.getBytes(StandardCharsets.UTF_8)
);
} catch (NumberFormatException e) {
return false;
}
}
}
拦截器集成:
/**
* 签名验证拦截器
*/
@Component
public class SignatureInterceptor implements HandlerInterceptor {
@Autowired
private SignatureValidator signatureValidator;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 只对短信接口进行签名验证
if (!request.getRequestURI().startsWith("/api/sms/")) {
return true;
}
// 获取所有请求参数
Map<String, String> params = new HashMap<>();
request.getParameterMap().forEach((key, values) -> {
params.put(key, values[0]);
});
// 验证签名
boolean isValid = signatureValidator.validateSignature(request, params);
if (!isValid) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
try {
response.getWriter().write(
"{\"code\": 401, \"msg\": \"签名验证失败\"}"
);
} catch (IOException e) {
log.error("写入响应失败", e);
}
return false;
}
return true;
}
}
3.3.2 Redis分布式限流器
令牌桶算法实现:
/**
* 分布式限流器(基于令牌桶算法)
*/
@Component
public class DistributedRateLimiter {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* Lua脚本:原子性执行限流检查和扣减
*/
private static final String RATE_LIMIT_SCRIPT =
"local key = KEYS[1] " +
"local capacity = tonumber(ARGV[1]) " +
"local tokens = tonumber(ARGV[2]) " +
"local interval = tonumber(ARGV[3]) " +
"local current = redis.call('HMGET', key, 'tokens', 'last_refill') " +
"local currentTokens = tonumber(current[1]) or capacity " +
"local lastRefill = tonumber(current[2]) or 0 " +
"local now = tonumber(ARGV[4]) " +
"local elapsed = now - lastRefill " +
" -- 计算补充的令牌数 " +
"if elapsed > 0 then " +
" local newTokens = math.min(capacity, currentTokens + elapsed * tokens / interval) " +
" currentTokens = newTokens " +
"end " +
" -- 判断是否有足够令牌 " +
"if currentTokens >= 1 then " +
" redis.call('HMSET', key, 'tokens', currentTokens - 1, 'last_refill', now) " +
" redis.call('EXPIRE', key, interval / 1000 + 60) " +
" return 1 " + -- 允许通过
"else " +
" redis.call('HMSET', key, 'tokens', currentTokens, 'last_refill', now) " +
" redis.call('EXPIRE', key, interval / 1000 + 60) " +
" return 0 " + -- 拒绝请求
"end";
/**
* 尝试获取令牌
*
* @param key 限流键(如:sms:limit:ip:xxx)
* @param capacity 桶容量
* @param tokensPerInterval 每个时间间隔生成的令牌数
* @param interval 时间间隔(毫秒)
* @return 是否获取成功
*/
public boolean tryAcquire(String key, long capacity,
long tokensPerInterval, long interval) {
long now = System.currentTimeMillis();
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(RATE_LIMIT_SCRIPT);
script.setResultType(Long.class);
Long result = redisTemplate.execute(
script,
Collections.singletonList(key),
String.valueOf(capacity),
String.valueOf(tokensPerInterval),
String.valueOf(interval),
String.valueOf(now)
);
return result != null && result == 1L;
}
/**
* 获取当前剩余令牌数
*/
public double getAvailableTokens(String key) {
Map<Object, Object> data = redisTemplate.opsForHash().entries(key);
if (data.isEmpty()) {
return 0;
}
String tokens = (String) data.get("tokens");
return StringUtils.isEmpty(tokens) ? 0 : Double.parseDouble(tokens);
}
}
滑动窗口限流实现:
/**
* 滑动窗口限流器
*/
@Component
public class SlidingWindowRateLimiter {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* Lua脚本:滑动窗口计数
*/
private static final String SLIDING_WINDOW_SCRIPT =
"local key = KEYS[1] " +
"local now = tonumber(ARGV[1]) " +
"local windowSize = tonumber(ARGV[2]) " +
"local maxCount = tonumber(ARGV[3]) " +
" -- 删除窗口外的数据 " +
"redis.call('ZREMRANGEBYSCORE', key, '-inf', now - windowSize) " +
" -- 获取当前窗口内的请求数 " +
"local currentCount = redis.call('ZCARD', key) " +
" -- 判断是否超限 " +
"if currentCount < maxCount then " +
" redis.call('ZADD', key, now, now) " +
" redis.call('EXPIRE', key, windowSize / 1000 + 60) " +
" return 1 " +
"else " +
" return 0 " +
"end";
/**
* 尝试通过限流
*
* @param key 限流键
* @param windowSize 窗口大小(毫秒)
* @param maxCount 最大请求数
* @return 是否通过
*/
public boolean tryAcquire(String key, long windowSize, long maxCount) {
long now = System.currentTimeMillis();
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(SLIDING_WINDOW_SCRIPT);
script.setResultType(Long.class);
Long result = redisTemplate.execute(
script,
Collections.singletonList(key),
String.valueOf(now),
String.valueOf(windowSize),
String.valueOf(maxCount)
);
return result != null && result == 1L;
}
}
3.3.3 多维度限流配置
/**
* 短信接口限流配置
*/
@Configuration
public class SmsRateLimitConfig {
/**
* 限流维度配置
*/
public enum LimitDimension {
// 单IP每分钟最多20次
IP_PER_MINUTE("sms:limit:ip:%s", 60 * 1000, 20),
// 单IP每小时最多100次
IP_PER_HOUR("sms:limit:ip:%s", 60 * 60 * 1000, 100),
// 单手机号每分钟最多3次
PHONE_PER_MINUTE("sms:limit:phone:%s", 60 * 1000, 3),
// 单手机号每小时最多10次
PHONE_PER_HOUR("sms:limit:phone:%s", 60 * 60 * 1000, 10),
// 单手机号每天最多20次
PHONE_PER_DAY("sms:limit:phone:%s", 24 * 60 * 60 * 1000, 20);
private final String keyPattern;
private final long windowSize;
private final long maxCount;
LimitDimension(String keyPattern, long windowSize, long maxCount) {
this.keyPattern = keyPattern;
this.windowSize = windowSize;
this.maxCount = maxCount;
}
public String buildKey(String identifier) {
return String.format(keyPattern, identifier);
}
public long getWindowSize() {
return windowSize;
}
public long getMaxCount() {
return maxCount;
}
}
}
3.3.4 短信发送核心服务
/**
* 短信发送服务(核心业务逻辑)
*/
@Service
@Slf4j
public class SmsSendService {
@Autowired
private SlidingWindowRateLimiter slidingWindowRateLimiter;
@Autowired
private DistributedRateLimiter distributedRateLimiter;
@Autowired
private IpBlacklistService ipBlacklistService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private SmsAsyncSender smsAsyncSender;
// 验证码有效期
private static final long CODE_EXPIRE_SECONDS = 300; // 5分钟
// 验证码长度
private static final int CODE_LENGTH = 6;
// 黑名单阈值:单IP一天内失败超过50次则封禁
private static final int BLACKLIST_THRESHOLD = 50;
/**
* 发送短信验证码
*/
public SmsResult sendSmsCode(String phone, String ip, String deviceId) {
// ========== 第一层:黑名单检查 ==========
if (ipBlacklistService.isBlacklisted(ip)) {
log.warn("IP在黑名单中,拒绝请求: ip={}", ip);
return SmsResult.failed(ErrorCode.IP_BLOCKED);
}
// ========== 第二层:多维度限流检查 ==========
if (!checkRateLimit(phone, ip, deviceId)) {
log.warn("触发限流: phone={}, ip={}, deviceId={}", phone, ip, deviceId);
return SmsResult.failed(ErrorCode.RATE_LIMIT);
}
// ========== 第三层:手机号格式验证 ==========
if (!isValidPhone(phone)) {
return SmsResult.failed(ErrorCode.INVALID_PHONE);
}
// ========== 第四层:生成验证码并存储 ==========
String code = generateRandomCode(CODE_LENGTH);
String codeKey = RedisKeyConstant.SMS_CODE + phone;
// 存储验证码
redisTemplate.opsForValue().set(
codeKey,
code,
CODE_EXPIRE_SECONDS,
TimeUnit.SECONDS
);
// ========== 第五层:异步发送短信 ==========
try {
smsAsyncSender.sendSmsAsync(phone, code, ip);
// 记录成功日志
logSmsSend(phone, ip, deviceId, true, null);
return SmsResult.success();
} catch (Exception e) {
log.error("短信发送失败: phone={}", phone, e);
// 记录失败日志
logSmsSend(phone, ip, deviceId, false, e.getMessage());
// 删除已存储的验证码
redisTemplate.delete(codeKey);
return SmsResult.failed(ErrorCode.SEND_FAILED);
}
}
/**
* 多维度限流检查
*/
private boolean checkRateLimit(String phone, String ip, String deviceId) {
long now = System.currentTimeMillis();
// 1. IP维度限流
String ipMinuteKey = LimitDimension.IP_PER_MINUTE.buildKey(ip);
String ipHourKey = LimitDimension.IP_PER_HOUR.buildKey(ip);
if (!slidingWindowRateLimiter.tryAcquire(
ipMinuteKey,
LimitDimension.IP_PER_MINUTE.getWindowSize(),
LimitDimension.IP_PER_MINUTE.getMaxCount())) {
return false;
}
if (!slidingWindowRateLimiter.tryAcquire(
ipHourKey,
LimitDimension.IP_PER_HOUR.getWindowSize(),
LimitDimension.IP_PER_HOUR.getMaxCount())) {
return false;
}
// 2. 手机号维度限流
String phoneMinuteKey = LimitDimension.PHONE_PER_MINUTE.buildKey(phone);
String phoneHourKey = LimitDimension.PHONE_PER_HOUR.buildKey(phone);
String phoneDayKey = LimitDimension.PHONE_PER_DAY.buildKey(phone);
if (!slidingWindowRateLimiter.tryAcquire(
phoneMinuteKey,
LimitDimension.PHONE_PER_MINUTE.getWindowSize(),
LimitDimension.PHONE_PER_MINUTE.getMaxCount())) {
return false;
}
if (!slidingWindowRateLimiter.tryAcquire(
phoneHourKey,
LimitDimension.PHONE_PER_HOUR.getWindowSize(),
LimitDimension.PHONE_PER_HOUR.getMaxCount())) {
return false;
}
if (!slidingWindowRateLimiter.tryAcquire(
phoneDayKey,
LimitDimension.PHONE_PER_DAY.getWindowSize(),
LimitDimension.PHONE_PER_DAY.getMaxCount())) {
return false;
}
// 3. 设备维度限流(如果有设备ID)
if (StringUtils.isNotEmpty(deviceId)) {
String deviceKey = "sms:limit:device:" + deviceId;
if (!slidingWindowRateLimiter.tryAcquire(
deviceKey,
60 * 60 * 1000, // 1小时
20)) { // 最多20次
return false;
}
}
return true;
}
/**
* 验证手机号格式
*/
private boolean isValidPhone(String phone) {
// 中国大陆手机号正则
String regex = "^1[3-9]\\d{9}$";
return StringUtils.isNotEmpty(phone) && phone.matches(regex);
}
/**
* 生成随机验证码
*/
private String generateRandomCode(int length) {
Random random = new Random();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < length; i++) {
sb.append(random.nextInt(10));
}
return sb.toString();
}
/**
* 记录短信发送日志
*/
private void logSmsSend(String phone, String ip, String deviceId,
boolean success, String errorMsg) {
String logKey = RedisKeyConstant.SMS_SEND_LOG +
DateUtil.format(new Date(), "yyyyMMdd");
Map<String, String> logData = new HashMap<>();
logData.put("phone", phone);
logData.put("ip", ip);
logData.put("deviceId", deviceId);
logData.put("timestamp", String.valueOf(System.currentTimeMillis()));
logData.put("success", String.valueOf(success));
if (StringUtils.isNotEmpty(errorMsg)) {
logData.put("errorMsg", errorMsg);
}
// 使用List结构存储日志
redisTemplate.opsForList().rightPushAll(logKey, logData.toString());
redisTemplate.expire(logKey, 7, TimeUnit.DAYS);
// 检查是否需要加入黑名单
checkAndAddToBlacklist(ip);
}
/**
* 检查并加入黑名单
*/
private void checkAndAddToBlacklist(String ip) {
String failKey = RedisKeyConstant.SMS_FAIL_COUNT + ip;
Long failCount = redisTemplate.opsForValue().increment(failKey);
// 设置过期时间
if (failCount == 1) {
redisTemplate.expire(failKey, 1, TimeUnit.DAYS);
}
// 超过阈值则加入黑名单
if (failCount >= BLACKLIST_THRESHOLD) {
String reason = "单日短信发送失败次数超过阈值";
ipBlacklistService.addToBlacklist(ip, reason);
log.warn("IP加入黑名单: ip={}, failCount={}", ip, failCount);
}
}
}
3.3.5 异步短信发送器
/**
* 异步短信发送器
*/
@Component
@Slf4j
public class SmsAsyncSender {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
/**
* 异步发送短信(通过消息队列)
*/
@Async("smsExecutor")
public void sendSmsAsync(String phone, String code, String ip) {
try {
// 构建短信消息
SmsMessage message = SmsMessage.builder()
.phone(phone)
.code(code)
.ip(ip)
.timestamp(System.currentTimeMillis())
.retryCount(0)
.build();
// 发送到Kafka
kafkaTemplate.send(
"sms-send-topic",
phone, // 使用手机号作为key,保证同一手机号的消息有序
JSON.toJSONString(message)
);
log.info("短信消息已发送到Kafka: phone={}", phone);
} catch (Exception e) {
log.error("发送短信消息到Kafka失败: phone={}", phone, e);
throw new SmsSendException("消息队列发送失败", e);
}
}
}
/**
* 短信消息消费者
*/
@Component
@Slf4j
public class SmsMessageConsumer {
@Autowired
private SmsProvider smsProvider;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@KafkaListener(
topics = "sms-send-topic",
groupId = "sms-consumer-group",
concurrency = "5" // 5个并发消费者
)
public void consumeSmsMessage(ConsumerRecord<String, String> record) {
try {
String messageJson = record.value();
SmsMessage message = JSON.parseObject(messageJson, SmsMessage.class);
// 幂等性检查:防止重复消费
String idempotentKey = RedisKeyConstant.SMS_IDEMPOTENT +
message.getPhone() + ":" +
message.getTimestamp();
if (Boolean.TRUE.equals(redisTemplate.hasKey(idempotentKey))) {
log.warn("重复消费消息: phone={}", message.getPhone());
return;
}
// 调用短信服务商接口
boolean success = smsProvider.sendSms(
message.getPhone(),
buildSmsContent(message.getCode())
);
if (success) {
// 标记为已处理
redisTemplate.opsForValue().set(
idempotentKey,
"1",
24,
TimeUnit.HOURS
);
log.info("短信发送成功: phone={}", message.getPhone());
} else {
// 失败重试
handleFailure(message);
}
} catch (Exception e) {
log.error("消费短信消息失败", e);
throw e; // 抛出异常触发Kafka重试
}
}
/**
* 处理发送失败
*/
private void handleFailure(SmsMessage message) {
int maxRetry = 3;
if (message.getRetryCount() < maxRetry) {
// 增加重试次数
message.setRetryCount(message.getRetryCount() + 1);
// 延迟重试(可以通过延迟消息队列实现)
// 这里简化为直接重新发送
try {
Thread.sleep(1000 * message.getRetryCount()); // 指数退避
kafkaTemplate.send(
"sms-send-topic",
message.getPhone(),
JSON.toJSONString(message)
);
} catch (Exception e) {
log.error("重试发送失败: phone={}", message.getPhone(), e);
}
} else {
log.error("短信发送重试次数超限: phone={}", message.getPhone());
// 可以告警通知人工处理
}
}
/**
* 构建短信内容
*/
private String buildSmsContent(String code) {
return String.format("【您的验证码】%s,5分钟内有效,请勿泄露给他人。", code);
}
}
3.3.6 验证码校验服务
/**
* 验证码校验服务
*/
@Service
@Slf4j
public class SmsCodeValidator {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 验证错误次数限制
private static final int MAX_VERIFY_FAIL_COUNT = 5;
/**
* 校验验证码
*/
public VerifyResult verifyCode(String phone, String code, String ip) {
// ========== 第一层:检查错误次数 ==========
String failCountKey = RedisKeyConstant.VERIFY_FAIL_COUNT + phone;
Integer failCount = (Integer) redisTemplate.opsForValue().get(failCountKey);
if (failCount != null && failCount >= MAX_VERIFY_FAIL_COUNT) {
return VerifyResult.failed(ErrorCode.VERIFY_TOO_MANY);
}
// ========== 第二层:从Redis获取验证码 ==========
String codeKey = RedisKeyConstant.SMS_CODE + phone;
String storedCode = redisTemplate.opsForValue().get(codeKey);
if (StringUtils.isEmpty(storedCode)) {
return VerifyResult.failed(ErrorCode.CODE_EXPIRED);
}
// ========== 第三层:比对验证码 ==========
if (!storedCode.equals(code)) {
// 增加错误次数
redisTemplate.opsForValue().increment(failCountKey);
redisTemplate.expire(failCountKey, 1, TimeUnit.HOURS);
return VerifyResult.failed(ErrorCode.CODE_ERROR);
}
// ========== 第四层:验证通过,清理数据 ==========
redisTemplate.delete(codeKey);
redisTemplate.delete(failCountKey);
return VerifyResult.success();
}
}
3.4 设备指纹识别
/**
* 设备指纹生成器
*/
@Component
@Slf4j
public class DeviceFingerprintGenerator {
private static final String DEVICE_FP_SALT = "your_salt_here";
/**
* 生成设备指纹
*/
public String generateFingerprint(HttpServletRequest request) {
try {
// 1. 收集设备特征
Map<String, String> features = collectDeviceFeatures(request);
// 2. 排序特征
TreeMap<String, String> sortedFeatures = new TreeMap<>(features);
// 3. 拼接字符串
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sortedFeatures.entrySet()) {
sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
}
sb.append("salt=").append(DEVICE_FP_SALT);
// 4. 计算SHA256
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(sb.toString().getBytes(StandardCharsets.UTF_8));
// 5. 转换为十六进制字符串(取前32位)
String fingerprint = bytesToHex(hash).substring(0, 32);
return fingerprint;
} catch (Exception e) {
log.error("生成设备指纹失败", e);
return UUID.randomUUID().toString(); // 降级处理
}
}
/**
* 收集设备特征
*/
private Map<String, String> collectDeviceFeatures(HttpServletRequest request) {
Map<String, String> features = new HashMap<>();
// User-Agent
features.put("ua", request.getHeader("User-Agent"));
// Accept-Language
features.put("lang", request.getHeader("Accept-Language"));
// Accept-Encoding
features.put("encoding", request.getHeader("Accept-Encoding"));
// IP地址
features.put("ip", getClientIp(request));
// Screen分辨率(如果前端传递)
String screen = request.getParameter("screen");
if (StringUtils.isNotEmpty(screen)) {
features.put("screen", screen);
}
// 时区(如果前端传递)
String timezone = request.getParameter("timezone");
if (StringUtils.isNotEmpty(timezone)) {
features.put("timezone", timezone);
}
return features;
}
/**
* 获取客户端真实IP
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 多级代理时取第一个IP
if (StringUtils.isNotEmpty(ip) && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
/**
* 字节数组转十六进制字符串
*/
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
3.5 异常IP识别与自动封禁
/**
* 异常IP检测服务
*/
@Component
@Slf4j
public class AbnormalIpDetector {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private IpBlacklistService ipBlacklistService;
// 检测窗口时间(1小时)
private static final long DETECTION_WINDOW = 60 * 60 * 1000;
/**
* 检测异常IP并封禁
*/
@Scheduled(fixedDelay = 5 * 60 * 1000) // 每5分钟执行一次
public void detectAndBlockAbnormalIps() {
log.info("开始检测异常IP...");
// 1. 获取所有IP的访问数据
Set<String> ips = getAllActiveIps();
for (String ip : ips) {
try {
if (isAbnormalIp(ip)) {
blockIp(ip);
}
} catch (Exception e) {
log.error("检测IP异常: ip={}", ip, e);
}
}
log.info("异常IP检测完成");
}
/**
* 获取所有活跃IP
*/
private Set<String> getAllActiveIps() {
Set<String> ips = new HashSet<>();
// 从Redis中获取所有活跃IP
Set<String> keys = redisTemplate.keys("sms:limit:ip:*");
if (keys != null) {
for (String key : keys) {
// 提取IP地址
String ip = key.substring("sms:limit:ip:".length());
ips.add(ip);
}
}
return ips;
}
/**
* 判断是否为异常IP
*/
private boolean isAbnormalIp(String ip) {
// 异常特征1:短时间内请求大量不同手机号
if (hasManyDifferentPhones(ip)) {
log.warn("IP请求大量不同手机号: ip={}", ip);
return true;
}
// 异常特征2:验证码验证失败率极高
if (hasHighFailRate(ip)) {
log.warn("IP验证码失败率极高: ip={}", ip);
return true;
}
// 异常特征3:请求时间分布过于均匀(疑似脚本)
if (hasUniformRequestPattern(ip)) {
log.warn("IP请求模式过于均匀: ip={}", ip);
return true;
}
return false;
}
/**
* 检查是否请求大量不同手机号
*/
private boolean hasManyDifferentPhones(String ip) {
// 统计1小时内该IP请求的不同手机号数量
String pattern = "sms:*:phone:*";
// 从日志中统计(简化实现)
String logKey = RedisKeyConstant.SMS_SEND_LOG +
DateUtil.format(new Date(), "yyyyMMdd");
// 使用Lua脚本统计
String luaScript =
"local count = 0 " +
"local phones = {} " +
"for i, data in ipairs(redis.call('LRANGE', KEYS[1], 0, -1)) do " +
" local json = cjson.decode(data) " +
" if json.ip == ARGV[1] then " +
" phones[json.phone] = true " +
" end " +
"end " +
"for _ in pairs(phones) do " +
" count = count + 1 " +
"end " +
"return count";
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(luaScript);
script.setResultType(Long.class);
Long phoneCount = redisTemplate.execute(
script,
Collections.singletonList(logKey),
ip
);
// 1小时内请求超过50个不同手机号判定为异常
return phoneCount != null && phoneCount > 50;
}
/**
* 检查验证失败率是否过高
*/
private boolean hasHighFailRate(String ip) {
String failKey = RedisKeyConstant.VERIFY_FAIL_COUNT + ip;
Integer failCount = (Integer) redisTemplate.opsForValue().get(failKey);
if (failCount == null || failCount < 10) {
return false;
}
// 获取总请求数
String totalKey = "sms:total:ip:" + ip;
Long totalCount = redisTemplate.opsForValue().increment(totalKey, 0);
if (totalCount == null) {
return false;
}
// 失败率超过80%判定为异常
double failRate = (double) failCount / totalCount;
return failRate > 0.8;
}
/**
* 检查请求模式是否过于均匀
*/
private boolean hasUniformRequestPattern(String ip) {
// 获取该IP的请求时间戳列表
String logKey = RedisKeyConstant.SMS_SEND_LOG +
DateUtil.format(new Date(), "yyyyMMdd");
// 使用Lua脚本提取时间戳
String luaScript =
"local timestamps = {} " +
"for i, data in ipairs(redis.call('LRANGE', KEYS[1], 0, -1)) do " +
" local json = cjson.decode(data) " +
" if json.ip == ARGV[1] then " +
" table.insert(timestamps, json.timestamp) " +
" end " +
"end " +
"return timestamps";
DefaultRedisScript<List> script = new DefaultRedisScript<>();
script.setScriptText(luaScript);
script.setResultType(List.class);
List<Long> timestamps = redisTemplate.execute(
script,
Collections.singletonList(logKey),
ip
);
if (timestamps == null || timestamps.size() < 10) {
return false;
}
// 计算时间间隔的标准差
double stdDev = calculateStandardDeviation(timestamps);
// 标准差小于100ms判定为异常(过于均匀)
return stdDev < 100;
}
/**
* 计算标准差
*/
private double calculateStandardDeviation(List<Long> timestamps) {
// 计算时间间隔
List<Long> intervals = new ArrayList<>();
for (int i = 1; i < timestamps.size(); i++) {
intervals.add(timestamps.get(i) - timestamps.get(i - 1));
}
// 计算平均值
double mean = intervals.stream()
.mapToLong(Long::longValue)
.average()
.orElse(0);
// 计算方差
double variance = intervals.stream()
.mapToDouble(interval -> Math.pow(interval - mean, 2))
.average()
.orElse(0);
// 标准差
return Math.sqrt(variance);
}
/**
* 封禁IP
*/
private void blockIp(String ip) {
String reason = "检测到异常行为";
ipBlacklistService.addToBlacklist(ip, reason);
log.warn("IP已自动封禁: ip={}, reason={}", ip, reason);
// 可以发送告警通知
sendAlert(ip, reason);
}
/**
* 发送告警
*/
private void sendAlert(String ip, String reason) {
// 实现告警逻辑(邮件、钉钉、企业微信等)
log.info("发送告警: ip={}, reason={}", ip, reason);
}
}
四、性能优化策略
4.1 Redis性能优化
4.1.1 Pipeline批量操作
/**
* Redis Pipeline批量操作工具
*/
@Component
public class RedisPipelineHelper {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 批量获取多个key的值
*/
public Map<String, String> multiGet(Collection<String> keys) {
if (keys == null || keys.isEmpty()) {
return Collections.emptyMap();
}
return redisTemplate.executePipelined(
(RedisCallback<Object>) connection -> {
for (String key : keys) {
connection.get(key.getBytes());
}
return null;
}
).stream()
.filter(Objects::nonNull)
.map(obj -> (String) obj)
.collect(Collectors.toMap(
value -> extractKey(value, keys), // 需要实现key提取逻辑
value -> value
));
}
/**
* 批量设置多个key的值
*/
public void multiSet(Map<String, String> data, long expireSeconds) {
if (data == null || data.isEmpty()) {
return;
}
redisTemplate.executePipelined(
(RedisCallback<Object>) connection -> {
for (Map.Entry<String, String> entry : data.entrySet()) {
byte[] key = entry.getKey().getBytes();
byte[] value = entry.getValue().getBytes();
connection.set(key, value);
connection.expire(key, expireSeconds);
}
return null;
}
);
}
}
4.1.2 本地缓存二级缓存
/**
* 本地缓存 + Redis二级缓存
*/
@Component
@Slf4j
public class TwoLevelCache {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 本地缓存(使用Caffeine)
private final Cache<String, String> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.SECONDS) // 本地缓存10秒
.build();
/**
* 获取缓存值
*/
public String get(String key) {
// 第一层:本地缓存
String value = localCache.getIfPresent(key);
if (value != null) {
log.debug("本地缓存命中: key={}", key);
return value;
}
// 第二层:Redis缓存
value = redisTemplate.opsForValue().get(key);
if (value != null) {
log.debug("Redis缓存命中: key={}", key);
// 回填本地缓存
localCache.put(key, value);
}
return value;
}
/**
* 设置缓存值
*/
public void put(String key, String value, long expireSeconds) {
// 同时设置本地缓存和Redis
localCache.put(key, value);
redisTemplate.opsForValue().set(key, value, expireSeconds, TimeUnit.SECONDS);
}
/**
* 删除缓存值
*/
public void delete(String key) {
localCache.invalidate(key);
redisTemplate.delete(key);
}
/**
* 批量获取
*/
public Map<String, String> multiGet(Collection<String> keys) {
Map<String, String> result = new HashMap<>();
// 先从本地缓存获取
Set<String> remainingKeys = new HashSet<>();
for (String key : keys) {
String value = localCache.getIfPresent(key);
if (value != null) {
result.put(key, value);
} else {
remainingKeys.add(key);
}
}
// 剩余的key从Redis获取
if (!remainingKeys.isEmpty()) {
List<String> redisValues = redisTemplate.opsForValue()
.multiGet(remainingKeys);
if (redisValues != null) {
int index = 0;
for (String key : remainingKeys) {
String value = redisValues.get(index++);
if (value != null) {
result.put(key, value);
localCache.put(key, value);
}
}
}
}
return result;
}
}
4.1.3 Redis集群优化配置
# application.yml
spring:
redis:
cluster:
nodes:
- redis-node1:6379
- redis-node2:6379
- redis-node3:6379
- redis-node4:6379
- redis-node5:6379
- redis-node6:6379
max-redirects: 3
lettuce:
pool:
max-active: 50
max-idle: 20
min-idle: 10
max-wait: 1000ms
timeout: 2000ms4.2 异步化优化
/**
* 短信异步发送配置
*/
@Configuration
@EnableAsync
public class SmsAsyncConfig {
/**
* 短信发送线程池
*/
@Bean("smsExecutor")
public Executor smsExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心线程数(根据CPU核心数和任务类型调整)
executor.setCorePoolSize(10);
// 最大线程数
executor.setMaxPoolSize(50);
// 队列容量
executor.setQueueCapacity(500);
// 线程名称前缀
executor.setThreadNamePrefix("sms-sender-");
// 拒绝策略:调用者运行
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 线程空闲时间
executor.setKeepAliveSeconds(60);
// 允许核心线程超时
executor.setAllowCoreThreadTimeOut(true);
// 等待任务完成后关闭
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}
4.3 数据库优化
/**
* 短信发送日志批量写入
*/
@Service
@Slf4j
public class SmsLogBatchWriter {
@Autowired
private JdbcTemplate jdbcTemplate;
// 批量写入阈值
private static final int BATCH_SIZE = 100;
// 批量写入间隔(毫秒)
private static final long FLUSH_INTERVAL = 5000;
// 待写入的数据缓存
private final List<SmsLog> pendingLogs = new ArrayList<>(BATCH_SIZE);
// 上次刷新时间
private long lastFlushTime = System.currentTimeMillis();
/**
* 添加日志(线程安全)
*/
@Async
public synchronized void addLog(SmsLog log) {
pendingLogs.add(log);
long now = System.currentTimeMillis();
// 达到批量大小或超时则刷新
if (pendingLogs.size() >= BATCH_SIZE ||
now - lastFlushTime > FLUSH_INTERVAL) {
flush();
}
}
/**
* 刷新数据到数据库
*/
private synchronized void flush() {
if (pendingLogs.isEmpty()) {
return;
}
try {
// 批量插入
String sql = "INSERT INTO sms_log (phone, ip, device_id, status, " +
"error_msg, created_at) VALUES (?, ?, ?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
SmsLog log = pendingLogs.get(i);
ps.setString(1, log.getPhone());
ps.setString(2, log.getIp());
ps.setString(3, log.getDeviceId());
ps.setInt(4, log.getStatus());
ps.setString(5, log.getErrorMsg());
ps.setTimestamp(6, new Timestamp(log.getCreatedAt()));
}
@Override
public int getBatchSize() {
return pendingLogs.size();
}
});
log.info("批量写入短信日志成功: count={}", pendingLogs.size());
} catch (Exception e) {
log.error("批量写入短信日志失败", e);
} finally {
pendingLogs.clear();
lastFlushTime = System.currentTimeMillis();
}
}
/**
* 定时刷新(防止数据积压)
*/
@Scheduled(fixedDelay = FLUSH_INTERVAL)
public void scheduledFlush() {
flush();
}
}
4.4 JVM优化建议
# JVM启动参数建议(针对高并发场景)
java -Xms4g -Xmx4g \
-XX:NewRatio=1 \
-XX:SurvivorRatio=8 \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=16m \
-XX:ParallelGCThreads=4 \
-XX:ConcGCThreads=2 \
-XX:+DisableExplicitGC \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/opt/logs/heap_dump.hprof \
-Duser.timezone=Asia/Shanghai \
-jar your-application.jar五、监控与告警
5.1 关键指标监控
/**
* 短信服务指标监控
*/
@Component
@Slf4j
public class SmsMetricsMonitor {
@Autowired
private MeterRegistry meterRegistry;
/**
* 记录短信发送指标
*/
public void recordSmsSend(String phone, boolean success, long costTime) {
// 1. 计数器
meterRegistry.counter(
"sms.send.count",
"success", String.valueOf(success)
).increment();
// 2. 耗时分布
meterRegistry.timer("sms.send.time").record(costTime, TimeUnit.MILLISECONDS);
// 3. 手机号分布
meterRegistry.counter(
"sms.send.by.phone",
"prefix", phone.substring(0, 3)
).increment();
// 4. 成功率
if (success) {
meterRegistry.gauge("sms.send.success.rate", 1.0);
}
}
/**
* 记录限流指标
*/
public void recordRateLimit(String dimension, String identifier) {
meterRegistry.counter(
"sms.rate.limit",
"dimension", dimension
).increment();
}
/**
* 记录黑名单拦截
*/
public void recordBlacklistBlock(String ip) {
meterRegistry.counter(
"sms.blacklist.block",
"ip", ip
).increment();
}
}
5.2 Prometheus + Grafana监控面板
关键监控指标:
| 指标名称 | 类型 | 说明 | 告警阈值 |
|---|---|---|---|
sms.send.count | Counter | 短信发送总数 | - |
sms.send.success.rate | Gauge | 发送成功率 | < 95% |
sms.send.time | Histogram | 发送耗时分布 | P99 > 5s |
sms.rate.limit | Counter | 限流次数 | > 100/min |
sms.blacklist.block | Counter | 黑名单拦截次数 | > 50/min |
redis.slowlog.count | Counter | Redis慢查询 | > 10/min |
Grafana面板查询示例:
# 短信发送成功率
sum(rate(sms_send_count{success="true"}[5m])) / sum(rate(sms_send_count[5m])) * 100
# 发送耗时P99
histogram_quantile(0.99, rate(sms_send_time_bucket[5m]))
# 限流拦截趋势
sum(rate(sms_rate_limit[5m])) by (dimension)
# 黑名单拦截Top10 IP
topk(10, sum(rate(sms_blacklist_block[5m])) by (ip))
5.3 告警规则配置
# alert-rules.yml
groups:
- name: sms_alerts
rules:
# 短信发送成功率过低
- alert: SmsSuccessRateLow
expr: |
sum(rate(sms_send_count{success="true"}[5m])) /
sum(rate(sms_send_count[5m])) * 100 < 95
for: 5m
labels:
severity: warning
annotations:
summary: "短信发送成功率过低"
description: "最近5分钟短信发送成功率为 {{ $value }}%"
# 发送耗时过高
- alert: SmsSendTimeHigh
expr: |
histogram_quantile(0.99, rate(sms_send_time_bucket[5m])) > 5000
for: 5m
labels:
severity: critical
annotations:
summary: "短信发送耗时过高"
description: "P99耗时为 {{ $value }}ms"
# 限流拦截过多
- alert: SmsRateLimitHigh
expr: |
sum(rate(sms_rate_limit[5m])) > 100
for: 3m
labels:
severity: warning
annotations:
summary: "限流拦截过多"
description: "每分钟限流拦截 {{ $value }} 次"
# Redis慢查询过多
- alert: RedisSlowlogHigh
expr: |
rate(redis_slowlog_count[5m]) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "Redis慢查询过多"
description: "每分钟 {{ $value }} 个慢查询"六、高并发场景下的特殊处理
6.1 缓存击穿防护
/**
* 缓存击穿防护(互斥锁 + 逻辑过期)
*/
@Component
public class CacheBreakdownProtection {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 锁的过期时间
private static final long LOCK_EXPIRE_SECONDS = 10;
/**
* 获取缓存数据(防止击穿)
*/
public <T> T getWithLock(String key, Class<T> type,
Supplier<T> dataLoader,
long expireSeconds) {
// 1. 先从缓存获取
String value = redisTemplate.opsForValue().get(key);
if (value != null) {
return JSON.parseObject(value, type);
}
// 2. 获取分布式锁
String lockKey = "lock:" + key;
String lockValue = UUID.randomUUID().toString();
try {
// 尝试获取锁
Boolean locked = redisTemplate.opsForValue().setIfAbsent(
lockKey,
lockValue,
LOCK_EXPIRE_SECONDS,
TimeUnit.SECONDS
);
if (Boolean.TRUE.equals(locked)) {
// 获取锁成功,加载数据
T data = dataLoader.get();
// 存入缓存
if (data != null) {
redisTemplate.opsForValue().set(
key,
JSON.toJSONString(data),
expireSeconds,
TimeUnit.SECONDS
);
}
return data;
} else {
// 获取锁失败,短暂休眠后重试
Thread.sleep(50);
return getWithLock(key, type, dataLoader, expireSeconds);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
// 释放锁(使用Lua脚本保证原子性)
if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
lockValue
);
}
}
}
}
6.2 缓存雪崩防护
/**
* 缓存雪崩防护(随机过期时间)
*/
@Component
public class CacheAvalancheProtection {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private final Random random = new Random();
/**
* 设置缓存(带随机过期时间)
*/
public void setWithRandomExpire(String key, String value,
long baseExpireSeconds) {
// 添加随机时间(0-300秒)
long randomExpire = random.nextInt(300);
long totalExpire = baseExpireSeconds + randomExpire;
redisTemplate.opsForValue().set(
key,
value,
totalExpire,
TimeUnit.SECONDS
);
}
/**
* 缓存预热(启动时加载热点数据)
*/
@PostConstruct
public void preloadHotData() {
log.info("开始预热缓存...");
// 预热限流配置
preloadRateLimitConfig();
// 预热黑名单
preloadBlacklist();
log.info("缓存预热完成");
}
/**
* 预热限流配置
*/
private void preloadRateLimitConfig() {
// 预加载常用的限流键到本地缓存
// 避免刚启动时大量请求穿透到Redis
}
/**
* 预热黑名单
*/
private void preloadBlacklist() {
// 从数据库加载黑名单到Redis
}
}
6.3 热点数据限流
/**
* 热点数据识别与特殊限流
*/
@Component
public class HotspotDataLimiter {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 检查是否为热点数据
*/
public boolean isHotspot(String key) {
String hotspotKey = "hotspot:" + key;
// 增加访问计数
Long count = redisTemplate.opsForValue().increment(hotspotKey);
// 设置过期时间(1分钟)
if (count == 1) {
redisTemplate.expire(hotspotKey, 1, TimeUnit.MINUTES);
}
// 1分钟内访问超过100次判定为热点
return count != null && count > 100;
}
/**
* 热点数据限流检查
*/
public boolean checkHotspotLimit(String key) {
if (!isHotspot(key)) {
return true; // 不是热点,不限流
}
// 热点数据使用更严格的限流
String limitKey = "hotspot:limit:" + key;
// 使用令牌桶算法,更小的桶容量
// 每秒最多10个请求
return redisTemplate.opsForValue().setIfAbsent(
limitKey,
"1",
100,
TimeUnit.MILLISECONDS
) != null;
}
}
七、完整流程总结
7.1 短信发送完整流程
用户请求
│
▼
前端校验(倒计时、图形验证)
│
▼
签名验证
│
▼
网关限流
│
▼
IP黑名单检查
│
▼
多维度限流(IP、手机号、设备)
│
▼
设备指纹识别
│
▼
异常行为检测
│
▼
生成验证码
│
▼
Redis存储
│
▼
Kafka异步发送
│
▼
短信服务商
│
▼
更新状态 & 记录日志
│
▼
返回结果
7.2 验证码校验完整流程
用户提交验证码
│
▼
参数校验
│
▼
错误次数检查
│
▼
Redis获取存储的验证码
│
▼
比对验证码
│
▼
更新错误次数(如果失败)
│
▼
删除验证码(如果成功)
│
▼
返回结果
八、总结与建议
8.1 核心要点总结
- 分层防护:从前端到后端,每层都要有防护措施
- 多维度限流:IP、手机号、设备三个维度结合
- 异步化处理:短信发送走消息队列,不阻塞主流程
- 监控告警:实时监控关键指标,及时发现问题
- 动态调整:根据攻击模式动态调整限流策略
8.2 性能优化要点
| 优化点 | 优化手段 | 预期效果 |
|---|---|---|
| Redis性能 | Pipeline、本地缓存、集群部署 | 降低延迟50%+ |
| 异步处理 | Kafka、线程池 | 提升吞吐量5-10倍 |
| 限流算法 | 令牌桶、滑动窗口 | 平滑流量,保护后端 |
| 数据库 | 批量写入、异步写入 | 减少DB压力 |
| JVM调优 | G1GC、堆内存配置 | 降低GC停顿 |
8.3 部署架构建议
┌─────────────────┐
│ CDN/WAF │
└────────┬────────┘
│
┌────────▼────────┐
│ Nginx集群 │
│ (4核8G * 3) │
└────────┬────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌───────▼───────┐ ┌────────▼────────┐ ┌───────▼───────┐
│ 应用节点1 │ │ 应用节点2 │ │ 应用节点3 │
│ (8核16G) │ │ (8核16G) │ │ (8核16G) │
└───────┬───────┘ └────────┬────────┘ └───────┬───────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌────────▼────────┐
│ Redis Cluster │
│ (主从 * 6) │
└────────┬────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌───────▼───────┐ ┌────────▼────────┐ ┌───────▼───────┐
│ Kafka │ │ MySQL集群 │ │ 监控系统 │
│ (3节点) │ │ (主从) │ │ (Prometheus) │
└───────────────┘ └─────────────────┘ └───────────────┘
8.4 运维建议
- 定期演练:每月进行一次压力测试和故障演练
- 预案准备:制定DDoS攻击、短信服务商故障等应急预案
- 日志审计:定期审计短信发送日志,发现潜在风险
- 成本控制:监控短信成本,设置成本告警阈值
- 安全更新:及时更新限流规则和黑名单
九、附录
9.1 常见问题FAQ
Q1:如何区分正常用户和攻击者?
A:通过多维度数据分析,包括:
- 请求频率(正常用户不会高频请求)
- 手机号分布(攻击者会使用大量不同手机号)
- 行为模式(脚本的时间间隔过于均匀)
- 验证失败率(攻击者失败率通常很高)
Q2:限流阈值如何设定?
A:根据业务特点和历史数据分析:
- 从保守值开始,逐步放开
- 监控限流拦截率,控制在5%以内
- 不同时间段可以使用不同阈值
Q3:如何处理短信服务商限流?
A:
- 使用多个短信服务商作为备份
- 实现智能路由,根据服务商状态自动切换
- 本地缓存发送队列,服务商恢复后重发
Q4:验证码过期时间多长合适?
A:
- 一般场景:5-10分钟
- 金融场景:3-5分钟
- 避免过长,增加安全风险
Q5:如何防止验证码被泄露?
A:
- 限制同一验证码只能使用一次
- 验证后立即删除
- 监控异常的验证行为(如同一IP验证多个手机号)
以上就是SpringBoot实现短信验证码接口防刷的完整方案的详细内容,更多关于SpringBoot短信验证码接口防刷的资料请关注脚本之家其它相关文章!
相关文章
SpringCloud Feign集成AOP的常见问题与解决
在使用 Spring Cloud Feign 作为微服务通信的工具时,我们可能会遇到 AOP 不生效的问题,这篇文章将深入探讨这一问题,给出几种常见的场景,分析可能的原因,并提供解决方案,希望对大家有所帮助2023-10-10
SpringBoot中Formatter和Converter用法和区别小结
本文主要介绍了SpringBoot中Formatter和Converter用法和区别,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧2023-07-07
Spring Cloud详解实现声明式微服务调用OpenFeign方法
这篇文章主要介绍了Spring Cloud实现声明式微服务调用OpenFeign方法,OpenFeign 是 Spring Cloud 家族的一个成员, 它最核心的作用是为 HTTP 形式的 Rest API 提供了非常简洁高效的 RPC 调用方式,希望对大家有所帮助。一起跟随小编过来看看吧2022-07-07
Java中的ArrayList、LinkedList、HashSet等容器详解
这篇文章主要介绍了Java中的ArrayList、LinkedList、HashSet等容器详解,集合表示一组对象,称为其元素,有些集合允许重复元素,而另一些则不允许,有些是有序的,有些是无序的,需要的朋友可以参考下2023-08-08
java.lang.NullPointerException异常问题解决方案
这篇文章主要介绍了java.lang.NullPointerException异常问题解决方案,本篇文章通过简要的案例,讲解了该项技术的了解与使用,以下就是详细内容,需要的朋友可以参考下2021-08-08
创建动态代理对象bean,并动态注入到spring容器中的操作
这篇文章主要介绍了创建动态代理对象bean,并动态注入到spring容器中的操作,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧2021-02-02


最新评论