接口防刷失效?Leader直接上压力, 重构防刷逻辑~

前言

最近监控告警时不时告警xxx兑换成功率 过低,嘎嘎打电话!Leader让我看看怎么回事

排查了一下发现是某个用户兑换时校验不通过 ,然后人家不信邪,无视错误提示,嘎嘎点,变相刷接口 ,从而导致整体兑换成功率变低进行告警

但我记得同事是写过一版接口防刷逻辑的,难道没防住???

带着疑惑,我去仔细研磨了一遍代码,的确有防刷逻辑,但好像也没防住。。。


防刷

第一版防刷逻辑(同事版)

java 复制代码
@Slf4j
@SpringBootTest
public class RiskCheckTest {

   @Resource
   private RedissonClient redissonClient;

   private static final String riskCheckRedisKeyPrefix = "riskCheckRedisKey:";

   public void riskCheck(ApplyRequest request) {
      if (countCheck(riskCheckRedisKeyPrefix + request.getUid(), 20, 60)
            || countCheck(riskCheckRedisKeyPrefix + request.getDeviceId(), 20, 60)
            || countCheck(riskCheckRedisKeyPrefix + request.getClientIp(), 20, 60)) {
         throw new RuntimeException("操作频繁, 请稍后再试~");
      }
   }

   public boolean countCheck(String redisKey, Integer maxCount, Integer expireTime) {
      try {
         RAtomicLong counter = redissonClient.getAtomicLong(redisKey);
         if (!counter.isExists() || counter.remainTimeToLive() <= 0) {
            counter.set(-1L);
            counter.expire(Duration.ofSeconds(expireTime));

            return counter.addAndGet(1) >= maxCount;
         }
      } catch (Exception e) {
         log.error("countCheck error, redisKey={}, maxCount={}, expireTime={}", redisKey, maxCount, expireTime, e);
      }
      return false;
   }

}

@Data
class ApplyRequest {

   private Long uid;

   private String clientIp;

   private String deviceId;

}

大致复现下同事代码,在找问题之前,我们先夸一下

  1. 生产环境往往是多台机器,用户的请求通过负载均衡打到不同的机器上 ,所以不能单机存储而需采用redis统一存储用户的接口访问行为
  2. 整体限流策略采用计数方法单位时间内,uid、deviceId、ip多维度访问次数超过阈值,即触发限流,根据上述配置,即1分钟之内,用户访问次数超过20次,即发生限流,用户必须等到下一个1分钟才能继续访问。

夸完之后,还需说一下此方案的缺陷

  1. 相信大家也都知道,计数限流都存在一个临界值的缺陷 ,按照上述代码配置来讲,我在11:59:59 - 12:00:00之内访问20次 ,次数不会触发限流,而到了12:00:00时,进入新的1分钟窗口 ,这个时间点可以重新访问20次 ,那么实际在11:59:59 - 12:00:00我可以访问40次,这显然不符合我们的限流预期
  2. 用户第一次访问时,对于计数器的值和过期时间的设置并不是原子性的
  3. 参数够不合理,窗口太大,等用户真正触发到限流时,成功率已经被拉低了一大截,导致出现误报的情况,容易影响心情(手动狗头)

第二版防刷逻辑(Redis滑动窗口)

在研磨同事代码后,我也指明了其代码的优缺点,既然明确不足之处,我们即可针对性进行改良~

  1. 用滑动窗口限流取代计数器限流
  2. 优化限流限流,控制到秒级
  3. 原子性设置value和过期时间

综上所述,我决定采用Redis lua脚本来落地实现~

数据结构采用Zset, 其存在score的概念,我们可存储时间戳 ,用户每次访问通过ZREMRANGEBYSCORE命令移除不在窗口内的数据 ,再通过ZCOUNT**统计窗口的内的成员数,即访问次数,**进而判断是否达到阈值触发限流

lua脚本如下👇🏻

lua 复制代码
local redisKey = KEYS[1]
local currentTime = tonumber(ARGV[1])
local windowTime = tonumber(ARGV[2])
local frequency = tonumber(ARGV[3])
local redisKeyExpireTime = tonumber(ARGV[4])
local hasCreate = 0

if redis.call('exists', redisKey) == 0 then
	-- key不存在,则创建一下,并且添加了数据
	redis.call('zadd', redisKey, currentTime, currentTime)
	hasCreate = 1
else
	-- key存在,但是过期 or 即将过期则续期一下
	local remainExpireTime = redis.call('ttl', redisKey)
	if remainExpireTime == -1 or remainExpireTime < 20 then
		redis.call('expire', redisKey, redisKeyExpireTime)
	end
end

-- 删除不在当前窗口内的数据
redis.call('ZREMRANGEBYSCORE', redisKey, 0, currentTime - windowTime)

-- 统计窗口内的数据
local count = tonumber(redis.call('ZCOUNT', redisKey, currentTime - windowTime, currentTime))
if count >= frequency then
	-- 达到频次,触发限流
	return true
end
-- 未达到频次,添加数据
if hasCreate == 0 then
	redis.call('zadd', redisKey, currentTime, currentTime)
end

return false

**最终编码如下: **

java 复制代码
@Slf4j
@SpringBootTest
public class SlidingWindowRateLimiter {

   @Resource
   private RedissonClient redissonClient;

   private static final String riskCheckRedisKeyPrefix = "riskCheckRedisKey:";

   public void riskCheck(ApplyRequest request) {
      if (countCheck(riskCheckRedisKeyPrefix + request.getUid(), 3, 1, 300)
            || countCheck(riskCheckRedisKeyPrefix + request.getDeviceId(), 3, 1, 300)
            || countCheck(riskCheckRedisKeyPrefix + request.getClientIp(), 3, 1, 300)) {
         throw new RuntimeException("操作频繁, 请稍后再试~");
      }
   }

   public boolean countCheck(String redisKey, Integer maxCount, Integer window, Integer keyExpireTime) {
      // 执行lua限流
      RScript script = redissonClient.getScript(StringCodec.INSTANCE);
      return script.eval(RScript.Mode.READ_WRITE, windowLimitLua(), RScript.ReturnType.BOOLEAN,
            Lists.newArrayList(redisKey),
            Lists.newArrayList(
                  System.currentTimeMillis(),
                  TimeUnit.SECONDS.toMillis(window),
                  maxCount,
                  keyExpireTime
            ).toArray());

   }

   private String windowLimitLua() {
      return "local redisKey = KEYS[1]\n" +
            "local currentTime = tonumber(ARGV[1])\n" +
            "local windowTime = tonumber(ARGV[2])\n" +
            "local frequency = tonumber(ARGV[3])\n" +
            "local redisKeyExpireTime = tonumber(ARGV[4])\n" +
            "local hasCreate = 0\n" +
            "if redis.call('exists', redisKey) == 0 then\n" +
            "\t-- key不存在,则创建一下,并且添加了数据\n" +
            "\tredis.call('zadd', redisKey, currentTime, currentTime)\n" +
            "\thasCreate = 1\n" +
            "else\n" +
            "\t-- key存在,但是过期 or 即将过期\n" +
            "\tlocal remainExpireTime = redis.call('ttl', redisKey)\n" +
            "\tif remainExpireTime == -1 or remainExpireTime < 20 then\n" +
            "\t\tredis.call('expire', redisKey, redisKeyExpireTime)\n" +
            "\tend\n" +
            "end\n" +
            "-- 删除不在当前窗口内的数据\n" +
            "redis.call('ZREMRANGEBYSCORE', redisKey, 0, currentTime - windowTime)\n" +
            "-- 统计窗口内的数据\n" +
            "local count = tonumber(redis.call('ZCOUNT', redisKey, currentTime - windowTime, currentTime))\n" +
            "if count >= frequency then\n" +
            "\t-- 达到频次,触发限流\n" +
            "\treturn true\n" +
            "end\n" +
            "-- 未达到频次,添加数据\n" +
            "if hasCreate == 0 then\n" +
            "\tredis.call('zadd', redisKey, currentTime, currentTime)\n" +
            "end\n" +
            "return false";
   }

}

@Data
class ApplyRequest {

   private Long uid;

   private String clientIp;

   private String deviceId;

}

至此编码完成,我们成功解决第一版中提到的一些缺陷。每秒uid、deviceId、clientIp多维度限制最多访问3次,当然这个次数可以结合接口rt来进行合理的设置。

比如接口rt ≈ 600ms比较慢,那么从用户角度来说,1s之内最多访问2次,此时要是设置的比较大其实就没有太大意义了,一切基于场景判断~

虽然我们成功完成了滑动限流,但是仔细想一想,我们限制1s 3次,即使用户触发限流,那也是这1s内的访问次数受限,等待下一个窗口,仍能进行访问,那么对于恶意用户来说,这套逻辑并没有限制住。

所以我们需要开发第三版!


第三版防刷逻辑(Redis滑动窗口 + 黑名单)

仅靠滑动窗口,虽然能做到限流,但也仅仅是访问频率变低了而已,并不能阻止恶意用户的继续访问。

故,我们需要在触发限流的基础上,增加拉黑操作 ,例如: xx时间内触发限流次数达到y次,将该用户拉黑z分钟

目标已明确,那么接下来就准备落地实现了~

但需要注意 一点的是,拉黑这个行为肯定也是需要一个redisKey的,如果是使用的redis集群版,在lua脚本中使用多个key会报一个错误ERR 'EVAL' command keys must in same slot

因为在集群版本中,redis每个key会根据CRC16算法,得到其对应的slot,那么key不同的话,算出来的slot也不同,其归属的redis实例也可能不同,这样一来lua脚本整体的原子性就无法保证了,所以lua脚本中限制key必须在同一个slot中,避免进行跨实例操作


单机版Redis

lua脚本如下

lua 复制代码
local limitKey = tostring(KEYS[1])
local limitCountKey = tostring(KEYS[2])
local blackKey = tostring(KEYS[3])
local currentTime = tonumber(ARGV[1])
local windowTime = tonumber(ARGV[2])
local frequency = tonumber(ARGV[3])
local limitKeyExpireTime = tonumber(ARGV[4])
local blackKeyExpireTime = tonumber(ARGV[5])
local limitCountKeyExpireTime = tonumber(ARGV[6])
local limitMaxCount = tonumber(ARGV[7])
local hasCreate = 0

-- 检查是否在黑名单里
if redis.call('exists', blackKey) == 1 then
	return true;
end

if redis.call('exists', limitKey) == 0 then
	-- key不存在,则创建一下,并且添加了数据
	redis.call('ZADD', limitKey, tonumber(currentTime), currentTime)
	-- 设置过期时间
	redis.call('expire', limitKey, limitKeyExpireTime)
	hasCreate = 1
else
	-- key存在,但是过期 or 即将过期
	local remainExpireTime = tonumber(redis.call('ttl', limitKey))
	if remainExpireTime == -1 or remainExpireTime < 20 then
		redis.call('expire', limitKey, limitKeyExpireTime)
	end
end

-- 删除不在当前窗口内的数据
redis.call('ZREMRANGEBYSCORE', limitKey, 0, tonumber(currentTime - windowTime))

-- 统计窗口内的数据量
local count = tonumber(redis.call('ZCOUNT', limitKey, tonumber(currentTime - windowTime), currentTime))
if count >= frequency then
	-- 达到频次,触发限流
	if redis.call('exists', limitCountKey) == 1 then
		local count = tonumber(redis.call('incr', limitCountKey))
		if count >= limitMaxCount then
			-- 拉黑
			redis.call('setex', blackKey, blackKeyExpireTime, 1)
			redis.call('del', limitCountKey)
		end
	else
		redis.call('setex', limitCountKey, limitCountKeyExpireTime, 1);
	end
	return true
end

-- 未达到频次,添加数据
if hasCreate == 0 then
	redis.call('ZADD', limitKey, tonumber(currentTime), currentTime) 
end

return false

最终代码如下

java 复制代码
@Slf4j
@SpringBootTest
public class SlidingWindowRateLimiter {

   @Resource
   private RedissonClient redissonClient;

   private static final String riskCheckRedisKeyPrefix = "riskCheckRedisKey:";
   private static final String riskCheckLimitCountRedisKey = "riskCheckLimitCountRedisKey:";
   private static final String riskCheckBlackRedisKey = "riskCheckBlackRedisKey:";

   @Test
   public void test() {
      for (int i = 1; i <= 20; i++) {
         try {
            if (i == 10) {
               System.out.println(111);
            }
            ApplyRequest applyRequest = new ApplyRequest();
            applyRequest.setUid(111L);
            applyRequest.setClientIp("127.0.0.1");
            applyRequest.setDeviceId("wlkabgowaibgoiaebgioaawpignipwfa");
            riskCheck(applyRequest);
            Thread.sleep(100);
         } catch (Exception e) {
            e.printStackTrace();
         }
      }
   }

   public void riskCheck(ApplyRequest request) {
      Long uid = request.getUid();
      String clientIp = request.getClientIp();
      String deviceId = request.getDeviceId();

      if (countCheck(riskCheckRedisKeyPrefix + uid, riskCheckLimitCountRedisKey + uid, riskCheckBlackRedisKey + uid,3, 1, 300, 60, 300, 10)
            || countCheck(riskCheckRedisKeyPrefix + deviceId, riskCheckLimitCountRedisKey + deviceId,riskCheckBlackRedisKey + uid,3, 1, 300, 60, 300, 10)
            || countCheck(riskCheckRedisKeyPrefix + clientIp, riskCheckLimitCountRedisKey + clientIp,riskCheckBlackRedisKey + uid,3, 1, 300, 60, 300, 10)) {
         throw new RuntimeException("操作频繁, 请稍后再试~");
      }
   }

   public boolean countCheck(String redisKey,
                             String limitCountKey,
                             String blackKey,
                             Integer maxCount,
                             Integer window,
                             Integer keyExpireTime,
                             Integer blackKeyExpireTime,
                             Integer limitCountKeyExpireTime,
                             Integer limitCount) {
      // 执行lua限流
      RScript script = redissonClient.getScript(StringCodec.INSTANCE);
      return script.eval(RScript.Mode.READ_WRITE, windowLimitLua(), RScript.ReturnType.BOOLEAN,
            Lists.newArrayList(redisKey, limitCountKey, blackKey),
            Lists.newArrayList(
                  System.currentTimeMillis(),
                  TimeUnit.SECONDS.toMillis(window),
                  maxCount,
                  keyExpireTime,
                  blackKeyExpireTime,
                  limitCountKeyExpireTime,
                  limitCount
            ).toArray());

   }

   private String windowLimitLua() {
      return "local limitKey = tostring(KEYS[1])\n" +
            "local limitCountKey = tostring(KEYS[2])\n" +
            "local blackKey = tostring(KEYS[3])\n" +
            "local currentTime = tonumber(ARGV[1])\n" +
            "local windowTime = tonumber(ARGV[2])\n" +
            "local frequency = tonumber(ARGV[3])\n" +
            "local limitKeyExpireTime = tonumber(ARGV[4])\n" +
            "local blackKeyExpireTime = tonumber(ARGV[5])\n" +
            "local limitCountKeyExpireTime = tonumber(ARGV[6])\n" +
            "local limitMaxCount = tonumber(ARGV[7])\n" +
            "local hasCreate = 0\n" +
            "\n" +
            "-- 检查是否在黑名单里\n" +
            "if redis.call('exists', blackKey) == 1 then\n" +
            "\treturn true;\n" +
            "end\n" +
            "\n" +
            "if redis.call('exists', limitKey) == 0 then\n" +
            "\t-- key不存在,则创建一下,并且添加了数据\n" +
            "\tredis.call('ZADD', limitKey, tonumber(currentTime), currentTime)\n" +
            "\t-- 设置过期时间\n" +
            "\tredis.call('expire', limitKey, limitKeyExpireTime)\n" +
            "\thasCreate = 1\n" +
            "else\n" +
            "\t-- key存在,但是过期 or 即将过期\n" +
            "\tlocal remainExpireTime = tonumber(redis.call('ttl', limitKey))\n" +
            "\tif remainExpireTime == -1 or remainExpireTime < 20 then\n" +
            "\t\tredis.call('expire', limitKey, limitKeyExpireTime)\n" +
            "\tend\n" +
            "end\n" +
            "\n" +
            "-- 删除不在当前窗口内的数据\n" +
            "redis.call('ZREMRANGEBYSCORE', limitKey, 0, tonumber(currentTime - windowTime))\n" +
            "\n" +
            "-- 统计窗口内的数据量\n" +
            "local count = tonumber(redis.call('ZCOUNT', limitKey, tonumber(currentTime - windowTime), currentTime))\n" +
            "if count >= frequency then\n" +
            "\t-- 达到频次,触发限流\n" +
            "\tif redis.call('exists', limitCountKey) == 1 then\n" +
            "\t\tlocal count = tonumber(redis.call('incr', limitCountKey))\n" +
            "\t\tif count >= limitMaxCount then\n" +
            "\t\t\t-- 拉黑\n" +
            "\t\t\tredis.call('setex', blackKey, blackKeyExpireTime, 1)\n" +
            "\t\t\tredis.call('del', limitCountKey)\n" +
            "\t\tend\n" +
            "\telse\n" +
            "\t\tredis.call('setex', limitCountKey, limitCountKeyExpireTime, 1);\n" +
            "\tend\n" +
            "\treturn true\n" +
            "end\n" +
            "\n" +
            "-- 未达到频次,添加数据\n" +
            "if hasCreate == 0 then\n" +
            "\tredis.call('ZADD', limitKey, tonumber(currentTime), currentTime) \n" +
            "end\n" +
            "\n" +
            "return false";
   }

}

@Data
class ApplyRequest {

   private Long uid;

   private String clientIp;

   private String deviceId;

}

集群版Redis

上面说了,在redis集群版中使用存在多个keylua会报错,其原因是因为多个key计算得到的slot不同

但也不是没办法,在网上搜了搜,说可以通过配置hash_tag来解决 这个问题,其本质上也是将所有key映射到同一个slot,具体实现大家可以去搜搜~

如果运维不允许配置的话,我们可以退而求其次,将拉黑操作脱离于lua脚本,另起编码完成,方案多多~


总结

本文衍生与业务场景痛点: 用户频繁刷接口导致成功率降低进而告警。

通过逐步分析、改良代码,最终完成限流 + 拉黑操作,符合预期,睡觉可以踏实了~ hh

恶意用户真的很讨厌!天天告警谁顶得住,敢刷我接口,直接给你拉黑!!!

最后,如有不足,请在评论区指教,阿里嘎多~

我是 Code皮皮虾 ,会在以后的日子里跟大家一起学习,一起进步! 觉得文章不错的话,可以在 掘金 关注我,这样就不会错过很多技术干货啦~

相关推荐
IT咖啡馆8 分钟前
35K star!生产环境的Java诊断工具,阿里开源神器
java·github
杰克尼12 分钟前
Java五子棋
java·开发语言
March€12 分钟前
常见算法复习
java·算法·排序算法
小松学前端1 小时前
第六章 6.1 字符串常用方法
java·开发语言
启山智软1 小时前
Java微服务商城系统的特点有哪些
java·大数据·开发语言·人工智能·微服务·架构·ux
一撮不知名的呆毛1 小时前
Lambda表达式(Java)
java
RW~1 小时前
Java 远程url文件sha256加密
java·开发语言
林小果12 小时前
建造者模式
java·开发语言·设计模式·建造者模式
吾爱星辰2 小时前
【解密 Kotlin 扩展函数】扩展函数的创建(十六)
java·开发语言·jvm·kotlin
v199498395632 小时前
会议室预约系统源码开发
java