SpringBoot 接口防刷的五種方案,太強了!
在當今的互聯網環境中,接口防刷已成為保障系統安全與穩定運行的關鍵環節。惡意的高頻請求如同隱藏在暗處的“殺手”,不僅會大量消耗服務器寶貴的資源,還可能引發數據異常,嚴重時甚至會導致整個系統癱瘓,給業務帶來不可估量的損失。
本文將深入剖析在 Spring Boot 框架下實現接口防刷的 5 種技術方案,幫助你根據實際需求選擇最合適的方法,為系統安全保駕護航。
1. 基于注解的訪問頻率限制
基于注解的訪問頻率限制是一種廣受歡迎的接口防刷方案,它通過自定義注解和 AOP 切面的巧妙結合,實現了對接口訪問頻率的有效控制。這種方法簡單易用,實現成本低,就像給接口穿上了一層輕便的“防護衣”。
實現步驟
1.1 創建限流注解
首先,我們需要創建一個自定義的限流注解,用于標記需要進行訪問頻率限制的接口方法。以下是具體的代碼實現:
圖片
在這個注解中,我們可以通過 time
屬性設置限制的時間段,通過 count
屬性設置在該時間段內允許的最大請求次數,通過 key
屬性指定限流的鍵,支持使用 SpEL 表達式進行靈活配置,message
屬性則用于在請求被限定時給用戶提供提示信息。
1.2 實現限流切面
接下來,我們需要實現一個 AOP 切面,用于處理限流邏輯。以下是具體的代碼實現:
@Aspect
@Component
@Slf4j
publicclass RateLimitAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
// 獲取請求的方法名
String methodName = pjp.getSignature().getName();
// 獲取請求的類名
String className = pjp.getTarget().getClass().getName();
// 組合限流 key
String limitKey = getLimitKey(pjp, rateLimit, methodName, className);
// 獲取限流參數
int time = rateLimit.time();
int count = rateLimit.count();
// 執行限流邏輯
boolean limited = isLimited(limitKey, time, count);
if (limited) {
thrownew RuntimeException(rateLimit.message());
}
// 執行目標方法
return pjp.proceed();
}
private String getLimitKey(ProceedingJoinPoint pjp, RateLimit rateLimit, String methodName, String className) {
// 獲取用戶自定義的 key
String key = rateLimit.key();
if (StringUtils.hasText(key)) {
// 支持 SpEL 表達式解析
StandardEvaluationContext context = new StandardEvaluationContext();
MethodSignature signature = (MethodSignature) pjp.getSignature();
String[] parameterNames = signature.getParameterNames();
Object[] args = pjp.getArgs();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
ExpressionParser parser = new SpelExpressionParser();
Expression expression = parser.parseExpression(key);
key = expression.getValue(context, String.class);
} else {
// 默認使用類名+方法名+IP 地址作為 key
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String ip = getIpAddress(request);
key = ip + ":" + className + ":" + methodName;
}
return"rate_limit:" + key;
}
private boolean isLimited(String key, int time, int count) {
// 使用 Redis 的計數器實現限流
try {
Long currentCount = redisTemplate.opsForValue().increment(key, 1);
// 如果是第一次訪問,設置過期時間
if (currentCount == 1) {
redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
return currentCount > count;
} catch (Exception e) {
log.error("限流異常", e);
returnfalse;
}
}
private String getIpAddress(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
}
在這個切面中,我們使用 @Around
注解攔截所有標記了 @RateLimit
注解的方法。在方法執行前,我們會根據注解中的參數計算出限流的鍵,并使用 Redis 的計數器來記錄請求次數。如果請求次數超過了限制,我們會拋出一個異常,提示用戶操作過于頻繁。
1.3 使用示例
以下是一個使用 @RateLimit
注解的示例:
圖片
在這個示例中,我們在 getUser
方法上使用了 @RateLimit
注解,設置了 60 秒內最多允許 3 次請求。在 updateUser
方法上,我們使用了 SpEL 表達式來動態生成限流的鍵,實現了更加靈活的限流策略。
優缺點分析
優點
- 實現簡單,上手容易:該方案的實現邏輯清晰,代碼量較少,即使是初學者也能快速掌握。在單機情況下,甚至可以去掉 Redis 換成本地緩存實現,進一步降低了實現難度。
- 注解式使用,對業務代碼無侵入:通過自定義注解的方式,我們可以將限流邏輯與業務邏輯分離,對業務代碼的改動非常小,不會影響原有的業務功能。
- 可以精確控制接口粒度:我們可以針對不同的接口方法設置不同的限流參數,實現對接口訪問頻率的精確控制。
- 支持靈活的限流策略配置:通過 SpEL 表達式,我們可以根據請求的參數、用戶信息等動態生成限流的鍵,實現更加靈活的限流策略。
缺點
- 限流邏輯相對簡單,無法應對復雜場景:該方案的限流邏輯主要基于固定的時間窗口和請求次數,對于一些復雜的場景,如突發流量、動態限流等,可能無法滿足需求。
- 缺少預警機制:當請求次數接近或達到限制時,系統無法及時發出預警,可能會導致用戶體驗下降。
2. 令牌桶算法實現限流
令牌桶算法是一種更加靈活的限流算法,它就像一個裝有令牌的桶,系統會以固定的速率向桶中添加令牌,每個請求需要從桶中獲取一個令牌才能被處理。這種算法可以允許突發流量,同時又能限制長期的平均流量,為系統提供了更加靈活的流量控制能力。
實現步驟
2.1 引入依賴
Google 提供的 Guava 庫中包含了令牌桶的實現,我們可以通過以下依賴將其引入項目:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
2.2 創建令牌桶限流器
接下來,我們需要創建一個令牌桶限流器,用于管理不同接口的令牌桶。以下是具體的代碼實現:
@Component
publicclass RateLimiter {
// 使用 ConcurrentHashMap 存儲不同接口的令牌桶
privatefinal ConcurrentHashMap<String, com.google.common.util.concurrent.RateLimiter> rateLimiterMap = new ConcurrentHashMap<>();
/**
* 獲取特定接口的令牌桶,不存在則創建
* @param key 限流鍵
* @param permitsPerSecond 每秒允許的請求量
* @return 令牌桶實例
*/
public com.google.common.util.concurrent.RateLimiter getRateLimiter(String key, double permitsPerSecond) {
return rateLimiterMap.computeIfAbsent(key,
k -> com.google.common.util.concurrent.RateLimiter.create(permitsPerSecond));
}
/**
* 嘗試獲取令牌
* @param key 限流鍵
* @param permitsPerSecond 每秒允許的請求量
* @param timeout 超時時間
* @param unit 時間單位
* @return 是否獲取成功
*/
public boolean tryAcquire(String key, double permitsPerSecond, long timeout, TimeUnit unit) {
com.google.common.util.concurrent.RateLimiter rateLimiter = getRateLimiter(key, permitsPerSecond);
return rateLimiter.tryAcquire(1, timeout, unit);
}
}
在這個限流器中,我們使用 ConcurrentHashMap
來存儲不同接口的令牌桶,確保線程安全。通過 getRateLimiter
方法,我們可以根據限流鍵獲取對應的令牌桶,如果令牌桶不存在,則會自動創建一個新的令牌桶。通過 tryAcquire
方法,我們可以嘗試從令牌桶中獲取一個令牌,如果在指定的超時時間內獲取成功,則返回 true
,否則返回 false
。
2.3 創建攔截器
為了實現對接口的限流,我們需要創建一個攔截器,在請求進入接口之前進行令牌的獲取操作。以下是具體的代碼實現:
圖片
在這個攔截器中,我們首先判斷請求的 URI 是否以 /api/
開頭,如果是,則進行限流處理。然后,我們獲取請求的 IP 地址和 URI,組合成限流鍵。接著,我們嘗試從令牌桶中獲取一個令牌,如果獲取失敗,則返回一個限流響應,提示用戶請求過于頻繁。
2.4 配置攔截器
最后,我們需要將攔截器配置到 Spring Boot 應用中,使其生效。以下是具體的代碼實現:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TokenBucketInterceptor tokenBucketInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenBucketInterceptor)
.addPathPatterns("/**");
}
}
在這個配置類中,我們使用 WebMvcConfigurer
接口的 addInterceptors
方法將攔截器添加到攔截器鏈中,并設置攔截所有的請求。
優缺點分析
優點
- 支持突發流量,不會完全拒絕短時高峰:令牌桶算法允許在短時間內有較高的請求速率,只要桶中有足夠的令牌。當突發流量到來時,系統可以快速處理這些請求,而不會像固定窗口算法那樣直接拒絕請求。
- 平滑的限流效果,用戶體驗更好:由于令牌桶算法是以固定的速率向桶中添加令牌,因此可以實現平滑的限流效果,避免了請求的突然中斷,提高了用戶體驗。
- 可以配置不同接口的不同限流策略:通過使用不同的限流鍵,我們可以為不同的接口配置不同的令牌桶,實現對不同接口的差異化限流。
- 無需額外的存儲設施:令牌桶算法的實現不需要額外的存儲設施,只需要在內存中維護一個令牌桶即可,降低了系統的復雜度和成本。
缺點
- 只適用于單機部署,分布式環境需要額外改造:該方案的令牌桶是在內存中維護的,因此只適用于單機部署的環境。在分布式環境中,需要將令牌桶的狀態存儲到共享的存儲設施中,如 Redis,才能實現分布式限流。
- 重啟應用后狀態丟失:由于令牌桶的狀態是在內存中維護的,因此當應用重啟后,令牌桶的狀態會丟失,需要重新初始化。
- 無法精確控制時間窗口內的請求總量:令牌桶算法只能控制請求的平均速率,無法精確控制在某個時間窗口內的請求總量。在某些對請求總量有嚴格限制的場景下,可能無法滿足需求。
3. 分布式限流(Redis + Lua 腳本)
在分布式系統中,單機限流方案往往難以滿足需求,因為不同的實例之間無法共享限流狀態。利用 Redis 和 Lua 腳本可以實現高效的分布式限流,確保系統在分布式環境下的安全性和穩定性。
實現步驟
3.1 定義 Lua 腳本
首先,我們需要定義一個 Redis 限流的 Lua 腳本,用于實現限流邏輯。以下是具體的腳本內容:
圖片
在這個腳本中,我們首先獲取限流鍵、限流窗口、限流閾值和當前時間戳。然后,我們移除過期的請求記錄,統計當前窗口內的請求數。如果請求數超過了閾值,我們返回 0 表示拒絕請求。否則,我們添加當前請求記錄,并設置過期時間,最后返回當前窗口剩余可用請求數。
3.2 創建 Redis 限流服務
接下來,我們需要創建一個 Redis 限流服務,用于執行 Lua 腳本并處理限流邏輯。以下是具體的代碼實現:
@Service
@Slf4j
publicclass RedisRateLimiterService {
@Autowired
private StringRedisTemplate redisTemplate;
private DefaultRedisScript<Long> rateLimiterScript;
@PostConstruct
public void init() {
// 加載 Lua 腳本
rateLimiterScript = new DefaultRedisScript<>();
rateLimiterScript.setLocation(new ClassPathResource("scripts/rate_limiter.lua"));
rateLimiterScript.setResultType(Long.class);
}
/**
* 嘗試獲取訪問權限
* @param key 限流鍵
* @param window 時間窗口(秒)
* @param threshold 閾值
* @return 剩余可用請求數,-1 表示被限流
*/
public long isAllowed(String key, int window, int threshold) {
try {
// 執行 lua 腳本
List<String> keys = Collections.singletonList(key);
Long remainingCount = redisTemplate.execute(
rateLimiterScript,
keys,
String.valueOf(window),
String.valueOf(threshold),
String.valueOf(System.currentTimeMillis())
);
return remainingCount == null ? -1 : remainingCount;
} catch (Exception e) {
log.error("Redis rate limiter error", e);
// 發生異常時放行請求
return threshold;
}
}
}
在這個服務中,我們使用 StringRedisTemplate
來執行 Lua 腳本。在 init
方法中,我們加載 Lua 腳本并設置返回值類型。在 isAllowed
方法中,我們執行 Lua 腳本并根據返回值判斷是否允許訪問。如果返回值為 -1 表示被限流,否則返回剩余可用請求數。
3.3 創建分布式限流注解
為了方便使用,我們可以創建一個分布式限流注解,用于標記需要進行分布式限流的接口方法。以下是具體的代碼實現:
圖片
在這個注解中,我們可以通過 prefix
屬性設置限流鍵的前綴,通過 window
屬性設置時間窗口,通過 threshold
屬性設置時間窗口內允許的最大請求數,通過 mode
屬性設置限流模式。
3.4 實現分布式限流切面
接下來,我們需要實現一個 AOP 切面,用于處理分布式限流邏輯。以下是具體的代碼實現:
@Aspect
@Component
@Slf4j
publicclass DistributedRateLimitAspect {
@Autowired
private RedisRateLimiterService rateLimiterService;
@Autowired(required = false)
private HttpServletRequest request;
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp, DistributedRateLimit rateLimit) throws Throwable {
String key = generateKey(pjp, rateLimit);
long remainingCount = rateLimiterService.isAllowed(
key,
rateLimit.window(),
rateLimit.threshold()
);
if (remainingCount < 0) {
thrownew RuntimeException("接口訪問過于頻繁,請稍后再試");
}
// 執行目標方法
return pjp.proceed();
}
private String generateKey(ProceedingJoinPoint pjp, DistributedRateLimit rateLimit) {
String methodName = pjp.getSignature().getName();
String className = pjp.getTarget().getClass().getName();
StringBuilder key = new StringBuilder(rateLimit.prefix());
key.append(className).append(".").append(methodName);
// 根據限流模式添加不同的后綴
switch (rateLimit.mode()) {
case"ip":
// 按 IP 限流
key.append(":").append(getIpAddress());
break;
case"user":
// 按用戶限流
Object userId = getUserId();
key.append(":").append(userId != null ? userId : "anonymous");
break;
case"all":
// 接口總體限流,不添加后綴
break;
default:
key.append(":").append(getIpAddress());
break;
}
return key.toString();
}
private String getIpAddress() {
// IP 獲取方法同上
if (request == null) {
return"unknown";
}
// 獲取 IP 的代碼同上一個示例
return"127.0.0.1"; // 簡化處理
}
// 獲取當前用戶 ID,根據實際認證系統實現
private Object getUserId() {
// 這里簡化處理,實際中應從認證信息中獲取
// 例如:SecurityContextHolder.getContext().getAuthentication().getPrincipal()
returnnull;
}
}
在這個切面中,我們使用 @Around
注解攔截所有標記了 @DistributedRateLimit
注解的方法。在方法執行前,我們會根據注解中的參數生成限流鍵,并調用 RedisRateLimiterService
的 isAllowed
方法判斷是否允許訪問。如果不允許訪問,我們會拋出一個異常,提示用戶接口訪問過于頻繁。
3.5 使用示例
以下是一個使用 @DistributedRateLimit
注解的示例:
@RestController
@RequestMapping("/api")
publicclass PaymentController {
@DistributedRateLimit(prefix = "pay:", window = 3600, threshold = 5, mode = "user")
@PostMapping("/payment")
public Result createPayment(@RequestBody PaymentRequest paymentRequest) {
// 創建支付業務邏輯
return paymentService.createPayment(paymentRequest);
}
@DistributedRateLimit(window = 60, threshold = 30, mode = "ip")
@GetMapping("/products")
public List<Product> getProducts() {
// 查詢產品列表
return productService.findAll();
}
@DistributedRateLimit(window = 1, threshold = 100, mode = "all")
@GetMapping("/hot/resource")
public Resource getHotResource() {
// 獲取熱門資源
return resourceService.getHotResource();
}
}
在這個示例中,我們在 createPayment
方法上使用了 @DistributedRateLimit
注解,設置了 3600 秒內每個用戶最多允許 5 次請求。在 getProducts
方法上,我們設置了 60 秒內每個 IP 最多允許 30 次請求。在 getHotResource
方法上,我們設置了 1 秒內整個接口最多允許 100 次請求。
優缺點分析
優點
- 適用于分布式系統,多實例間共享限流狀態:通過使用 Redis 作為共享存儲,不同的實例可以共享限流狀態,確保系統在分布式環境下的一致性和穩定性。
- 支持多種限流模式:可以根據 IP、用戶、接口總量等不同的維度進行限流,滿足不同場景的需求。
- 基于滑動窗口,計數更精確:Lua 腳本使用滑動窗口算法來統計請求數,相比固定窗口算法,計數更加精確,可以有效避免誤判。
- 使用 Lua 腳本保證原子性,避免競態條件:Lua 腳本在 Redis 中是原子執行的,確保了限流邏輯的原子性,避免了多個實例同時修改限流狀態時可能出現的競態條件。
缺點
- 強依賴 Redis:該方案的實現依賴于 Redis,如果 Redis 出現故障,可能會影響系統的正常運行。
- 實現復雜度較高:需要編寫 Lua 腳本并進行 Redis 操作,實現復雜度相對較高,對開發人員的技術要求也較高。
4. 集成 Sentinel 實現接口防刷
阿里巴巴開源的 Sentinel 是一個強大的流量控制組件,它就像一個智能的“交通警察”,可以對系統的流量進行實時監控和控制,提供了豐富的限流、熔斷、系統保護等功能。
實現步驟
4.1 添加依賴
首先,我們需要在項目中添加 Sentinel 的依賴:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
<version>2021.0.4.0</version>
</dependency>
4.2 配置 Sentinel
在 application.properties
中添加 Sentinel 的配置:
# Sentinel 控制臺地址
spring.cloud.sentinel.transport.dashboard=localhost:8080
# 取消 Sentinel 控制臺懶加載
spring.cloud.sentinel.eager=true
# 應用名稱
spring.application.name=my-application
在這個配置中,我們設置了 Sentinel 控制臺的地址,取消了控制臺的懶加載,并指定了應用的名稱。
4.3 創建 Sentinel 配置
接下來,我們需要創建一個 Sentinel 配置類,用于定義流控規則。以下是具體的代碼實現:
圖片
在這個配置類中,我們創建了一個 SentinelResourceAspect
實例,并在 init
方法中定義了流控規則。對于 /api/user
接口,我們設置了每秒允許 10 個請求的限流規則。對于 /api/order
接口,我們設置了每秒允許 5 個請求的限流規則,并開啟了預熱模式,預熱期為 10 秒。
4.4 創建 URL 資源解析器
為了讓 Sentinel 能夠正確識別請求的資源,我們需要創建一個 URL 資源解析器。以下是具體的代碼實現:
@Component
publicclass UrlCleaner implements RequestOriginParser {
@Override
public String parseOrigin(HttpServletRequest request) {
// 獲取請求的 URL 路徑
String path = request.getRequestURI();
// 可以添加更復雜的解析邏輯,例如:
// 1. 去除路徑變量:/api/user/123 -> /api/user/{id}
// 2. 添加請求方法前綴:GET:/api/user
return path;
}
}
在這個解析器中,我們簡單地返回請求的 URL 路徑,你可以根據實際需求添加更復雜的解析邏輯。
4.5 創建全局異常處理器
當請求被 Sentinel 限流時,會拋出 BlockException
異常,我們需要創建一個全局異常處理器來處理這個異常。以下是具體的代碼實現:
@RestControllerAdvice
publicclass SentinelExceptionHandler {
@ExceptionHandler(BlockException.class)
public Result handleBlockException(BlockException e) {
String message = "請求過于頻繁,請稍后再試";
if (e instanceof FlowException) {
message = "接口限流:" + message;
} elseif (e instanceof DegradeException) {
message = "服務降級:系統繁忙,請稍后再試";
} elseif (e instanceof ParamFlowException) {
message = "熱點參數限流:請求過于頻繁";
} elseif (e instanceof SystemBlockException) {
message = "系統保護:系統資源不足";
} elseif (e instanceof AuthorityException) {
message = "授權控制:沒有訪問權限";
}
return Result.error(429, message);
}
}
在這個異常處理器中,我們根據不同的 BlockException
類型返回不同的錯誤信息,提示用戶請求被限流的原因。
4.6 使用 @SentinelResource
注解
為了讓 Sentinel 能夠對接口進行限流,我們需要在接口方法上使用 @SentinelResource
注解。以下是具體的代碼實現:
@RestController
@RequestMapping("/api")
publicclass UserController {
// 使用資源名定義限流資源
@SentinelResource(value = "getUserById",
blockHandler = "getUserBlockHandler",
fallback = "getUserFallback")
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
return userService.getUser(id);
}
// 限流處理方法
public User getUserBlockHandler(Long id, BlockException e) {
log.warn("Get user request blocked: {}", id, e);
thrownew RuntimeException("請求頻率過高,請稍后再試");
}
// 異常回退方法
public User getUserFallback(Long id, Throwable t) {
log.error("Get user failed: {}", id, t);
User fallbackUser = new User();
fallbackUser.setId(id);
fallbackUser.setName("Unknown");
return fallbackUser;
}
}
在這個示例中,我們在 getUser
方法上使用了 @SentinelResource
注解,指定了資源名、限流處理方法和異常回退方法。當請求被限流時,會調用 getUserBlockHandler
方法進行處理。當方法執行過程中出現異常時,會調用 getUserFallback
方法進行回退。
4.7 更復雜的限流規則配置
除了基本的流控規則,Sentinel 還支持更復雜的限流規則配置,如基于 QPS + 調用關系的限流規則、基于并發線程數的限流規則、熱點參數限流規則等。以下是具體的代碼實現:
@Service
@Slf4j
publicclass SentinelRuleService {
public void initComplexFlowRules() {
List<FlowRule> rules = new ArrayList<>();
// 基于 QPS + 調用關系的限流規則
FlowRule apiRule = new FlowRule();
apiRule.setResource("/api/data");
apiRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
apiRule.setCount(20);
// 限制調用來源
apiRule.setLimitApp("frontend"); // 只限制來自前端應用的調用
// 流控策略:關聯資源
apiRule.setStrategy(RuleConstant.STRATEGY_RELATE);
apiRule.setRefResource("/api/important"); // 當 important 接口 QPS 高時,限制 data 接口
rules.add(apiRule);
// 基于并發線程數的限流
FlowRule threadRule = new FlowRule();
threadRule.setResource("/api/heavy-task");
threadRule.setGrade(RuleConstant.FLOW_GRADE_THREAD); // 基于線程數
threadRule.setCount(5); // 最多 5 個線程同時處理
rules.add(threadRule);
// 加載規則
FlowRuleManager.loadRules(rules);
}
public void initHotspotRules() {
// 熱點參數限流規則
List<ParamFlowRule> rules = new ArrayList<>();
ParamFlowRule rule = new ParamFlowRule("/api/product");
// 對第 0 個參數(productId)進行限流
rule.setParamIdx(0);
rule.setCount(5);
// 特例配置
ParamFlowItem item1 = new ParamFlowItem();
item1.setObject("1"); // productId = 1 的商品
item1.setCount(10); // 可以有更高的 QPS
ParamFlowItem item2 = new ParamFlowItem();
item2.setObject("2"); // productId = 2 的商品
item2.setCount(2); // 更嚴格的限制
rule.setParamFlowItemList(Arrays.asList(item1, item2));
rules.add(rule);
ParamFlowRuleManager.loadRules(rules);
}
}
在這個服務中,我們定義了基于 QPS + 調用關系的限流規則、基于并發線程數的限流規則和熱點參數限流規則,并使用 FlowRuleManager
和 ParamFlowRuleManager
加載這些規則。
優缺點分析
優點
- 功能全面:Sentinel 支持 QPS 限流、并發線程數限流、熱點參數限流等多種限流方式,還提供了熔斷、系統保護等功能,可以滿足不同場景的需求。
- 支持多種控制策略:可以選擇直接拒絕、預熱、排隊等多種控制策略,根據實際情況靈活調整限流行為。
- 提供控制臺可視化管理:Sentinel 提供了可視化的控制臺,可以實時監控系統的流量情況,方便進行規則的配置和管理。
- 支持動態規則調整:可以通過控制臺或 API 動態調整限流規則,無需重啟應用,提高了系統的靈活性和可維護性。
- 可與 Spring Cloud 體系無縫集成:Sentinel 可以與 Spring Cloud 體系無縫集成,方便在微服務架構中使用。
缺點
- 學習曲線較陡峭:Sentinel 的功能豐富,配置復雜,對于初學者來說,學習成本較高。
- 分布式場景下需要額外配置規則持久化:在分布式場景下,需要額外配置規則持久化,確保不同實例之間的規則一致性。
- 引入了額外的依賴:集成 Sentinel 需要引入額外的依賴,增加了項目的復雜度和維護成本。
5. 驗證碼與行為分析防刷
對于某些敏感操作,如登錄、注冊、支付等,單純的限流可能無法有效防止惡意請求。此時,可以結合驗證碼和行為分析來進一步增強系統的安全性,有效區分人類用戶和自動化腳本。
實現步驟
5.1 圖形驗證碼實現
首先,我們需要添加圖形驗證碼的依賴:
<dependency>
<groupId>com.github.whvcse</groupId>
<artifactId>easy-captcha</artifactId>
<version>1.6.2</version>
</dependency>
5.2 創建驗證碼服務
接下來,我們需要創建一個驗證碼服務,用于生成和驗證驗證碼。以下是具體的代碼實現:
@Service
publicclass CaptchaService {
@Autowired
private StringRedisTemplate redisTemplate;
privatestaticfinallong CAPTCHA_EXPIRE_TIME = 5 * 60; // 5 分鐘
/**
* 生成驗證碼
* @param request HTTP 請求
* @param response HTTP 響應
* @return 驗證碼 Base64 字符串
*/
public String generateCaptcha(HttpServletRequest request, HttpServletResponse response) {
// 生成驗證碼
SpecCaptcha captcha = new SpecCaptcha(130, 48, 5);
// 生成驗證碼 ID
String captchaId = UUID.randomUUID().toString();
// 將驗證碼存入 Redis
redisTemplate.opsForValue().set(
"captcha:" + captchaId,
captcha.text().toLowerCase(),
CAPTCHA_EXPIRE_TIME,
TimeUnit.SECONDS
);
// 設置 Cookie
Cookie cookie = new Cookie("captchaId", captchaId);
cookie.setMaxAge((int) CAPTCHA_EXPIRE_TIME);
cookie.setPath("/");
response.addCookie(cookie);
// 返回 Base64 編碼的驗證碼圖片
return captcha.toBase64();
}
/**
* 驗證驗證碼
* @param request HTTP 請求
* @param captchaCode 用戶輸入的驗證碼
* @return 是否驗證通過
*/
public boolean validateCaptcha(HttpServletRequest request, String captchaCode) {
// 從 Cookie 獲取驗證碼 ID
Cookie[] cookies = request.getCookies();
String captchaId = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("captchaId".equals(cookie.getName())) {
captchaId = cookie.getValue();
break;
}
}
}
if (captchaId == null) {
returnfalse;
}
// 從 Redis 獲取正確的驗證碼
String key = "captcha:" + captchaId;
String correctCode = redisTemplate.opsForValue().get(key);
// 驗證成功后刪除驗證碼
if (correctCode != null && correctCode.equals(captchaCode.toLowerCase())) {
redisTemplate.delete(key);
returntrue;
}
returnfalse;
}
}
在這個服務中,我們使用 easy-captcha
庫生成驗證碼,并將驗證碼存入 Redis 中。在驗證驗證碼時,我們從 Cookie 中獲取驗證碼 ID,然后從 Redis 中獲取正確的驗證碼進行比對。
5.3 創建驗證碼控制器
為了方便前端獲取驗證碼,我們需要創建一個驗證碼控制器。以下是具體的代碼實現:
@RestController
@RequestMapping("/api/captcha")
public class CaptchaController {
@Autowired
private CaptchaService captchaService;
@GetMapping
public Map<String, String> getCaptcha(HttpServletRequest request, HttpServletResponse response) {
String base64 = captchaService.generateCaptcha(request, response);
return Map.of("captcha", base64);
}
}
在這個控制器中,我們提供了一個 GET
請求接口,用于生成驗證碼并返回 Base64 編碼的驗證碼圖片。
5.4 創建驗證碼注解
為了標記需要進行驗證碼驗證的接口方法,我們可以創建一個驗證碼注解。以下是具體的代碼實現:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CaptchaRequired {
String captchaParam() default "captchaCode";
}
在這個注解中,我們可以通過 captchaParam
屬性指定驗證碼參數的名稱。
5.5 實現驗證碼攔截器
接下來,我們需要實現一個驗證碼攔截器,在請求進入接口之前進行驗證碼驗證。以下是具體的代碼實現:
@Component
publicclass CaptchaInterceptor implements HandlerInterceptor {
@Autowired
private CaptchaService captchaService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
returntrue;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
CaptchaRequired captchaRequired = handlerMethod.getMethodAnnotation(CaptchaRequired.class);
if (captchaRequired == null) {
returntrue;
}
// 獲取驗證碼參數
String captchaParam = captchaRequired.captchaParam();
String captchaCode = request.getParameter(captchaParam);
if (StringUtils.hasText(captchaCode)) {
// 驗證驗證碼
boolean valid = captchaService.validateCaptcha(request, captchaCode);
if (valid) {
returntrue;
}
}
// 驗證失敗
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.getWriter().write("{\"code\":400,\"message\":\"驗證碼錯誤或已過期\"}");
returnfalse;
}
}
在這個攔截器中,我們首先判斷請求的處理方法是否標記了 @CaptchaRequired
注解。如果標記了,則獲取驗證碼參數并調用 CaptchaService
的 validateCaptcha
方法進行驗證。如果驗證失敗,則返回一個錯誤響應,提示用戶驗證碼錯誤或已過期。
5.6 創建行為分析服務
除了驗證碼,我們還可以通過行為分析來檢測可疑的機器行為。以下是具體的代碼實現:
@Service
@Slf4j
publicclass BehaviorAnalysisService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 檢查是否是可疑的機器行為
* @param request HTTP 請求
* @return 是否可疑
*/
public boolean isSuspicious(HttpServletRequest request) {
// 1. 獲取客戶端信息
String ip = getIpAddress(request);
String userAgent = request.getHeader("User-Agent");
String requestId = request.getSession().getId();
// 2. 檢查訪問頻率
String freqKey = "behavior:freq:" + ip;
Long count = redisTemplate.opsForValue().increment(freqKey, 1);
redisTemplate.expire(freqKey, 1, TimeUnit.MINUTES);
if (count != null && count > 30) {
log.warn("訪問頻率異常: IP={}, count={}", ip, count);
returntrue;
}
// 3. 檢查 User-Agent
if (userAgent == null || isBotuserAgent(userAgent)) {
log.warn("可疑的 User-Agent: {}", userAgent);
returntrue;
}
// 4. 檢查請求時間模式
String timeKey = "behavior:time:" + ip;
long now = System.currentTimeMillis();
String lastTimeStr = redisTemplate.opsForValue().get(timeKey);
if (lastTimeStr != null) {
long lastTime = Long.parseLong(lastTimeStr);
long interval = now - lastTime;
// 如果請求間隔非常均勻,可能是機器人
if (isUniformInterval(ip, interval)) {
log.warn("請求間隔異常均勻: IP={}, interval={}", ip, interval);
returntrue;
}
}
redisTemplate.opsForValue().set(timeKey, String.valueOf(now), 10, TimeUnit.MINUTES);
// 更多高級檢測邏輯...
returnfalse;
}
/**
* 檢查是否是機器人 UA
*/
private boolean isBotuserAgent(String userAgent) {
String ua = userAgent.toLowerCase();
return ua.contains("bot") || ua.contains("spider") || ua.contains("crawl") ||
ua.isEmpty() || ua.length() < 40;
}
/**
* 檢查請求間隔是否異常均勻
*/
private boolean isUniformInterval(String ip, long interval) {
String key = "behavior:intervals:" + ip;
// 獲取最近的幾個間隔
List<String> intervalStrs = redisTemplate.opsForList().range(key, 0, 4);
redisTemplate.opsForList().leftPush(key, String.valueOf(interval));
redisTemplate.opsForList().trim(key, 0, 9); // 只保留最近 10 個
redisTemplate.expire(key, 10, TimeUnit.MINUTES);
if (intervalStrs == null || intervalStrs.size() < 5) {
returnfalse;
}
// 計算間隔的方差,方差小說明請求間隔很均勻
List<Long> intervals = intervalStrs.stream()
.map(Long::parseLong)
.collect(Collectors.toList());
double mean = intervals.stream().mapToLong(Long::longValue).average().orElse(0);
double variance = intervals.stream()
.mapToDouble(i -> Math.pow(i - mean, 2))
.average()
.orElse(0);
return variance < 100; // 方差閾值,需要根據實際情況調整
}
// getIpAddress 方法同上
}
在這個服務中,我們從多個維度對請求進行分析,包括訪問頻率、User-Agent 和請求時間模式。如果發現可疑的行為,我們會記錄日志并返回 true
。
5.7 創建行為分析攔截器
最后,我們需要創建一個行為分析攔截器,在請求進入接口之前進行行為分析。以下是具體的代碼實現:
@Component
publicclass BehaviorAnalysisInterceptor implements HandlerInterceptor {
@Autowired
private BehaviorAnalysisService behaviorAnalysisService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 對于需要保護的端點進行檢查
String path = request.getRequestURI();
if (path.startsWith("/api/") && isPotentialRiskEndpoint(path)) {
boolean suspicious = behaviorAnalysisService.isSuspicious(request);
if (suspicious) {
// 需要驗證碼或其他額外驗證
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("{\"code\":429,\"message\":\"檢測到異常訪問,請進行驗證\",\"needCaptcha\":true}");
returnfalse;
}
}
returntrue;
}
/**
* 判斷是否是高風險端點
*/
private boolean isPotentialRiskEndpoint(String path) {
return path.contains("/login") ||
path.contains("/register") ||
path.contains("/payment") ||
path.contains("/order") ||
path.contains("/password");
}
}
在這個攔截器中,我們首先判斷請求的 URI 是否以 /api/
開頭,并且是否是高風險端點。如果是,則調用 BehaviorAnalysisService
的 isSuspicious
方法進行行為分析。如果發現可疑行為,則返回一個錯誤響應,提示用戶進行驗證。
5.8 使用示例
以下是一個使用驗證碼和行為分析的示例:
@RestController
@RequestMapping("/api")
publicclass UserController {
@CaptchaRequired
@PostMapping("/login")
public Result login(@RequestParam String username,
@RequestParam String password,
@RequestParam String captchaCode) {
// 登錄邏輯
return userService.login(username, password);
}
@CaptchaRequired
@PostMapping("/register")
public Result register(@RequestBody UserRegisterDTO registerDTO,
@RequestParam String captchaCode) {
// 注冊邏輯
return userService.register(registerDTO);
}
}
在這個示例中,我們在 login
和 register
方法上使用了 @CaptchaRequired
注解,要求用戶輸入驗證碼進行驗證。
優缺點分析
優點
- 能有效區分人類用戶和自動化腳本:通過驗證碼和行為分析,可以準確地識別出自動化腳本的惡意請求,有效防止刷接口行為。
- 對惡意用戶有較強的阻止作用:驗證碼和行為分析的雙重防護,大大增加了惡意用戶的攻擊成本,使其難以得逞。
- 針對敏感操作提供額外安全層:對于登錄、注冊、支付等敏感操作,驗證碼和行為分析可以提供額外的安全保障,保護用戶的隱私和財產安全。
- 可以實現自適應安全策略:根據用戶的行為特征和系統的安全狀況,可以動態調整驗證碼和行為分析的策略,實現自適應的安全防護。
缺點
- 增加了用戶操作成本,可能影響用戶體驗:要求用戶輸入驗證碼會增加用戶的操作步驟,降低用戶體驗。特別是對于一些頻繁操作的用戶,可能會感到厭煩。
- 實現復雜,需要前后端配合:驗證碼和行為分析的實現涉及到前后端的多個環節,需要前后端密切配合,增加了開發和維護的難度。
- 某些驗證碼可能被 OCR 技術破解:雖然現在的驗證碼技術不斷發展,但仍然存在被 OCR 技術破解的風險,需要不斷更新和改進驗證碼的生成和驗證方式。
- 行為分析可能產生誤判:行為分析是基于一定的規則和算法進行的,可能會出現誤判的情況,將正常用戶的行為誤判為可疑行為,影響用戶的正常使用。
方案對比與選擇
方案 | 實現難度 | 防刷效果 | 分布式支持 | 用戶體驗 | 適用場景 |
基于注解的訪問頻率限制 | 低 | 中 | 需配合 Redis | 一般 | 一般接口,簡單場景 |
令牌桶算法 | 中 | 中高 | 單機 | 好 | 允許突發流量的場景 |
分布式限流(Redis+Lua) | 高 | 高 | 支持 | 一般 | 分布式系統,精確限流 |
Sentinel | 中高 | 高 | 需額外配置 | 可配置 | 復雜系統,多維度防護 |
驗證碼與行為分析 | 高 | 高 | 支持 | 較差 | 敏感操作,關鍵業務 |
總結
接口防刷是一個系統性工程,需要綜合考慮安全性、用戶體驗、性能開銷和運維復雜度等多方面因素。本文介紹的 5 種方案各有優缺點,你可以根據實際需求靈活選擇和組合。
無論采用哪種方案,接口防刷都應該遵循以下原則:
- 最小影響原則:盡量不影響正常用戶的體驗,確保系統在安全的前提下能夠高效運行。
- 梯度防護原則:根據接口的重要程度和風險等級,采用不同強度的防護措施,實現精準防護。
- 可監控原則:提供充分的監控和告警機制,及時發現和處理異常情況,確保系統的安全性和穩定性。
- 靈活調整原則:支持動態調整防護參數和策略,根據系統的運行情況和安全需求,及時做出調整。
通過合理實施接口防刷策略,可以有效提高系統的安全性和穩定性,為用戶提供更好的服務體驗。希望本文能對你有所幫助,讓你的系統在復雜的網絡環境中更加安全可靠!