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. 使用切面的方式进行请求拦截,避免代码入侵;