一、从生活场景理解限流:为什么需要“守门员”

想象一下,你开了一家非常火爆的奶茶店。开业第一天,顾客蜂拥而至,把小小的店铺挤得水泄不通。收银员手忙脚乱,制作员完全跟不上订单,最终系统崩溃,新老顾客都怨声载道。这个场景,是不是像极了我们的线上服务?当突如其来的高并发请求(比如秒杀活动、热点新闻)涌向服务器时,如果服务器没有保护措施,很容易就会因为过载而崩溃,导致所有用户都无法访问。

这时候,我们就需要一个聪明的“守门员”——限流。它的作用不是拒绝服务,而是为了保护服务,让它能在自己能承受的范围内,平稳、有序地处理请求。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应用接口:用户操作(如点击、查询)希望立刻得到反馈,可以利用桶内积累的令牌快速处理。
    • 网络流量整形:在保证平均带宽的前提下,允许短暂的流量峰值,充分利用网络资源。

技术优缺点总结

  • 漏桶算法
    • 优点:输出绝对平滑,能有效防止突发流量对下游的冲击,实现简单。
    • 缺点:无法应对突发流量,即使系统空闲,请求也必须排队流出,可能增加请求延迟。对于正常的流量波动不够友好。
  • 令牌桶算法
    • 优点:允许一定程度的突发流量,能快速响应用户请求,更充分地利用系统资源,在空闲期能积累“信用”(令牌)。
    • 缺点:突发可能会给下游带来压力,实现相对复杂一点点。如果桶的容量设置过大,起不到保护下游的作用。

注意事项与进阶思考

  1. 分布式限流:上面的例子用的是ngx.shared.DICT,它只在单个OpenResty工作进程内共享。对于多机部署,你需要引入Redis等外部存储来实现集群限流,同时要注意原子性操作(使用increvallua脚本等)和时钟同步问题。
  2. 限流Key的设计:根据业务灵活选择。可以是客户端IP用户ID接口路径,甚至是它们的组合(如用户ID_接口),实现更细粒度的控制。
  3. 失败处理策略:直接返回503429是最简单的。更友好的方式可以是将请求放入队列短暂等待,或者返回一个“优雅降级”的默认内容。
  4. 动态调整参数:生产环境中,限流参数(capacityrate)不应是硬编码的。可以将其配置在etcdApollo等配置中心,实现动态调整,便于运维。
  5. 结合其他中间件:OpenResty社区有成熟的限流库,如lua-resty-limit-traffic,它封装了漏桶、令牌桶甚至更复杂的算法,并解决了多进程、分布式的一些问题,在生产中可以直接考虑使用。

五、总结:没有最好,只有最合适

回到最初的奶茶店问题。如果你的制作流程非常固定,一秒只能做两杯,多一杯原料都会浪费(类似数据库写入),那么用“漏桶”这种匀速发放的方式最合适,能保证店铺长期稳定运行。如果你的店铺有多个熟练员工,平时大家比较悠闲(系统空闲),突然来了一群客人,你们可以短时间内快速服务一批(消耗积累的令牌),然后再恢复到平稳节奏,那么“令牌桶”显然更能提升顾客满意度,也更能利用好员工的时间(系统资源)。

因此,漏桶和令牌桶没有绝对的优劣。漏桶关注的是“流出的稳定性”,而令牌桶关注的是“流入的突发性”

在实际的OpenResty开发中,我的建议是:

  • 优先考虑令牌桶算法,因为它更符合大多数Web场景对响应速度的期待,且能容忍合理波动。
  • 只有当你的下游服务极其脆弱,或者业务上严格要求请求间隔必须均匀时,才使用漏桶算法
  • 在OpenResty中,除了自己手写,务必了解一下lua-resty-limit-traffic这样的官方推荐库,它能帮你规避很多底层陷阱。

希望这篇对比能帮你理清思路,为你的下一个OpenResty项目选择一个合适的“流量守门员”,在保障系统稳定的同时,也能带给用户更好的体验。