1. 程式人生 > >一個簡單IP防刷工具類, 10分鐘內只最多允許1000次使用者操作

一個簡單IP防刷工具類, 10分鐘內只最多允許1000次使用者操作

  IP防刷,也就是在短時間內有大量相同ip的請求,可能是惡意的,也可能是超出業務範圍的。總之,我們需要杜絕短時間內大量請求的問題,怎麼處理?

  其實這個問題,真的是太常見和太簡單了,但是真正來做的時候,可能就不一定很簡單了哦。

  我這裡給一個解決方案,以供參考!

主要思路或者需要考慮的問題為:

  1. 因為現在的伺服器環境幾乎都是分散式環境,所以,用本地計數的方式肯定是不行了,所以我們需要一個第三方的工具來輔助計數;

  2. 可以選用資料庫、快取中介軟體、zk等元件來解決分散式計數問題;

  3. 使用自增計數,儘量保持原子性,避免誤差;

  4. 統計週期為從當前倒推 interval 時間,還是直接以某個開始時間計數;

  5. 在何處進行攔截? 每個方法開始前? 還是請求入口處?

 

實現程式碼示例如下:

 

import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import redis.clients.jedis.Jedis; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; /** * IP 防刷工具類, 10分鐘內只最多允許1000次使用者操作 */ @Aspect public class IpFlushFirewall { @Resource private Jedis redisTemplate; /** * 最大ip限制次數 */ private static int maxLimitIpHit = 1000;
/** * 檢查時效,單位:秒 */ private static int checkLimitIpHitInterval = 600; // 自測試有效性 public static void main(String[] args) { IpFlushFirewall ipTest = new IpFlushFirewall(); // 測試時直接使用new Jedis(), 正式執行時使用 redis-data 元件配置即可 ipTest.redisTemplate = new Jedis("127.0.0.1", 6379); for (int i = 0; i < 10; i++) { System.out.println("new action: +" + i); ipTest.testLoginAction(new Object()); System.out.println("action: +" + i + ", passed..."); } } // 測試訪問的方法 public Object testLoginAction(Object req) { // ip防刷 String reqIp = "127.0.0.1"; checkIpLimit(reqIp); // 使用者資訊校驗 System.out.println("login success..."); // 返回使用者資訊 return null; } // 檢測限制入口 public void checkIpLimit(String ip) { if(isIpLimited(ip)) { throw new RuntimeException("操作頻繁,請稍後再試!"); } } // ip 防刷 / 使用切面進行攔截 @Before(value = "execution(public * com.*.*.*(..))") public void checkIpLimit() { RequestAttributes ra = RequestContextHolder.getRequestAttributes(); ServletRequestAttributes sra = (ServletRequestAttributes) ra; HttpServletRequest request = sra.getRequest(); String ip = getIp(request); if(isIpLimited(ip)) { throw new RuntimeException("操作頻繁,請稍後再試!"); } } public static String getIp(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.getRemoteAddr(); } // 多級代理問題 if(ip.contains(",")) { ip = ip.substring(0, ip.indexOf(',')).trim(); } return ip; } /** * 判斷ip是否受限制, 非核心場景,對於非原子的更新計數問題不大,否則考慮使用分散式鎖呼叫更新 */ private boolean isIpLimited(String reqIp) { String ipHitCache = getIpHitCacheKey(reqIp); // 先取舊資料作為本次判斷,再記錄本次訪問 String hitsStr = redisTemplate.get(ipHitCache); recordNewIpRequest(reqIp); // 新週期內,首次訪問 if(hitsStr == null) { return false; } // 之前有命中 // 總數未超限,直接通過 if(!isOverMaxLimit(Integer.valueOf(hitsStr) + 1)) { return false; } // 當前訪問後超過限制後,再判斷週期內的資料 Long retainIpHits = countEffectiveIntervalIpHit(reqIp); redisTemplate.set(ipHitCache, retainIpHits + ""); // 將有效計數更新回計數器,刪除無效計數後,在限制範圍內,則不限制操作 if(!isOverMaxLimit(retainIpHits.intValue())) { return false; } return true; } // 是否超過最大限制 private boolean isOverMaxLimit(Integer nowCount) { return nowCount > maxLimitIpHit; } // 每次訪問必須記錄 private void recordNewIpRequest(String reqIp) { if(redisTemplate.exists(getIpHitCacheKey(reqIp))) { // 自增訪問量 redisTemplate.incr(getIpHitCacheKey(reqIp)); } else { redisTemplate.set(getIpHitCacheKey(reqIp), "1"); } redisTemplate.expire(getIpHitCacheKey(reqIp), checkLimitIpHitInterval); Long nowTime = System.currentTimeMillis() / 1000; // 使用 sorted set 儲存記錄時間,方便刪除, zset 元素儘可能保持唯一,否則 redisTemplate.zadd(getIpHitStartTimeCacheKey(reqIp), nowTime , reqIp + "-" + System.nanoTime() + Math.random()); redisTemplate.expire(getIpHitStartTimeCacheKey(reqIp), checkLimitIpHitInterval); } /** * 統計計數週期內有效的的訪問次數(刪除無效統計) * * @param reqIp 請求ip * @return 有效計數 */ private Long countEffectiveIntervalIpHit(String reqIp) { // 刪除統計週期外的計數 Long nowTime = System.currentTimeMillis() / 1000; redisTemplate.zremrangeByScore(getIpHitStartTimeCacheKey(reqIp), nowTime - checkLimitIpHitInterval, nowTime); return redisTemplate.zcard(getIpHitStartTimeCacheKey(reqIp)); } // ip 訪問計數器快取key private String getIpHitCacheKey(String reqIp) { return "secure.ip.limit." + reqIp; } // ip 訪問開始時間快取key private String getIpHitStartTimeCacheKey(String reqIp) { return "secure.ip.limit." + reqIp + ".starttime"; } }

 

  如上解決思路為:

    1. 使用 redis 做計數器工具,做到資料統一的同時,redis 的高效能特性也保證了整個應用效能;

    2. 使用 redis 的 incr 做自增,使用一個 zset 來儲存記錄開始時間;

    3. 在計數超過限制後,再做開始有效性的檢測,保證準確的同時,避免了每次都手動檢查有時間有效性的動作;

    4. 使用切面的方式進行請求攔截,避免程式碼入侵;