java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot短信验证码接口防刷

SpringBoot实现短信验证码接口防刷的完整方案

作者:J_liaty

本文介绍了在高并发场景下构建分层防护体系,以保障短信验证码接口的安全性、性能和成本控制,通过分层防护、多维度限流、异步化处理、监控告警等策略,有效应对各种攻击类型,提升系统的整体安全性和可用性,需要的朋友可以参考下

核心目标:在高并发场景下,构建分层防护体系,既保障接口安全,又保证响应速度,同时控制短信成本。

一、问题背景与挑战

1.1 为什么需要防刷?

短信验证码接口是攻击者的高价值目标,主要原因:

1.2 典型攻击场景

攻击类型攻击手段防护难点
暴力刷量单IP高频请求不同手机号识别IP伪装
代理IP池使用代理IP切换发送设备指纹识别
打码平台人工+自动化脚本绕过图形验证行为分析
重放攻击拦截请求重复发送签名校验
分布式攻击多节点协同攻击全局限流

1.3 防护目标

二、整体架构设计

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);
}

注意事项

3.1.2 图形验证码

实现逻辑

  1. 用户点击"发送验证码"
  2. 弹出图形验证码(滑块/点选/旋转)
  3. 验证通过后获得临时token
  4. 携带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;
    }
}

高级防护

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": "请求过于频繁,请稍后再试"}';
    }
}

参数说明

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 签名校验机制

签名流程

  1. 前端按字典序排序所有参数
  2. 拼接密钥和时间戳
  3. 计算MD5/SHA256签名
  4. 将签名放在请求头中
/**
 * 签名验证工具类
 */
@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: 2000ms

4.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.countCounter短信发送总数-
sms.send.success.rateGauge发送成功率< 95%
sms.send.timeHistogram发送耗时分布P99 > 5s
sms.rate.limitCounter限流次数> 100/min
sms.blacklist.blockCounter黑名单拦截次数> 50/min
redis.slowlog.countCounterRedis慢查询> 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 核心要点总结

  1. 分层防护:从前端到后端,每层都要有防护措施
  2. 多维度限流:IP、手机号、设备三个维度结合
  3. 异步化处理:短信发送走消息队列,不阻塞主流程
  4. 监控告警:实时监控关键指标,及时发现问题
  5. 动态调整:根据攻击模式动态调整限流策略

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 运维建议

  1. 定期演练:每月进行一次压力测试和故障演练
  2. 预案准备:制定DDoS攻击、短信服务商故障等应急预案
  3. 日志审计:定期审计短信发送日志,发现潜在风险
  4. 成本控制:监控短信成本,设置成本告警阈值
  5. 安全更新:及时更新限流规则和黑名单

九、附录

9.1 常见问题FAQ

Q1:如何区分正常用户和攻击者?

A:通过多维度数据分析,包括:

Q2:限流阈值如何设定?

A:根据业务特点和历史数据分析:

Q3:如何处理短信服务商限流?

A:

Q4:验证码过期时间多长合适?

A:

Q5:如何防止验证码被泄露?

A:

以上就是SpringBoot实现短信验证码接口防刷的完整方案的详细内容,更多关于SpringBoot短信验证码接口防刷的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文