业务背景
项目中有一个抽奖接口,此接口需要处理高并发问题以及使用脚本作弊的问题。
本文主要探讨如何最大程度地减少脚本作弊行为对抽奖业务的影响。
设计思路
如何减少脚本作弊行为对抽奖业务的影响
使用令牌桶算法,对频率过高的用户请求进行拦截
通过拦截部分流量,剩余的请求仍会影响公平性
对于连续达到令牌耗尽的次数超过限制的用户会视为异常用户,并暂时封禁其抽奖资格
如何设置令牌桶的参数
和前端人员协调好落下红包雨的速度,将令牌桶限流的阈值调的比前端红包雨落下的速度稍大即可
令牌桶算法
令牌桶限流是一种常用的流量控制算法,用于限制系统或服务对请求的处理速率。其原理基于令牌桶的概念,通过控制令牌的生成和消耗来实现流量的平滑控制。
在令牌桶限流算法中,令牌桶可以看作是一个存放令牌的容器,以固定的速率产生令牌。每个令牌代表系统可处理的一个请求。当请求到达时,首先需要获取一个令牌才能被处理。
令牌桶限流的实现逻辑如下:
-
令牌产生:令牌桶以恒定的速率生成令牌,例如每秒生成n个令牌。这个速率决定了系统允许的最大处理能力。
-
令牌消耗:每当请求到达时,需要尝试获取一个令牌。如果令牌桶中有可用的令牌,则请求被允许处理,并从令牌桶中消耗一个令牌。如果令牌桶中没有可用的令牌,则请求被暂时阻塞或丢弃。
此处参考本连接的算法并根据自己的业务需求进行改进:基于 Redis 和 Lua 实现分布式令牌桶限流 - 掘金 (juejin.cn)https://juejin.cn/post/6922809716804419591
--[[
1. key - 令牌桶的 key
2. intervalPerTokens - 生成令牌的间隔(ms)
3. curTime - 当前时间
4. initTokens - 令牌桶初始化的令牌数
5. bucketMaxTokens - 令牌桶的上限
6. resetBucketInterval - 重置桶内令牌的时间间隔
7. currentTokens - 当前桶内令牌数
8. bucket - 当前 key 的令牌桶对象
]] --
local key = KEYS[1]
local intervalPerTokens = tonumber(ARGV[1])
local curTime = tonumber(ARGV[2])
local initTokens = tonumber(ARGV[3])
local bucketMaxTokens = tonumber(ARGV[4])
local resetBucketInterval = tonumber(ARGV[5])
-- 最大失败次数
local MAX_FAIL_TIMES = 20
-- 封禁时长
local BAN_DURATION = 60000
local bucket = redis.call('hgetall', key)
local currentTokens
-- 限流 判断是否作弊
local lockKey = "lock:" .. key
local newValue = redis.call('INCR', lockKey)
redis.call('EXPIRE', "lock:" .. key, 5000)
if newValue > MAX_FAIL_TIMES or newValue < -1 then
-- 用户行为异常 进行封禁
redis.call('set', lockKey, -100000)
redis.call('EXPIRE', lockKey, BAN_DURATION)
return -1
end
-- 若当前桶未初始化,先初始化令牌桶
if table.maxn(bucket) == 0 then
-- 初始桶内令牌
currentTokens = initTokens
-- 设置桶最近的填充时间是当前
redis.call('hset', key, 'lastRefillTime', curTime)
-- 初始化令牌桶的过期时间, 设置为间隔的 1.5 倍
redis.call('pexpire', key, resetBucketInterval * 1.5)
-- 若桶已初始化,开始计算桶内令牌
-- 为什么等于 4 ? 因为有两对 field, 加起来长度是 4
-- { "lastRefillTime(上一次更新时间)","curTime(更新时间值)","tokensRemaining(当前保留的令牌)","令牌数" }
elseif table.maxn(bucket) == 4 then
-- 上次填充时间
local lastRefillTime = tonumber(bucket[2])
-- 剩余的令牌数
local tokensRemaining = tonumber(bucket[4])
-- 当前时间大于上次填充时间
if curTime > lastRefillTime then
-- 拿到当前时间与上次填充时间的时间间隔
-- 举例理解: curTime = 2620 , lastRefillTime = 2000, intervalSinceLast = 620
local intervalSinceLast = curTime - lastRefillTime
-- 如果当前时间间隔 大于 令牌的生成间隔
-- 举例理解: intervalSinceLast = 620, resetBucketInterval = 1000
if intervalSinceLast > resetBucketInterval then
-- 将当前令牌填充满
currentTokens = initTokens
-- 更新重新填充时间
redis.call('hset', key, 'lastRefillTime', curTime)
-- 如果当前时间间隔 小于 令牌的生成间隔
else
-- 可授予的令牌 = 向下取整数( 上次填充时间与当前时间的时间间隔 / 两个令牌许可之间的时间间隔 )
-- 举例理解 : intervalPerTokens = 200 ms , 令牌间隔时间为 200ms
-- intervalSinceLast = 620 ms , 当前距离上一个填充时间差为 620ms
-- grantedTokens = 620/200 = 3.1 = 3
local grantedTokens = math.floor(intervalSinceLast / intervalPerTokens)
-- 可授予的令牌 > 0 时
-- 举例理解 : grantedTokens = 620/200 = 3.1 = 3
if grantedTokens > 0 then
-- 生成的令牌 = 上次填充时间与当前时间的时间间隔 % 两个令牌许可之间的时间间隔
-- 举例理解 : padMillis = 620%200 = 20
-- curTime = 2620
-- curTime - padMillis = 2600
local padMillis = math.fmod(intervalSinceLast, intervalPerTokens)
-- 将当前令牌桶更新到上一次生成时间
redis.call('hset', key, 'lastRefillTime', curTime - padMillis)
end
-- 更新当前令牌桶中的令牌数
-- Math.min(根据时间生成的令牌数 + 剩下的令牌数, 桶的限制) => 超出桶最大令牌的就丢弃
currentTokens = math.min(grantedTokens + tokensRemaining, bucketMaxTokens)
end
else
-- 如果当前时间小于或等于上次更新的时间, 说明刚刚初始化, 当前令牌数量等于桶内令牌数
-- 不需要重新填充
currentTokens = tokensRemaining
end
end
-- 如果当前桶内令牌小于 0,抛出异常
assert(currentTokens >= 0)
-- 如果当前令牌 == 0 ,更新桶内令牌, 返回 0
if currentTokens == 0 then
redis.call('hset', key, 'tokensRemaining', currentTokens)
return 0
else
-- 如果当前令牌 大于 0, 更新当前桶内的令牌 -1 , 再返回当前桶内令牌数
redis.call('hset', key, 'tokensRemaining', currentTokens - 1)
return currentTokens
end
算法的实现逻辑如下:
- 首先,判断是否有作弊行为。如果某用户的请求失败次数超过预设的最大失败次数(MAX_FAIL_TIMES),或者失败次数小于-1(异常情况),则封禁该用户一段时间(BAN_DURATION)。
- 如果令牌桶尚未初始化,则进行初始化。将桶内的令牌数量设置为初始令牌数(initTokens),记录当前时间为最近一次填充时间(lastRefillTime),并设置令牌桶的过期时间为重置桶内令牌时间间隔的1.5倍。
- 如果令牌桶已初始化,则计算当前桶内的令牌数量。
- 如果当前时间大于最近一次填充时间,说明需要进行令牌填充。
- 如果当前时间与最近一次填充时间的时间间隔大于重置桶内令牌时间间隔,则将令牌桶中的令牌数量设置为初始令牌数,并更新最近一次填充时间为当前时间。
- 如果当前时间间隔小于重置桶内令牌时间间隔,则根据时间间隔计算可授予的令牌数,并更新最近一次填充时间。同时,更新令牌桶中的令牌数量为可授予的令牌数和剩余令牌数的较小值。
- 如果当前时间小于等于最近一次更新的时间,说明刚刚初始化,当前令牌数量为桶内令牌数,无需重新填充。
- 如果当前时间大于最近一次填充时间,说明需要进行令牌填充。
- 确保当前桶内的令牌数量大于等于0。
- 如果当前令牌数量为0,更新令牌桶中的令牌数量为0,并返回0。
- 如果当前令牌数量大于0,更新令牌桶中的令牌数量为当前令牌数量减1,并返回当前令牌数量。
部分业务代码
@Around("pointcut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
MethodSignature signature = (MethodSignature) point.getSignature();
Method signatureMethod = signature.getMethod();
Limit limit = signatureMethod.getAnnotation(Limit.class);
String key = getCombinKey(limit, signatureMethod);
List<String> keys = Collections.singletonList(key);
String luaScript = buildLuaScript();
RedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
// 这个是调用lua脚本的代码
Long count = rateLimiter.rateLimit(key, 5000, new Date().getTime(), 3, 100, 10000);
if(count != null && count != 0 && count != -1){
return point.proceed();
}else if(count == -1){
throw new BusinessException("账号有异常行为!");
}
else{
throw new BusinessException("访问过于频繁!");
}
}
效果图
此处使用jmeter压测
此处附上github仓库的地址,如果觉得有用,请点一个珍贵的star,谢谢!
chenyi0008/lottery (github.com)https://github.com/chenyi0008/lottery/tree/chen
具体实现的代码在此处