SpringBoot實(shí)現(xiàn)短信驗(yàn)證碼接口防刷的完整方案
核心目標(biāo):在高并發(fā)場景下,構(gòu)建分層防護(hù)體系,既保障接口安全,又保證響應(yīng)速度,同時(shí)控制短信成本。
一、問題背景與挑戰(zhàn)
1.1 為什么需要防刷?
短信驗(yàn)證碼接口是攻擊者的高價(jià)值目標(biāo),主要原因:
- 直接利益驅(qū)動(dòng):刷 單、薅羊毛、惡意注冊(cè)
- 成本敏感:短信成本按條計(jì)費(fèi),刷量會(huì)導(dǎo)致成本激增
- 業(yè)務(wù)影響:真實(shí)用戶體驗(yàn)下降,品牌受損
1.2 典型攻擊場景
| 攻擊類型 | 攻擊手段 | 防護(hù)難點(diǎn) |
|---|---|---|
| 暴力刷量 | 單IP高頻請(qǐng)求不同手機(jī)號(hào) | 識(shí)別IP偽裝 |
| 代理IP池 | 使用代理IP切換發(fā)送 | 設(shè)備指紋識(shí)別 |
| 打碼平臺(tái) | 人工+自動(dòng)化腳本繞過圖形驗(yàn)證 | 行為分析 |
| 重放攻擊 | 攔截請(qǐng)求重復(fù)發(fā)送 | 簽名校驗(yàn) |
| 分布式攻擊 | 多節(jié)點(diǎn)協(xié)同攻擊 | 全局限流 |
1.3 防護(hù)目標(biāo)
- 安全性:攔截90%以上的惡意請(qǐng)求
- 性能:P99響應(yīng)時(shí)間 < 100ms
- 可用性:99.99%可用性,保證真實(shí)用戶不受影響
- 成本:短信成本控制在合理范圍
二、整體架構(gòu)設(shè)計(jì)
2.1 分層防護(hù)體系
┌─────────────────────────────────────────────────────────────┐
│ CDN/WAF層 │
│ - DDoS防護(hù) - IP黑名單 - 地域封禁 │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ 網(wǎng)關(guān)層(Nginx/API Gateway) │
│ - 全局限流 - IP封禁 - 協(xié)議過濾 │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ 應(yīng)用層(后端服務(wù)) │
│ - 簽名校驗(yàn) - 圖形驗(yàn)證 - 業(yè)務(wù)限流 │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ 緩存層(Redis Cluster) │
│ - 計(jì)數(shù)器存儲(chǔ) - 黑名單緩存 - 分布式鎖 │
└──────────────────────┬──────────────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────────────┐
│ 數(shù)據(jù)層(MySQL/消息隊(duì)列) │
│ - 記錄日志 - 異步發(fā)送短信 - 持久化數(shù)據(jù) │
└─────────────────────────────────────────────────────────────┘2.2 防護(hù)層級(jí)說明
| 防護(hù)層 | 核心能力 | 技術(shù)手段 | 響應(yīng)時(shí)間 |
|---|---|---|---|
| CDN/WAF | 攔截已知攻擊 | IP信譽(yù)庫、攻擊特征匹配 | < 10ms |
| 網(wǎng)關(guān)層 | 全局流量控制 | 限流算法、IP封禁 | < 5ms |
| 應(yīng)用層 | 業(yè)務(wù)邏輯驗(yàn)證 | 簽名、驗(yàn)證碼、限流 | 10-50ms |
| 緩存層 | 高速狀態(tài)存儲(chǔ) | Redis計(jì)數(shù)器、分布式鎖 | < 10ms |
| 數(shù)據(jù)層 | 數(shù)據(jù)持久化 | 異步寫入、消息隊(duì)列 | 不阻塞 |
三、核心防護(hù)策略詳解
3.1 前端防護(hù)層
3.1.1 按鈕倒計(jì)時(shí)
// 前端實(shí)現(xiàn)示例
let countdown = 60;
let timer = null;
function sendSmsCode(phone) {
// 倒計(jì)時(shí)中禁用
if (countdown < 60) {
showToast('請(qǐng)等待倒計(jì)時(shí)結(jié)束');
return;
}
// 發(fā)送請(qǐng)求
$.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('發(fā)送驗(yàn)證碼');
countdown = 60;
}
}, 1000);
}注意事項(xiàng):
- 倒計(jì)時(shí)結(jié)束后必須重新獲取驗(yàn)證碼token
- 防止用戶修改頁面代碼繞過
- 前端限制不能替代后端驗(yàn)證
3.1.2 圖形驗(yàn)證碼
實(shí)現(xiàn)邏輯:
- 用戶點(diǎn)擊"發(fā)送驗(yàn)證碼"
- 彈出圖形驗(yàn)證碼(滑塊/點(diǎn)選/旋轉(zhuǎn))
- 驗(yàn)證通過后獲得臨時(shí)token
- 攜帶token請(qǐng)求短信接口
// 圖形驗(yàn)證碼生成(使用Google Guava)
public class CaptchaService {
/**
* 生成滑塊驗(yàn)證碼
*/
public CaptchaVO generateSliderCaptcha(String sessionId) {
// 1. 生成滑塊和背景圖
SliderImageResult result = sliderCaptchaGenerator.generate();
// 2. 存儲(chǔ)驗(yàn)證信息到Redis,5分鐘過期
String captchaKey = RedisKeyConstant.SLIDER_CAPTCHA + sessionId;
CaptchaInfo captchaInfo = new CaptchaInfo(
result.getX(),
result.getY(),
System.currentTimeMillis()
);
// 使用Hash結(jié)構(gòu)存儲(chǔ),支持部分字段查詢
redisTemplate.opsForHash().putAll(captchaKey, captchaInfo.toMap());
redisTemplate.expire(captchaKey, 5, TimeUnit.MINUTES);
// 3. 返回前端信息(不含真實(shí)坐標(biāo))
return CaptchaVO.builder()
.sessionId(sessionId)
.backgroundImage(result.getBackgroundImage())
.sliderImage(result.getSliderImage())
.token(UUID.randomUUID().toString()) // 臨時(shí)token
.build();
}
/**
* 驗(yàn)證滑塊位置
*/
public boolean verifySliderCaptcha(String sessionId, String token,
int userX, int userY) {
String captchaKey = RedisKeyConstant.SLIDER_CAPTCHA + sessionId;
// 獲取存儲(chǔ)的驗(yàn)證信息
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;
// 驗(yàn)證通過后刪除驗(yàn)證碼
if (isValid) {
redisTemplate.delete(captchaKey);
}
return isValid;
}
}
高級(jí)防護(hù):
- 軌跡分析:記錄鼠標(biāo)移動(dòng)軌跡,判斷是否真人操作
- 時(shí)間檢測:驗(yàn)證響應(yīng)時(shí)間 < 1秒判定為腳本
- 設(shè)備指紋:生成設(shè)備唯一標(biāo)識(shí),防止繞過
3.2 網(wǎng)關(guān)層防護(hù)
3.2.1 Nginx限流配置
# 限流配置:定義限流區(qū)域
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/ {
# 請(qǐng)求限流:每秒5個(gè)請(qǐng)求,允許突發(fā)10個(gè)
limit_req zone=sms_limit burst=10 nodelay;
# 連接數(shù)限制:同一IP最多20個(gè)并發(fā)連接
limit_conn sms_conn 20;
# 限流返回狀態(tài)碼
limit_req_status 429;
# 限流后返回JSON響應(yīng)
error_page 429 = @rate_limited;
proxy_pass http://backend;
}
location @rate_limited {
default_type application/json;
return 429 '{"code": 429, "msg": "請(qǐng)求過于頻繁,請(qǐng)稍后再試"}';
}
}參數(shù)說明:
zone=sms_limit:10m:限流區(qū)域名稱,10MB內(nèi)存(可存約16萬個(gè)IP狀態(tài))rate=5r/s:每秒允許5個(gè)請(qǐng)求burst=10:允許突發(fā)10個(gè)請(qǐng)求(緩沖區(qū)大小)nodelay:超過burst立即拒絕,不延遲處理
3.2.2 IP黑名單動(dòng)態(tài)管理
/**
* IP黑名單管理服務(wù)
*/
@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);
// 記錄封禁原因和封禁時(shí)間
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);
// 同時(shí)更新主黑名單的過期時(shí)間
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 應(yīng)用層防護(hù)
3.3.1 簽名校驗(yàn)機(jī)制
簽名流程:
- 前端按字典序排序所有參數(shù)
- 拼接密鑰和時(shí)間戳
- 計(jì)算MD5/SHA256簽名
- 將簽名放在請(qǐng)求頭中
/**
* 簽名驗(yàn)證工具類
*/
@Component
public class SignatureValidator {
// 簽名密鑰(實(shí)際應(yīng)放在配置中心)
@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. 按字典序排序參數(shù)
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. 添加密鑰和時(shí)間戳
sb.append("timestamp=").append(timestamp).append("&");
sb.append("secret=").append(secret);
// 4. 計(jì)算MD5
return DigestUtils.md5Hex(sb.toString());
}
/**
* 驗(yàn)證簽名
*/
public boolean validateSignature(HttpServletRequest request,
Map<String, String> params) {
// 1. 獲取請(qǐng)求頭中的簽名和時(shí)間戳
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. 檢查時(shí)間戳是否過期
if (System.currentTimeMillis() - timestamp > SIGN_EXPIRE_TIME) {
return false;
}
// 3. 計(jì)算簽名
String expectedSignature = generateSignature(params, signSecret, timestamp);
// 4. 比對(duì)簽名(使用防時(shí)序攻擊的比較方法)
return MessageDigest.isEqual(
signature.getBytes(StandardCharsets.UTF_8),
expectedSignature.getBytes(StandardCharsets.UTF_8)
);
} catch (NumberFormatException e) {
return false;
}
}
}
攔截器集成:
/**
* 簽名驗(yàn)證攔截器
*/
@Component
public class SignatureInterceptor implements HandlerInterceptor {
@Autowired
private SignatureValidator signatureValidator;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 只對(duì)短信接口進(jìn)行簽名驗(yàn)證
if (!request.getRequestURI().startsWith("/api/sms/")) {
return true;
}
// 獲取所有請(qǐng)求參數(shù)
Map<String, String> params = new HashMap<>();
request.getParameterMap().forEach((key, values) -> {
params.put(key, values[0]);
});
// 驗(yàn)證簽名
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\": \"簽名驗(yàn)證失敗\"}"
);
} catch (IOException e) {
log.error("寫入響應(yīng)失敗", e);
}
return false;
}
return true;
}
}
3.3.2 Redis分布式限流器
令牌桶算法實(shí)現(xiàn):
/**
* 分布式限流器(基于令牌桶算法)
*/
@Component
public class DistributedRateLimiter {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* Lua腳本:原子性執(zhí)行限流檢查和扣減
*/
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 " +
" -- 計(jì)算補(bǔ)充的令牌數(shù) " +
"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 " + -- 拒絕請(qǐng)求
"end";
/**
* 嘗試獲取令牌
*
* @param key 限流鍵(如:sms:limit:ip:xxx)
* @param capacity 桶容量
* @param tokensPerInterval 每個(gè)時(shí)間間隔生成的令牌數(shù)
* @param interval 時(shí)間間隔(毫秒)
* @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;
}
/**
* 獲取當(dāng)前剩余令牌數(shù)
*/
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);
}
}
滑動(dòng)窗口限流實(shí)現(xiàn):
/**
* 滑動(dòng)窗口限流器
*/
@Component
public class SlidingWindowRateLimiter {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* Lua腳本:滑動(dòng)窗口計(jì)數(shù)
*/
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]) " +
" -- 刪除窗口外的數(shù)據(jù) " +
"redis.call('ZREMRANGEBYSCORE', key, '-inf', now - windowSize) " +
" -- 獲取當(dāng)前窗口內(nèi)的請(qǐng)求數(shù) " +
"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 最大請(qǐng)求數(shù)
* @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每小時(shí)最多100次
IP_PER_HOUR("sms:limit:ip:%s", 60 * 60 * 1000, 100),
// 單手機(jī)號(hào)每分鐘最多3次
PHONE_PER_MINUTE("sms:limit:phone:%s", 60 * 1000, 3),
// 單手機(jī)號(hào)每小時(shí)最多10次
PHONE_PER_HOUR("sms:limit:phone:%s", 60 * 60 * 1000, 10),
// 單手機(jī)號(hào)每天最多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 短信發(fā)送核心服務(wù)
/**
* 短信發(fā)送服務(wù)(核心業(yè)務(wù)邏輯)
*/
@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;
// 驗(yàn)證碼有效期
private static final long CODE_EXPIRE_SECONDS = 300; // 5分鐘
// 驗(yàn)證碼長度
private static final int CODE_LENGTH = 6;
// 黑名單閾值:單IP一天內(nèi)失敗超過50次則封禁
private static final int BLACKLIST_THRESHOLD = 50;
/**
* 發(fā)送短信驗(yàn)證碼
*/
public SmsResult sendSmsCode(String phone, String ip, String deviceId) {
// ========== 第一層:黑名單檢查 ==========
if (ipBlacklistService.isBlacklisted(ip)) {
log.warn("IP在黑名單中,拒絕請(qǐng)求: ip={}", ip);
return SmsResult.failed(ErrorCode.IP_BLOCKED);
}
// ========== 第二層:多維度限流檢查 ==========
if (!checkRateLimit(phone, ip, deviceId)) {
log.warn("觸發(fā)限流: phone={}, ip={}, deviceId={}", phone, ip, deviceId);
return SmsResult.failed(ErrorCode.RATE_LIMIT);
}
// ========== 第三層:手機(jī)號(hào)格式驗(yàn)證 ==========
if (!isValidPhone(phone)) {
return SmsResult.failed(ErrorCode.INVALID_PHONE);
}
// ========== 第四層:生成驗(yàn)證碼并存儲(chǔ) ==========
String code = generateRandomCode(CODE_LENGTH);
String codeKey = RedisKeyConstant.SMS_CODE + phone;
// 存儲(chǔ)驗(yàn)證碼
redisTemplate.opsForValue().set(
codeKey,
code,
CODE_EXPIRE_SECONDS,
TimeUnit.SECONDS
);
// ========== 第五層:異步發(fā)送短信 ==========
try {
smsAsyncSender.sendSmsAsync(phone, code, ip);
// 記錄成功日志
logSmsSend(phone, ip, deviceId, true, null);
return SmsResult.success();
} catch (Exception e) {
log.error("短信發(fā)送失敗: phone={}", phone, e);
// 記錄失敗日志
logSmsSend(phone, ip, deviceId, false, e.getMessage());
// 刪除已存儲(chǔ)的驗(yàn)證碼
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. 手機(jī)號(hào)維度限流
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. 設(shè)備維度限流(如果有設(shè)備ID)
if (StringUtils.isNotEmpty(deviceId)) {
String deviceKey = "sms:limit:device:" + deviceId;
if (!slidingWindowRateLimiter.tryAcquire(
deviceKey,
60 * 60 * 1000, // 1小時(shí)
20)) { // 最多20次
return false;
}
}
return true;
}
/**
* 驗(yàn)證手機(jī)號(hào)格式
*/
private boolean isValidPhone(String phone) {
// 中國大陸手機(jī)號(hào)正則
String regex = "^1[3-9]\\d{9}$";
return StringUtils.isNotEmpty(phone) && phone.matches(regex);
}
/**
* 生成隨機(jī)驗(yàn)證碼
*/
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();
}
/**
* 記錄短信發(fā)送日志
*/
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結(jié)構(gòu)存儲(chǔ)日志
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);
// 設(shè)置過期時(shí)間
if (failCount == 1) {
redisTemplate.expire(failKey, 1, TimeUnit.DAYS);
}
// 超過閾值則加入黑名單
if (failCount >= BLACKLIST_THRESHOLD) {
String reason = "單日短信發(fā)送失敗次數(shù)超過閾值";
ipBlacklistService.addToBlacklist(ip, reason);
log.warn("IP加入黑名單: ip={}, failCount={}", ip, failCount);
}
}
}
3.3.5 異步短信發(fā)送器
/**
* 異步短信發(fā)送器
*/
@Component
@Slf4j
public class SmsAsyncSender {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
/**
* 異步發(fā)送短信(通過消息隊(duì)列)
*/
@Async("smsExecutor")
public void sendSmsAsync(String phone, String code, String ip) {
try {
// 構(gòu)建短信消息
SmsMessage message = SmsMessage.builder()
.phone(phone)
.code(code)
.ip(ip)
.timestamp(System.currentTimeMillis())
.retryCount(0)
.build();
// 發(fā)送到Kafka
kafkaTemplate.send(
"sms-send-topic",
phone, // 使用手機(jī)號(hào)作為key,保證同一手機(jī)號(hào)的消息有序
JSON.toJSONString(message)
);
log.info("短信消息已發(fā)送到Kafka: phone={}", phone);
} catch (Exception e) {
log.error("發(fā)送短信消息到Kafka失敗: phone={}", phone, e);
throw new SmsSendException("消息隊(duì)列發(fā)送失敗", e);
}
}
}
/**
* 短信消息消費(fèi)者
*/
@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個(gè)并發(fā)消費(fèi)者
)
public void consumeSmsMessage(ConsumerRecord<String, String> record) {
try {
String messageJson = record.value();
SmsMessage message = JSON.parseObject(messageJson, SmsMessage.class);
// 冪等性檢查:防止重復(fù)消費(fèi)
String idempotentKey = RedisKeyConstant.SMS_IDEMPOTENT +
message.getPhone() + ":" +
message.getTimestamp();
if (Boolean.TRUE.equals(redisTemplate.hasKey(idempotentKey))) {
log.warn("重復(fù)消費(fèi)消息: phone={}", message.getPhone());
return;
}
// 調(diào)用短信服務(wù)商接口
boolean success = smsProvider.sendSms(
message.getPhone(),
buildSmsContent(message.getCode())
);
if (success) {
// 標(biāo)記為已處理
redisTemplate.opsForValue().set(
idempotentKey,
"1",
24,
TimeUnit.HOURS
);
log.info("短信發(fā)送成功: phone={}", message.getPhone());
} else {
// 失敗重試
handleFailure(message);
}
} catch (Exception e) {
log.error("消費(fèi)短信消息失敗", e);
throw e; // 拋出異常觸發(fā)Kafka重試
}
}
/**
* 處理發(fā)送失敗
*/
private void handleFailure(SmsMessage message) {
int maxRetry = 3;
if (message.getRetryCount() < maxRetry) {
// 增加重試次數(shù)
message.setRetryCount(message.getRetryCount() + 1);
// 延遲重試(可以通過延遲消息隊(duì)列實(shí)現(xiàn))
// 這里簡化為直接重新發(fā)送
try {
Thread.sleep(1000 * message.getRetryCount()); // 指數(shù)退避
kafkaTemplate.send(
"sms-send-topic",
message.getPhone(),
JSON.toJSONString(message)
);
} catch (Exception e) {
log.error("重試發(fā)送失敗: phone={}", message.getPhone(), e);
}
} else {
log.error("短信發(fā)送重試次數(shù)超限: phone={}", message.getPhone());
// 可以告警通知人工處理
}
}
/**
* 構(gòu)建短信內(nèi)容
*/
private String buildSmsContent(String code) {
return String.format("【您的驗(yàn)證碼】%s,5分鐘內(nèi)有效,請(qǐng)勿泄露給他人。", code);
}
}
3.3.6 驗(yàn)證碼校驗(yàn)服務(wù)
/**
* 驗(yàn)證碼校驗(yàn)服務(wù)
*/
@Service
@Slf4j
public class SmsCodeValidator {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 驗(yàn)證錯(cuò)誤次數(shù)限制
private static final int MAX_VERIFY_FAIL_COUNT = 5;
/**
* 校驗(yàn)驗(yàn)證碼
*/
public VerifyResult verifyCode(String phone, String code, String ip) {
// ========== 第一層:檢查錯(cuò)誤次數(shù) ==========
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獲取驗(yàn)證碼 ==========
String codeKey = RedisKeyConstant.SMS_CODE + phone;
String storedCode = redisTemplate.opsForValue().get(codeKey);
if (StringUtils.isEmpty(storedCode)) {
return VerifyResult.failed(ErrorCode.CODE_EXPIRED);
}
// ========== 第三層:比對(duì)驗(yàn)證碼 ==========
if (!storedCode.equals(code)) {
// 增加錯(cuò)誤次數(shù)
redisTemplate.opsForValue().increment(failCountKey);
redisTemplate.expire(failCountKey, 1, TimeUnit.HOURS);
return VerifyResult.failed(ErrorCode.CODE_ERROR);
}
// ========== 第四層:驗(yàn)證通過,清理數(shù)據(jù) ==========
redisTemplate.delete(codeKey);
redisTemplate.delete(failCountKey);
return VerifyResult.success();
}
}
3.4 設(shè)備指紋識(shí)別
/**
* 設(shè)備指紋生成器
*/
@Component
@Slf4j
public class DeviceFingerprintGenerator {
private static final String DEVICE_FP_SALT = "your_salt_here";
/**
* 生成設(shè)備指紋
*/
public String generateFingerprint(HttpServletRequest request) {
try {
// 1. 收集設(shè)備特征
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. 計(jì)算SHA256
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(sb.toString().getBytes(StandardCharsets.UTF_8));
// 5. 轉(zhuǎn)換為十六進(jìn)制字符串(取前32位)
String fingerprint = bytesToHex(hash).substring(0, 32);
return fingerprint;
} catch (Exception e) {
log.error("生成設(shè)備指紋失敗", e);
return UUID.randomUUID().toString(); // 降級(jí)處理
}
}
/**
* 收集設(shè)備特征
*/
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);
}
// 時(shí)區(qū)(如果前端傳遞)
String timezone = request.getParameter("timezone");
if (StringUtils.isNotEmpty(timezone)) {
features.put("timezone", timezone);
}
return features;
}
/**
* 獲取客戶端真實(shí)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();
}
// 多級(jí)代理時(shí)取第一個(gè)IP
if (StringUtils.isNotEmpty(ip) && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
/**
* 字節(jié)數(shù)組轉(zhuǎn)十六進(jìn)制字符串
*/
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識(shí)別與自動(dòng)封禁
/**
* 異常IP檢測服務(wù)
*/
@Component
@Slf4j
public class AbnormalIpDetector {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private IpBlacklistService ipBlacklistService;
// 檢測窗口時(shí)間(1小時(shí))
private static final long DETECTION_WINDOW = 60 * 60 * 1000;
/**
* 檢測異常IP并封禁
*/
@Scheduled(fixedDelay = 5 * 60 * 1000) // 每5分鐘執(zhí)行一次
public void detectAndBlockAbnormalIps() {
log.info("開始檢測異常IP...");
// 1. 獲取所有IP的訪問數(shù)據(jù)
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:短時(shí)間內(nèi)請(qǐng)求大量不同手機(jī)號(hào)
if (hasManyDifferentPhones(ip)) {
log.warn("IP請(qǐng)求大量不同手機(jī)號(hào): ip={}", ip);
return true;
}
// 異常特征2:驗(yàn)證碼驗(yàn)證失敗率極高
if (hasHighFailRate(ip)) {
log.warn("IP驗(yàn)證碼失敗率極高: ip={}", ip);
return true;
}
// 異常特征3:請(qǐng)求時(shí)間分布過于均勻(疑似腳本)
if (hasUniformRequestPattern(ip)) {
log.warn("IP請(qǐng)求模式過于均勻: ip={}", ip);
return true;
}
return false;
}
/**
* 檢查是否請(qǐng)求大量不同手機(jī)號(hào)
*/
private boolean hasManyDifferentPhones(String ip) {
// 統(tǒng)計(jì)1小時(shí)內(nèi)該IP請(qǐng)求的不同手機(jī)號(hào)數(shù)量
String pattern = "sms:*:phone:*";
// 從日志中統(tǒng)計(jì)(簡化實(shí)現(xiàn))
String logKey = RedisKeyConstant.SMS_SEND_LOG +
DateUtil.format(new Date(), "yyyyMMdd");
// 使用Lua腳本統(tǒng)計(jì)
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小時(shí)內(nèi)請(qǐng)求超過50個(gè)不同手機(jī)號(hào)判定為異常
return phoneCount != null && phoneCount > 50;
}
/**
* 檢查驗(yàn)證失敗率是否過高
*/
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;
}
// 獲取總請(qǐng)求數(shù)
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;
}
/**
* 檢查請(qǐng)求模式是否過于均勻
*/
private boolean hasUniformRequestPattern(String ip) {
// 獲取該IP的請(qǐng)求時(shí)間戳列表
String logKey = RedisKeyConstant.SMS_SEND_LOG +
DateUtil.format(new Date(), "yyyyMMdd");
// 使用Lua腳本提取時(shí)間戳
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;
}
// 計(jì)算時(shí)間間隔的標(biāo)準(zhǔn)差
double stdDev = calculateStandardDeviation(timestamps);
// 標(biāo)準(zhǔn)差小于100ms判定為異常(過于均勻)
return stdDev < 100;
}
/**
* 計(jì)算標(biāo)準(zhǔn)差
*/
private double calculateStandardDeviation(List<Long> timestamps) {
// 計(jì)算時(shí)間間隔
List<Long> intervals = new ArrayList<>();
for (int i = 1; i < timestamps.size(); i++) {
intervals.add(timestamps.get(i) - timestamps.get(i - 1));
}
// 計(jì)算平均值
double mean = intervals.stream()
.mapToLong(Long::longValue)
.average()
.orElse(0);
// 計(jì)算方差
double variance = intervals.stream()
.mapToDouble(interval -> Math.pow(interval - mean, 2))
.average()
.orElse(0);
// 標(biāo)準(zhǔn)差
return Math.sqrt(variance);
}
/**
* 封禁IP
*/
private void blockIp(String ip) {
String reason = "檢測到異常行為";
ipBlacklistService.addToBlacklist(ip, reason);
log.warn("IP已自動(dòng)封禁: ip={}, reason={}", ip, reason);
// 可以發(fā)送告警通知
sendAlert(ip, reason);
}
/**
* 發(fā)送告警
*/
private void sendAlert(String ip, String reason) {
// 實(shí)現(xiàn)告警邏輯(郵件、釘釘、企業(yè)微信等)
log.info("發(fā)送告警: ip={}, reason={}", ip, reason);
}
}
四、性能優(yōu)化策略
4.1 Redis性能優(yōu)化
4.1.1 Pipeline批量操作
/**
* Redis Pipeline批量操作工具
*/
@Component
public class RedisPipelineHelper {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 批量獲取多個(gè)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), // 需要實(shí)現(xiàn)key提取邏輯
value -> value
));
}
/**
* 批量設(shè)置多個(gè)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 本地緩存二級(jí)緩存
/**
* 本地緩存 + Redis二級(jí)緩存
*/
@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;
}
/**
* 設(shè)置緩存值
*/
public void put(String key, String value, long expireSeconds) {
// 同時(shí)設(shè)置本地緩存和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集群優(yōu)化配置
# 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 異步化優(yōu)化
/**
* 短信異步發(fā)送配置
*/
@Configuration
@EnableAsync
public class SmsAsyncConfig {
/**
* 短信發(fā)送線程池
*/
@Bean("smsExecutor")
public Executor smsExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 核心線程數(shù)(根據(jù)CPU核心數(shù)和任務(wù)類型調(diào)整)
executor.setCorePoolSize(10);
// 最大線程數(shù)
executor.setMaxPoolSize(50);
// 隊(duì)列容量
executor.setQueueCapacity(500);
// 線程名稱前綴
executor.setThreadNamePrefix("sms-sender-");
// 拒絕策略:調(diào)用者運(yùn)行
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
// 線程空閑時(shí)間
executor.setKeepAliveSeconds(60);
// 允許核心線程超時(shí)
executor.setAllowCoreThreadTimeOut(true);
// 等待任務(wù)完成后關(guān)閉
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}
4.3 數(shù)據(jù)庫優(yōu)化
/**
* 短信發(fā)送日志批量寫入
*/
@Service
@Slf4j
public class SmsLogBatchWriter {
@Autowired
private JdbcTemplate jdbcTemplate;
// 批量寫入閾值
private static final int BATCH_SIZE = 100;
// 批量寫入間隔(毫秒)
private static final long FLUSH_INTERVAL = 5000;
// 待寫入的數(shù)據(jù)緩存
private final List<SmsLog> pendingLogs = new ArrayList<>(BATCH_SIZE);
// 上次刷新時(shí)間
private long lastFlushTime = System.currentTimeMillis();
/**
* 添加日志(線程安全)
*/
@Async
public synchronized void addLog(SmsLog log) {
pendingLogs.add(log);
long now = System.currentTimeMillis();
// 達(dá)到批量大小或超時(shí)則刷新
if (pendingLogs.size() >= BATCH_SIZE ||
now - lastFlushTime > FLUSH_INTERVAL) {
flush();
}
}
/**
* 刷新數(shù)據(jù)到數(shù)據(jù)庫
*/
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();
}
}
/**
* 定時(shí)刷新(防止數(shù)據(jù)積壓)
*/
@Scheduled(fixedDelay = FLUSH_INTERVAL)
public void scheduledFlush() {
flush();
}
}
4.4 JVM優(yōu)化建議
# JVM啟動(dòng)參數(shù)建議(針對(duì)高并發(fā)場景)
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五、監(jiān)控與告警
5.1 關(guān)鍵指標(biāo)監(jiān)控
/**
* 短信服務(wù)指標(biāo)監(jiān)控
*/
@Component
@Slf4j
public class SmsMetricsMonitor {
@Autowired
private MeterRegistry meterRegistry;
/**
* 記錄短信發(fā)送指標(biāo)
*/
public void recordSmsSend(String phone, boolean success, long costTime) {
// 1. 計(jì)數(shù)器
meterRegistry.counter(
"sms.send.count",
"success", String.valueOf(success)
).increment();
// 2. 耗時(shí)分布
meterRegistry.timer("sms.send.time").record(costTime, TimeUnit.MILLISECONDS);
// 3. 手機(jī)號(hào)分布
meterRegistry.counter(
"sms.send.by.phone",
"prefix", phone.substring(0, 3)
).increment();
// 4. 成功率
if (success) {
meterRegistry.gauge("sms.send.success.rate", 1.0);
}
}
/**
* 記錄限流指標(biāo)
*/
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監(jiān)控面板
關(guān)鍵監(jiān)控指標(biāo):
| 指標(biāo)名稱 | 類型 | 說明 | 告警閾值 |
|---|---|---|---|
sms.send.count | Counter | 短信發(fā)送總數(shù) | - |
sms.send.success.rate | Gauge | 發(fā)送成功率 | < 95% |
sms.send.time | Histogram | 發(fā)送耗時(shí)分布 | P99 > 5s |
sms.rate.limit | Counter | 限流次數(shù) | > 100/min |
sms.blacklist.block | Counter | 黑名單攔截次數(shù) | > 50/min |
redis.slowlog.count | Counter | Redis慢查詢 | > 10/min |
Grafana面板查詢示例:
# 短信發(fā)送成功率
sum(rate(sms_send_count{success="true"}[5m])) / sum(rate(sms_send_count[5m])) * 100
# 發(fā)送耗時(shí)P99
histogram_quantile(0.99, rate(sms_send_time_bucket[5m]))
# 限流攔截趨勢(shì)
sum(rate(sms_rate_limit[5m])) by (dimension)
# 黑名單攔截Top10 IP
topk(10, sum(rate(sms_blacklist_block[5m])) by (ip))
5.3 告警規(guī)則配置
# alert-rules.yml
groups:
- name: sms_alerts
rules:
# 短信發(fā)送成功率過低
- 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: "短信發(fā)送成功率過低"
description: "最近5分鐘短信發(fā)送成功率為 {{ $value }}%"
# 發(fā)送耗時(shí)過高
- alert: SmsSendTimeHigh
expr: |
histogram_quantile(0.99, rate(sms_send_time_bucket[5m])) > 5000
for: 5m
labels:
severity: critical
annotations:
summary: "短信發(fā)送耗時(shí)過高"
description: "P99耗時(shí)為 {{ $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 }} 個(gè)慢查詢"六、高并發(fā)場景下的特殊處理
6.1 緩存擊穿防護(hù)
/**
* 緩存擊穿防護(hù)(互斥鎖 + 邏輯過期)
*/
@Component
public class CacheBreakdownProtection {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 鎖的過期時(shí)間
private static final long LOCK_EXPIRE_SECONDS = 10;
/**
* 獲取緩存數(shù)據(jù)(防止擊穿)
*/
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)) {
// 獲取鎖成功,加載數(shù)據(jù)
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 緩存雪崩防護(hù)
/**
* 緩存雪崩防護(hù)(隨機(jī)過期時(shí)間)
*/
@Component
public class CacheAvalancheProtection {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private final Random random = new Random();
/**
* 設(shè)置緩存(帶隨機(jī)過期時(shí)間)
*/
public void setWithRandomExpire(String key, String value,
long baseExpireSeconds) {
// 添加隨機(jī)時(shí)間(0-300秒)
long randomExpire = random.nextInt(300);
long totalExpire = baseExpireSeconds + randomExpire;
redisTemplate.opsForValue().set(
key,
value,
totalExpire,
TimeUnit.SECONDS
);
}
/**
* 緩存預(yù)熱(啟動(dòng)時(shí)加載熱點(diǎn)數(shù)據(jù))
*/
@PostConstruct
public void preloadHotData() {
log.info("開始預(yù)熱緩存...");
// 預(yù)熱限流配置
preloadRateLimitConfig();
// 預(yù)熱黑名單
preloadBlacklist();
log.info("緩存預(yù)熱完成");
}
/**
* 預(yù)熱限流配置
*/
private void preloadRateLimitConfig() {
// 預(yù)加載常用的限流鍵到本地緩存
// 避免剛啟動(dòng)時(shí)大量請(qǐng)求穿透到Redis
}
/**
* 預(yù)熱黑名單
*/
private void preloadBlacklist() {
// 從數(shù)據(jù)庫加載黑名單到Redis
}
}
6.3 熱點(diǎn)數(shù)據(jù)限流
/**
* 熱點(diǎn)數(shù)據(jù)識(shí)別與特殊限流
*/
@Component
public class HotspotDataLimiter {
@Autowired
private RedisTemplate<String, String> redisTemplate;
/**
* 檢查是否為熱點(diǎn)數(shù)據(jù)
*/
public boolean isHotspot(String key) {
String hotspotKey = "hotspot:" + key;
// 增加訪問計(jì)數(shù)
Long count = redisTemplate.opsForValue().increment(hotspotKey);
// 設(shè)置過期時(shí)間(1分鐘)
if (count == 1) {
redisTemplate.expire(hotspotKey, 1, TimeUnit.MINUTES);
}
// 1分鐘內(nèi)訪問超過100次判定為熱點(diǎn)
return count != null && count > 100;
}
/**
* 熱點(diǎn)數(shù)據(jù)限流檢查
*/
public boolean checkHotspotLimit(String key) {
if (!isHotspot(key)) {
return true; // 不是熱點(diǎn),不限流
}
// 熱點(diǎn)數(shù)據(jù)使用更嚴(yán)格的限流
String limitKey = "hotspot:limit:" + key;
// 使用令牌桶算法,更小的桶容量
// 每秒最多10個(gè)請(qǐng)求
return redisTemplate.opsForValue().setIfAbsent(
limitKey,
"1",
100,
TimeUnit.MILLISECONDS
) != null;
}
}
七、完整流程總結(jié)
7.1 短信發(fā)送完整流程
用戶請(qǐng)求
│
▼
前端校驗(yàn)(倒計(jì)時(shí)、圖形驗(yàn)證)
│
▼
簽名驗(yàn)證
│
▼
網(wǎng)關(guān)限流
│
▼
IP黑名單檢查
│
▼
多維度限流(IP、手機(jī)號(hào)、設(shè)備)
│
▼
設(shè)備指紋識(shí)別
│
▼
異常行為檢測
│
▼
生成驗(yàn)證碼
│
▼
Redis存儲(chǔ)
│
▼
Kafka異步發(fā)送
│
▼
短信服務(wù)商
│
▼
更新狀態(tài) & 記錄日志
│
▼
返回結(jié)果
7.2 驗(yàn)證碼校驗(yàn)完整流程
用戶提交驗(yàn)證碼
│
▼
參數(shù)校驗(yàn)
│
▼
錯(cuò)誤次數(shù)檢查
│
▼
Redis獲取存儲(chǔ)的驗(yàn)證碼
│
▼
比對(duì)驗(yàn)證碼
│
▼
更新錯(cuò)誤次數(shù)(如果失?。?
│
▼
刪除驗(yàn)證碼(如果成功)
│
▼
返回結(jié)果
八、總結(jié)與建議
8.1 核心要點(diǎn)總結(jié)
- 分層防護(hù):從前端到后端,每層都要有防護(hù)措施
- 多維度限流:IP、手機(jī)號(hào)、設(shè)備三個(gè)維度結(jié)合
- 異步化處理:短信發(fā)送走消息隊(duì)列,不阻塞主流程
- 監(jiān)控告警:實(shí)時(shí)監(jiān)控關(guān)鍵指標(biāo),及時(shí)發(fā)現(xiàn)問題
- 動(dòng)態(tài)調(diào)整:根據(jù)攻擊模式動(dòng)態(tài)調(diào)整限流策略
8.2 性能優(yōu)化要點(diǎn)
| 優(yōu)化點(diǎn) | 優(yōu)化手段 | 預(yù)期效果 |
|---|---|---|
| Redis性能 | Pipeline、本地緩存、集群部署 | 降低延遲50%+ |
| 異步處理 | Kafka、線程池 | 提升吞吐量5-10倍 |
| 限流算法 | 令牌桶、滑動(dòng)窗口 | 平滑流量,保護(hù)后端 |
| 數(shù)據(jù)庫 | 批量寫入、異步寫入 | 減少DB壓力 |
| JVM調(diào)優(yōu) | G1GC、堆內(nèi)存配置 | 降低GC停頓 |
8.3 部署架構(gòu)建議
┌─────────────────┐
│ CDN/WAF │
└────────┬────────┘
│
┌────────▼────────┐
│ Nginx集群 │
│ (4核8G * 3) │
└────────┬────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌───────▼───────┐ ┌────────▼────────┐ ┌───────▼───────┐
│ 應(yīng)用節(jié)點(diǎn)1 │ │ 應(yīng)用節(jié)點(diǎn)2 │ │ 應(yīng)用節(jié)點(diǎn)3 │
│ (8核16G) │ │ (8核16G) │ │ (8核16G) │
└───────┬───────┘ └────────┬────────┘ └───────┬───────┘
│ │ │
└────────────────────┼────────────────────┘
│
┌────────▼────────┐
│ Redis Cluster │
│ (主從 * 6) │
└────────┬────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌───────▼───────┐ ┌────────▼────────┐ ┌───────▼───────┐
│ Kafka │ │ MySQL集群 │ │ 監(jiān)控系統(tǒng) │
│ (3節(jié)點(diǎn)) │ │ (主從) │ │ (Prometheus) │
└───────────────┘ └─────────────────┘ └───────────────┘
8.4 運(yùn)維建議
- 定期演練:每月進(jìn)行一次壓力測試和故障演練
- 預(yù)案準(zhǔn)備:制定DDoS攻擊、短信服務(wù)商故障等應(yīng)急預(yù)案
- 日志審計(jì):定期審計(jì)短信發(fā)送日志,發(fā)現(xiàn)潛在風(fēng)險(xiǎn)
- 成本控制:監(jiān)控短信成本,設(shè)置成本告警閾值
- 安全更新:及時(shí)更新限流規(guī)則和黑名單
九、附錄
9.1 常見問題FAQ
Q1:如何區(qū)分正常用戶和攻擊者?
A:通過多維度數(shù)據(jù)分析,包括:
- 請(qǐng)求頻率(正常用戶不會(huì)高頻請(qǐng)求)
- 手機(jī)號(hào)分布(攻擊者會(huì)使用大量不同手機(jī)號(hào))
- 行為模式(腳本的時(shí)間間隔過于均勻)
- 驗(yàn)證失敗率(攻擊者失敗率通常很高)
Q2:限流閾值如何設(shè)定?
A:根據(jù)業(yè)務(wù)特點(diǎn)和歷史數(shù)據(jù)分析:
- 從保守值開始,逐步放開
- 監(jiān)控限流攔截率,控制在5%以內(nèi)
- 不同時(shí)間段可以使用不同閾值
Q3:如何處理短信服務(wù)商限流?
A:
- 使用多個(gè)短信服務(wù)商作為備份
- 實(shí)現(xiàn)智能路由,根據(jù)服務(wù)商狀態(tài)自動(dòng)切換
- 本地緩存發(fā)送隊(duì)列,服務(wù)商恢復(fù)后重發(fā)
Q4:驗(yàn)證碼過期時(shí)間多長合適?
A:
- 一般場景:5-10分鐘
- 金融場景:3-5分鐘
- 避免過長,增加安全風(fēng)險(xiǎn)
Q5:如何防止驗(yàn)證碼被泄露?
A:
- 限制同一驗(yàn)證碼只能使用一次
- 驗(yàn)證后立即刪除
- 監(jiān)控異常的驗(yàn)證行為(如同一IP驗(yàn)證多個(gè)手機(jī)號(hào))
以上就是SpringBoot實(shí)現(xiàn)短信驗(yàn)證碼接口防刷的完整方案的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot短信驗(yàn)證碼接口防刷的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
基于springboot創(chuàng)建mybatis的完整步驟
MyBatis是一款優(yōu)秀的數(shù)據(jù)庫持久層框架,相比Hibernate我更喜歡使用MyBatis,看的到SQL還是讓人更安心點(diǎn),這篇文章主要給大家介紹了關(guān)于基于springboot創(chuàng)建mybatis的完整步驟,需要的朋友可以參考下2024-03-03
Spring實(shí)戰(zhàn)之搜索Bean類操作示例
這篇文章主要介紹了Spring實(shí)戰(zhàn)之搜索Bean類操作,結(jié)合實(shí)例形式分析了Spring搜索Bean類的相關(guān)配置、接口實(shí)現(xiàn)與操作技巧,需要的朋友可以參考下2019-12-12
Java泛型模擬scala實(shí)現(xiàn)自定義ArrayList方式
這篇文章主要介紹了Java泛型模擬scala實(shí)現(xiàn)自定義ArrayList方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-10-10
Spring Cloud Alibaba教程之Sentinel的使用
這篇文章主要介紹了Spring Cloud Alibaba教程之Sentinel的使用,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2019-09-09
springboot2?使用activiti6?idea插件的過程詳解
這篇文章主要介紹了springboot2?使用activiti6?idea插件,本文通過截圖實(shí)例代碼相結(jié)合給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2022-03-03
Java線程創(chuàng)建靜態(tài)代理模式代碼實(shí)例
這篇文章主要介紹了Java線程創(chuàng)建靜態(tài)代理模式代碼實(shí)例,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-11-11
Java NIO實(shí)例UDP發(fā)送接收數(shù)據(jù)代碼分享
這篇文章主要介紹了Java NIO實(shí)例UDP發(fā)送接收數(shù)據(jù)代碼分享,分享了客戶端和服務(wù)端完整代碼,小編覺得還是挺不錯(cuò)的,共需要的朋友參考。2017-11-11
SpringBoot使用RestTemplate如何通過http請(qǐng)求將文件下載到本地
文章介紹了如何通過編寫代碼批量下載文件,解決了沒有提供批量下載接口的問題,首先篩選出需要下載的文件ID,然后通過后端代碼發(fā)起HTTP請(qǐng)求,將下載的資源寫入本地文件中,總結(jié)了實(shí)現(xiàn)方式和注意事項(xiàng),希望能為類似任務(wù)提供參考2025-02-02

