导读:本期聚焦于杨子江创作的《边缘节点限流怎么做?漏桶、令牌桶与滑动窗口算法实现与对比》,敬请观看详情。限流是网关和边缘节点最核心的自我保护手段之一。漏桶算法以恒定速率放行请求,输出流量平滑但无法应对突发;令牌桶通过预存令牌允许一定程度的突发流量,是Nginx和Guava中的主流方案;滑动窗口则通过细分子窗口提升精度,避免固定窗口的临界突刺问题。本文从原理入手,用Go和Java分别给出三种算法的完整实现,分析各自的时间复杂度、内存开销与适用场景,并对比单机限流与分布式限流的落地差异,帮助你在API网关、CDN边缘节点上选对限流策略。

流量突增是边缘服务最常见的稳定性威胁,一次营销活动、一个热门接口被爬虫盯上,都可能让后端服务在几秒内被打垮。限流就是在这种场景下给系统加一道闸门,超过容量的请求直接拒绝或排队。目前业界主流的三种算法是漏桶、令牌桶和滑动窗口,它们思路不同,实现的复杂度和应对突发流量的表现也差异很大。这篇文章会把三种算法的原理拆开讲清楚,并给出可以直接运行的代码实现。

边缘节点限流怎么做?漏桶、令牌桶与滑动窗口算法实现与对比

漏桶算法:恒定速率的守护者

漏桶算法的思路非常直观:请求先进入一个固定容量的桶里排队,桶以恒定速率向外漏水,也就是以固定速率处理请求。当桶被填满时,新到的请求直接被丢弃。这个模型保证了无论外部流量多汹涌,出口的处理速率始终稳定,对下游服务非常友好。

漏桶的核心价值在于“整流”。假设后端数据库每秒只能稳定承受500次查询,上游流量却是忽高忽低的,漏桶可以把毛刺削平,让下游看到的永远是一条平稳的直线。但它也有明显的短板:无法利用系统的空闲处理能力。即使下游在空闲期明明可以跑满1000 QPS,漏桶也只会按500的速率放行,突发的大量请求会在桶满后被大量拒绝,用户体验较差。

工程上漏桶通常不会真的维护一个请求队列,而是用“上次放行时间 + 速率”来计算当前水位,这样实现是无锁友好的,内存开销也极小。下面是一个Go语言版本的实现:

package main

import (
	"sync"
	"time"
)

type LeakyBucket struct {
	mu       sync.Mutex
	rate     float64   // 每秒放行的请求数
	capacity float64   // 桶容量
	water    float64   // 当前水量
	lastTime time.Time // 上次更新时间
}

func NewLeakyBucket(rate, capacity float64) *LeakyBucket {
	return &LeakyBucket{
		rate:     rate,
		capacity: capacity,
		water:    0,
		lastTime: time.Now(),
	}
}

// Allow 判断请求是否放行
func (lb *LeakyBucket) Allow() bool {
	lb.mu.Lock()
	defer lb.mu.Unlock()

	now := time.Now()
	// 根据时间差漏掉的水量
	elapsed := now.Sub(lb.lastTime).Seconds()
	lb.water -= elapsed * lb.rate
	if lb.water < 0 {
		lb.water = 0
	}
	lb.lastTime = now

	// 尝试加水
	if lb.water+1 > lb.capacity {
		return false // 桶满,拒绝
	}
	lb.water++
	return true
}

注意这个实现把“漏水”放在判断之前,用懒计算的方式同步水位,避免了后台定时器的开销。每次调用的时间复杂度是O(1),内存只依赖几个字段,非常适合在高并发网关的每个接口上各挂一个实例。

令牌桶算法:允许突发的弹性方案

令牌桶是漏桶的镜像:系统以恒定速率往桶里投放令牌,请求到来时先取令牌,取到就放行,取不到就拒绝。桶有容量上限,令牌数不会无限增长。关键区别在于,当系统空闲一段时间后,桶里会积累一批令牌,此时如果突发流量到来,可以一次性消耗积攒的令牌快速放行,这就是令牌桶“允许突发”的特性。

举个例子,桶容量是100,速率是每秒10个令牌。系统空闲10秒后桶满,此时瞬间来150个请求,前100个可以立即通过,之后的请求按每秒10个的速率放行。这种弹性让令牌桶在真实业务中比漏桶更受欢迎——大多数系统的负载本来就有波动,允许短时突发更符合业务诉求。Nginx的limit_req模块、Guava的RateLimiter底层都是令牌桶的变体。

Guava还实现了平滑预热版本的令牌桶,冷启动时放令牌的速度从低到高逐渐爬升,避免刚重启的服务被瞬间打满。下面是核心的Java实现逻辑,可以配合Redis实现分布式令牌桶:

public class TokenBucket {
    private final double capacity;     // 桶容量
    private final double rate;         // 每秒生成令牌数
    private double tokens;             // 当前令牌数
    private long lastRefillTime;       // 上次补充时间戳(毫秒)

    public TokenBucket(double capacity, double rate) {
        this.capacity = capacity;
        this.rate = rate;
        this.tokens = capacity; // 初始满桶,允许启动突发
        this.lastRefillTime = System.currentTimeMillis();
    }

    public synchronized boolean tryAcquire(int permits) {
        refill();
        if (tokens >= permits) {
            tokens -= permits;
            return true;
        }
        return false;
    }

    private void refill() {
        long now = System.currentTimeMillis();
        double add = (now - lastRefillTime) / 1000.0 * rate;
        tokens = Math.min(capacity, tokens + add);
        lastRefillTime = now;
    }
}

实现时有个细节值得注意:初始令牌数是否给满。如果服务刚启动就给满桶令牌,重启瞬间可能放出超出下游承受能力的流量;生产环境更稳妥的做法是初始令牌数设为容量的一半,或者干脆从零开始预热。

滑动窗口:精度与开销的折中

固定窗口是最朴素的计数限流:每分钟一个计数器,超过阈值就拒绝。它的问题在于临界突刺——前一分钟的后10秒和后一分钟的前10秒加起来,实际可以在20秒内放过两倍阈值的流量。滑动窗口就是为了解决这个问题:不再按自然时间切分,而是统计“过去N秒”这个不断滑动的区间内的请求数。

精确的滑动窗口需要记录每个请求的时间戳,内存开销随请求量线性增长,显然不适合边缘节点。实际工程中普遍采用滑动窗口的近似实现:把大窗口切成多个细小的格子,每个格子独立计数,统计时把窗口覆盖到的格子加总。窗口滑动时只是推进格子指针,精度取决于格子粒度。比如1分钟的窗口切成60个1秒的格子,误差可以控制在单格计数的范围内,而内存只需60个整数。

package main

import (
	"sync"
	"time"
)

type SlidingWindow struct {
	mu       sync.Mutex
	window   time.Duration // 窗口总时长
	buckets  []int64       // 各子窗口计数
	size     int           // 子窗口数量
	lastSlot int           // 当前子窗口下标
	lastTime time.Time
}

func NewSlidingWindow(window time.Duration, size int) *SlidingWindow {
	return &SlidingWindow{
		window:   window,
		buckets:  make([]int64, size),
		size:     size,
		lastTime: time.Now(),
	}
}

func (sw *SlidingWindow) Allow(limit int64) bool {
	sw.mu.Lock()
	defer sw.mu.Unlock()

	now := time.Now()
	slotDur := sw.window / time.Duration(sw.size)
	// 计算当前请求属于哪个子窗口
	cur := int(now.UnixNano()/int64(slotDur)) % sw.size
	elapsed := int(now.Sub(sw.lastTime) / slotDur)

	// 清空滑过的子窗口
	for i := 0; i < elapsed && i < sw.size; i++ {
		idx := (sw.lastSlot + 1 + i) % sw.size
		sw.buckets[idx] = 0
	}
	sw.lastSlot = cur
	sw.lastTime = now

	// 统计窗口内总数
	var total int64
	for _, c := range sw.buckets {
		total += c
	}
	if total >= limit {
		return false
	}
	sw.buckets[cur]++
	return true
}

统计总数这一步是O(n),n为子窗口数量。对精度要求极高的场景可以把子窗口数量加到几百个,总耗时仍然在微秒级;如果追求极致性能,可以用循环前缀和的方式把统计降为O(1),代价是每次写入多一次累加。

三种算法如何选择

从流量整形角度看,漏桶输出最平滑,适合保护脆弱的下游,比如调用第三方付费API、写入单机MySQL这类对平稳性敏感的场景。令牌桶兼顾了平均速率和突发容忍,是面向用户的HTTP接口限流的首选,绝大多数网关默认方案都是它。滑动窗口本质上是在做统计限流而非整形,它不控制请求放出的节奏,只控制总量,适合配额类场景,比如“每个IP每分钟最多1000次请求”。

在边缘节点部署时还要考虑分布式问题。单机算法实例各自独立计数,在多节点负载均衡下,实际总阈值会被放大N倍。解决思路是把算法状态外移到Redis:令牌桶可以用Lua脚本原子地执行补充和扣减,滑动窗口可以用ZSET记录请求时间戳配合ZRANGEBYCOUNT统计。但引入Redis会增加一次网络往返,高QPS场景下可以采用“本地预扣减 + 异步回补”的分层策略,边缘节点先按本地配额快速拦截大部分流量,剩余少量不确定的请求再走集中式判断。

最后给一个简单直接的选型建议:保护下游用漏桶,接口限流用令牌桶,配额统计用滑动窗口。如果流量规模大到单机限流不够用,优先在接入层做本地粗筛,再配合Redis做精确控制,两级配合比单层的集中式限流在性能和精度上都能拿到更好的平衡。

限流算法令牌桶滑动窗口修改时间:2026-09-12 19:12:46

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260912/55498.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。