Skip to content

分布式限流:算法、集群原子性与生产治理

一、学完本页要会什么

限流不是简单写一句“超过 100 QPS 返回 429”。生产级限流需要回答:100 是怎么测出来的、按哪个维度统计、突发能放多少、多个实例如何共享状态、限流器故障时放行还是拒绝、被拒绝后客户端是否重试、入口放行后数据库是否仍会被打满。

学完本页,你应该能够:

  1. 区分速率限制、并发限制、排队、熔断和降级。
  2. 推导固定窗口、滑动日志、滑动计数、令牌桶和漏桶的状态变化。
  3. 根据 Little 定律解释为什么低 QPS 的慢接口也能耗尽线程池。
  4. 写出 JDK 8 可运行的线程安全令牌桶。
  5. 解释 Redis Lua 为什么能原子限流,又为什么不等于绝对精确的全球限流。
  6. 设计接口、用户、IP、租户、业务资源和全局多层限流键。
  7. 解释 Gateway、Sentinel、Nginx、Envoy 和服务内限流的差异。
  8. 处理 Redis 故障、时钟漂移、热点 key、规则变更和限流重试风暴。
  9. 根据容量测试制定阈值并通过指标验证。
  10. 按 Runbook 定位“明明限流了系统仍然崩”和“限流误伤”。

二、限流到底限制什么

限流是对一定范围内的请求建立容量预算,在请求消耗昂贵资源前作出放行、排队或拒绝决策。它保护的是系统可持续服务能力,不是为了让报表上的请求量好看。

必须区分四个概念:

能力限制对象典型问题决策依据
速率限制 Rate Limit单位时间进入多少请求突发 QPS 打垮系统请求数/时间、令牌速率
并发限制 Concurrency Limit同时执行多少请求慢请求占满线程和连接当前 in-flight 数
排队 Queueing等待进入的请求量和时长短突发是否可平滑吸收队列长度、等待预算
熔断 Circuit Breaker是否继续访问不健康依赖下游持续慢或失败慢调用、异常比例、状态机

限流可以在下游完全健康时拒绝超出容量的流量;熔断是在下游健康恶化后快速阻断。二者常同时存在。

三、阈值不能靠猜:容量、QPS、并发与延迟

Little 定律在稳定系统中给出近似关系:

text
平均并发数 L = 平均到达率 λ × 平均停留时间 W

例如接口只有 20 QPS,但平均耗时 5 秒:

text
并发约为 20 × 5 = 100

如果业务线程池只有 80 个线程,20 QPS 也能把线程池占满。因此:

  • 快接口主要关注速率和 CPU。
  • 慢查询、导出、第三方调用要重点限制并发。
  • 数据库还要看连接池、锁等待和磁盘能力。
  • MQ 消费要看每条耗时、队列并行度和下游容量。

阈值设计的证据链:

mermaid
flowchart TD
    A["定义业务 SLO 与资源上限"] --> B["压测得到不同并发下吞吐和 P95/P99"]
    B --> C["找到延迟陡升、错误增长或资源饱和点"]
    C --> D["保留故障、GC和流量波动安全余量"]
    D --> E["形成初始速率、并发和队列阈值"]
    E --> F["灰度上线并用生产指标修正"]

不要把压测峰值直接当生产阈值。单节点压测到 1000 QPS,不代表 10 节点一定是 10000 QPS,因为数据库、Redis、带宽和热点行可能是共享瓶颈。

四、一次限流决策发生什么

mermaid
flowchart TD
    A["请求到达保护点"] --> B["解析可信身份和业务参数"]
    B --> C["生成规则与限流 Key"]
    C --> D["读取并原子更新窗口、令牌或并发状态"]
    D --> E{"是否获得容量"}
    E -- "获得" --> F["记录放行指标并执行业务"]
    E -- "未获得" --> G{"规则允许短暂排队吗"}
    G -- "允许且仍有等待预算" --> H["有界排队"]
    G -- "不允许" --> I["返回 429 或明确降级结果"]
    F --> J["并发限制器在 finally 中释放许可"]
    H --> D

安全边界:限流身份必须来自认证后的用户/租户,不能完全信任客户端自报的 X-Tenant-Id;否则攻击者可不断伪造新 key 绕过限流并制造高基数状态。

五、固定窗口计数器

5.1 状态与公式

把时间按窗口大小 W 分段:

text
windowId = floor(currentTime / W)
key = rule + dimension + windowId

窗口内计数 < limit 就递增并放行,否则拒绝。

优点:实现简单、内存少、Redis 中容易原子实现。缺点:边界突刺。若限制每秒 100 次,在第 1 秒最后 10ms 放 100 次,第 2 秒最初 10ms 又放 100 次,20ms 内可能通过 200 次。

mermaid
flowchart TD
    A["窗口 A 末尾通过 100"] --> B["计数器跨边界清零"]
    B --> C["窗口 B 开头再通过 100"]
    C --> D["极短时间承受约 2 倍窗口配额"]

固定窗口适合低成本粗粒度保护,不适合对瞬时流量非常敏感的数据库写接口。

六、滑动日志

滑动日志保存最近窗口内每个请求的时间戳。请求到来时:

  1. 删除 now - window 之前的时间戳。
  2. 统计剩余记录。
  3. 未达到阈值则插入当前时间戳并放行。

它能精确回答“最近 60 秒是否超过 100 次”,但每个请求都要保存记录,时间和空间成本较高。Redis 常用 ZSET:score 是时间戳,member 需要唯一值。

lua
-- 教学示意:生产还要处理 member 唯一性、TTL 和参数校验
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then
  return 0
end
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, window)
return 1

同一毫秒多个请求若 member 只用时间戳,会互相覆盖,导致少计数。应使用时间戳加请求唯一后缀,但要防止攻击者制造无限成员。

七、滑动窗口计数器

滑动计数是精度和成本的折中。把大窗口拆成多个小桶,例如最近 60 秒拆成 60 个 1 秒桶。请求到来时增加当前桶,计算最近 60 个桶之和。

text
估算最近窗口请求数 = 完整小桶之和 + 边界桶按覆盖比例折算

桶越细:

  • 边界误差越小。
  • 需要维护的桶越多。
  • 轮转和汇总成本越高。

Sentinel 等流量治理框架会使用时间窗口和桶化统计,但具体桶数、统计维度与版本实现应以源码和配置为准,不能把教学模型当成所有产品的固定实现。

八、令牌桶

令牌桶同时表达两个容量:

  • rate:长期平均放行速率,例如每秒补充 100 个令牌。
  • capacity:桶最大容量,例如允许积攒 200 个令牌吸收短突发。

请求到来时不需要后台线程每毫秒真的放令牌,可以懒计算:

text
elapsed = now - lastRefillTime
refilled = elapsed × rate
tokens = min(capacity, oldTokens + refilled)

if tokens >= requestedTokens:
    tokens -= requestedTokens
    allow
else:
    reject or calculate wait time
mermaid
flowchart TD
    A["按经过时间计算应补令牌"] --> B["令牌数不超过桶容量"]
    B --> C{"令牌是否足够"}
    C -- "足够" --> D["原子扣令牌并放行"]
    C -- "不足" --> E["拒绝或等待下一批令牌"]

桶容量不是越大越好。rate=100/s, capacity=1000 表示长时间空闲后可以瞬间放行 1000 个请求;若数据库只能承受 200 并发,这个突发会绕过长期平均速率保护。

九、漏桶与整形

漏桶把请求放入有界队列,按稳定速率流出:

text
queueSize < capacity:入队
queueSize >= capacity:拒绝
worker 按固定速率取出处理

令牌桶更像“允许受控突发的准入控制”;漏桶更像“把不平滑输入整形成平滑输出”。漏桶代价是等待延迟,如果请求已经超过用户总时间预算,即使最后处理成功也没有价值。

适合:异步任务、可排队写入、第三方接口有严格调用速率。谨慎用于同步登录、支付等低延迟接口。

十、并发限制与自适应并发

并发限制器在进入资源前获取许可,离开时必须在 finally 中释放:

java
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;

public class ExportConcurrencyGuard {
    private final Semaphore permits = new Semaphore(20);

    public byte[] export() throws Exception {
        if (!permits.tryAcquire(50, TimeUnit.MILLISECONDS)) {
            throw new IllegalStateException("EXPORT_BUSY");
        }
        try {
            return doExport();
        } finally {
            permits.release();
        }
    }

    private byte[] doExport() {
        return new byte[0];
    }
}

如果异常路径忘记释放许可,容量会逐渐泄漏,最终所有请求被拒绝。并发限制只限制当前进程时,10 个实例各 20 并发,总上限约为 200;如果数据库只能承受 100,就还需要全局预算或按实例动态分配。

自适应并发会观察最小延迟、当前延迟、队列或错误,动态调整上限。它能适应容量变化,但需要防止指标噪声导致阈值振荡,并保留静态安全上限。

十一、算法选型对比

算法保存状态突发能力精度成本典型用途
固定窗口当前窗口计数边界可能双倍突发较低粗粒度接口/IP限制
滑动日志每次请求时间戳严格限制最近窗口低频高价值操作
滑动计数多个时间桶可控中高通用流量统计与治理
令牌桶令牌数、上次补充时间由桶容量控制API 准入、允许短突发
漏桶队列和固定流出速率输入可突发,输出平滑队列成本异步整形、第三方速率保护
并发限制in-flight 数不直接限制每秒速率对资源占用直接低到中慢请求、连接和线程保护

生产常组合:入口令牌桶控制速率,服务内 Semaphore 控制并发,线程池队列保持很小,Sentinel/熔断器处理下游恶化。

十二、本地限流和分布式限流

12.1 本地限流

每个实例独立维护状态,优点是低延迟、Redis 故障不影响、实现简单。缺点是总配额随实例数变化,并且流量不均时某个实例先被打满。

假设全局目标 1000 QPS、当前 10 个实例,简单分配为每实例 100 QPS。但扩容到 20 个实例若规则没更新,总放行会变成 2000 QPS;缩容后又可能过度拒绝。

12.2 分布式限流

多个实例共享 Redis、网关限流服务或集中式配额,实现更接近全局上限。代价是每次决策增加网络依赖和共享热点。

12.3 混合方案

常见生产方案是“全局粗预算 + 本地快速保护”:

mermaid
flowchart TD
    A["全局配额服务按周期租给实例一批令牌"] --> B["实例在本地低延迟消费子配额"]
    B --> C["配额低时申请下一批"]
    C --> D{"全局服务暂时不可用"}
    D -- "是" --> E["按剩余租约和安全本地上限运行"]
    D -- "否" --> A

这种方案牺牲绝对瞬时精度,换取低延迟和可用性。必须限制预取批次,否则多个实例持有的大量未使用令牌会造成全局超放。

十三、Redis Lua 固定窗口:原子性到底解决了什么

错误写法是客户端执行 GET → 判断 → INCR → EXPIRE。两个请求可同时读到相同旧值并一起放行,且客户端在 INCR 后崩溃可能漏设过期时间。

Lua 在 Redis 主执行线程中作为一个命令整体运行,使读取、判断、递增和设置 TTL 不被其他命令插入:

lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttlMillis = tonumber(ARGV[2])

local current = tonumber(redis.call('GET', key) or '0')
if current >= limit then
    local ttl = redis.call('PTTL', key)
    return {0, current, ttl}
end

current = redis.call('INCR', key)
if current == 1 then
    redis.call('PEXPIRE', key, ttlMillis)
end
local ttl = redis.call('PTTL', key)
return {1, current, ttl}

返回放行结果、当前计数和 TTL,调用方可以计算 Retry-After 并记录指标。

Lua 原子性只针对该 Redis 执行环境中的脚本:

  • 不能保证业务请求随后一定执行成功。
  • Redis 主从切换和异步复制可能影响刚写入状态,取决于部署和故障窗口。
  • 多地域各自 Redis 不能天然组成一个精确全球计数器。
  • 长 Lua 会阻塞 Redis 处理其他命令,脚本必须短小且参数受控。

十四、Redis 令牌桶的状态和原子更新

每个桶至少保存:

text
tokens:当前剩余令牌
lastRefillTime:上次计算补充的时间

一次 Lua 决策应原子完成:读取状态、计算经过时间、补令牌、判断、扣减、保存状态、更新 TTL。

时间来源要统一。若每个应用实例把自己的 now 传入 Redis,机器时钟漂移可能导致令牌多补或少补。可以考虑使用 Redis TIME 作为该限流状态的统一时间来源,但仍要理解主从切换、跨地域和时钟校正的边界。

浮点计算也要谨慎。高精度配额可用整数最小单位,例如把一个令牌拆成 1_000_000 微令牌,减少不同语言浮点差异,同时检查整数溢出。

TTL 应覆盖桶从满令牌恢复所需的空闲时间并留余量;TTL 太短会频繁重置成满桶导致超放,永不过期则会积累大量僵尸 key。

十五、Redis Cluster、热点 Key 和高基数风险

Lua 涉及多个 key 时,在 Redis Cluster 中必须位于同一 Hash Slot,通常使用 Hash Tag:

text
rate:{tenant-1001}:global
rate:{tenant-1001}:order-create

但把同一租户所有规则放同一 Slot 也可能制造热点。设计要权衡原子多 key 操作和负载分散。

高基数风险:攻击者伪造不同 IP、设备号、用户标识,使 Redis 每次创建新 key。防护方法包括:

  • 只使用认证后可信维度。
  • key 设置合理 TTL。
  • 对未知主体先做网关全局/IP 段粗限流。
  • 限制规则和维度数量。
  • 监控新增 key 速率、内存和热点命令。
  • 对超高频全局 key 采用本地子配额或分片近似计数。

十六、多层与分层限流

只做一个全局 QPS 限制会出现两个问题:大客户抢光所有配额,或者细粒度攻击在全局阈值以下持续打某个昂贵接口。

mermaid
flowchart TD
    A["边缘/CDN<br/>抗大流量和恶意来源"] --> B["Gateway 全局与路由预算"]
    B --> C["租户/用户公平配额"]
    C --> D["服务接口速率限制"]
    D --> E["线程池/连接池并发限制"]
    E --> F["数据库、第三方、热点SKU资源限制"]

一次请求可能同时消耗:

  • 全站总配额。
  • order-create 接口配额。
  • 当前租户配额。
  • 当前用户配额。
  • 热点 skuId 配额。
  • 数据库写并发许可。

多层规则的顺序应从便宜、粗粒度到昂贵、细粒度。认证前无法可靠按用户限流,可以先按 IP/设备/全局限制;认证后再按租户和用户。

十七、Gateway、Sentinel、Nginx、Envoy 怎么选

实现位置擅长局限
CDN/WAF边缘抗攻击、IP/路径规则不理解深层业务状态
Nginx高性能入口、连接和请求速率分布式共享配额和业务参数扩展有限
Spring Cloud GatewayRoute、用户/租户、KeyResolver,Spring 体系集成Reactor 链中不能做阻塞限流逻辑
Sentinel资源、QPS、并发、热点参数、熔断和系统保护规则持久化与集群精度需要额外设计
Resilience4j应用内 RateLimiter/Bulkhead/CircuitBreaker默认更偏单进程组件组合
Envoy/Service Mesh代理层多语言统一治理不自动理解业务幂等和数据库容量
Redis Lua自定义全局维度与原子状态网络依赖、热点、故障策略和运维成本

组件不是越多越好。若 Gateway、服务和 Mesh 都按同一阈值限流,可能重复拒绝且难定位。每一层应该有明确保护对象和独立指标。

十八、Sentinel 限流和统计原理

Sentinel 将 Web 路径、方法或外部调用抽象为资源。请求进入资源后建立 Context/Entry,通过 Slot Chain 更新统计并匹配规则。滑动时间窗口中的桶记录通过、阻塞、异常、RT 和并发等指标。

规则适合保护注意
QPS快接口的进入速率慢接口只看 QPS 可能耗尽线程
并发线程数慢调用、导出、第三方接口要结合线程模型理解
热点参数特定 skuId、tenantId 等热点参数索引、类型和高基数风险
关联流控写接口保护被其影响的查询/资源规则关系要有业务证据
排队等待平滑可等待的小突发必须受总超时和队列长度约束

Sentinel 的 BlockException 表示治理规则主动拒绝,不等于业务异常。blockHandler 不能返回伪成功。控制台直接推到应用内存的规则重启可能丢失,生产要持久化并纳入变更流程。

深入学习:Sentinel 原理

十九、Gateway KeyResolver 示例及安全边界

java
@Bean
public KeyResolver tenantKeyResolver() {
    return exchange -> {
        // 示例:生产应从已验证认证上下文读取,不信任客户端自报 Header
        String tenantId = exchange.getPrincipal()
                .map(Principal::getName)
                .defaultIfEmpty("anonymous")
                .block();
        return Mono.just("tenant:" + tenantId);
    };
}

上面为了突出错误而展示了 .block()Gateway 的 Reactor 事件循环中不应这样写。 正确写法保持异步链:

java
@Bean
public KeyResolver safeTenantKeyResolver() {
    return exchange -> exchange.getPrincipal()
            .map(Principal::getName)
            .defaultIfEmpty("anonymous")
            .map(name -> "tenant:" + name);
}

如果 Principal 只是用户名,还应加入可信租户上下文和规则 ID,防止不同规则共用 key。匿名用户不能全部永远使用一个热点 key,可以在全局预算下结合规范化 IP 段,但要考虑代理链和可信 X-Forwarded-For 来源。

二十、限流器故障:Fail-open 还是 Fail-closed

Redis 或集中配额服务故障时,没有万能答案:

接口倾向原因与补充
商品查询Fail-open + 本地安全上限可用性优先,但不能无限放行
登录/验证码本地严格限流或 Fail-closed安全和成本风险高
支付扣款通常 Fail-closed 或进入待处理重复和超载代价高,需明确业务提示
内部健康检查独立白名单和固定预算不能被普通业务挤占,也不能无限制

推荐“有界降级”,而不是全放或全拒:

  1. Redis 正常时执行全局规则。
  2. Redis 超时后快速切本地保守令牌桶。
  3. 降低非核心接口配额,保留核心链路预算。
  4. 记录降级状态、持续时间和放行数量。
  5. Redis 恢复后逐步切回,避免所有实例同时重连和突发。

限流器自身调用必须有极短超时和隔离,否则为了决定是否放行,业务线程反而长时间堵在 Redis 上。

二十一、被限流后的协议和客户端行为

HTTP 接口通常返回 429 Too Many Requests,并可提供 Retry-After。业务 JSON 要区分限流规则和普通 500:

json
{
  "code": "RATE_LIMITED",
  "message": "当前请求超过系统可处理容量,请稍后重试",
  "retryAfterMillis": 800,
  "rule": "tenant-order-create"
}

安全上不要向外暴露内部实例、Redis key 或精确防攻击策略。客户端收到 429 后应:

  • 尊重 Retry-After
  • 指数退避并加入随机抖动。
  • 不在 SDK、Gateway 和业务服务三层同时立即重试。
  • 写请求保持同一幂等键。
  • 用户交互显示明确状态,避免连续点击。

如果 10 万客户端都在整秒后同时重试,会产生新的同步流量尖峰,这叫惊群或重试风暴。

二十二、规则发布、动态调整和公平性

规则不是越动态越先进。一次把阈值从 1000 降到 100,可能瞬间拒绝大量在途业务;从 100 提到 1000,可能把隐藏瓶颈直接打穿。

生产规则应包含:

  • 唯一规则 ID 和版本。
  • 作用资源、维度和环境。
  • 算法、速率、容量、并发、排队时间。
  • 生效开始/结束时间。
  • 创建人、审批人、变更原因。
  • 灰度范围和自动回滚条件。

多租户公平常采用分层预算:先保留系统总容量,再给租户基础份额,剩余容量允许活跃租户借用。严格静态平均分配会浪费空闲租户额度;完全共享又会让大租户饿死小租户。

二十三、必须监控哪些指标

指标说明诊断价值
allowed_total放行数实际进入业务的流量
rejected_total拒绝数,按规则/租户/接口判断误伤或攻击
queued_current / wait_time排队量和等待时间是否把拒绝变成超时
in_flight当前并发线程、连接是否接近上限
limiter_latency限流决策耗时Redis/规则中心是否成为瓶颈
fail_open_total限流器故障降级放行风险窗口是否扩大
Redis hotkey/CPU/latency共享状态健康是否存在热点或长脚本
downstream P95/P99/error被保护资源表现阈值是否真正有效

只统计“拒绝了多少”不够。若拒绝数很低但数据库已满,保护点太晚或维度错误;若拒绝很多而 CPU 很空,可能阈值过低、流量不均或共享依赖瓶颈判断错误。

二十四、商业场景设计

24.1 发送验证码

组合规则:

  • 单 IP 每分钟请求上限。
  • 单手机号每分钟和每天上限。
  • 单设备上限。
  • 全局短信供应商配额。
  • 同一手机号发送最小间隔。

手机号要规范化和加密/哈希后再进入 key,日志中不能输出完整手机号。仅按 IP 会误伤 NAT 用户,也挡不住代理池;仅按手机号会被攻击者用大量号码消耗短信费。

24.2 秒杀下单

mermaid
flowchart TD
    A["CDN/WAF 过滤异常流量"] --> B["Gateway 活动总令牌"]
    B --> C["用户和设备频率限制"]
    C --> D["库存预扣或排队令牌"]
    D --> E["有界 MQ 队列削峰"]
    E --> F["订单消费者幂等创建"]

限流只保护容量,不能单独防超卖。库存仍需原子条件更新、预扣、幂等订单和补偿释放。

24.3 第三方医院接口

第三方约定每秒 50 次:使用漏桶或令牌桶限制长期速率,再限制同时在途调用数,例如 10。任务队列必须有最大长度和超时;否则上游持续生产任务时,内存队列无限增长最终 OOM。

24.4 多租户报表导出

使用全局导出并发 20、单租户并发 2、单用户并发 1,并把任务异步化。导出任务用业务 ID 幂等,队列返回排队位置和状态查询地址,而不是让 HTTP 连接等待几十分钟。

二十五、生产故障排查 Runbook

25.1 明明限流了,系统还是崩了

  1. 确认保护点是否在昂贵操作之前。
  2. 看放行 QPS 与 in-flight,而不只看配置阈值。
  3. 检查令牌桶 capacity 是否允许过大瞬时突发。
  4. 检查多实例本地限流是否让总配额乘以实例数。
  5. 检查 Gateway、SDK、服务重试是否放大流量。
  6. 检查真正瓶颈是否是数据库锁、连接池、热点 key 或第三方接口。
  7. 检查限流拒绝是否触发客户端无退避重试。
  8. 先降低非核心预算、关闭多层重试、隔离热点,再调整规则。

25.2 大量正常用户被误伤

  1. 按规则、租户、用户、IP、实例拆分拒绝指标。
  2. 检查是否把反向代理 IP 当成所有用户 IP。
  3. 检查租户身份是否未认证、key 是否全部变成 anonymous
  4. 检查窗口边界、时区、时钟和 TTL。
  5. 检查 Redis key 是否发生命名冲突。
  6. 检查某实例流量偏斜但使用了本地固定配额。
  7. 灰度调整阈值,不要一次性全量放大。

25.3 Redis 限流延迟升高

  1. 看 Redis 命令延迟、CPU、网络、连接池等待和慢日志。
  2. 定位热点 key、长 Lua、高基数 key 和大 ZSET。
  3. 检查脚本是否每次扫描/删除大量滑动日志成员。
  4. 检查 Cluster key 是否集中到一个 Slot。
  5. 切换本地保守限流止损,避免业务线程等待 Redis。
  6. 按准确性要求选择分桶、子配额或专门限流服务。

25.4 扩容后限流突然失效或更严格

本地限流总配额会随实例数改变;集中限流不会自动给新增实例更多全局容量,但并发瓶颈也不一定随实例线性扩展。查规则作用域、实例数、共享下游容量和负载分布,再决定重新分配,而不是简单按实例数倍增。

二十六、JDK 8 可运行 Demo:线程安全令牌桶

java
public class TokenBucketDemo {
    static final class TokenBucket {
        private final long capacity;
        private final double tokensPerNano;
        private double tokens;
        private long lastRefillNanos;

        TokenBucket(long capacity, double tokensPerSecond) {
            if (capacity <= 0 || tokensPerSecond <= 0) {
                throw new IllegalArgumentException("capacity and rate must be positive");
            }
            this.capacity = capacity;
            this.tokensPerNano = tokensPerSecond / 1_000_000_000D;
            this.tokens = capacity;
            this.lastRefillNanos = System.nanoTime();
        }

        synchronized boolean tryAcquire(int requested) {
            if (requested <= 0 || requested > capacity) {
                throw new IllegalArgumentException("invalid requested tokens");
            }
            refill();
            if (tokens < requested) {
                return false;
            }
            tokens -= requested;
            return true;
        }

        private void refill() {
            long now = System.nanoTime();
            long elapsed = now - lastRefillNanos;
            if (elapsed <= 0) {
                return;
            }
            tokens = Math.min(capacity, tokens + elapsed * tokensPerNano);
            lastRefillNanos = now;
        }
    }

    public static void main(String[] args) throws Exception {
        TokenBucket bucket = new TokenBucket(3, 2.0D);
        System.out.println(bucket.tryAcquire(1));
        System.out.println(bucket.tryAcquire(1));
        System.out.println(bucket.tryAcquire(1));
        System.out.println(bucket.tryAcquire(1));
        Thread.sleep(600L);
        System.out.println(bucket.tryAcquire(1));
    }
}

预期输出:

text
true
true
true
false
true

为什么使用 System.nanoTime():它表示单调经过时间,适合进程内计算间隔,不受系统墙上时钟向前/向后调整的直接影响。它不能跨进程比较,也不能持久化为现实时间。

为什么 tryAcquire 要同步:补充和扣减是一个状态转换。如果两个线程同时读取相同 token 数再扣减,就会超放。真实高性能实现可用 CAS 或成熟库,但必须保持状态转换原子性。

二十七、常见错误与后果

错误为什么错误后果
阈值凭感觉没有容量曲线和 SLO 依据要么挡不住,要么大量误伤
慢接口只限 QPS低 QPS 也可积累高并发线程池、连接池耗尽
桶容量设置极大空闲后可瞬间释放大量请求数据库被突发打穿
多实例各用全局阈值总配额按实例数放大扩容后保护失效
Redis GET 后客户端判断判断和递增不原子并发超放、TTL 丢失
信任客户端租户 Header攻击者可伪造维度绕过限流、高基数攻击
Redis 故障无限 Fail-open保护在最需要时消失下游雪崩
限流返回 500调用方误判服务故障告警噪声、错误重试
429 后立即固定间隔重试客户端同步重试新一轮流量尖峰
无界排队把拒绝隐藏成等待超时、内存增长、OOM
只看平均耗时尾延迟和少量热点被掩盖阈值调整方向错误

二十八、面试标准回答

28.1 常见限流算法有什么区别

固定窗口按自然时间段计数,简单但有边界双倍突发;滑动日志保存每个请求时间,精确但成本高;滑动计数按小桶汇总,在精度和成本间折中;令牌桶按速率补令牌并用桶容量允许受控突发;漏桶通过有界队列按固定速率流出,输出最平滑;并发限制直接限制同时占用线程、连接或下游资源的请求数。

28.2 为什么 Lua 能做 Redis 分布式限流

Lua 脚本在 Redis 中作为一个整体执行,使读取状态、判断、递增或扣令牌、设置过期不被其他命令穿插,避免客户端多命令的并发竞态。但它只保证 Redis 这一侧的原子状态转换,不保证后续业务成功,也不能取消主从切换、跨地域、热点 key 和 Redis 故障边界。

28.3 QPS 不高为什么还会线程池耗尽

并发约等于 QPS 乘以平均耗时。20 QPS、每次 5 秒就约有 100 个在途请求,所以慢接口要限制并发、缩短超时并隔离资源,不能只看每秒请求数。

28.4 网关限流和服务内限流为什么都需要

网关尽早拒绝全局、路由、IP、用户或租户流量,保护整个后端;服务内限流能理解业务参数并保护线程池、数据库、第三方接口等具体资源。两层阈值要有不同保护对象和指标,不能无设计地重复配置同一个数字。

28.5 Redis 挂了应该放行还是拒绝

按业务风险决定。查询类可 Fail-open 但切到本地保守上限;验证码、登录、支付等安全或资金接口更偏 Fail-closed 或进入待处理。生产应使用有界降级、极短超时、独立监控和恢复灰度,而不是全局统一放行或拒绝。

二十九、关联知识点

本章小结

生产级限流是容量工程,不是一个计数器。先确定保护资源和业务 SLO,再选择速率、并发、突发和排队模型;单机与全局状态按精度和可用性权衡;规则必须有可信身份、原子更新、故障降级、监控和灰度发布。只有当被保护资源在高压下仍保持可接受延迟和成功率,限流才真正有效。