一、从生活场景理解限流:为什么需要“守门员”
想象一下,你开了一家非常火爆的奶茶店。开业第一天,顾客蜂拥而至,把小小的店铺挤得水泄不通。收银员手忙脚乱,制作员完全跟不上订单,最终系统崩溃,新老顾客都怨声载道。这个场景,是不是像极了我们的线上服务?当突如其来的高并发请求(比如秒杀活动、热点新闻)涌向服务器时,如果服务器没有保护措施,很容易就会因为过载而崩溃,导致所有用户都无法访问。
这时候,我们就需要一个聪明的“守门员”——限流。它的作用不是拒绝服务,而是为了保护服务,让它能在自己能承受的范围内,平稳、有序地处理请求。OpenResty,这个基于Nginx和Lua的高性能Web平台,就是我们实现这个“守门员”的绝佳工具。它让我们可以用Lua脚本轻松地编写限流逻辑。今天,我们就来聊聊OpenResty里最经典的两位“守门员”:漏桶算法和令牌桶算法,看看在实际项目中,我们该如何选择它们。
二、漏桶算法:匀速输出的“老实人”
我们先来看漏桶算法。你可以把它想象成一个底部有固定大小出水口的水桶。不管上方的水(请求)以多快、多猛的速度倒进来,桶里的水都只会以恒定、均匀的速率从出水口流出去。如果水倒得太快,桶装满了,那么多出来的水就会溢出去(也就是拒绝请求)。
它的核心特点就两个字:匀速。它强制让请求的处理间隔变得平均,非常适合用来平滑突发流量,为后端服务提供一个稳定的请求流。
技术栈:OpenResty + Lua
下面我们来看一个在OpenResty中用Lua实现漏桶算法的完整示例。我们会用一个共享字典来模拟“桶”的状态。
-- 技术栈:OpenResty + Lua
-- 文件名:leaky_bucket.lua
local dict = ngx.shared.rate_limit_dict -- 假设在nginx.conf中已定义共享内存区
-- 漏桶限流函数
-- key: 限流的标识,如客户端IP或用户ID
-- capacity: 桶的容量(最大积压请求数)
-- leak_rate: 漏水速率,单位:请求数/秒
local function leaky_bucket_limit(key, capacity, leak_rate)
local now = ngx.now() -- 获取当前时间戳,精度更高
local last_time_key = key .. "_last_time"
local water_level_key = key .. "_water_level"
-- 从共享字典中获取上一次处理时间和当前水位
local last_time = dict:get(last_time_key) or now
local current_water = dict:get(water_level_key) or 0
-- 计算从上一次到现在,漏出了多少水(处理了多少请求)
local time_passed = now - last_time
local leaked = time_passed * leak_rate
-- 更新当前水位:旧水位 - 漏掉的水,但不能小于0
current_water = math.max(current_water - leaked, 0)
-- 尝试注入新的请求(一滴水)
if current_water + 1 > capacity then
-- 桶已满,拒绝请求
dict:set(last_time_key, last_time) -- 注意:拒绝时,时间不更新,水位也不变
return false, "请求过快,请稍后再试。", current_water
else
-- 桶未满,接受请求,水位+1,并更新处理时间
current_water = current_water + 1
dict:set(water_level_key, current_water)
dict:set(last_time_key, now)
return true, "请求通过。", current_water
end
end
-- 在access_by_lua阶段调用
local key = ngx.var.remote_addr -- 使用客户端IP作为限流key
local capacity = 10 -- 桶容量为10个请求
local leak_rate = 2 -- 漏水速率为每秒2个请求
local is_passed, msg, water = leaky_bucket_limit(key, capacity, leak_rate)
if not is_passed then
ngx.log(ngx.WARN, "客户端 ", key, " 被限流,当前水位:", water)
ngx.exit(503) -- 返回服务暂不可用状态码
end
-- 如果通过,请求将继续流向后续阶段(如代理到后端服务)
代码解读:
这个例子模拟了一个漏桶。我们为每个客户端IP维护一个“桶”。capacity是桶的大小,leak_rate是出水速度。每次请求进来,先计算自上次请求后流走了多少水(leaked),然后更新水位。只有水位+1后不超过容量,请求才被允许。它的输出非常稳定,但缺点也很明显:面对突发流量时,即使系统有能力处理,也会因为“匀速”的限制而让部分请求等待,响应可能不够及时。
三、令牌桶算法:灵活应对的“聪明人”
令牌桶算法是另一个思路。想象有一个桶,这个桶里放的不是水,而是“令牌”。系统会以恒定的速率往桶里添加令牌,直到桶被填满为止。当一个请求到来时,它需要从桶里拿走一个令牌。只有拿到令牌的请求才能被处理。如果桶里没有令牌了,请求就只能被拒绝或等待。
它的核心特点是:允许突发。只要桶里有足够的令牌,一瞬间的突发请求可以立刻被处理掉,这对于需要快速响应的场景非常友好。
技术栈:OpenResty + Lua
下面我们实现一个令牌桶算法。为了更贴近生产环境,我们引入一个简单的“预计算”优化,减少每次请求的计算量。
-- 技术栈:OpenResty + Lua
-- 文件名:token_bucket.lua
local dict = ngx.shared.rate_limit_dict
-- 令牌桶限流函数(带预计算优化)
-- key: 限流标识
-- capacity: 桶容量(最大令牌数)
-- fill_rate: 令牌添加速率,单位:令牌数/秒
local function token_bucket_limit(key, capacity, fill_rate)
local now = ngx.now()
local tokens_key = key .. "_tokens"
local last_fill_key = key .. "_last_fill"
-- 获取当前令牌数和上次补充时间
local current_tokens = dict:get(tokens_key) or capacity -- 初始化为满桶
local last_fill_time = dict:get(last_fill_key) or now
-- 计算自上次补充后,应该新增多少令牌
local time_passed = now - last_fill_time
local tokens_to_add = time_passed * fill_rate
if tokens_to_add > 0 then
-- 补充令牌,但不能超过容量
current_tokens = math.min(current_tokens + tokens_to_add, capacity)
-- 更新令牌数和补充时间(注意:这里是关键,只在补充时才更新存储)
dict:set(tokens_key, current_tokens)
dict:set(last_fill_key, now)
end
-- 尝试消费一个令牌
if current_tokens < 1 then
-- 令牌不足,拒绝请求
return false, "令牌不足,请求被限流。", current_tokens
else
-- 令牌充足,消费一个令牌
current_tokens = current_tokens - 1
dict:set(tokens_key, current_tokens) -- 更新消费后的令牌数
-- 注意:这里不更新 last_fill_time,因为它只在补充逻辑中更新
return true, "请求通过,消费一个令牌。", current_tokens
end
end
-- 在access_by_lua阶段调用
local key = ngx.var.remote_addr
local capacity = 20 -- 桶里最多存20个令牌
local fill_rate = 5 -- 每秒生产5个令牌
local is_passed, msg, tokens = token_bucket_limit(key, capacity, fill_rate)
if not is_passed then
ngx.log(ngx.WARN, "客户端 ", key, " 令牌不足,剩余令牌:", tokens)
ngx.exit(429) -- 使用429 Too Many Requests状态码更贴切
end
-- 请求通过
代码解读:
这个实现中,我们为每个客户端维护令牌数。每次请求先根据时间差计算应该补充多少令牌,然后尝试消费一个。capacity决定了突发的上限(比如瞬间最多处理20个请求),fill_rate决定了长期的平均处理速率。它比漏桶更灵活,既能限制平均速率,又能允许一定程度的突发。
四、深入对比:如何选择你的“守门员”
看完了具体实现,我们来系统性地对比一下,帮助你在实际场景中做出选择。
应用场景分析:
- 选择漏桶算法,当你的首要目标是“绝对平滑”:
- 数据库写入:你希望写入操作平稳压力,避免瞬间IO高峰拖垮数据库。
- 下游脆弱服务保护:你调用的某个外部API或老旧系统承受能力弱,需要你以恒定速率调用。
- 消息队列的消费者:希望从队列里拉取消息的速度是均匀的,而不是忽快忽慢。
- 选择令牌桶算法,当你的系统需要“快速响应”与“弹性”:
- 开放API接口:允许用户在某段时间内突发调用,提升用户体验,但长期来看要控制总调用量。
- Web应用接口:用户操作(如点击、查询)希望立刻得到反馈,可以利用桶内积累的令牌快速处理。
- 网络流量整形:在保证平均带宽的前提下,允许短暂的流量峰值,充分利用网络资源。
技术优缺点总结:
- 漏桶算法:
- 优点:输出绝对平滑,能有效防止突发流量对下游的冲击,实现简单。
- 缺点:无法应对突发流量,即使系统空闲,请求也必须排队流出,可能增加请求延迟。对于正常的流量波动不够友好。
- 令牌桶算法:
- 优点:允许一定程度的突发流量,能快速响应用户请求,更充分地利用系统资源,在空闲期能积累“信用”(令牌)。
- 缺点:突发可能会给下游带来压力,实现相对复杂一点点。如果桶的容量设置过大,起不到保护下游的作用。
注意事项与进阶思考:
- 分布式限流:上面的例子用的是
ngx.shared.DICT,它只在单个OpenResty工作进程内共享。对于多机部署,你需要引入Redis等外部存储来实现集群限流,同时要注意原子性操作(使用incr、evallua脚本等)和时钟同步问题。 - 限流Key的设计:根据业务灵活选择。可以是
客户端IP、用户ID、接口路径,甚至是它们的组合(如用户ID_接口),实现更细粒度的控制。 - 失败处理策略:直接返回
503或429是最简单的。更友好的方式可以是将请求放入队列短暂等待,或者返回一个“优雅降级”的默认内容。 - 动态调整参数:生产环境中,限流参数(
capacity,rate)不应是硬编码的。可以将其配置在etcd、Apollo等配置中心,实现动态调整,便于运维。 - 结合其他中间件:OpenResty社区有成熟的限流库,如
lua-resty-limit-traffic,它封装了漏桶、令牌桶甚至更复杂的算法,并解决了多进程、分布式的一些问题,在生产中可以直接考虑使用。
五、总结:没有最好,只有最合适
回到最初的奶茶店问题。如果你的制作流程非常固定,一秒只能做两杯,多一杯原料都会浪费(类似数据库写入),那么用“漏桶”这种匀速发放的方式最合适,能保证店铺长期稳定运行。如果你的店铺有多个熟练员工,平时大家比较悠闲(系统空闲),突然来了一群客人,你们可以短时间内快速服务一批(消耗积累的令牌),然后再恢复到平稳节奏,那么“令牌桶”显然更能提升顾客满意度,也更能利用好员工的时间(系统资源)。
因此,漏桶和令牌桶没有绝对的优劣。漏桶关注的是“流出的稳定性”,而令牌桶关注的是“流入的突发性”。
在实际的OpenResty开发中,我的建议是:
- 优先考虑令牌桶算法,因为它更符合大多数Web场景对响应速度的期待,且能容忍合理波动。
- 只有当你的下游服务极其脆弱,或者业务上严格要求请求间隔必须均匀时,才使用漏桶算法。
- 在OpenResty中,除了自己手写,务必了解一下
lua-resty-limit-traffic这样的官方推荐库,它能帮你规避很多底层陷阱。
希望这篇对比能帮你理清思路,为你的下一个OpenResty项目选择一个合适的“流量守门员”,在保障系统稳定的同时,也能带给用户更好的体验。
评论