Scala+Redis使用令牌桶实现分布式限流

本文介绍了如何利用令牌桶算法在分布式环境中实现限流,对比了单点限流和分布式限流的差异。通过Lua脚本在Redis中实现令牌桶,确保在微服务架构中对流量进行有效控制,防止系统过载。示例代码展示了Scala如何调用Lua脚本进行限流操作。
摘要由CSDN通过智能技术生成

Scala+Redis+lua使用令牌桶算法实现分布式限流

分布式限流与单点限流

单点限流:简单来讲可以认为访问的api服务端只有一台机器,在该机器上运行的代码在接口里面实现一个本地的限流比如本地定义一个全局变量S作为令牌桶,当有请求进来访问接口时S-1,当本次访问完成时S+1,当S<=0时可以阻塞本次请求或者直接拒绝请求。使用一个单点限流即可对该系统进行限流保护,也可以对该系统所依赖的下游服务进行限流保护。

分布式限流:但是通常来讲,线上业务多使用的是微服务,是分布式部署的系统,这个情况下单点的限流虽然对本系统的单个机器可以起到保护作用,但是针对本系统所共享的其他下游依赖系统比如Mysql等无法保护,而且在系统进行扩容缩容的时候也无法准确的控制整个服务的性能限制。

所以通常使用第三方的中间件实现一个分布式的限流器,简单理解即讲本地定义的全局变量S这个令牌桶定义到系统的所有机器都可以访问到的第三方中间件上,这样当本次有请求访问接口的时候,先去中间件上尝试拿令牌,如果能拿到则放行,本次调用正常执行业务逻辑,如果拿不到令牌简单处理既是拒绝本次调用请求,进一步的升级处理可以拿到需要等待的时间WaitTime,然后让本次调用的线程sleep共WaitTime这么长的时间之后再放行。

令牌桶算法

为什么选择令牌桶算法,以及四种限流算法各自的优缺点本文将不在赘述,网上有很多方便查找和学习的资料。正式进入本文令牌桶算法的简单理解阶段,首先我们试想一个场景,即医院的排队拿号看病系统,在该系统中我们做如下假设:

a.我们假定某个科室一共有1千名医生值班。

b.我们假定这一千名医生中每一个医生看完一个病人需要1s。

c.我们假定每一名病人看病之前都需要到令牌机器上去拿一个令牌,然后等待系统语音呼叫指定令牌的病人去看病。

那么我们可以看到该医院某科室的限流上限是1000/s,那么该医院的系统看完一个病人花费的时间是1s/1000=0.001s,也就是说该医院每0.001s会有一个医生空闲出来,令牌机器上会生成一个新的令牌,每一个令牌代表有一个医生空闲。

理解了以上的表述那么接着我们将来看病的病人分为3类,第一类病人既医院的排队拿号看病系统还未初始化时的病人,我们记为病人A,病人A来看病时医院需要做如下的事情

1:初始化令牌机器,设置最大的令牌数量=1000。

2:生成最大令牌数量大小的号码存储起来,以供病人拿号,病人拿一个就少一个。

3:初始化下次生成令牌的时刻,通常是timeStamp,,因为病人A来看病时,令牌机器中已经有1000个令牌,而且没准今天一共都来不了1000个病人,所以设置下次生产令牌的时刻=0。

4:给病人A一个令牌,令牌存储-1。

第二类病人为在他看病拿号时,令牌系统还有令牌,既此时还有医生空闲可以看病,我们记为病人B,病人B来看病时医院需要做如下的事情

1:病人B来看病的时刻如果大于系统存储的下次生产令牌的时刻,那么需要更新系统存储中的令牌数量,新增(病人B看病时刻-下次生产令牌的时刻)*(1000个令牌/1000ms)个令牌,时间乘以速率;Max.min(新增的令牌加上还剩余的令牌,一共一千个医生)为最终此时令牌存储中真正的令牌数即空闲的医生数量。

2:此时的令牌存储-1,给病人B一个令牌让他去看病,因为此时不需要等待,所以将下次生产令牌事件改为病人B来看病的时刻,更新下次令牌生产的时刻,注意本次的病人B等待时间为0。

第三类病人为在他看病拿号试,令牌系统里没有令牌,这种情况下结合实际医院的看不流程会知道该病人需要等待医生空闲下来既需要等待令牌系统中他所得知的下次令牌生产时刻-他尝试拿号的时刻这么一段时间,我们记为病人C。

为了方便理解,简单举例说明,比如令牌系统中记录的下次生产令牌的时刻是第10分钟第0秒第0毫秒,在这个时刻之后比如第10分钟第0秒第1毫秒内一共有5名病人来尝试拿号看病,病人C排第五,那么很显然这五名病人按照先后顺序分别需要等待1ms、2ms、3ms、4ms、5ms。那么病人C得到信息他拿到一个号,但是这个号会在5ms之后呼叫他去看病。病人C来看病时医院需要做如下事情

1:病人C来看病的时刻小于系统存储的下次生产令牌的时刻,既此时前面还有病人等着没有医生空闲。那么此时病人C需要1个令牌,生产一个令牌的等待时间是1ms,所以病人C得知的需要等待时长=(系统记录的下次生产令牌的时刻+生产一个令牌的时长1ms)-病人C拿号的时刻。

2:更新系统中下次生产令牌的时刻=系统记录的下次生产令牌的时刻+生产一个令牌的时长1ms。

3:返回病人C等待市场waittime。

lua+redis脚本实现

我们用lua+redis来编写实现上述的医院看病的排队拿号系统

redis.relicate_commands()


local stored_permits = "STORED_PERMITS" //系统中的剩余令牌数
local max_permits = "MAX_PERMITS" //系统支持的最大令牌数
local next_free_permit_timestamp = "NEXT_FREE_PERMIT_TIMESTAMP" //系统存储的下次生产令牌的时刻
local key_expire_time = "KEY_EXPIRE_TIME" //redis的存储key的过期时间

//获取当前时刻,精确到毫秒
local function getCurrentTimestamp()
    local time = redis.call('TIME')
    local second = time[1] //系统时间当前秒数
    local microsecond = time[2] //系统时间当前微秒数
    return (tonumber(second)*1000000 + tonumber(microsecond))/1000 返回毫秒数
end

local function acquire(key, needPermits, nowTimestamp)
    local redisConfig = redis.call('HMGET', key, stored_permits, max_permits, next_free_permit_timestamp, key_expire_time)
    local storedPermits = tonumber(redisConfig[1])
    local maxPermits = tonumber(redisConfig[2])
    local nextFreePermitTimestamp = tonumber(redisConfig[3])
    local expireTime = tonumber(redisConfig[4])

    redis.call('expire', key, expireTime) //每次访问redis需要给该key的配置续期
    
    //如果当前病人访问的时刻比保存的下次生产令牌时刻大,那么需要讲相应的令牌数量补充进来
    if(nowTimestamp > nextFreePermitTimestamp) then
        local newPermits = (nowTimestamp - nextFreePermitTimestamp) *(max_permits/1000) //1000代表1千毫秒
        storedPermits = math.min(maxPermits, (newPermits+storedPermits))
        nextFreePermitTimestamp = nowTimestamp
    end
    
    local trueGetPermits = math.min(storedPermits, needPermits)
    local notGetPermits = needPermits - trueGetPermits
    local watiForEnoughPermitsMills = notGetPermits * (1000/maxPermits) //欠缺的令牌数*生产一个令牌需要多长时间
    nextFreePermitTimestamp = nextFreePermitTimestamp + watiForEnoughPermitsMills //watiForEnoughPermitsMills时间内生产的令牌被预支了
    local waitTime = nextFreePermitTimestamp - nowTimestamp
    
    redis.call('hmset', key, stored_permits, storedPermits-trueGetPermits, next_free_permit_timestamp, nextFreePermitTimestamp)
end

if(redis.call('ttl',KEYS[1]) < 0) then
    redis.call('hmset',KEYS[1], stored_permits, ARGV[3], max_permits, ARGV[3], next_free_permit_timestamp, '0', key_expire_time, ARGV[4])
end

local nowTimeStamp = getCurrentTimestamp
local waitTime = acquire(KEYS[1], tonumber(ARGV[1]), nowTimestamp)

return waitTime
        

Scala 实现调用lua脚本进行一次拿号操作

我们使用redis的java客户端jedis

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.8.2</version>
<dependency>
/**
*该方法是伪代码,rpc接口inference,当收到请求时先tryAcquire一下,进行限流
*由于每次waittime > 0时是执行线程等待所以可以达到削峰填谷的限流效果,也可以直接拒绝
*根据自己业务进行选择
*/
def inference(request) = {
    //假设限流为每秒1000QPS,redisKey的过期时间为3600s
    val waittime = tryAcquire(redisKey, 1, 1000, 3600000)
    if(waittime > 0) {
        MILLISECONDS.sleep(waittime) //该病人需要等待
    }
    //正常业务处理流程
    ……
}

/**
*生成一个jedis对象 执行script脚本 获取当前病人拿号时需要等待的时长
*/
private def tryAcquire(key: String, needPermits: String, maxQps: String, redisExpireTime: String) {
    val jedis = new Jedis("redis ip",port)
    val script = getScript()
    val resp = jedis.eval(script, 1, key, needPermits, maxQps, redisExpireTime)
    val result = resp.asInstanceOf[java.util.ArrayList[String]].asScala
    var waitTime = result(0).toDouble
    waitTime = Math.max(0, Math.ceil(waitTime).toLong)
    waitTime
}



private def getScript(): String = {
    """
    redis.relicate_commands()


    local stored_permits = "STORED_PERMITS" //系统中的剩余令牌数
    local max_permits = "MAX_PERMITS" //系统支持的最大令牌数
    local next_free_permit_timestamp = "NEXT_FREE_PERMIT_TIMESTAMP" //系统存储的下次生产令牌的时刻
    local key_expire_time = "KEY_EXPIRE_TIME" //redis的存储key的过期时间

    //获取当前时刻,精确到毫秒
    local function getCurrentTimestamp()
        local time = redis.call('TIME')
        local second = time[1] //系统时间当前秒数
        local microsecond = time[2] //系统时间当前微秒数
        return (tonumber(second)*1000000 + tonumber(microsecond))/1000 返回毫秒数
    end

    local function acquire(key, needPermits, nowTimestamp)
        local redisConfig = redis.call('HMGET', key, stored_permits, max_permits, next_free_permit_timestamp, key_expire_time)
        local storedPermits = tonumber(redisConfig[1])
        local maxPermits = tonumber(redisConfig[2])
        local nextFreePermitTimestamp = tonumber(redisConfig[3])
        local expireTime = tonumber(redisConfig[4])

        redis.call('expire', key, expireTime) //每次访问redis需要给该key的配置续期
    
        //如果当前病人访问的时刻比保存的下次生产令牌时刻大,那么需要讲相应的令牌数量补充进来
        if(nowTimestamp > nextFreePermitTimestamp) then
            local newPermits = (nowTimestamp - nextFreePermitTimestamp) *(max_permits/1000) //1000代表1千毫秒
            storedPermits = math.min(maxPermits, (newPermits+storedPermits))
            nextFreePermitTimestamp = nowTimestamp
        end
    
        local trueGetPermits = math.min(storedPermits, needPermits)
        local notGetPermits = needPermits - trueGetPermits
        local watiForEnoughPermitsMills = notGetPermits * (1000/maxPermits) //欠缺的令牌数*生产一个令牌需要多长时间
        nextFreePermitTimestamp = nextFreePermitTimestamp + watiForEnoughPermitsMills //watiForEnoughPermitsMills时间内生产的令牌被预支了
        local waitTime = nextFreePermitTimestamp - nowTimestamp
    
        redis.call('hmset', key, stored_permits, storedPermits-trueGetPermits, next_free_permit_timestamp, nextFreePermitTimestamp)
    end

    if(redis.call('ttl',KEYS[1]) < 0) then
        redis.call('hmset',KEYS[1], stored_permits, ARGV[2], max_permits, ARGV[2], next_free_permit_timestamp, '0', key_expire_time, ARGV[3])
    end

    local nowTimeStamp = getCurrentTimestamp
    local waitTime = acquire(KEYS[1], tonumber(ARGV[1]), nowTimestamp)

    return waitTime 
    """.stripMargin
}

进一步还可以添加本地的预支令牌缓存,比如我们规定每次去redis拿令牌的话都推荐一次拿5个令牌,这样当需要的令牌数量小于5时,我们去redis拿5个令牌,扣除本次需要的,剩余的用一个全局变量保存起来,等下次有请求进来时,先扣除本地缓存的令牌数量,本地缓存的令牌数量不够时再去redis拿。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值