限流是微服务稳定性的第一道防线。设想一个电商促销场景:秒杀开始瞬间,网关每秒收到平时几十倍的请求,如果没有限流措施,下游的订单服务、库存服务会被瞬间打垮,进而引发整个服务链路的雪崩。Golang凭借其出色的并发模型和丰富的生态,为限流实现提供了多种选择。本文将从算法原理入手,逐步给出可运行的代码示例和架构层面的实践建议。

四种主流限流算法的原理与缺陷
限流算法是所有流控方案的基础,选型之前必须理解每种算法的边界条件。固定窗口计数是最简单的方案:把时间划分为固定的窗口,比如每1秒一个窗口,窗口内计数超过阈值就拒绝请求。它的实现只需要一个原子计数器和一个时间戳,但在窗口边界处会出现流量突刺问题,例如限制每秒100个请求,第1秒的最后100毫秒来了100个请求,第2秒的前100毫秒又来了100个请求,实际上200毫秒内通过了200个请求,瞬时压力达到限流值的两倍。
滑动窗口计数是对固定窗口的改良,它把大窗口切分成多个小格子,每次滑动时统计最近若干个格子的总量,从而平滑了边界效应。格子切得越细,限流越精确,但内存和计算开销也随之增加。漏桶算法则完全不同,它以恒定速率处理请求,多余的请求进入队列排队,队列满了就丢弃。漏桶的输出速率绝对均匀,适合对下游调用方有严格速率要求的场景,缺点是无法应对合理的突发流量。
令牌桶是微服务领域最常用的算法:系统以固定速率往桶里放令牌,桶有容量上限,请求到来时先取令牌,取到才放行,取不到就拒绝。因为桶里可以积累令牌,所以允许一定程度的突发流量,这种特性非常契合互联网业务的流量波动。后面代码示例中会看到,Golang官方的rate包底层就是令牌桶实现。
基于官方rate包的实战代码
Golang官方提供了golang.org/x/time/rate包,它实现了一个高性能的令牌桶,支持并发安全调用,是单机限流的首选。先看一个基础用法,下面的代码创建了一个每秒生成100个令牌、桶容量为50的限流器:
package main
import (
"net/http"
"time"
"golang.org/x/time/rate"
)
func main() {
// 每秒100个令牌,桶容量50,允许突发
limiter := rate.NewLimiter(100, 50)
http.HandleFunc("/order", func(w http.ResponseWriter, r *http.Request) {
// Wait会阻塞等待令牌,适合可排队场景
if !limiter.Allow() {
// Allow立即返回,不等待,适合直接拒绝
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
w.Write([]byte("order created"))
})
http.ListenAndServe(":8080", nil)
_ = time.Now()
}
Allow方法在令牌不足时立即返回false,对应快速失败策略;而Wait方法会阻塞当前goroutine直到拿到令牌或超时,适合允许请求排队削峰的场景。两者务必根据业务语义选择:对外部用户的接口通常用Allow快速拒绝,对内部异步任务可以用Wait平滑消费。
实际微服务中往往需要按不同维度限流,比如按API路径、按用户ID分别限流。这时可以用一个带锁的map来维护多个limiter实例:
type LimiterGroup struct {
mu sync.RWMutex
limiters map[string]*rate.Limiter
r rate.Limit
b int
}
func (g *LimiterGroup) Get(key string) *rate.Limiter {
g.mu.RLock()
lim, ok := g.limiters[key]
g.mu.RUnlock()
if ok {
return lim
}
// 双检写,避免并发重复创建
g.mu.Lock()
defer g.mu.Unlock()
if lim, ok = g.limiters[key]; ok {
return lim
}
lim = rate.NewLimiter(g.r, g.b)
g.limiters[key] = lim
return lim
}
需要注意,key可能是无界的,比如以用户ID为维度时,长期运行会导致map无限膨胀。工程上常见的做法是定期清理长时间未访问的limiter,或者改用带过期结构的LRU缓存。另外rate包只支持单机限流,如果服务部署了多个实例,总限流值需要按实例数分配,或者引入集中式的分布式限流。
分布式限流与多层流控架构
单机限流解决不了集群维度的问题。比如集群总限流1000 QPS,部署10个实例时每台限100即可,但这种静态分配在流量不均、实例伸缩时误差很大。分布式限流的经典做法是基于Redis实现,利用Lua脚本保证原子性。核心思路是用INCR计数配合EXPIRE实现固定窗口,或者用令牌桶的Lua实现保证多实例共享同一个桶:
-- 按秒级窗口计数限流
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local current = tonumber(redis.call('GET', key) or "0")
if current + 1 > limit then
return 0
end
redis.call('INCR', key)
redis.call('EXPIRE', key, 1)
return 1
Go侧通过go-redis包执行这段脚本即可。分布式限流的代价是每次请求多一次网络往返,高并发下Redis本身可能成为瓶颈,可以配合本地缓存预分配配额来降低压力,即每个实例定期从Redis批量领取一批配额,本地消耗完再去领取,将精度换性能。
成熟的方案还可以直接引入Sentinel-Golang,它提供了流量整形、熔断降级、热点参数限流等完整能力,支持QPS和并发线程数两种统计模式,并且有控制台可以动态调整规则。对于自建能力有限的团队,用成熟组件比自己造轮子更稳妥。最后从架构上看,限流应该分层实施:接入层在Nginx或网关做粗粒度全局限流,保护整个集群;服务层针对核心接口做细粒度限流;依赖层对下游调用设置并发上限和超时,防止被慢依赖拖垮。三层配合,才能在流量洪峰下真正做到优雅降级而不是整体瘫痪。
限流策略选型与被拒绝请求的处理
限流算法和工具有很多,选型时要结合业务特点。匀速处理型任务如消息消费、数据同步,漏桶更合适;面向用户的在线接口,令牌桶能兼顾平均速率和突发容忍;需要精确配额比如按租户计费的场景,分布式计数更可靠。被限流的请求也不能一拒了之,常见的处理策略包括返回429状态码并携带Retry-After头部、降级到缓存或默认数据、写入消息队列延迟处理。给客户端明确的信号比直接断连友好得多。
还有一个容易被忽视的点是限流指标的可观测性。被拒绝的请求数、当前令牌余量、排队等待时长都应该暴露成Prometheus指标,接入告警。限流阈值不应该是拍脑袋定的数字,而是通过压测得出系统容量后,预留一定余量(比如容量的70%)来设定,并随系统扩缩容动态调整。只有把限流纳入容量规划体系,它才能真正发挥保护作用,而不是简单地变成一个挡流量的闸门。