18. Nginx 限流模块源码深度解析:limit_conn / limit_req / limit_rate 实现原理
1. 引言
在高并发场景下,限流是保障系统稳定性的关键手段。Nginx 作为最流行的反向代理和 Web 服务器,内置了三个核心限流模块:ngx_http_limit_conn_module(连接数限制)、ngx_http_limit_req_module(请求速率限制)和ngx_http_core_module中的limit_rate(响应速率限制)。本文将深入 Nginx 1.27.x 源码,剖析这三个模块的实现机制,并对比漏桶、令牌桶等主流限流算法,帮助读者从原理层面理解 Nginx 的限流能力。
2. 核心数据结构与共享内存
Nginx 的限流模块都依赖于共享内存(ngx_shm_zone_t)来存储状态,这使得限流可以在多 worker 进程间协同工作。
2.1 红黑树与 LRU 队列
每个限流模块在共享内存中维护一棵红黑树(ngx_rbtree_t)和一个LRU 队列(ngx_queue_t)。
- 红黑树:以
key(如$binary_remote_addr)为索引,用于快速查找某个客户端是否已有记录。 - LRU 队列:按节点的最近访问时间排序。当共享内存不足时,Nginx 会从 LRU 队列尾部淘汰最久未使用的节点,以腾出空间给新节点。
// 以 limit_conn 为例,其共享内存节点结构typedefstruct{ngx_rbtree_node_trbtree_node;// 红黑树节点,key 为 hash 值ngx_queue_tqueue;// LRU 队列节点u_char color;// 红黑树颜色u_char len;// key 长度ngx_uint_tconn;// 当前连接数u_char data[1];// 变长 key 数据}ngx_http_limit_conn_node_t;3.limit_conn:连接数限制
3.1 配置指令
http { limit_conn_zone $binary_remote_addr zone=addr:10m; server { location / { limit_conn addr 10; } } }limit_conn_zone:定义共享内存区域,$binary_remote_addr作为 key,10m表示分配 10MB 共享内存。limit_conn:在 location 中引用该区域,并设置最大连接数为 10。
3.2 源码实现流程
limit_conn的钩子挂在NGX_HTTP_PREACCESS_PHASE阶段,在访问控制之前执行。
核心函数:ngx_http_limit_conn_handler
- 获取节点:根据 key(如
$binary_remote_addr)在共享内存的红黑树中查找或创建ngx_http_limit_conn_node_t节点。 - 递增计数:将节点中的
conn字段加 1。 - 阈值判断:如果
conn > limit,则触发限流,返回NGX_HTTP_SERVICE_UNAVAILABLE(503)。 - 请求结束回调:注册一个
ngx_http_cleanup_t回调,在请求结束时将conn减 1。
// 简化后的核心逻辑ngx_int_tngx_http_limit_conn_handler(ngx_http_request_t*r){// 1. 获取共享内存区域ctx=ngx_http_get_module_ctx(r,ngx_http_limit_conn_module);zone=ctx->shm_zone->data;// 2. 在红黑树中查找或创建节点node=ngx_http_limit_conn_lookup(zone->rbtree,key,key_len);if(node==NULL){node=ngx_slab_alloc_locked(zone->shpool,sizeof(ngx_http_limit_conn_node_t));// 插入红黑树和 LRU 队列}// 3. 递增连接数node->conn++;// 4. 判断是否超过限制if(node->conn>ctx->limit){node->conn--;// 回滚returnNGX_HTTP_SERVICE_UNAVAILABLE;}// 5. 注册清理回调cln=ngx_http_cleanup_add(r,0);cln->handler=ngx_http_limit_conn_cleanup;cln->data=node;returnNGX_DECLINED;}// 请求结束时的清理函数voidngx_http_limit_conn_cleanup(void*data){ngx_http_limit_conn_node_t*node=data;node->conn--;}3.3 关键特性
- 精确计数:基于请求的生命周期,精确控制并发连接数。
- 无延迟:超过限制直接拒绝,不排队。
- 粒度:通常基于 IP,但可以自定义 key(如
$server_name)。
4.limit_req:请求速率限制
4.1 配置指令
http { limit_req_zone $binary_remote_addr zone=req:10m rate=10r/s; server { location / { limit_req zone=req burst=20 nodelay; } } }rate=10r/s:平均速率限制为每秒 10 个请求。burst=20:允许突发 20 个请求的排队队列。nodelay:排队中的请求不延迟处理,而是立即处理,但会消耗未来的配额。
4.2 算法核心:漏桶算法(Leaky Bucket)
Nginx 的limit_req模块实现的是漏桶算法的变体。其核心思想是:
- 请求以不确定的速率进入桶中。
- 桶以固定的速率(
rate)漏水(处理请求)。 - 如果桶满了(超过
burst),新的请求被拒绝。
4.3 源码实现流程
limit_req同样挂在NGX_HTTP_PREACCESS_PHASE阶段。
核心数据结构:ngx_http_limit_req_node_t
typedefstruct{ngx_rbtree_node_trbtree_node;ngx_queue_tqueue;u_char color;u_char len;ngx_msec_tlast;// 上一次请求的时间戳(毫秒)ngx_uint_texcess;// 当前累积的超出量(以毫秒为单位)u_char data[1];}ngx_http_limit_req_node_t;核心函数:ngx_http_limit_req_handler
- 计算时间差:
ms = ngx_current_msec - node->last。 - 计算可消耗的配额:
excess = node->excess - ms。如果excess < 0,说明桶已经漏完,重置为 0。 - 计算本次请求的消耗:每个请求消耗
1000 / rate毫秒的配额。例如rate=10r/s,则每个请求消耗 100ms。 - 判断是否超限:
excess = excess + 1000/rate。如果excess > burst * 1000/rate,则拒绝请求。 - 更新节点:
node->last = ngx_current_msec; node->excess = excess;。
// 简化后的核心逻辑ngx_int_tngx_http_limit_req_handler(ngx_http_request_t*r){// ... 获取节点 nodenow=ngx_current_msec;ms=(ngx_msec_t)(now-node->last);// 1. 计算桶内剩余水量(excess 是累积的超出时间)excess=node->excess;if(ms>excess){excess=0;}else{excess-=ms;}// 2. 每个请求消耗的配额(毫秒)cost=1000/ctx->rate;// 例如 rate=10, cost=100ms// 3. 判断是否超过 burst 限制if(excess+cost>ctx->burst*cost){returnNGX_HTTP_SERVICE_UNAVAILABLE;// 503}// 4. 更新节点状态node->last=now;node->excess=excess+cost;// 5. 如果配置了 nodelay,且 excess <= burst,立即处理if(ctx->nodelay&&excess<=ctx->burst*cost){// 直接返回,不延迟}else{// 否则,延迟 excess 毫秒后再处理ngx_add_timer(r->connection->write,excess);}returnNGX_DECLINED;}4.5 数值推演:限速 100r/s 时不同请求间隔的处理
为了更直观地理解limit_req的漏桶算法,我们以rate=100r/s(即每个请求消耗 10ms 配额)为例,推演请求间隔分别为1ms、10ms和100ms时,excess值的变化以及请求的处理结果。假设burst=0(无排队队列)。
4.5.1 基础参数
rate = 100r/s→ 每个请求的配额cost = 1000 / 100 = 10ms。burst = 0→ 桶的容量为burst * cost = 0ms,即excess不能超过 0。- 初始状态:
node->last = T0,node->excess = 0。
4.5.2 场景一:请求间隔 100ms(远大于 cost)
请求到达时间线:T0、T0+100ms、T0+200ms……
| 时间点 | 距上次请求ms | 漏水后excess | 加cost后excess | 是否超限 | 说明 |
|---|---|---|---|---|---|
T0 | — | 0 | 0 + 10 = 10 | 否 | 第一个请求,excess=10 |
T0+100ms | 100 | 10 - 100 = -90→ 0 | 0 + 10 = 10 | 否 | 间隔远大于 10ms,桶已漏空 |
T0+200ms | 100 | 10 - 100 = -90→ 0 | 0 + 10 = 10 | 否 | 同上,持续正常处理 |
结论:当请求间隔(100ms)远大于每个请求的配额(10ms)时,桶始终处于排空状态,每个请求都能正常通过,excess始终在 10ms 左右徘徊,不会累积。
4.5.3 场景二:请求间隔 10ms(等于 cost)
请求到达时间线:T0、T0+10ms、T0+20ms……
| 时间点 | 距上次请求ms | 漏水后excess | 加cost后excess | 是否超限 | 说明 |
|---|---|---|---|---|---|
T0 | — | 0 | 0 + 10 = 10 | 否 | 第一个请求 |
T0+10ms | 10 | 10 - 10 = 0 | 0 + 10 = 10 | 否 | 刚好漏完,再进一个 |
T0+20ms | 10 | 10 - 10 = 0 | 0 + 10 = 10 | 否 | 稳定状态 |
结论:当请求间隔(10ms)恰好等于cost时,系统达到稳态。每次请求到来时,上一个请求的配额刚好被漏完,excess始终在 0~10ms 之间波动,请求以精确的 100r/s 速率被处理。
4.5.4 场景三:请求间隔 1ms(远小于 cost)
请求到达时间线:T0、T0+1ms、T0+2ms、T0+3ms……
| 时间点 | 距上次请求ms | 漏水后excess | 加cost后excess | 是否超限 | 说明 |
|---|---|---|---|---|---|
T0 | — | 0 | 0 + 10 = 10 | 否 | 第一个请求 |
T0+1ms | 1 | 10 - 1 = 9 | 9 + 10 = 19 | 是(19 > 0) | 桶已满,拒绝 |
T0+2ms | 1 | 19 - 1 = 18 | 18 + 10 = 28 | 是(28 > 0) | 继续拒绝 |
T0+3ms | 1 | 28 - 1 = 27 | 27 + 10 = 37 | 是(37 > 0) | 继续拒绝 |
结论:当请求间隔(1ms)远小于cost(10ms)时,桶的漏水速度(每次漏 1ms)远小于进水速度(每次进 10ms),excess迅速累积并超过桶容量(0ms)。从第二个请求开始,所有请求都会被拒绝(返回 503),直到请求间隔拉长,让桶有足够时间漏空。
4.5.5 加入burst参数的影响
如果配置burst=5,则桶容量变为5 * 10ms = 50ms。在场景三中:
| 时间点 | 漏水后excess | 加cost后excess | 是否超限 | 说明 |
|---|---|---|---|---|
T0 | 0 | 10 | 否 | 正常 |
T0+1ms | 9 | 19 | 否(19 ≤ 50) | 排队 |
T0+2ms | 18 | 28 | 否(28 ≤ 50) | 排队 |
T0+3ms | 27 | 37 | 否(37 ≤ 50) | 排队 |
T0+4ms | 36 | 46 | 否(46 ≤ 50) | 排队 |
T0+5ms | 45 | 55 | 是(55 > 50) | 拒绝 |
- 无
nodelay:前 5 个请求进入队列,每个延迟excess毫秒后处理(第 1 个延迟 10ms,第 2 个延迟 19ms……),第 6 个请求被拒绝。 - 有
nodelay:前 5 个请求立即处理,但excess依然累积到 55ms,第 6 个请求被拒绝。后续请求必须等待excess漏到 50ms 以下才能继续。
4.4nodelay参数的影响
- 无
nodelay:请求进入队列,按固定速率处理。客户端会感受到延迟。 - 有
nodelay:只要excess未超过burst,请求立即被处理。这允许在短时间内处理大量突发请求,但后续请求会因为excess累积而被拒绝,从而保证长期平均速率。
5.limit_rate:响应速率限制
5.1 配置指令
location / { limit_rate 200k; limit_rate_after 1m; # 下载 1MB 后开始限速 }5.2 源码实现流程
limit_rate的实现不在独立的模块中,而是集成在ngx_http_core_module的请求处理流程里。
核心机制:ngx_http_write_filter
当 Nginx 准备向客户端发送响应体时,会调用ngx_http_write_filter函数。该函数会检查r->limit_rate字段。
// ngx_http_write_filter 中的关键逻辑ngx_int_tngx_http_write_filter(ngx_http_request_t*r,ngx_chain_t*in){// ... 计算本次要发送的字节数 size// 1. 检查是否已超过 limit_rate_afterif(r->limit_rate&&r->limit_rate_after){if(r->written>r->limit_rate_after){// 2. 计算本次发送的延迟delay=(ngx_msec_t)(size*1000/r->limit_rate);// 3. 设置定时器,延迟发送ngx_add_timer(r->connection->write,delay);returnNGX_AGAIN;}}// ... 实际发送数据}5.3 关键特性
- 基于令牌桶的变体:可以理解为每个请求有一个独立的令牌桶,
limit_rate是令牌的生成速率。 - 按连接限速:每个连接独立计算,不共享状态。
limit_rate_after:允许在传输初期全速发送,提升用户体验。
6. 主流限流算法对比
下面通过 Mermaid 流程图直观对比漏桶算法与令牌桶算法的核心流程:
核心差异总结:漏桶算法强制输出速率恒定,无论流入多快都按固定速率处理请求,适合需要精确整形的场景(如limit_req);令牌桶算法允许短时间内的突发流量,只要桶中有令牌即可立即处理,适合需要允许突发的场景(如limit_rate允许连接开始时全速下载)。选择哪种算法取决于业务需求:保护后端防止被冲垮选漏桶,提升用户体验允许短时突发选令牌桶。
| 算法 | 核心思想 | 优点 | 缺点 | Nginx 对应模块 |
|---|---|---|---|---|
| 计数器 | 固定时间窗口内计数,超过阈值则拒绝。 | 实现简单,内存占用低。 | 存在临界突变问题(窗口切换时流量尖刺)。 | 无(可自行用 Lua 实现) |
| 滑动窗口 | 将时间窗口细分为多个小格,滑动统计。 | 解决了计数器的临界突变问题,精度高。 | 实现复杂度较高,需要存储多个时间格的数据。 | 无(可自行用 Lua 实现) |
| 漏桶算法 | 请求进入桶中,以固定速率漏出。 | 能够平滑流量,强制输出速率恒定。 | 无法应对突发流量(即使有 burst 参数,也只是排队)。 | limit_req(核心实现) |
| 令牌桶算法 | 以固定速率向桶中添加令牌,请求需获取令牌才能执行。 | 允许一定程度的突发流量(桶内令牌可累积)。 | 实现比漏桶稍复杂,需要定时生成令牌。 | limit_rate(变体实现) |
6.1 漏桶 vs 令牌桶:核心差异
- 漏桶:关注的是流出速率。无论流入多快,流出都是恒定的。它像一个强制整形器。
- 令牌桶:关注的是平均流入速率。允许短时间内的流量超过平均速率,只要桶里有令牌。它像一个允许突发的限流器。
Nginx 的选择:
limit_req选择漏桶,是因为它需要精确控制请求的处理速率,防止后端被突发流量冲垮。即使配置了burst,也只是让请求排队,输出速率依然恒定。limit_rate选择令牌桶的变体,是因为它希望允许连接在开始时全速下载(利用累积的令牌),之后再平滑限速。
7. 总结
Nginx 的三个限流模块各有侧重:
limit_conn:基于计数的并发连接数控制,简单直接。limit_req:基于漏桶算法的请求速率整形,是保护后端最常用的手段。limit_rate:基于令牌桶思想的响应带宽控制,用于优化带宽分配。
理解它们的源码实现和算法差异,能帮助我们在实际运维中做出更合理的配置选择,例如:使用limit_req配合burst和nodelay来应对秒杀场景,使用limit_rate来限制大文件下载的带宽占用。