分布式限流:算法、集群原子性与生产治理
一、学完本页要会什么
限流不是简单写一句“超过 100 QPS 返回 429”。生产级限流需要回答:100 是怎么测出来的、按哪个维度统计、突发能放多少、多个实例如何共享状态、限流器故障时放行还是拒绝、被拒绝后客户端是否重试、入口放行后数据库是否仍会被打满。
学完本页,你应该能够:
- 区分速率限制、并发限制、排队、熔断和降级。
- 推导固定窗口、滑动日志、滑动计数、令牌桶和漏桶的状态变化。
- 根据 Little 定律解释为什么低 QPS 的慢接口也能耗尽线程池。
- 写出 JDK 8 可运行的线程安全令牌桶。
- 解释 Redis Lua 为什么能原子限流,又为什么不等于绝对精确的全球限流。
- 设计接口、用户、IP、租户、业务资源和全局多层限流键。
- 解释 Gateway、Sentinel、Nginx、Envoy 和服务内限流的差异。
- 处理 Redis 故障、时钟漂移、热点 key、规则变更和限流重试风暴。
- 根据容量测试制定阈值并通过指标验证。
- 按 Runbook 定位“明明限流了系统仍然崩”和“限流误伤”。
二、限流到底限制什么
限流是对一定范围内的请求建立容量预算,在请求消耗昂贵资源前作出放行、排队或拒绝决策。它保护的是系统可持续服务能力,不是为了让报表上的请求量好看。
必须区分四个概念:
| 能力 | 限制对象 | 典型问题 | 决策依据 |
|---|---|---|---|
| 速率限制 Rate Limit | 单位时间进入多少请求 | 突发 QPS 打垮系统 | 请求数/时间、令牌速率 |
| 并发限制 Concurrency Limit | 同时执行多少请求 | 慢请求占满线程和连接 | 当前 in-flight 数 |
| 排队 Queueing | 等待进入的请求量和时长 | 短突发是否可平滑吸收 | 队列长度、等待预算 |
| 熔断 Circuit Breaker | 是否继续访问不健康依赖 | 下游持续慢或失败 | 慢调用、异常比例、状态机 |
限流可以在下游完全健康时拒绝超出容量的流量;熔断是在下游健康恶化后快速阻断。二者常同时存在。
三、阈值不能靠猜:容量、QPS、并发与延迟
Little 定律在稳定系统中给出近似关系:
平均并发数 L = 平均到达率 λ × 平均停留时间 W例如接口只有 20 QPS,但平均耗时 5 秒:
并发约为 20 × 5 = 100如果业务线程池只有 80 个线程,20 QPS 也能把线程池占满。因此:
- 快接口主要关注速率和 CPU。
- 慢查询、导出、第三方调用要重点限制并发。
- 数据库还要看连接池、锁等待和磁盘能力。
- MQ 消费要看每条耗时、队列并行度和下游容量。
阈值设计的证据链:
flowchart TD
A["定义业务 SLO 与资源上限"] --> B["压测得到不同并发下吞吐和 P95/P99"]
B --> C["找到延迟陡升、错误增长或资源饱和点"]
C --> D["保留故障、GC和流量波动安全余量"]
D --> E["形成初始速率、并发和队列阈值"]
E --> F["灰度上线并用生产指标修正"]不要把压测峰值直接当生产阈值。单节点压测到 1000 QPS,不代表 10 节点一定是 10000 QPS,因为数据库、Redis、带宽和热点行可能是共享瓶颈。
四、一次限流决策发生什么
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 分段:
windowId = floor(currentTime / W)
key = rule + dimension + windowId窗口内计数 < limit 就递增并放行,否则拒绝。
优点:实现简单、内存少、Redis 中容易原子实现。缺点:边界突刺。若限制每秒 100 次,在第 1 秒最后 10ms 放 100 次,第 2 秒最初 10ms 又放 100 次,20ms 内可能通过 200 次。
flowchart TD
A["窗口 A 末尾通过 100"] --> B["计数器跨边界清零"]
B --> C["窗口 B 开头再通过 100"]
C --> D["极短时间承受约 2 倍窗口配额"]固定窗口适合低成本粗粒度保护,不适合对瞬时流量非常敏感的数据库写接口。
六、滑动日志
滑动日志保存最近窗口内每个请求的时间戳。请求到来时:
- 删除
now - window之前的时间戳。 - 统计剩余记录。
- 未达到阈值则插入当前时间戳并放行。
它能精确回答“最近 60 秒是否超过 100 次”,但每个请求都要保存记录,时间和空间成本较高。Redis 常用 ZSET:score 是时间戳,member 需要唯一值。
-- 教学示意:生产还要处理 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 个桶之和。
估算最近窗口请求数 = 完整小桶之和 + 边界桶按覆盖比例折算桶越细:
- 边界误差越小。
- 需要维护的桶越多。
- 轮转和汇总成本越高。
Sentinel 等流量治理框架会使用时间窗口和桶化统计,但具体桶数、统计维度与版本实现应以源码和配置为准,不能把教学模型当成所有产品的固定实现。
八、令牌桶
令牌桶同时表达两个容量:
rate:长期平均放行速率,例如每秒补充 100 个令牌。capacity:桶最大容量,例如允许积攒 200 个令牌吸收短突发。
请求到来时不需要后台线程每毫秒真的放令牌,可以懒计算:
elapsed = now - lastRefillTime
refilled = elapsed × rate
tokens = min(capacity, oldTokens + refilled)
if tokens >= requestedTokens:
tokens -= requestedTokens
allow
else:
reject or calculate wait timeflowchart TD
A["按经过时间计算应补令牌"] --> B["令牌数不超过桶容量"]
B --> C{"令牌是否足够"}
C -- "足够" --> D["原子扣令牌并放行"]
C -- "不足" --> E["拒绝或等待下一批令牌"]桶容量不是越大越好。rate=100/s, capacity=1000 表示长时间空闲后可以瞬间放行 1000 个请求;若数据库只能承受 200 并发,这个突发会绕过长期平均速率保护。
九、漏桶与整形
漏桶把请求放入有界队列,按稳定速率流出:
queueSize < capacity:入队
queueSize >= capacity:拒绝
worker 按固定速率取出处理令牌桶更像“允许受控突发的准入控制”;漏桶更像“把不平滑输入整形成平滑输出”。漏桶代价是等待延迟,如果请求已经超过用户总时间预算,即使最后处理成功也没有价值。
适合:异步任务、可排队写入、第三方接口有严格调用速率。谨慎用于同步登录、支付等低延迟接口。
十、并发限制与自适应并发
并发限制器在进入资源前获取许可,离开时必须在 finally 中释放:
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 混合方案
常见生产方案是“全局粗预算 + 本地快速保护”:
flowchart TD
A["全局配额服务按周期租给实例一批令牌"] --> B["实例在本地低延迟消费子配额"]
B --> C["配额低时申请下一批"]
C --> D{"全局服务暂时不可用"}
D -- "是" --> E["按剩余租约和安全本地上限运行"]
D -- "否" --> A这种方案牺牲绝对瞬时精度,换取低延迟和可用性。必须限制预取批次,否则多个实例持有的大量未使用令牌会造成全局超放。
十三、Redis Lua 固定窗口:原子性到底解决了什么
错误写法是客户端执行 GET → 判断 → INCR → EXPIRE。两个请求可同时读到相同旧值并一起放行,且客户端在 INCR 后崩溃可能漏设过期时间。
Lua 在 Redis 主执行线程中作为一个命令整体运行,使读取、判断、递增和设置 TTL 不被其他命令插入:
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 令牌桶的状态和原子更新
每个桶至少保存:
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:
rate:{tenant-1001}:global
rate:{tenant-1001}:order-create但把同一租户所有规则放同一 Slot 也可能制造热点。设计要权衡原子多 key 操作和负载分散。
高基数风险:攻击者伪造不同 IP、设备号、用户标识,使 Redis 每次创建新 key。防护方法包括:
- 只使用认证后可信维度。
- key 设置合理 TTL。
- 对未知主体先做网关全局/IP 段粗限流。
- 限制规则和维度数量。
- 监控新增 key 速率、内存和热点命令。
- 对超高频全局 key 采用本地子配额或分片近似计数。
十六、多层与分层限流
只做一个全局 QPS 限制会出现两个问题:大客户抢光所有配额,或者细粒度攻击在全局阈值以下持续打某个昂贵接口。
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 Gateway | Route、用户/租户、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 示例及安全边界
@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 事件循环中不应这样写。 正确写法保持异步链:
@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 或进入待处理 | 重复和超载代价高,需明确业务提示 |
| 内部健康检查 | 独立白名单和固定预算 | 不能被普通业务挤占,也不能无限制 |
推荐“有界降级”,而不是全放或全拒:
- Redis 正常时执行全局规则。
- Redis 超时后快速切本地保守令牌桶。
- 降低非核心接口配额,保留核心链路预算。
- 记录降级状态、持续时间和放行数量。
- Redis 恢复后逐步切回,避免所有实例同时重连和突发。
限流器自身调用必须有极短超时和隔离,否则为了决定是否放行,业务线程反而长时间堵在 Redis 上。
二十一、被限流后的协议和客户端行为
HTTP 接口通常返回 429 Too Many Requests,并可提供 Retry-After。业务 JSON 要区分限流规则和普通 500:
{
"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 秒杀下单
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 明明限流了,系统还是崩了
- 确认保护点是否在昂贵操作之前。
- 看放行 QPS 与 in-flight,而不只看配置阈值。
- 检查令牌桶 capacity 是否允许过大瞬时突发。
- 检查多实例本地限流是否让总配额乘以实例数。
- 检查 Gateway、SDK、服务重试是否放大流量。
- 检查真正瓶颈是否是数据库锁、连接池、热点 key 或第三方接口。
- 检查限流拒绝是否触发客户端无退避重试。
- 先降低非核心预算、关闭多层重试、隔离热点,再调整规则。
25.2 大量正常用户被误伤
- 按规则、租户、用户、IP、实例拆分拒绝指标。
- 检查是否把反向代理 IP 当成所有用户 IP。
- 检查租户身份是否未认证、key 是否全部变成
anonymous。 - 检查窗口边界、时区、时钟和 TTL。
- 检查 Redis key 是否发生命名冲突。
- 检查某实例流量偏斜但使用了本地固定配额。
- 灰度调整阈值,不要一次性全量放大。
25.3 Redis 限流延迟升高
- 看 Redis 命令延迟、CPU、网络、连接池等待和慢日志。
- 定位热点 key、长 Lua、高基数 key 和大 ZSET。
- 检查脚本是否每次扫描/删除大量滑动日志成员。
- 检查 Cluster key 是否集中到一个 Slot。
- 切换本地保守限流止损,避免业务线程等待 Redis。
- 按准确性要求选择分桶、子配额或专门限流服务。
25.4 扩容后限流突然失效或更严格
本地限流总配额会随实例数改变;集中限流不会自动给新增实例更多全局容量,但并发瓶颈也不一定随实例线性扩展。查规则作用域、实例数、共享下游容量和负载分布,再决定重新分配,而不是简单按实例数倍增。
二十六、JDK 8 可运行 Demo:线程安全令牌桶
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));
}
}预期输出:
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,再选择速率、并发、突发和排队模型;单机与全局状态按精度和可用性权衡;规则必须有可信身份、原子更新、故障降级、监控和灰度发布。只有当被保护资源在高压下仍保持可接受延迟和成功率,限流才真正有效。
