结合上一篇文章 《redis在学生抢房应用中的实践小结》 中提及的用redis实现 DDOS设计时遇到的expire的坑 。其实,redis官网中对incr命令的介绍中已经有关于如何用redis来做 rate limit 的探讨。这里将实现的两种模式翻译一下,并适当加了一些批注说明,原文可见 官网 。
模式:Rate limiter
频次限制器模式是一种特殊的计数器,它常被用来限制某个操作可以被执行的频次。这个模式的实质其实是限制对一个公共API执行访问请求的次数限制。我们使用 incr
命令提供该模式的两种实现。这里我们假设需要解决的问题是:对每个IP,限制对某API的调用次数最高位10次每秒。
模式:Rate limiter 1
对该模式一个相对简单和直接的实现,请见如下代码:
FUNCTION LIMIT_API_CALL(ip)
ts = CURRENT_UNIX_TIME()
keyname = ip+":"+ts
current = GET(keyname)
IF current != NULL AND current > 10 THEN
ERROR "too many requests per second"
ELSE
MULTI
INCR(keyname,1)
EXPIRE(keyname,10)
EXEC
PERFORM_API_CALL()
END
简单来说,我们对 每个IP的每一秒
都有一个计数器,但每个计数器都有一个额外的设置:它们都将被设置一个10秒的过期时间。这可以使得当时间已经不是当前秒时(此时该计数器也无效了),能够让redis自动移除它。
需要注意的是,这里我们使用 multi
和 exec
命令来确保对每个API调用既执行了 incr
也同时能够执行 expire
命令。
multi命令用于标识一个命令集被包含在一个事务块中,exec保证该事务块命令集执行的原子性。
模式:Rate limiter 2
另外的一种实现是采用单一的计数器,但是为了避免 race condition
(竞态条件),它也更复杂。我们来看几种不同的变体:
FUNCTION LIMIT_API_CALL(ip):
current = GET(ip)
IF current != NULL AND current > 10 THEN
ERROR "too many requests per second"
ELSE
value = INCR(ip)
IF value == 1 THEN
EXPIRE(value,1)
END
PERFORM_API_CALL()
END
该计数器在当前秒内第一次请求被执行时创建,但它只能存活一秒。如果在当前秒内,发送超过10次请求,那么该计数器将超过10。否则它将失效并从0开始重新计数。
在上面的代码中,存在一个race condition。如果因为某个原因,上面的代码只执行了 incr
命令,却没有执行 expire
命令,那么这个key将会被泄漏,直到我们再次遇到相同的ip(备注,如果这里没有辅助的删除该key的措施,那么该key将永不过期,也将每次都发生错误,详情可见本人之前一篇文章)。
这种问题也不难处理,可以将 incr
命令以及另外的 expire
命令打包到一个lua脚本里,该脚本可以用 eval
命令提交给redis执行(该方式只在redis版本大于等于2.6之后才能支持)。
local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
redis.call("expire",KEYS[1],1)
end
当然,也有另一种方式来解决这个问题而不需要动用lua脚本,但需要用redis的list数据结构来替代计数器。这种实现方式将会更复杂,并使用更高级的特性。但它有一个好处是记住调用当前API的每个客户端的IP。这种方式可能很有用也可能没用,这取决于应用需求。
FUNCTION LIMIT_API_CALL(ip)
current = LLEN(ip)
IF current > 10 THEN
ERROR "too many requests per second"
ELSE
IF EXISTS(ip) == FALSE
MULTI
RPUSH(ip,ip)
EXPIRE(ip,1)
EXEC
ELSE
RPUSHX(ip,ip)
END
PERFORM_API_CALL()
END
rpushx
命令只在key存在时才会将值加入list
仍然需要注意的是,这里也存在一个race condition(但这却不会产生太大的影响)。问题是: exists
可能返回 false
,但在我们执行 multi/exec
块内的创建list的代码之前,该list可能已被其他客户端创建。然而,在这个race condition发生时,将仅仅只是丢失一个API调用,所以rate limiting仍然工作得很好。
这里产生race condition不会有大问题的原因在于,else分支使用的rpushx,它不会导致if not than init的问题,并且expire命令将在创建list的时候以原子的形式捆绑执行。不会产生key泄漏,导致永不失效的情况产生。