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,再选择速率、并发、突发和排队模型;单机与全局状态按精度和可用性权衡;规则必须有可信身份、原子更新、故障降级、监控和灰度发布。只有当被保护资源在高压下仍保持可接受延迟和成功率,限流才真正有效。