用令牌桶限制速率并保留可控突发
Envoy 本地限流按单调时钟补充令牌,用原子更新协调同一桶的并发扣减。桶容量决定可积累的突发量。
工程问题#
代理既要限制持续请求速率,也要允许空闲后的一小段突发。多个 worker 共享本地配额时,还必须避免各自检查成功却重复消费同一份令牌。
实际实现#
HTTP 过滤器调用 LocalRateLimiterImpl::requestAllowed 检查描述符桶和默认桶。在启用 no_timer_based_rate_limit_token_bucket 的路径上,AtomicTokenBucket 将 tokens_per_fill / fill_interval 换算为每秒速率。AtomicTokenBucketImpl 把余额编码为一个时间值:当前时间与该值的差乘以速率得到令牌数,上限为桶容量;消费后用 CAS 推进这个时间值,冲突则重试。
处理流程#
- 按请求描述符找到要检查的桶;默认桶是否额外扣减由配置决定
- 按单调时间差计算现有令牌,并以 max_tokens 限制积累量
- 令牌不足则返回不允许;足够时用 CAS 提交扣减,冲突后重新检查余额
- HTTP 过滤器按 enforced 配置选择继续放行或返回本地限流响应
源码片段#
固定版本 · 原文摘录313 // See if the request is forbidden by the default token bucket.314 if (matched_results.empty() || always_consume_default_token_bucket_) {315 if (const bool result = default_token_bucket_->consume(share_factor); !result) {316 // If the request is forbidden by the default token bucket, return the result and the317 // default token bucket.318 return {false, makeOptRefFromPtr<TokenBucketContext>(default_token_bucket_.get())};319 }320321 // If the request is allowed then return the result the token bucket. The descriptor322 // token bucket will be selected as priority if it exists.323 return {true, makeOptRef<TokenBucketContext>(matched_results.empty()324 ? *default_token_bucket_325 : matched_results[0].token_bucket.get())};326 };请求检查直接调用默认桶的 consume。这里只展示默认桶分支;此前代码先处理匹配的描述符桶,失败会提前返回。
159AtomicTokenBucket::AtomicTokenBucket(uint64_t max_tokens, uint64_t tokens_per_fill,160 std::chrono::milliseconds fill_interval,161 TimeSource& time_source)162 : token_bucket_(max_tokens, time_source,163 // Calculate the fill rate in tokens per second.164 tokens_per_fill / std::chrono::duration<double>(fill_interval).count()) {}165166bool AtomicTokenBucket::consume(double factor, uint64_t to_consume) {167 ASSERT(!(factor <= 0.0 || factor > 1.0));168 auto cb = [tokens = to_consume / factor](double total) { return total < tokens ? 0.0 : tokens; };169 return token_bucket_.consume(cb) != 0.0;170}把补充间隔换算为每秒速率;消费回调只有在 total 足够时才扣减。share_factor 会调整实际消费量,不能忽略本地集群配额配置。
56 template <class GetConsumedTokens> double consume(const GetConsumedTokens& cb) {57 const double time_now = timeNowInSeconds();5859 double time_old = time_in_seconds_.load(std::memory_order_relaxed);60 double time_new{};61 double consumed{};62 do {63 const double total_tokens = std::min(max_tokens_, (time_now - time_old) * fill_rate_);64 if (consumed = cb(total_tokens); consumed == 0) {65 return 0;66 }用单调时钟与保存的时间值计算余额,以 max_tokens 封顶。余额不足时回调返回 0,本次检查不更新原子状态。
74 // Move the time_in_seconds_ forward by the number of tokens consumed.75 const double total_tokens_new = total_tokens - consumed;76 time_new = time_now - (total_tokens_new / fill_rate_);77 } while (78 !time_in_seconds_.compare_exchange_weak(time_old, time_new, std::memory_order_relaxed));7980 return consumed;81 }扣减后的余额重新编码为时间值,通过 compare_exchange_weak 提交;失败会更新 time_old 并回到前面的 do 循环重新计算。memory_order_relaxed 只同步这份配额,不负责发布其他对象状态。
四段分别展示请求配额检查、单位换算、余额计算和原子提交。最后两段来自同一个模板函数的不同连续区间,未拼接为一段源码。模板允许调用方定义消费回调,本文的 HTTP 本地限流回调只在余额足够时返回正数。
收益与取舍#
工程分析带来的收益
- 不需要为每个请求保存时间戳,单桶状态与请求数量无关
- 容量和补充速率分别控制突发量与长期速率
- 同一桶的原子扣减避免多个线程共同消费同一份余额
付出的代价
- 单次无冲突补充与扣减为常数工作;CAS 竞争重试、描述符匹配和多桶排序另计,不能保证整次检查严格 O(1)
- 共享范围取决于本地过滤器配置;它不是跨 Envoy 实例的强一致全局配额
- 多个描述符桶逐个消费,后续桶拒绝不会回滚此前扣减;因此一次失败请求也可能消耗部分桶的额度
可比较的选择
nginx 漏桶限流nginx 通过随时间衰减的 excess 记录超额量,并可在 burst 范围内延迟请求;Envoy 这里检查令牌并决定是否允许,不采用同样的定时延迟放行机制。两者都可以控制突发,不能简单分成“令牌桶允许突发、漏桶禁止突发”。
外部全局限流服务可协调多个代理实例的共享额度,但需要远程调用、服务可用性和失败策略;本地桶常用于每个实例的保护。
版本与适用范围#
固定 Envoy v1.33.0。本文分析运行时开关 envoy.reloadable_features.no_timer_based_rate_limit_token_bucket 启用后的原子桶;同文件仍保留按定时器补充的 TimerTokenBucket,不能将两条路径混为一种实现。令牌不足是否最终拒绝还受过滤器启用比例、enforced 和路由配置影响。
参考来源#
源码、官方文档与原始论文source/extensions/filters/http/local_ratelimit/local_ratelimit.cc · Filter::decodeHeaders / Filter::requestAllowed
source/extensions/filters/common/local_ratelimit/local_ratelimit_impl.cc · AtomicTokenBucket / LocalRateLimiterImpl::requestAllowed
source/common/common/token_bucket_impl.h · AtomicTokenBucketImpl::consume
相关实现与比较#
1 个相关案例limit_req 把漏桶计算、红黑树查状态与队列回收组合起来,按配置的 key 管理请求速率。