尧图精选

统一限流中间件实战:令牌桶、滑动窗口与分布式限流设计

🕒 发布时间:2026/9/26 21:01:39 📁 来源:尧图网络
最近我一直在打磨一个内部代号叫 atlas 的限流组件起因很简单线上服务时不时被突发流量冲垮下游数据库连接被打满业务方第一反应永远是“加机器”但加了机器之后问题又从数据库蔓延到第三方 API 的配额上。说白了光靠扩容解决不了流量洪峰对系统稳定性的冲击必须有一个能把“请求速率”这件事管起来的中间层。atlas 就是在这个背景下做的核心定位是一个轻量、可配置、支持多种限流算法、能接入 HTTP 和 gRPC 请求链路的统一限流服务。这篇文章不打算讲太多理论重点是我在设计和实现 atlas 过程中的思路、踩过的坑、以及沉淀下来可以直接用的代码和配置方案。如果你是后端开发、运维或者架构师正在纠结“限流到底怎么落地”“令牌桶和滑动窗口到底选哪个”“多实例部署时限流怎么搞”这篇文章应该能给你不少参考。1. 为什么需要 atlas从限流脚本到限流基础设施1.1 临时脚本的本质问题每个业务方都在重复造轮子在 atlas 之前团队里其实已经有很多“限流方案”了但它们大多是临时脚本某个活动上线前业务同学在 Nginx 配置里加几条limit_req或者运营找开发在接口代码里写一段if counter threshold { return error }。这种方案最直接的问题有三个——第一规则散落各处没有一个统一的视图能回答“当前全链路哪些接口在限流、阈值是多少”。第二不同语言写出来的限流逻辑语义不一致有的按 IP 限有的按用户 ID 限有的直接按全局计数出了问题很难对齐。第三也是最要命的这些临时方案普遍没有监控限流到底拦了多少请求、误伤了多少正常用户完全是黑盒。atlas 的定位就是把“限流”从一段可有可无的判断逻辑升级成一个独立的服务组件。业务方不再需要关心限流怎么算、用什么算法只需要在配置里声明“这个接口每秒最多 50 个请求突发可以到 100”剩下的评估、拦截、统计全部由 atlas 统一处理。1.2 atlas 解决的核心痛点限流规则的集中管理与热更新我见过不少团队把限流配置写死在代码里每次调整阈值都要重新发布服务这在高频调优的场景下完全不可接受。atlas 从设计之初就把“配置与执行分离”当作第一原则规则放在 YAML 文件或配置中心里服务启动时加载运行期间通过监听配置变更做热更新。这意味着某个接口在下游服务性能劣化时运维可以直接把阈值从 200 下调到 120不需要动代码、不需要重启进程配置生效时间控制在秒级。热更新能力在线上故障处理时非常关键——系统已经快被打爆了你不可能让业务方等一次完整的发版流程。把限流独立成组件还有一个隐性收益它的接入成本对业务方极低。网关层统一挂一个中间件或者在服务框架里加一个拦截器业务代码几乎不需要改动就能获得限流保护。我在设计时一直提醒自己限流这种“横切关注点”不应该侵入业务逻辑它应该像安全带一样系上就行不用你思考怎么系。1.3 适合谁来用、在什么阶段引入如果你是一个单体应用每天 QPS 也就几百直接塞一个限流组件可能有点大炮打蚊子。但你的服务一旦开始出现下面几个信号就说明该引入统一限流了同一个接口的限流逻辑被两个以上服务重复实现线上出现过因瞬时流量导致数据库、缓存或第三方 API 被打挂的事故业务方经常说“这个接口能不能放量”你自己也说不清当前到底有没有余量你希望把限流接入公司的监控告警体系而不是每次等事故发生后复盘。atlas 很轻核心代码量不大但它解决的问题是结构性的它把“限流”从一个临时补丁变成了一个可管理、可观测、可度量容量的基础设施。2. 解析atlas 的核心机制与关键技术点2.1 限流是怎么“算”出来的计数器、滑动窗口与令牌桶限流的本质是“在单位时间内控制请求数量”但实现的方式差别很大。我在 atlas 里最开始实现的是固定窗口计数器后来又补了滑动窗口和令牌桶三种算法各有适用场景做的时候一定要想清楚。固定窗口计数器最简单把时间切成固定长度的窗口比如每秒一个窗口窗口内每来一个请求计数器加一超过阈值就拒绝。这种实现容易踩一个经典坑——窗口边界突发。比如某个窗口的最后 100ms 进了 999 个请求下一个窗口的前 100ms 又进了 999 个请求两个窗口各自都没超限但实际 200ms 内打进了 1998 个请求这个“两倍阈值”的漏洞在压力测试时特别明显。我一开始图省事用了固定窗口上线后刚好被一个爬虫在整秒边界猛打机器 CPU 没爆但下游数据库连接被打满了。滑动窗口是对固定窗口的改进不再用“一个窗口重置一次”的思路而是把每个请求的时间戳记录下来检查时统计最近 N 秒内的请求数是否超限。这种做法的精度高很多但需要存储每个请求的时间戳内存占用会随 QPS 线性增长所以我在 atlas 里对时间戳做了降采样用一个环形数组按毫秒粒度聚合而不是存全量时间戳精度损失在可接受范围内。令牌桶是另一种思路系统以恒定速率往桶里放令牌每个请求要消耗一个令牌令牌不够就拒绝或等待。它最大的好处是允许一定程度的突发流量比如桶容量是 100每秒补充 10 个令牌那么瞬间打到 100 个请求可以被放行之后只能按每秒 10 个的速率继续。这套逻辑非常适合秒杀、活动页这类“平时流量低、瞬时流量高”的场景。atlas 里我把令牌桶实现成基于时间计算的双变量模型——lastRefillTime 和 storedTokens不需要后台线程真正“放令牌”而是等请求来了按时间差一次补齐这个细节很关键省掉了定时器的并发复杂性。2.2 滑动窗口与令牌桶的适用场景对比算法核心思想优点缺点典型场景固定窗口时间窗口内计数实现简单、内存占用低窗口边界突发可能打两倍阈值对精度要求不高的内部接口滑动窗口统计最近 N 秒请求数精度高、边界平滑需要维护时间窗口数据面向外部 API 的限流令牌桶匀速补充令牌、允许突发支持突发流量、平滑速率参数调优容量速率需要经验秒杀、抢购、活动入口做一个中等 QPS 的服务我建议优先用令牌桶因为它的参数语义很容易让业务方理解——“桶容量”代表允许的最大突发量“补充速率”代表稳态 QPS这两个值跟产品沟通时几乎零障碍。滑动窗口适合那种“必须精确控制每个自然秒/分钟内的请求数”的场景比如按用户维度限流时业务方明确说“每个用户每分钟最多 30 次”这个语义用滑动窗口更直观。2.3 atlas 的分层架构从规则配置到执行引擎atlas 在内部大致分了三层规则解析层、决策引擎层、执行与统计层。规则解析层负责把 YAML/JSON 配置翻译成内部规则结构体包括时间窗口、阈值、限流维度全局限流、按 IP、按用户、按参数、降级策略等决策引擎层根据当前请求的上下文数据计算出“这个请求该不该放行”执行与统计层负责真正的拒绝/放行动作以及把拦截记录、放行记录、延迟时间写入监控系统。三层分开的核心原因只有一个让限流策略可以热更新。业务方在控制台上改一个阈值不需要重启服务、不需要重新发布配置中心把新的规则推给 atlas规则解析层更新内存中的规则模型后续请求立刻按新规则评估。为了让这个过程不出错我在规则结构体里加了一个版本号字段每次配置变更就递增版本号决策引擎在判断时带上版本号方便排查“某个请求到底用的是哪一版规则”。2.4 动态阈值与自动降级机制很多团队的限流阈值是拍脑袋定的运维看着监控把某个数字从 100 调到 80第二天又调回 120。atlas 里我加了动态阈值能力它会周期性统计每个接口的实际 QPS、成功率、P99 延迟如果发现某个接口的 P99 延迟连续 3 个窗口超过基线值的 1.5 倍自动把限流阈值往下调一个梯度等延迟恢复正常再慢慢回调。这个机制上线后确实减少了不少人工介入但也踩过坑有一次接口依赖的下游慢atlas 自动把阈值从 200 调到了 80业务方反馈“怎么突然限这么死”排查后发现是下游慢导致误判而数据库连接池本身没问题。后来我加了“降级最小阈值保护”规定自动调节只能在一个范围内浮动比如 200 最大降到 120再低就需要人工确认。这个保护机制特别重要自动化一定要有限度。3. 实操手把手搭一套可用的限流服务3.1 技术选型与目录结构我用的是 Go主要是因为并发模型适合网关类服务部署又是一个单二进制运维成本低。如果你团队主栈是 Java用 Spring Boot 也能做同样的事核心思路不变。源码结构我建议这样组织atlas/ ├── config/ │ ├── example.yaml │ └── loader.go ├── core/ │ ├── limiter.go # 限流器接口与工厂 │ ├── token_bucket.go # 令牌桶实现 │ ├── sliding_window.go# 滑动窗口实现 │ └── fixed_window.go # 固定窗口实现 ├── middleware/ │ ├── http.go # HTTP 中间件 │ └── grpc.go # gRPC 拦截器 ├── stats/ │ ├── metrics.go # 指标统计 │ └── reporter.go # 上报接口 ├── rule/ │ ├── model.go # 规则模型 │ └── parser.go # 规则解析 └── main.go这个结构把规则、算法、接入层拆得很干净每加一种新算法不需要动中间件代码每加一个新接入协议也不需要动算法代码各自独立演进。3.2 核心代码限流器工厂与令牌桶实现先看限流器工厂这是 atlas 对外提供能力的关键入口用工厂模式的意义在于上层中间件根本不需要关心当前用的是哪种算法只需要调用Allow(ctx, businessKey)方法具体算法由配置决定后续新增算法也不影响上层逻辑。package core type Limiter interface { Allow(ctx context.Context, key string) (bool, error) } type Config struct { Algorithm string // token_bucket / sliding_window / fixed_window Rate float64 // 令牌补充速率每秒 Capacity int // 桶容量 / 窗口内最大请求数 Window time.Duration // 仅滑动/固定窗口使用 } func NewLimiter(cfg Config) (Limiter, error) { switch cfg.Algorithm { case token_bucket: return NewTokenBucket(cfg.Rate, cfg.Capacity), nil case sliding_window: return NewSlidingWindow(cfg.Window, cfg.Capacity), nil case fixed_window: return NewFixedWindow(cfg.Window, cfg.Capacity), nil default: return nil, fmt.Errorf(unknown algorithm: %s, cfg.Algorithm) } }令牌桶实现里最关键的就是“按时间差补齐令牌”这段我说一下为什么这么写。如果单独开一个 goroutine 每秒往桶里放令牌你还需要处理锁竞争、定时器生命周期、停止信号而且在低流量时白白浪费一个 goroutine。而按时间差补齐的思路是每次请求进来时先看距离上次补充令牌过了多久这段时间内理论上应该新产生多少令牌一次性加进去然后消费一个令牌。这样完全没有后台任务代码反而更健壮。type TokenBucket struct { mu sync.Mutex rate float64 capacity float64 storedTokens float64 lastRefillTime time.Time } func (tb *TokenBucket) Allow(ctx context.Context, key string) (bool, error) { tb.mu.Lock() defer tb.mu.Unlock() now : time.Now() elapsed : now.Sub(tb.lastRefillTime).Seconds() tb.storedTokens math.Min(tb.capacity, tb.storedTokenselapsed*tb.rate) tb.lastRefillTime now if tb.storedTokens 1 { tb.storedTokens-- return true, nil } return false, nil }我建议把storedTokens和lastRefillTime放到同一个结构体里用同一把锁保护不要拆成两个锁否则容易出现“读到旧时间戳、更新了新令牌”的脏读问题。这个坑我实际踩到过当时并发一高限流直接变成了随机放行排查了大半天。3.3 中间件接入以 HTTP 为例限流服务要真正发挥作用必须接入到每个请求的调用链路上。我用 HTTP 中间件来演示因为大部分业务服务的入口就是 HTTP。中间件做的事情只有三件从请求上下文提取限流维度默认按 IP路由组合、调用 limiter 判断是否放行、统计结果并写入监控。func RateLimit(limiter core.Limiter, ruleGetter rule.Getter) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dim : r.RemoteAddr r.URL.Path rule, ok : ruleGetter.Get(r.URL.Path) if !ok { next.ServeHTTP(w, r) return } // 这里用 rule 构造当前 key 专属的限流器实际工程中会用 key 分片缓存 allowed, err : limiter.Allow(r.Context(), dim) if err ! nil { // 限流器异常时建议放行避免限流组件本身成为故障点 next.ServeHTTP(w, r) return } if !allowed { http.Error(w, {code:429,message:too many requests}, http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) } }注意一个设计细节当限流器本身返回错误时我选择放行而不是拒绝。这是微服务架构里的一个常见原则——降级方向要偏向“可用性”因为限流器故障如果导致所有请求被拒绝后果比限流器失效严重得多。当然你也可以把这个开关做成配置项让安全要求极高的系统选择“fail closed”。3.4 用 YAML 配置规则不写代码也能调整策略配置这块我用的 YAML因为它容易读也适合写注释。每个接口一条规则支持的字段包括限流维度、算法、阈值、突发容量、降级策略等。示例配置如下rules: - path: /api/order/create algorithm: token_bucket rate: 50 capacity: 20 dimensions: [ip, user_id] fallback: type: reject status_code: 429 - path: /api/product/list algorithm: sliding_window window: 1m max_requests: 300 dimensions: [ip] fallback: type: queue timeout_ms: 200fallback字段值得多说两句。当请求被限流后不一定都要直接拒绝有些场景适合排队等待有些场景适合返回旧缓存有些场景适合降级到一个简化接口。我在 atlas 里支持的降级类型有三种reject直接返回错误、queue短暂排队超过timeout_ms再拒绝、stale返回缓存数据如果业务方实现了缓存读取接口。这个设计在流量尖峰时非常有用比如秒杀场景所有请求都直接 reject 会导致大量用户看到错误页但如果用queue让请求排队 200ms相当一部分请求可以在这段时间内被正常处理。3.5 多实例部署时的分布式限流问题单机限流只能约束单个实例上的请求量。如果你的服务部署了 3 个副本每个副本的限流阈值是 100那实际打到下游的总量可能是 300这会对数据库或者第三方 API 造成不可控压力。我在 atlas 里做了两种方案来应对。第一种是本地限流 多实例分摊假设整体阈值是 3003 个实例每个实例分 100配置中心自动算好下发。这种方案简单但有个问题——如果流量分布不均匀某个实例先打满其他实例还很空闲整体容量利用率会打折扣。第二种是集中式限流引入 Redis每次请求用INCR EXPIRE命令完成一次计数或者用 Lua 脚本保证“检查扣减”的原子性。这种方案的精度高、全局可控但会多一跳网络延迟而且 Redis 本身的性能也会成为瓶颈。我用 Lua 脚本实现了最简单的“滑动窗口 请求计数”分布式版本核心逻辑大概是这样的-- KEYS[1] 限流 key -- ARGV[1] 窗口大小毫秒 -- ARGV[2] 窗口内最大请求数 -- ARGV[3] 当前时间戳毫秒 local window tonumber(ARGV[1]) local max tonumber(ARGV[2]) local now tonumber(ARGV[3]) local key KEYS[1] redis.call(ZREMRANGEBYSCORE, key, 0, now - window) local current redis.call(ZCARD, key) if current max then redis.call(ZADD, key, now, now .. : .. math.random(1000000)) redis.call(PEXPIRE, key, window) return 1 end return 0这个脚本用 Redis 的有序集合保存每个请求的时间戳每次请求进来先把窗口外的旧数据清掉再统计剩余数量是否超限。虽然比本地限流慢一到两毫秒但对于大多数业务场景完全可接受。脚本一定要用 Lua 封装成原子操作不能先查再写否则并发下会超限。3.6 指标采集限流系统不能“黑盒运行”限流服务本身也需要被监控否则你根本不知道它到底拦了多少请求、哪些接口在频繁被限、限流的延迟影响到什么程度。我在 atlas 里用 Prometheus 格式暴露指标主要统计三类atlas_requests_total每个规则下请求总数、atlas_blocked_total被拦截数、atlas_queue_wait_seconds排队等待耗时直方图。这些指标在 Grafana 上展示后能很清晰地回答几个关键问题某个接口的限流是否过于激进导致大量真实用户被拦某个接口的请求量是不是异常上涨可能是爬虫限流之后用户侧的实际体验延迟是多少我在实际运营中比较看重封锁率blocked / total如果某个接口封锁率超过 30%我会第一个怀疑规则配置有问题而不是单纯认为“流量太大”因为正常业务里被限流应该是少数异常请求而不是普通用户。4. 调试与坑限流服务上线后的真实问题4.1 时间精度导致的误限流有一次测试同学反馈“同一个请求明明间隔了 2 秒还是被限流了”。我检查代码后发现是服务器时钟跳跃的问题当时那台机器启用了 NTP 自动校时系统时间往前跳了几秒导致lastRefillTime跟当前时间的差值异常变大令牌被一次性补齐看起来就像限流失效。反过来如果时间往回跳令牌补充的速率会被大幅拉低出现误限流。解决办法有两个思路一是使用单调时钟Go 里time.Now()本身包含单调时钟但如果你把时间戳传到别的地方去比较就可能丢掉单调性二是在容器环境里尽量保证宿主机时钟同步正常且不要对容器做频繁的校时操作。这个坑比较隐蔽排查成本高建议在写限流代码时就把“时钟源选型”当作一个设计点。4.2 内存泄漏如果限流器的 key 数量失控按用户维度限流时每个用户都会产生一个 key如果每个 key 都建一个限流器对象长期运行下来可能出现内存泄漏。比如游戏平台有 1000 万注册用户虽然每天活跃的可能只有 10 万但如果不做清理所有访问过的用户都会留一个对象在内存里内存占用会非常可观。我在 atlas 里用了一个“两级缓存”的思路核心限流数据用 LRU 缓存保存只保留最近活跃的 N 万个 key超过容量的老 key 按淘汰策略移除同时配合一个后台清理任务定期扫描长期不活跃的 key 并释放。这套机制保证了服务可以长期运行不重启。上线初期我没加这个机制结果跑了一周后内存占用持续走高最后只能定时重启非常被动。4.3 分布式限流的 Redis 抖动集中式限流方案里Redis 是核心依赖Redis 一抖动所有请求都会卡在 Lua 脚本执行上。我遇到过一次 Redis 主从切换大概几十秒内所有限流请求全部超时因为脚本阻塞在网络上。后来我在客户端加了三件事连接池大小合理配置不能过大、Redis 操作加上超时控制不能无限等、Redis 不可用时快速降级为本地限流哪怕精度下降也不能让整个服务不可用。还有个经验Lua 脚本不要写得太复杂。虽然 Redis 保证脚本原子性但脚本执行期间会阻塞其他命令脚本越慢阻塞时间越长。我一度在脚本里做了多个 key 的 ZRANGEBYSCORE 操作导致高峰期 Redis 负载飙升后来简化脚本逻辑把一部分计算放到应用层问题才缓解。4.4 常见问题速查表现象可能原因排查思路请求被误限流时间跳跃、配置阈值过低检查时钟同步查看指标中的 rate 和 capacity限流失效流量穿透多实例未做分布式协调、规则热更新未生效确认实例数、检查规则版本号Redis 高延迟Lua 脚本复杂、连接池过高精简脚本、调整连接池参数、增加超时内存持续上涨限流器 key 未清理启用 LRU 缓存添加后台清理任务某接口封锁率过高规则配置激进、爬虫攻击查看被限流 IP 分布适当放宽容许突发配置变更后行为异常规则版本未刷新检查配置中心和本地缓存同步机制5. 进阶让限流系统长出“自适应”能力5.1 从一个网关服务到治理平台的演进路径做一个限流服务很容易但把它做成一个治理平台很难。我建议你在 atlas 的功能稳定后逐步叠加动态调参、灰度发布规则、自动化压测、与告警系统联动。动态调参就是把前面提到的动态阈值做得更精细灰度发布规则是让新配置先只对 1% 流量生效观察指标稳定后再扩大到全量自动化压测是定期模拟流量峰值验证限流策略是否还靠谱。这一层做得好不好取决于你和业务方有多深的协作。我一直跟业务团队强调限流阈值不是一个运维指标它本质上是“业务对系统容量的理解”。你把每个接口的真实容量摸透了限流才能既保护系统又不误伤用户。atlas 的终极形态其实是一个“容量地图”上面标记着每个接口、每个下游依赖、每种流量模式的安全边界。5.2 给技术选型者的最后提醒如果你正准备在自己的项目里做一个叫 atlas 的东西记住三件事第一限流算法不用贪多先实现令牌桶一种把参数、监控、热更新做完善比堆三种算法有价值得多。第二限流服务必须自监控否则它就是黑盒别人不敢依赖。第三所有降级策略都要有“最小可用性”的兜底绝不能因为限流组件故障导致整个业务崩溃。我自己把 atlas 从 v0.1 一路迭代到 v0.8最深的体会是限流不是一个纯技术问题它是系统稳定性文化和容量管理方法论的一部分。代码本身很简单难的是想清楚“什么场景需要什么样的保护、保护到多强、误伤面多大时该放弃保护”。只要你把这些问题想透了哪怕就叫 atlas也会成为团队里有口皆碑的基础设施。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →