基于令牌桶算法对高并发接口的优化

业务背景

项目中有一个抽奖接口,此接口需要处理高并发问题以及使用脚本作弊的问题。

本文主要探讨如何最大程度地减少脚本作弊行为对抽奖业务的影响。

设计思路

如何减少脚本作弊行为对抽奖业务的影响

使用令牌桶算法,对频率过高的用户请求进行拦截

通过拦截部分流量,剩余的请求仍会影响公平性

对于连续达到令牌耗尽的次数超过限制的用户会视为异常用户,并暂时封禁其抽奖资格

如何设置令牌桶的参数

和前端人员协调好落下红包雨的速度,将令牌桶限流的阈值调的比前端红包雨落下的速度稍大即可

令牌桶算法

令牌桶限流是一种常用的流量控制算法,用于限制系统或服务对请求的处理速率。其原理基于令牌桶的概念,通过控制令牌的生成和消耗来实现流量的平滑控制。

在令牌桶限流算法中,令牌桶可以看作是一个存放令牌的容器,以固定的速率产生令牌。每个令牌代表系统可处理的一个请求。当请求到达时,首先需要获取一个令牌才能被处理。

令牌桶限流的实现逻辑如下:

  1. 令牌产生:令牌桶以恒定的速率生成令牌,例如每秒生成n个令牌。这个速率决定了系统允许的最大处理能力。

  2. 令牌消耗:每当请求到达时,需要尝试获取一个令牌。如果令牌桶中有可用的令牌,则请求被允许处理,并从令牌桶中消耗一个令牌。如果令牌桶中没有可用的令牌,则请求被暂时阻塞或丢弃。

此处参考本连接的算法并根据自己的业务需求进行改进:基于 Redis 和 Lua 实现分布式令牌桶限流 - 掘金 (juejin.cn)icon-default.png?t=N7T8https://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

算法的实现逻辑如下:

  1. 首先,判断是否有作弊行为。如果某用户的请求失败次数超过预设的最大失败次数(MAX_FAIL_TIMES),或者失败次数小于-1(异常情况),则封禁该用户一段时间(BAN_DURATION)。
  2. 如果令牌桶尚未初始化,则进行初始化。将桶内的令牌数量设置为初始令牌数(initTokens),记录当前时间为最近一次填充时间(lastRefillTime),并设置令牌桶的过期时间为重置桶内令牌时间间隔的1.5倍。
  3. 如果令牌桶已初始化,则计算当前桶内的令牌数量。
    • 如果当前时间大于最近一次填充时间,说明需要进行令牌填充。
      • 如果当前时间与最近一次填充时间的时间间隔大于重置桶内令牌时间间隔,则将令牌桶中的令牌数量设置为初始令牌数,并更新最近一次填充时间为当前时间。
      • 如果当前时间间隔小于重置桶内令牌时间间隔,则根据时间间隔计算可授予的令牌数,并更新最近一次填充时间。同时,更新令牌桶中的令牌数量为可授予的令牌数和剩余令牌数的较小值。
    • 如果当前时间小于等于最近一次更新的时间,说明刚刚初始化,当前令牌数量为桶内令牌数,无需重新填充。
  4. 确保当前桶内的令牌数量大于等于0。
  5. 如果当前令牌数量为0,更新令牌桶中的令牌数量为0,并返回0。
  6. 如果当前令牌数量大于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)icon-default.png?t=N7T8https://github.com/chenyi0008/lottery/tree/chen

具体实现的代码在此处

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/530119.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

基于ros的相机内参标定过程

基于ros的相机内参标定过程 1. 安装还对应相机的驱动2. 启动相机节点发布主题3. 下载camera_calibartion4. 将红框的文件夹复制在自己的工作空间里边&#xff0c;编译5. 标定完成以后&#xff0c;生成内参参数文件camera.yaml。将文件放在对应的路径下&#xff0c;修改config文…

vex-table—— 获取插入或修改数据后的tableData

例子来自vxe-table。在开发过程中发现新增数据后&#xff0c;输出this.tableData&#xff0c;发现数据并没有被修改 想要获取更新的数据方式为 mounted () {const $table this.$refs.xTableconsole.log("&#x1f680; ~ mounted ~ $table:", $table.tableData)},

[开源] 基于transformer的时间序列预测模型python代码

分享一下基于transformer的时间序列预测模型python代码&#xff0c;给大家&#xff0c;记得点赞哦 #!/usr/bin/env python # coding: 帅帅的笔者import torch import torch.nn as nn import numpy as np import pandas as pd import time import math import matplotlib.pyplo…

BoostCompass(数据准备预处理模块)

阅读导航 一、网页数据下载二、编写数据去标签与数据清洗的模块 Parser✅boost 开发库的安装1. 基本思路2. 详细讲解&#xff08;1&#xff09;程序递归遍历目录&#xff0c;收集所有HTML文件的路径&#xff08;2&#xff09;对每个HTML文件进行解析&#xff0c;提取出文档标题…

【HTML】简单制作一个3D动态粒子效果的时空隧道

目录 前言 开始 HTML部分 CSS部分 效果图 总结 前言 无需多言&#xff0c;本文将详细介绍一段HTML&#xff0c;具体内容如下&#xff1a; 开始 首先新建文件夹&#xff0c;创建两个文本文档&#xff0c;其中HTML的文件名改为[index.html]&#xff0c;CSS的文件名改为[Bab…

【CPA考试】2024注册会计师报名照片尺寸要求解读及手机拍照方法

随着2024年注册会计师考试的临近&#xff0c;众多会计专业人士和学生都开始准备报名参加这一行业的重要考试&#xff0c;报名时间为4月8日至4月30日。报名过程中&#xff0c;一张符合要求的证件照是必不可少的。本文将为您详细解读2024年注册会计师考试报名照片的尺寸要求&…

Kafka基础/1

Kafka 概念 Kafka 是一个分布式的流媒体平台。 应用&#xff1a;消息系统、日志收集、用户行为追踪、流式处理 特点&#xff1a;高吞吐量、消息持久化、高可靠性、高扩展性 术语&#xff1a; broker&#xff1a;Kafka 的服务器&#xff0c;Kafka 当中每一台服务器&#xf…

网络安全---Packet Tracer - 配置扩展 ACL

一、实验目的 在Windows环境下利用Cisco Packet Tracer进行 配置防火墙操作。 二、实验环境 1.Windows10、Cisco Packet Tracer 8.2 2.相关的环境设置 在最初的时候&#xff0c;我们已经得到了搭建好的拓扑模型&#xff0c;利用已经搭建好的拓扑模型&#xff0c;进行后续的…

SOLIDWORKS如何新建定义材质库

SolidWorks材质库中包含了大量的材料选项&#xff0c;涵盖了金属、塑料、橡胶、复合材料等各种类型&#xff0c;每种材料都有详细的特性参数。用户可以根据设计需求&#xff0c;在材质库中选择合适的材料&#xff0c;从而更好地满足设计要求。在有限元分析中&#xff0c;需要附…

【架构师】-- 成长路线图

成长为软件架构师不是一件容易的事&#xff0c;这篇文章列举了架构师需要学习的技术储备&#xff0c;给出了成为软件架构师的路线图&#xff0c;帮助有志于在架构领域成长的同学可以明确学习的方向。原文&#xff1a;Master Plan for becoming a Software Architect[1] 软件架…

easyExcel - 动态复杂表头的编写

目录 前言一、情景介绍二、问题分析三、代码实现方式一&#xff1a;head 设置方式二&#xff1a;模板导出方式三&#xff1a;自定义工具类 前言 Java-easyExcel入门教程&#xff1a;https://blog.csdn.net/xhmico/article/details/134714025 之前有介绍过如何使用 easyExcel&…

LeetCode_144(二叉树前序遍历)

1.递归 public List<Integer> preorderTraversal(TreeNode root) {List<Integer> res new ArrayList<>();accessTree(root,res);return res;}public void accessTree(TreeNode root,List<Integer>res){if(root null){return;}res.add(root.val);acce…

Redis 八种常用数据类型常用命令和应用场景

5 种基础数据类型&#xff1a;String&#xff08;字符串&#xff09;、List&#xff08;列表&#xff09;、Set&#xff08;集合&#xff09;、Hash&#xff08;散列&#xff09;、Zset&#xff08;有序集合&#xff09;。 3 种特殊数据类型&#xff1a;HyperLogLog&#xff0…

计算机视觉——Python OpenCV BGR转HSV

这里将介绍如何使用 OpenCV 与 Python 来作彩色影像转HSV(RGB to HSV 或 BGR to HSV)&#xff0c;在写 Python 影像处理程序时常会用到 OpenCV cvtColor 作颜色空间转换的功能&#xff0c;接下来介绍怎么使用 Python 搭配 OpenCV 模块来进行 RGB/BGR 转 HSV 彩色转HSV空间。 H…

03 Php学习:echo 、 print 、EOF

echo 和 print 在 PHP 中有两个基本的输出方式&#xff1a; echo 和 print。 echo 和 print 区别: echo - 可以输出一个或多个字符串print - 只允许输出一个字符串&#xff0c;返回值总为 1 注意&#xff1a;echo 输出的速度比 print 快&#xff0c; echo 没有返回值&…

VS Code开发插件使用 pnpm 打包异常的解决姿势

前言 刚刚准备发一个插件&#xff0c;发现用 pnpm 打出一个本地插件包直接扑街了。 这里只聚焦错误问题的解决&#xff0c;不是发插件的教程。。 聊点背景信息&#xff0c;vscode 的插件命令行的是 vsce 这个模块提供的 cli 能力去做的 环境 pnpm : 8.x 错误截图 本地打…

C++ Virtual详解

Virtual是C OO机制中很重要的一个关键字。只要是学过C的人都知道在类Base中加了Virtual关键字的函数就是虚拟函数&#xff08;例如函数print&#xff09;&#xff0c;于是在Base的派生类Derived中就可以通过重写虚拟函数来实现对基类虚拟函数的覆盖。当基类Base的指针point指向…

LRU算法的实现

目录 一&#xff0c;LRU算法 二&#xff0c;使用场景 三&#xff0c;LRU算法实现 一&#xff0c;LRU算法 LRU-least recently used-最近最少使用算法&#xff0c;是一种内存数据淘汰策略&#xff0c;使用常见是当内存不足时&#xff0c;需要淘汰最近最少使用的数据。LRU常用…

Mac 安装 brew brew cask 遇到的问题以及解决办法

安装Homebrew和Homebrew Cask是在Mac上管理软件包的常用方法。虽然大多数情况下安装这两个工具是比较简单的&#xff0c;但有时候也可能遇到一些问题。下面是一些常见的问题以及解决办法&#xff1a; 问题1&#xff1a;无法安装Homebrew 解决办法&#xff1a; 1.确保你的Mac已连…