前言
最近监控告警时不时告警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;
}
大致复现下同事代码,在找问题之前,我们先夸一下
- 生产环境往往是多台机器,用户的请求通过负载均衡打到不同的机器上 ,所以不能单机存储而需采用
redis
统一存储用户的接口访问行为 - 整体限流策略采用计数方法 ,单位时间内,
uid、deviceId、ip
多维度访问次数超过阈值,即触发限流,根据上述配置,即1分钟之内,用户访问次数超过20次,即发生限流,用户必须等到下一个1分钟才能继续访问。
夸完之后,还需说一下此方案的缺陷
- 相信大家也都知道,计数限流都存在一个临界值的缺陷 ,按照上述代码配置来讲,我在
11:59:59 - 12:00:00
之内访问20次 ,次数不会触发限流,而到了12:00:00
时,进入新的1分钟窗口 ,这个时间点可以重新访问20次 ,那么实际在11:59:59 - 12:00:00
我可以访问40次,这显然不符合我们的限流预期 - 用户第一次访问时,对于计数器的值和过期时间的设置并不是原子性的
- 参数够不合理,窗口太大,等用户真正触发到限流时,成功率已经被拉低了一大截,导致出现误报的情况,容易影响心情(手动狗头)
第二版防刷逻辑(Redis滑动窗口)
在研磨同事代码后,我也指明了其代码的优缺点,既然明确不足之处,我们即可针对性进行改良~
- 用滑动窗口限流取代计数器限流
- 优化限流限流,控制到秒级
- 原子性设置
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
集群版中使用存在多个key
的lua
会报错,其原因是因为多个key
计算得到的slot
不同
但也不是没办法,在网上搜了搜,说可以通过配置hash_tag
来解决 这个问题,其本质上也是将所有key
映射到同一个slot
,具体实现大家可以去搜搜~
如果运维不允许配置的话,我们可以退而求其次,将拉黑操作脱离于lua
脚本,另起编码完成,方案多多~
总结
本文衍生与业务场景痛点: 用户频繁刷接口导致成功率降低进而告警。
通过逐步分析、改良代码,最终完成限流 + 拉黑操作,符合预期,睡觉可以踏实了~ hh
恶意用户真的很讨厌!天天告警谁顶得住,敢刷我接口,直接给你拉黑!!!
最后,如有不足,请在评论区指教,阿里嘎多~
我是 Code皮皮虾 ,会在以后的日子里跟大家一起学习,一起进步! 觉得文章不错的话,可以在 掘金 关注我,这样就不会错过很多技术干货啦~