很多开发者在接入Envoy时,都会把限流理解成“每秒最多放行多少个请求”。这个理解没有错,可真落地的时候,限流往往不是一道简单的算术题。同一个接口,老用户和新用户不同,普通访问和秒杀请求不同,甚至同一个IP后面的所有人也不该被当成同一个人。如果只用一条总规则,要么流量拐个弯就漏过去了,要么误伤一批正常用户。限流要精准,就得学会“看人下菜碟”。

一、别把所有人都当成同一个客人

1.1 一刀切限流为什么不好用

假设你开一家小饭馆,只有两个灶台,一天最多接待一百人。你可以在门口挂个牌子“今天只接待一百人”。这样的限流公平吗?并不公平。老顾客、会员、带着小孩来的人,和路边临时进来歇脚的人,占用的资源完全不一样。程序接口也是同样的道理。一个管理后台的接口,本来只给管理员用,结果被普通用户扫到了,你给他的限额和普通浏览页接口应该完全不同。如果所有接口共享同一套限流,后台可能被刷几次就挂,或者普通页面被后台请求误伤。

1.2 请求属性从哪里来

在HTTP世界里,请求属性就是那些已经存在的信息:请求头里的用户ID、会员等级、设备类型,路径里的业务标识,甚至来源IP和Cookie。Envoy在做路由转发时,这些信息都攥在手里,就看你会不会用。把属性提取出来,作为限流判断的“身份标签”,才能做到“同一套规则,不同人不同待遇”。

二、Envoy里两个限流器的分工

Envoy为开发者准备了两个看起来相似、但性格完全不同的限流器:本地限流和全局限流。

2.1 本地限流:快但是“各管各”

envoy.filters.http.local_ratelimit 是跑在Envoy进程里的限流器。它像房间门口的一条长板凳,坐满了人就不让进。它的特点是速度极快,毫秒级判断,不依赖任何外部服务。可问题是,每个Envoy实例只按自己看到的请求来计数。假如你在Kubernetes里跑了十个Envoy副本,每个副本限制10 QPS,理论上总量是100 QPS,但如果某个副本流量很小,另一个副本已经冲破100,总控反而失衡。所以它适合做第一道防线,不适合做全局仲裁。

这里简单插一句令牌桶到底是什么。你可以想象一个口很小的瓶子,每秒往里面扔几个小牌子,请求进来时必须从小瓶子里摸一个牌子,摸到了就放行,摸不到就拒绝。瓶子的最大容量就是max_tokens,往里补充的速度就是tokens_per_fill。这个设计既能容忍偶发的请求突刺,又能控制长期平均速率。

2.2 全局限流:准但是要问路

envoy.filters.http.ratelimit 是一个“外挂裁判”。每次请求到了,Envoy拼出一组属性,通过gRPC发给专门的限流服务。限流服务手里拿着所有实例的请求数据,能给出全局性的裁决。这样做的好处是精确,坏处是多一次网络往返。如果限流服务卡顿,整个请求都会变慢;如果服务挂了,Envoy还必须决定是全部拒绝还是全部放行。后面的配置里会用failure_mode_deny来控制这个行为。

三、把请求属性变成限流依据

要协同,先得解决“怎么看人”的问题。HTTP请求里,最方便拿到的属性通常都在header里。Envoy允许你在路由规则里通过rate_limits的actions,把header值转成descriptor。你可以先把descriptor理解成一张“身份证”,上面写着一串键值对。

3.1 从header里提取身份信息

比如一个支付接口,你希望限制每个用户每分钟最多下单5次。那你就需要把X-User-Id这个请求头提取出来。写起来像这样:

# 技术栈:Envoy YAML 配置
routes:
- match:
    prefix: "/pay"
  route:
    cluster: pay_backend
    rate_limits:
    - actions:
      # 这个动作会把请求头里的 X-User-Id 值抓出来
      # 重命名成 user_id,作为 descriptor 里的一个键
      - request_headers:
          header_name: X-User-Id
          descriptor_key: user_id

实际调用时,如果请求头里X-User-Id是9527,那么Envoy发给限流服务的descriptor就是“user_id=9527”。外部限流服务看到这个标识,就能对它单独配额度。

3.2 多属性组合,形成更贴近业务的规则

只拿一个用户ID还不够。比如业务方希望“普通用户每10秒最多请求1次,VIP用户每10秒最多5次”。这时可以在rate_limits里再加一个动作,把会员等级也拼进去。

# 技术栈:Envoy YAML 配置
routes:
- match:
    prefix: "/pay"
  route:
    cluster: pay_backend
    rate_limits:
    - actions:
      # 第一个属性:用户ID,用来唯一标识用户
      - request_headers:
          header_name: X-User-Id
          descriptor_key: user_id
      # 第二个属性:会员等级,用来区分 VIP 和普通用户
      - request_headers:
          header_name: X-Member-Tier
          descriptor_key: member_tier

当这两个属性组合在一起,支付请求就被打上了类似“user_id=9527, member_tier=pro”的标签。外部限流服务看到这串标签,就能制定差异化策略。这一步就是“基于请求属性”的核心。

四、协同作战:完整配置长什么样

理论聊到这里,接下来看怎么把本地限流和全局限流放在同一个请求链里。

4.1 filter顺序很关键

在Envoy的HTTP过滤器链里,顺序就是执行顺序。通常建议把本地限流放在全局限流前面。理由很简单:本地限流便宜,先让它在入口拦下明显超量的人,减轻全局限流服务的压力;如果本地没拦住,再交给全局限流做跨实例仲裁。当然,你也可以反过来先全局后本地,但那样每个请求都会先走一遍gRPC调用,成本会高很多。

4.2 一个完整可参考的配置示例

下面这个配置,对接口 /pay 做“本地+全局”双重限流,对 /health 不做限制。完整代码里已经写了注释。

# 技术栈:Envoy YAML 配置
static_resources:
  listeners:
  - name: main_listener
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 8080
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: edge_http
          codec_type: AUTO
          route_config:
            name: local_route
            virtual_hosts:
            - name: backend
              domains: ["*"]
              routes:
              # /health 走正常转发,不做限流
              - match:
                  prefix: "/health"
                route:
                  cluster: backend_cluster
              # /pay 是重点保护对象
              - match:
                  prefix: "/pay"
                route:
                  cluster: backend_cluster
                  # 全局限流需要提取的请求属性
                  rate_limits:
                  - actions:
                    - request_headers:
                        header_name: X-User-Id
                        descriptor_key: user_id
                    - request_headers:
                        header_name: X-Member-Tier
                        descriptor_key: member_tier
                typed_per_filter_config:
                  # 单独给 /pay 配置本地限流的更小令牌桶
                  envoy.filters.http.local_ratelimit:
                    "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
                    stat_prefix: local_pay
                    token_bucket:
                      max_tokens: 50
                      tokens_per_fill: 10
                      fill_interval: 1s
          # HTTP 过滤器链顺序:先本地限流,再全局限流,最后转发
          http_filters:
          - name: envoy.filters.http.local_ratelimit
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
              stat_prefix: local_all
              token_bucket:
                max_tokens: 200
                tokens_per_fill: 20
                fill_interval: 1s
          - name: envoy.filters.http.ratelimit
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.ratelimit.v3.RateLimit
              domain: order_api
              # 外部限流服务连接失败时,直接拒绝请求,避免击穿后端
              failure_mode_deny: true
              rate_limit_service:
                grpc_service:
                  envoy_grpc:
                    cluster_name: rls_grpc
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
  # 真正的业务服务
  - name: backend_cluster
    type: STATIC
    connect_timeout: 0.25s
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: backend_cluster
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: 127.0.0.1
                port_value: 9090
  # 外部全局限流服务
  - name: rls_grpc
    type: STATIC
    connect_timeout: 1s
    http2_protocol_options: {}
    load_assignment:
      cluster_name: rls_grpc
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: 127.0.0.1
                port_value: 8081

也许你已经注意到,rls_grpc这个cluster启用了HTTP/2协议。因为Envoy和外部限流服务之间的接口是gRPC接口,而gRPC基于HTTP/2。这个cluster只是通信管道,真正的业务流量还是走backend_cluster。

4.3 这个配置到底做了什么

当用户请求到达 /pay 时,流程是这样的:首先,本地限流filter检查“本机”的令牌桶。如果本机已经跑到峰值,立刻返回429,不会再往外发。如果本地还有余量,请求会继续走到全局限流filter。Envoy从请求头里拿到“X-User-Id”和“X-Member-Tier”,拼成descriptor发给rls_grpc。外部限流服务拿着这串标签,从全局维度检查这个用户当前总请求量,超了就拒绝,没超就放行。这样,同一个用户即使换了机器访问,也会被全局看住;而同一台机器的突发流量,又会被本地限流快速拦住。

五、哪些场景适合这么玩

这种组合方式最能发挥价值的地方有三个。

第一,多实例服务的“总量控制”。当服务跑在十几个Pod后面时,本地限流只能管单实例,全局限流能把所有Pod加在一起算总账。支付类、秒杀类接口特别适合。

第二,按用户差异化限流。通过请求头里的会员等级,给免费用户和VIP用户分配完全不同的额度。比如普通用户每分钟最多访问300次,VIP用户每分钟最多3000次。

第三,保护下游系统不被突发流量打崩。本地限流作为快速开关,能第一时间挡掉异常IP的疯狂请求;全局限流再做一轮更精细的过滤,比如限制某个账号的总并发。两个一起用,下游收到的流量会平滑很多。

六、要留心的坑

再好的方案,落不了地都是白搭。这里说几个容易踩坑的地方。

  1. 不要让descriptor基数失控。如果把用户ID直接当成descriptor键,限流服务内部就要为每个用户存一份数据。大促时几百万用户就对应几百万条记录,内存和查询压力都会暴涨。必要时可以只提取高风险的userId范围,或者对用户ID做哈希分桶。

  2. failure_mode_deny要谨慎。这个参数决定全局限流服务不可用时Envoy怎么做。设成true,服务一挂所有请求都拒绝,相当于自断后路。如果你的业务允许短暂超量,可以设成false,让流量暂时绕过限流服务。

  3. 本地限流的桶不要设得和全局一样大。如果本地最大桶等于全局总配额,全局限流就没有意义;如果设得太小,又会把正常用户提前挡在外面。一般建议本地桶设为全局桶除以实例数的80%。

  4. 支付和订单类接口,限流结果要接入监控。统计被local_ratelimit拒绝和被全局限流拒绝的请求数量,就能快速判断是机器单点问题,还是业务整体超量。

  5. 注意filter顺序。把本地限流放在全局限流前面,是为了先减少对全局服务的调用。如果把顺序弄反,每个请求都会先打给全局服务,网关耗时会明显变长。

七、文章总结

Envoy的限流能力并不复杂,组合起来却是一门手艺活。本地限流负责快,全局限流负责准,请求属性负责让规则“认识人”。做好三者的协同,才能达到既保护下游,又不误伤用户的效果。希望文章里的配置能给你一个起点,让你在真实业务中少踩几个坑。