限流的本质:从限流算法到分布式流控的架构思考

目录

一、为什么你的系统需要限流

每个系统都有容量边界。缓存解决读的问题,降级解决非核心链路的问题,但当写操作高并发、稀缺资源被争抢、昂贵查询集中涌入时——需要通过准入控制,把接受的工作限制在可处理范围内。 资源隔离、扩容和负载削减也应配合使用。

手段 解决的问题 核心机制 局限性
缓存 提速 将高频数据放入更快的存储层 不能单独保证写入容量和正确性
降级 止损 放弃非核心功能保核心链路 只能舍弃业务允许降级的部分,秒杀也可关闭非必要展示或功能
限流 控流 主动丢弃/延迟超量请求 需要准确的容量评估,否则误杀或漏放

但"限流"这两个字过于笼统。请求涌入太快是限流问题,同时处理的请求太多也是限流问题,同样叫限流,控制的东西完全不同。限流不是一个算法,而是一套控制体系。 要选对方案,首先要搞清楚:你到底在控制什么?


二、你到底在控制什么——四种限流模型

大多数人一提"限流"就想到令牌桶、漏桶。但算法只是手段,在选算法之前要先回答一个更根本的问题:你要控制的是什么物理量?

现实中的限流需求可以归纳为四种控制模型,每种控制着不同的"物理量",适用不同的场景,也对应不同的算法家族:

到达速率控制——控制"多快进来"

本质:单位时间内允许通过的请求数量。

典型场景:API 接口限制每秒 1000 次调用、用户登录接口限制每分钟 5 次尝试、短信验证码 60 秒内只能发一次。

这是最常见的限流需求。它的核心关注点是"单位时间的请求数"——不管每个请求要跑多久、占多少资源,只要单位时间内的数量不超标就放行。

并发占用控制——控制"同时多少个"

本质:任意时刻正在处理的请求数量。

典型场景:数据库连接池最多 50 个连接、报表导出接口最多同时执行 3 个、文件上传同时只允许 10 个。

与速率控制的区别:速率控制不关心每个请求"待多久",并发控制则相反——一个跑 10 分钟的报表任务,速率限制器根本管不住它。如果你有 10 个这样的任务同时运行,速率限制器显示"每秒只进来 1 个"一切正常,但系统已经被压垮了。

长期配额控制——控制"总共多少次"

本质:一个较长周期内允许消耗的总量。

典型场景:免费用户每天 100 次 API 调用、每月 10GB 流量配额、每个租户每月 100 万次查询。

配额控制关注的是"累计消耗",时间尺度从小时到月不等。它与速率控制看似相似(都是"一段时间内的请求数"),但有本质区别:速率控制关注的是"瞬时压力"——保护系统不被打垮;配额控制关注的是"商业资源"——控制成本或实现产品差异化。一个配额为每天 1000 次的用户,完全可以在第一秒就用完所有配额,如果没有同时配置短周期速率限制,长期配额不会拦住这次突发。

执行节奏控制——控制"多快出去"

本质:请求被处理和释放的速率,确保输出均匀平稳。

典型场景:消息队列消费速率控制、音视频流恒定码率传输、对接物理设备接口(打印机、传感器)。

前面三种都是在"入口"做控制:请求来了,判断能不能进。节奏控制不同,它控制的是"出口"——请求已经被接受,但要排队按固定节奏释放。即使系统空闲、令牌充裕,也不会加速处理。

为什么不能互相替代

控制模型 控制的物理量 如果只用速率限制…
到达速率 单位时间请求数 ✅ 这正是它干的事
并发占用 同时在处理的请求数 ❌ 10 个慢请求各跑 10 分钟,速率上看只有"1 个/分钟",但并发已爆
长期配额 累计消耗总量 ❌ 速率 100/s 的限制管不住"每天只许用 1000 次"的商业规则
执行节奏 输出的均匀程度 ❌ 令牌桶允许突发消费,下游设备收到脉冲流量就炸了

需求 → 算法决策总表

在进入具体算法之前,先给出一张导航图。后续章节会逐一展开每种算法的原理和实现:

你的需求 控制模型 推荐算法 章节
API 限制每秒 N 次调用 到达速率 固定窗口 / 滑动窗口计数器 3.1
精确统计每个请求的时间分布 到达速率 滑动窗口日志 3.1
允许突发但限制平均速率 突发 + 速率 令牌桶 / GCRA 3.2
下游绝对不能承受波动 执行节奏 漏桶 3.3
限制同时处理的请求数 并发占用 信号量 / Bulkhead 3.4
每天/每月 N 次调用额度 长期配额 固定窗口长周期 / 滚动配额 3.5

三、限流算法详解与工程实现

下面给出固定窗口、近似滑动计数、令牌桶、GCRA 和严格间隔准入的 Java 教学实现,采用 JDK 17 语法、按 key 维护状态。示例需要导入 ConcurrentHashMap、AtomicLong 等 JDK 类型;状态表未实现回收,只适用于 key 数量受控的实验。生产中需限制基数,并保证回收不会让同一个 key 同时存在两份活动状态。所有实现遵循统一接口:

interface RateLimiter {
    boolean allow(String key);
}

3.1 速率控制家族——控制"多快进来"

这一族算法的共同目标:在一个时间窗口内,限制请求的通过数量。区别在于如何定义和计算"窗口"。

固定窗口计数器(Fixed Window Counter)

核心原理

在一个固定时间窗口内维护计数器,超过阈值就拒绝,窗口结束时归零。

|← 窗口1 (0-1s) →|← 窗口2 (1-2s) →|
    count=0→100        count=0→...
    阈值=100           阈值=100

Java 实现

固定窗口和滑动窗口共用一个 Window 状态类:

class Window {
    long windowStart;
    int count;
    int preCount;  // 滑动窗口用:上一个窗口的计数

    Window(long windowStart) {
        this.windowStart = windowStart;
    }
}
class FixedWindowRateLimiter implements RateLimiter {

    private final int limit;
    private final long windowNanos;
    private final ConcurrentHashMap<String, Window> map = new ConcurrentHashMap<>();

    // limit: 窗口内最大请求数, windowMillis: 窗口大小(毫秒)
    FixedWindowRateLimiter(int limit, long windowMillis) {
        this.limit = limit;
        if (limit <= 0 || windowMillis <= 0) {
            throw new IllegalArgumentException("positive limit and window required");
        }
        this.windowNanos = Math.multiplyExact(windowMillis, 1_000_000L);
        if (windowNanos > Long.MAX_VALUE / 4) {
            throw new IllegalArgumentException("window too large");
        }
    }

    @Override
    public boolean allow(String key) {
        Window w = map.computeIfAbsent(key, ignored -> new Window(System.nanoTime()));
        synchronized (w) {
            long now = System.nanoTime();
            long elapsed = now - w.windowStart;
            // 窗口过期:对齐到窗口边界(而不是 windowStart = now)
            if (elapsed >= windowNanos) {
                long periods = elapsed / windowNanos;
                w.windowStart += periods * windowNanos;
                w.count = 0;
            }
            if (w.count < limit) {
                w.count++;
                return true;
            }
            return false;
        }
    }
}

注意窗口过期时用 windowStart += periods * windowNanos 对齐到窗口边界,而不是直接 windowStart = now。后者会导致窗口漂移——每次重置都把窗口起点推到当前时间,使得窗口大小不再固定。

经典问题:窗口边界的 2 倍峰值

|← 窗口1 →|← 窗口2 →|
      ↑
   最后100ms涌入100个  最前100ms涌入100个

   → 200ms 内实际通过了 200 个请求(2 倍于阈值)

适用场景

  • 精度要求不高的简单限流(大部分业务场景)
  • 需要快速实现的场景
  • 阈值本身留有足够余量(2 倍偶发峰值可承受)

工程判断:在很多场景中,固定窗口的精度已经足够。边界处偶尔的 2 倍峰值,对于留有余量的系统来说不是问题。不要为理论上的完美过度工程化。


滑动窗口计数器(Sliding Window Counter)

核心原理

固定窗口的边界问题源于"硬切割"——窗口一旦翻页,上个窗口的计数彻底清零。滑动窗口计数器的思路是:保留上一个窗口的计数,用加权平均来近似"滑动"效果。

|← 上一个窗口 →|← 当前窗口 →|
   preCount=80     count=20
                     ↑ now(已过 30% 窗口)

估算值 = count + preCount × (1 - 30%) = 20 + 80 × 0.7 = 76

窗口刚翻页时(elapsed ≈ 0),上个窗口的权重接近 100%,相当于还在"旧窗口"里计数;窗口快结束时(elapsed ≈ windowSize),上个窗口的权重接近 0%,退化为固定窗口。这种线性插值在大多数场景下精度足够,且存储开销和固定窗口一样是 O(1)。

与固定窗口的对比

维度 固定窗口 滑动窗口计数器
精度 存在边界 2 倍峰值 近似平滑,仍可能误放或误拒
实现复杂度 一个计数器 两个计数器 + 加权计算
存储开销 O(1) O(1)
适用场景 精度要求低、快速实现 允许近似统计,需要减少固定边界突变

Java 实现

class SlidingWindowRateLimiter implements RateLimiter {

    private final int limit;
    private final long windowNanos;
    private final ConcurrentHashMap<String, Window> map = new ConcurrentHashMap<>();

    SlidingWindowRateLimiter(int limit, long windowMillis) {
        this.limit = limit;
        if (limit <= 0 || windowMillis <= 0) {
            throw new IllegalArgumentException("positive limit and window required");
        }
        this.windowNanos = Math.multiplyExact(windowMillis, 1_000_000L);
        if (windowNanos > Long.MAX_VALUE / 4) {
            throw new IllegalArgumentException("window too large");
        }
    }

    @Override
    public boolean allow(String key) {
        Window w = map.computeIfAbsent(key, ignored -> new Window(System.nanoTime()));
        synchronized (w) {
            long now = System.nanoTime();
            long elapsed = now - w.windowStart;
            if (elapsed >= windowNanos) {
                long periods = elapsed / windowNanos;
                // 跨了 2 个以上窗口,上个窗口数据已无意义
                w.preCount = (periods >= 2) ? 0 : w.count;
                w.count = 0;
                w.windowStart += periods * windowNanos;
                elapsed = now - w.windowStart;
            }
            // 加权估算:当前计数 + 上一窗口计数 × 未过期比例
            double weight = (double) elapsed / windowNanos;
            double estimated = w.count + w.preCount * (1.0 - weight);
            if (estimated + 1.0 <= limit) {
                w.count++;
                return true;
            }
            return false;
        }
    }
}

工程实践:更精细的子窗口方案

上面的两窗口加权方案简洁高效,在大多数场景下已足够。如果需要更精细的滑动效果,可以将窗口划分为多个子窗口(slot),例如阿里巴巴的 Sentinel 框架使用 LeapArray 数据结构:

  • 将 1 秒划分为若干个 WindowWrap(默认 2 个,即 500ms 一个子窗口)
  • 每个子窗口维护独立的 pass/block/exception 等计数器
  • 通过环形数组 + 时间戳判断实现窗口滑动,避免频繁创建销毁对象

存储开销从 O(1) 变为 O(N)(N 为子窗口数),精度更高,但实现复杂度也相应增加。


滑动窗口日志(Sliding Window Log)

核心原理

记录每一个请求的精确时间戳,判断时移除窗口外的过期记录,然后统计剩余记录数。这是精度最高的速率控制算法——没有子窗口的近似,每个请求的时间位置都被精确记录。

窗口大小 = 1s,阈值 = 5

请求日志: [0.1, 0.3, 0.5, 0.8, 0.9]
                                    ↑ 当前时间 = 1.2s

移除 < 0.2 的记录 → [0.3, 0.5, 0.8, 0.9]
当前窗口内 4 个请求 < 阈值 5 → 放行,记录 1.2

伪代码

def sliding_log_allow(key, threshold, window_size):
    now = current_time()

    # 移除窗口外的过期记录
    store.remove_before(key, now - window_size)

    # 统计当前窗口内的请求数
    count = store.count(key)

    if count < threshold:
        store.add(key, now)  # 记录本次请求时间戳
        return True
    return False

优势与代价

维度 说明
精度 在时间源和原子更新正确时,精确统计约定滚动区间
存储开销 O(N),N 为窗口内的请求数。QPS 1000 + 1s 窗口 = 1000 条记录
适用场景 低 QPS + 高精度要求(如 API 计费、安全审计)
不适用 高 QPS 场景——存储和清理开销过大

工程判断:滑动窗口日志的精度是三种窗口算法中最高的,但存储成本也最高。在 Redis 中通常用 Sorted Set 实现(第四章会详细展示)。对于大多数业务场景,滑动窗口计数器是更好的平衡点。


3.2 突发与平均速率——控制"允许多大的脉冲"

窗口类算法有一个共同的局限:它们只看"窗口内的总量",不区分"均匀到达"和"一瞬间全来"。令牌桶和 GCRA 解决的正是这个问题——允许一定程度的突发,但限制长期平均速率。

令牌桶算法(Token Bucket)

核心原理

令牌桶的理念:在空闲时积蓄能力,在繁忙时释放能力。

令牌以固定速率补充至容量上限,请求原子检查并扣减,余额不足时拒绝或有界等待

  • 系统以恒定速率向桶中放入令牌
  • 每个请求消耗一个(或多个)令牌
  • 令牌充足时请求立即通过
  • 令牌耗尽时请求被拒绝或阻塞等待
  • 桶有容量上限,多余令牌溢出

核心参数

参数 含义 设计考量
令牌生成速率 系统的持续处理能力 对应系统稳态吞吐上限
桶容量 允许的最大突发量 编码了对突发流量的容忍度

Java 实现

class TokenBucketRateLimiter implements RateLimiter {

    private final double capacity;       // 桶容量(最大突发量)
    private final double refillPerNano;  // 每纳秒补充的令牌数
    private final boolean warmUp;        // true = 冷启动从 0 开始

    private static class Bucket {
        double tokens;
        long lastRefillTime;
    }

    private final ConcurrentHashMap<String, Bucket> map = new ConcurrentHashMap<>();

    /**
     * @param permitsPerSecond 每秒放入的令牌数
     * @param burst            桶容量(最大突发量)
     * @param warmUp           true = 新 key 从 0 令牌开始(冷启动)
     */
    TokenBucketRateLimiter(double permitsPerSecond, int burst, boolean warmUp) {
        if (!Double.isFinite(permitsPerSecond) || permitsPerSecond <= 0 || burst < 1) {
            throw new IllegalArgumentException("positive finite rate and burst required");
        }
        this.capacity = burst;
        this.refillPerNano = permitsPerSecond / 1_000_000_000.0;
        this.warmUp = warmUp;
    }

    @Override
    public boolean allow(String key) {
        Bucket b = map.computeIfAbsent(key, ignored -> {
            Bucket bucket = new Bucket();
            bucket.tokens = warmUp ? 0 : capacity;  // 冷启动 vs 满桶启动
            bucket.lastRefillTime = System.nanoTime();
            return bucket;
        });
        synchronized (b) {
            long now = System.nanoTime();
            long elapsed = now - b.lastRefillTime;
            if (elapsed > 0) {
                // 懒填充:按经过的时间补充令牌
                b.tokens = Math.min(capacity, b.tokens + elapsed * refillPerNano);
                b.lastRefillTime = now;
            }
            if (b.tokens >= 1.0) {
                b.tokens -= 1.0;
                return true;
            }
            return false;
        }
    }
}

关键实现细节:"懒填充"(lazy refill)。不需要一个后台线程不断往桶里放令牌,只需在每次请求到来时,根据距上次填充的时间差计算应补充的令牌数。warmUp 仅控制初始令牌为零或满桶,并未实现速率逐渐提高的预热曲线;空桶开始也不能等同于缓存预热完成。

适用场景

  • 互联网 API 限流(绝大多数场景的首选)
  • 允许合理突发的业务场景(秒杀、热点事件引发的流量脉冲)
  • 需要区分长期速率和瞬时峰值的场景

工程实践:Guava RateLimiter 的两种模式

Guava 提供了两种令牌桶实现,对应两种不同的业务需求:

// 模式一:SmoothBursty —— 允许突发
// 以每秒 100 个令牌的速率生成,桶容量等于 1 秒的产量(100)
com.google.common.util.concurrent.RateLimiter limiter =
    com.google.common.util.concurrent.RateLimiter.create(100.0);

// 场景:API 网关限流
// 特点:空闲期积累的令牌可以一次性消费,应对突发
if (limiter.tryAcquire()) {
    processRequest();
} else {
    return Response.status(429).build();
}
// 模式二:SmoothWarmingUp —— 冷启动预热
// 速率 100/s,预热期 3 秒
com.google.common.util.concurrent.RateLimiter limiter =
    com.google.common.util.concurrent.RateLimiter.create(100.0, 3, TimeUnit.SECONDS);

// 场景:数据库连接池、缓存冷启动
// 特点:系统刚启动时不会全速放量,给下游一个"热身"时间
// 预热通过改变许可的等待成本逐步提升吞吐,不应概括成速率线性增长,避免冷系统被瞬时流量打垮

Guava 的许可预留和等待行为与本文的拒绝式教学实现不同,使用时以其 API 契约为准。Guava RateLimiter

SmoothBursty vs SmoothWarmingUp 的选择

维度 SmoothBursty SmoothWarmingUp
突发处理 允许消费积累的令牌,支持突发 冷启动期间限制突发
典型场景 API 限流、消息推送 数据库预热、缓存预热
核心关注 流量的峰谷平衡 系统的冷热状态转换

关键注意:Guava RateLimiter 是单机限流。它只能控制当前 JVM 进程的流量,跨节点共享配额需要额外协调,可以采用集中决策、分片或额度租赁等方案。


GCRA(Generic Cell Rate Algorithm)

核心原理

在单位请求成本和参数对应的条件下,GCRA 可表达与令牌桶相对应的准入约束,但实现更精简。它不维护"当前令牌数",而是维护一个"理论到达时间"(TAT,Theoretical Arrival Time)——下一个请求最早应该在什么时候到达。

核心思想:如果请求到达得比预期频率更快,TAT 会不断后推;如果请求到达得比预期慢,TAT 会被拉回到当前时间附近(但不会超前太多,受突发容量限制)。

参数:
  emission_interval = 1/rate     -- 每个请求的理论间隔
  burst_tolerance   = burst * emission_interval  -- 允许的最大提前量

判断逻辑:
  candidate = max(TAT, now) + emission_interval
  如果 candidate - now > burst_tolerance → 拒绝,保留原 TAT
  否则 → 放行,将 TAT 原子更新为 candidate

Java 实现

GCRA 的每个 key 用 AtomicLong 保存理论到达时间,并通过 CAS 更新。状态表的创建仍由 ConcurrentHashMap 管理,竞争激烈时还需评估 CAS 自旋成本:

class GcraRateLimiter implements RateLimiter {
    private final long intervalNanos;
    private final long burstWindowNanos;
    private final ConcurrentHashMap<String, AtomicLong> tatByKey = new ConcurrentHashMap<>();

    GcraRateLimiter(double permitsPerSecond, int burstPermits) {
        if (!Double.isFinite(permitsPerSecond)
                || permitsPerSecond <= 0 || permitsPerSecond > 1_000_000_000.0
                || burstPermits < 1) {
            throw new IllegalArgumentException("invalid rate or burst");
        }
        double interval = Math.ceil(1_000_000_000.0 / permitsPerSecond);
        if (interval > Long.MAX_VALUE / 4.0) {
            throw new IllegalArgumentException("rate too small");
        }
        this.intervalNanos = (long) interval;
        this.burstWindowNanos = Math.multiplyExact(intervalNanos, burstPermits);
        if (burstWindowNanos > Long.MAX_VALUE / 4) {
            throw new IllegalArgumentException("burst window too large");
        }
    }

    @Override
    public boolean allow(String key) {
        AtomicLong state = tatByKey.computeIfAbsent(
                key, ignored -> new AtomicLong(System.nanoTime()));
        while (true) {
            long previous = state.get();
            long now = System.nanoTime();
            long base = now - previous >= 0 ? now : previous;
            long candidate = base + intervalNanos;
            if (candidate - now > burstWindowNanos) {
                return false;
            }
            if (state.compareAndSet(previous, candidate)) {
                return true;
            }
            Thread.onSpinWait();
        }
    }
}

先计算 candidate,再比较 candidate 与 now 的间隔;只有通过检查才提交 CAS。burstPermits 表示空闲后同一时刻可通过的总数,设为 1 时不能额外多放一个。纳秒间隔向上取整可能让实际速率略低于配置值;Thread.onSpinWait 只是处理器提示,不是等待或公平性保证。

为什么 GCRA 值得关注

优势 说明
状态极简 只需存储一个值(TAT),对比令牌桶需要存 tokens + last_refill
天然适合分布式 一个 Redis key 存一个时间戳,原子 CAS 即可更新
整数计时 使用整数间隔,避免浮点累加;纳秒量化仍有误差

GCRA 起源于 ATM 流量控制;Redis 扩展 redis-cell 提供了一个公开实现,可用于对照参数语义。redis-cell


3.3 流量整形——控制"多快出去"

漏桶整形与准入限制不是同一个动作

漏桶可以描述一个有界等待队列:按设定节奏释放请求,队列满则拒绝。它约束的是派发节奏,不能保证任务完成时间也均匀。

前面的 allow(key) 接口只有放行或拒绝,无法表达“已接受、稍后派发”。即使返回 true 的时间被限制,线程调度和后续处理仍可能让实际派发聚集。真正整形需要有界队列、调度器、超时与取消机制。

可以用下面的严格间隔准入器说明“不积蓄突发额度”的行为。它复用 GCRA,并把一次可立即通过的请求数设为 1;调用者过早到达会被拒绝,它不排队,也不是完整整形器。

class SpacingRateLimiter implements RateLimiter {
    private final GcraRateLimiter delegate;

    SpacingRateLimiter(double permitsPerSecond) {
        this.delegate = new GcraRateLimiter(permitsPerSecond, 1);
    }

    @Override
    public boolean allow(String key) {
        return delegate.allow(key);
    }
}

若业务要求排队,应在真正派发的时刻控制下一次释放时间,并限制队列长度和最大等待时间;调度器延误后是否追赶需要明确,不能用补发一批请求破坏下游的突发约束。

Nginx 的 limit_req 使用漏桶方法,默认可以延迟超额请求;加 nodelay 后,允许的突发请求立即转发。因此“使用漏桶”不等于“所有配置都会匀速输出”。例如:

# 放在 http 上下文,server 中还需配置实际 upstream/proxy_pass
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
    location /api/ {
        # 允许 burst 范围内的超额请求立即转发,超出则拒绝
        limit_req zone=api burst=20 nodelay;
        limit_req_status 429;
    }
}

在空闲状态、同一时刻集中到达且没有时间恢复的理想化计数下,是基础请求加 20 个超额额度,不能把 10r/s 与 burst 相加得到“瞬间 30 个”。实际边界还受实现的时间精度影响。多实例 Nginx 的状态是否共享也需要单独配置与验证。Nginx limit_req 文档


3.4 并发控制——控制"同时多少个"

前面所有算法都在控制"速率"——单位时间通过多少个请求。但有些场景下,速率不是瓶颈,并发才是。

问题场景:一个报表导出接口,每次调用需要 30 秒完成,消耗大量 CPU 和内存。即使限制为每秒 1 个请求,如果 30 秒内每秒都来一个,就有 30 个同时在执行——足以打垮服务。

信号量 / Bulkhead

核心原理

维护一个并发计数器。请求进入时 +1,请求完成时 -1。计数器达到上限时,新请求被拒绝或排队。

请求进入 → 计数器 +1 → [正在处理:当前 3/5] → 完成 → 计数器 -1
                ↓
          计数器 = 5 → 拒绝/排队

伪代码

class ConcurrencyLimiter:
    def __init__(self, max_concurrent):
        self.max_concurrent = max_concurrent
        self.current = 0         # 当前并发数
        self.lock = Lock()

    def acquire(self):
        with self.lock:
            if self.current >= self.max_concurrent:
                return False
            self.current += 1
            return True

    def release(self):
        with self.lock:
            self.current -= 1

关键区别:为什么速率限制替代不了并发控制?

场景 速率限制(10 req/s) 并发控制(max=5)
快请求(10ms) 正常工作 不会触发(并发始终很低)
慢请求(30s) 30s 内放入 300 个请求,全部同时在跑 只允许 5 个同时执行,第 6 个拒绝或进入有界等待
资源保护效果 慢请求场景下完全失效 精确保护下游并发能力

工程实践

  • Java:Semaphore、Resilience4j 的 Bulkhead
  • 数据库:连接池本质就是并发控制
  • Nginx:limit_conn 限制并发连接数
  • 可使用独立线程池或信号量隔离;Sentinel 的并发线程数控制不等同于创建独立线程池

Bulkhead(舱壁隔离)模式:把不同依赖的并发限制隔离开,A 服务的慢查询把自己的并发额度用完,可以限制对 B 的影响,但仍需检查共享线程、连接池等资源。


3.5 配额控制——控制"总共多少次"

固定窗口长周期 / 滚动配额

核心原理

配额控制在技术实现上往往就是一个大窗口的固定窗口计数器——窗口大小从分钟级变成天/月级。但它的设计意图完全不同:速率控制保护系统不被打垮,配额控制实现商业规则。

典型实现

def check_quota(user_id, tier):
    quotas = {
        "free": {"daily": 100, "monthly": 1000},
        "pro":  {"daily": 10000, "monthly": 100000},
    }

    daily_key = f"quota:daily:{user_id}:{today()}"
    monthly_key = f"quota:monthly:{user_id}:{this_month()}"

    daily_count = store.get(daily_key, 0)
    monthly_count = store.get(monthly_key, 0)

    limits = quotas[tier]
    if daily_count >= limits["daily"] or monthly_count >= limits["monthly"]:
        return False, remaining(limits, daily_count, monthly_count)

    store.increment(daily_key)
    store.increment(monthly_key)
    return True, remaining(limits, daily_count + 1, monthly_count + 1)

上述伪代码仅表达业务判断;读取两种额度和同时递增必须放在一个原子操作或事务中,失败时不能只扣掉其中一种。Redis Cluster 多 key 脚本还要求相关 key 在同一 hash slot;长期计费额度需要持久账本与对账,不能只依赖可丢失的缓存计数。

工程要点

  • 配额通常需要返回剩余量(X-RateLimit-Remaining header),方便调用方规划使用
  • 长周期配额的窗口边界(如月初重置)是产品决策,不是技术决策
  • 配额超限的拒绝策略通常比速率限制更"温和"——返回明确的额度信息和升级引导,而非简单的 429

算法对比总表

算法 控制模型 核心特征 突发处理 存储开销 推荐场景
固定窗口 到达速率 简单计数 边界 2 倍峰值 O(1) 快速实现、精度要求低
滑动窗口计数器 到达速率 双窗口加权 平滑 O(1) 精度要求高、阈值紧
滑动窗口日志 到达速率 精确时间戳 完美 O(N) 请求数 低 QPS + 高精度
令牌桶 突发 + 速率 积蓄+释放 允许有限突发 O(1) API 限流(首选)
GCRA 突发 + 速率 单时间戳 允许有限突发 O(1) 分布式 API 限流
漏桶 执行节奏 恒定输出 不允许突发 O(1) 流量整形、硬件接口
信号量 并发占用 进出计数 不涉及 O(1) 慢请求、连接池
固定窗口长周期 长期配额 累计统计 不涉及 O(1) 商业配额、计费

选择策略:先确定你的控制模型(速率/并发/配额/节奏),再在对应的算法家族中选择。如果没有特殊需求,令牌桶是互联网业务的默认选择。


四、从单机到分布式:最关键的认知跃迁

单机限流为什么在集群中失效

一个团队用 Guava RateLimiter 限制短信 API 调用为 400 QPS,本地测试完美。代码部署到 4 个节点后,4 个节点各自以 400 QPS 发送,服务商实际承受 1600 QPS,接口再次崩溃。

根因:单机限流只能控制单个进程的流量,对其他节点一无所知。

直觉的修复是均分配额:4 个节点各分 100 QPS。但这引入新问题:

理想中:
  节点A: 100 QPS → 25%
  节点B: 100 QPS → 25%
  节点C: 100 QPS → 25%
  节点D: 100 QPS → 25%

现实中(负载不均):
  节点A: 240 QPS → 只放行 100,拒绝 140 ✗
  节点B: 120 QPS → 只放行 100,拒绝  20 ✗
  节点C:  30 QPS → 只用了 30,浪费  70
  节点D:  10 QPS → 只用了 10,浪费  90

  总放行:240 QPS(理论可放 400,实际只放了 240)
  → 系统实际吞吐远低于理论上限

动态调整配额(根据节点负载实时重新分配)?复杂度爆炸——你需要协调机制感知节点上下线、收集实时负载、计算下发配额,这本身就是一个分布式系统问题。

共享存储是一种方案;也可租赁额度到节点、按 key 分片,或分别使用本地保护与全局配额。选择取决于精度、协调成本和故障策略。

分布式限流的核心原则

限流的粒度决定了它的准确性。

保护对象 限流粒度 方案
本机 CPU/内存 进程级 Guava RateLimiter、Sentinel
外部 API 配额 系统级(全集群) Redis 分布式计数器
业务规则(如用户发送频率) 用户级 Redis + 用户维度 key

Redis 分布式限流:共享状态的边界

Redis 可用于集中维护短周期计数,但需要同时评估网络时延、热点 key、故障恢复和预算误差:

限流需求 Redis 特性 为什么匹配
原子性:"读取-判断-递增"必须原子 INCR 原子命令 + Lua 脚本 脚本内检查与更新不被其他命令插入;多条客户端命令仍可能竞争
极致性能:每个请求都要过限流 内存操作,亚毫秒级延迟 需要实测每次远程调用和热点 key 的开销
共享状态:所有节点看到同一个计数器 独立服务,集群可访问 请求汇聚到同一状态,复制故障与结果未知仍需处理
自动过期:时间窗口结束后计数器清零 Key 级别 TTL 无需额外清理逻辑

工程实践:基于 Redis + Lua 的固定窗口限流

为什么检查与更新必须原子执行?

不用 Lua 的伪代码:

count = redis.GET(key)          -- 步骤1:读取
if count < threshold:           -- 步骤2:判断
    redis.INCR(key)             -- 步骤3:递增
    return ALLOW
else:
    return REJECT

并发问题:两个节点同时读到 count=399(阈值 400),都判断"未超限",都执行 INCR。最终 count=401,但两个请求都通过了。高并发下,这种竞态条件被急剧放大,限流形同虚设。

Lua 脚本实现(原子操作)

-- KEYS[1]: 限流 key,如 "rate_limit:sms_api:1609459200"
-- ARGV[1]: 阈值
-- ARGV[2]: 窗口过期时间(秒)

local key = KEYS[1]
local threshold = tonumber(ARGV[1])
local expire_time = tonumber(ARGV[2])

local current = tonumber(redis.call('GET', key) or "0")

if current + 1 > threshold then
    return 0  -- 拒绝
else
    redis.call('INCR', key)
    if current == 0 then
        redis.call('EXPIRE', key, expire_time)
    end
    return 1  -- 放行
end

该脚本假定参数为正整数、key 只由本限流器维护,时间窗口后缀由统一口径计算。TTL 用于清理,并非决定后缀所指窗口的边界;保留时间应覆盖窗口和允许的请求迟到范围。

Key 设计规范

格式:rate_limit:{业务标识}:{维度}:{时间窗口}
示例:
  rate_limit:sms_api:global:1609459200       -- 全局短信 API 限流
  rate_limit:login:user:12345:1609459200     -- 用户维度登录限流
  rate_limit:order:tenant:abc:1609459200     -- 租户维度下单限流

工程实践:基于 Redis 的滑动窗口限流

当固定窗口的边界问题不可接受时,可以用 Redis Sorted Set 实现滑动窗口:

-- KEYS[1]: 限流 key
-- ARGV[1]: 阈值
-- ARGV[2]: 窗口大小(毫秒)
-- ARGV[3]: 当前时间戳(毫秒)
-- ARGV[4]: 唯一请求ID

local key = KEYS[1]
local threshold = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local request_id = ARGV[4]

-- 移除窗口外的过期记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)

if count < threshold then
    -- 添加当前请求,score 为时间戳
    redis.call('ZADD', key, now, request_id)
    redis.call('PEXPIRE', key, window)
    return 1  -- 放行
else
    return 0  -- 拒绝
end

滚动区间约定为 (now - window, now]。每次实际准入尝试必须使用唯一 member;重复 request_id 会更新旧记录而少计一次。该脚本不实现请求幂等,时间源回拨、脚本超时和重试需在协议中处理。

两种 Redis 方案的对比

维度 固定窗口(String + INCR) 滑动窗口(Sorted Set)
存储开销 O(1),一个 key 一个计数器 O(N),N 为窗口内请求数
时间复杂度 O(1) O(log N + M),M 为本次删除的过期记录数
精度 边界可能 2 倍峰值 精确
适用 大部分场景 阈值紧、精度要求高

固定窗口还是滚动窗口由契约决定,与“余量小于 20%”没有固定对应关系。Lua 的原子执行不保证故障切换后计数绝不丢失,也不会把外部 API 调用纳入同一事务。Redis 脚本语义

关于时钟同步

分布式系统中,各节点用本地时间计算 Redis key 中的时间窗口标识,时钟偏移可能导致不同节点在不同窗口中计数。严格做法是用 Redis 服务端时间 redis.call('TIME')。即使偏差较小,在窗口边界也可能把同一时间的请求分到不同窗口;时钟回拨和故障切换还需要考虑。

需要统一口径时,以同一决策节点的时间计算窗口;若允许客户端时间,必须明确偏差预算。单机经过时间使用单调时钟,日/月商业配额则按约定时区和日历边界计算。


五、多层限流:纵深防御架构

一个常见误区是试图在某一层解决所有限流问题。良好的限流架构应该是分层的——每一层保护不同的东西,承担不同的职责。

请求依次经过接入、网关、业务与依赖访问保护,各层职责不同

各层详细对比

层级 保护对象 限流维度 典型工具 算法
接入层 基础设施 IP、连接数 Nginx limit_req/limit_conn 漏桶
API 网关 服务处理能力 用户 ID、API Key、租户 Redis + Lua、Sentinel 令牌桶/滑动窗口
业务层 业务规则 业务实体(用户行为频率) Redis + 业务代码 固定窗口
数据层 存储和依赖 并发连接数 连接池、Hystrix、Resilience4j 信号量/熔断

各层工程实践

接入层:Nginx 配置示例

http {
    # IP 维度的请求速率限制
    limit_req_zone $binary_remote_addr zone=ip_rate:10m rate=100r/s;
    limit_req_zone $binary_remote_addr zone=login_rate:10m rate=5r/m;

    # IP 维度的并发连接数限制
    limit_conn_zone $binary_remote_addr zone=ip_conn:10m;

    server {
        # API 接口:每 IP 100r/s,突发 50
        location /api/ {
            limit_req zone=ip_rate burst=50 nodelay;
            limit_conn ip_conn 50;
            limit_req_status 429;
        }

         # 登录接口使用独立的更低速率区域
        location = /api/login {
            limit_req zone=login_rate burst=2;
            limit_conn ip_conn 10;
            limit_conn_status 429;
            limit_req_status 429;
        }
    }
}

前述 Nginx 配置省略监听和 upstream,只展示限流指令。反向代理环境需仅信任已知代理提供的真实 IP;NAT 用户共享 IP,IP 限流不能替代用户配额。limit_conn 在 HTTP/2、HTTP/3 中按并发请求计数,不等于单纯的 TCP 连接数。Nginx limit_conn

API 网关层:差异化限流

// 不同级别用户的限流配置
public class RateLimitConfig {
    // 免费用户:60 次/分钟
    // 付费用户:600 次/分钟
    // 企业用户:6000 次/分钟

    public int getThreshold(User user) {
        return switch (user.getTier()) {
            case FREE       -> 60;
            case PREMIUM    -> 600;
            case ENTERPRISE -> 6000;
        };
    }

    // 不同 API 端点的限流配置
    // 重查询接口:50 QPS
    // 轻量读接口:5000 QPS
    // 写操作接口:200 QPS

    public int getThreshold(String endpoint) {
        return switch (endpoint) {
            case "/api/report/generate" -> 50;    // 计算密集
            case "/api/user/info"       -> 5000;  // 轻量读
            case "/api/order/create"    -> 200;   // 写操作
            default                     -> 1000;
        };
    }
}

业务层:业务规则型限流

// 业务限流的阈值来自产品需求,不是压测
public class BusinessRateLimiter {

    // 防骚扰:每用户每分钟最多 5 条短信
    public boolean allowSendSms(long userId) {
        String key = "biz:sms:" + userId + ":" + currentMinute();
        return redisRateLimiter.tryAcquire(key, 5, 60);
    }

    // 反垃圾:新账号每个自然日最多发 10 条帖子
    public boolean allowPost(long userId, boolean isNewAccount) {
        if (!isNewAccount) return true;
        String key = "biz:post:new:" + userId + ":" + today();
        return redisRateLimiter.tryAcquire(key, 10, 86400);
    }

    // 运营策略:商家每天最多创建 100 个促销活动
    public boolean allowCreatePromotion(long merchantId) {
        String key = "biz:promo:" + merchantId + ":" + today();
        return redisRateLimiter.tryAcquire(key, 100, 86400);
    }
}

数据层:隐式限流

数据层的"限流"通常不以限流的名义出现,但本质上发挥着同样的作用:

  • 连接池:连接池满时新请求排队等待 → 并发度上限
  • 线程池隔离:为每个下游依赖分配独立线程池 → 故障隔离
  • 熔断器:错误率超阈值时停止调用 → 基于故障状态的熔断,区别于速率控制

每一层保护不同的东西。 接入层保护基础设施不被滥用流量冲垮;API 网关保护服务处理能力不被超载;业务层保护业务规则不被绕过;数据层保护最脆弱的存储和依赖。


六、限流的工程闭环

限流架构设计完了,还差两个关键环节:阈值从哪来?被拒绝的请求去哪了?这两个问题不解决,限流就是半成品。

6.1 阈值从哪来

所有限流工程中最难的问题不是技术实现,而是:阈值应该设多少?

四步确定阈值

步骤 方法 产出
1. 压测基线 逐步加压,观察 P99 延迟和错误率的拐点 系统实际容量边界
2. 安全系数 依据突发、扩容和故障接管留余量,不能固定套比例 留出余量应对突发波动
3. 持续监控 监控 P99、错误率、CPU、内存 发现容量变化及时调整
4. 渐进调整 从保守值开始,观察线上表现后逐步放宽 避免上线即翻车

自适应限流

更高级的形态是基于实时指标的自动限流。以 Sentinel 为例:

// 基于系统负载的自适应限流
SystemRule rule = new SystemRule();
rule.setHighestCpuUsage(0.8);    // CPU > 80% 时触发限流
rule.setHighestSystemLoad(2.5);   // System Load > 2.5 时触发限流
rule.setAvgRt(200);
SystemRuleManager.loadRules(java.util.List.of(rule));               // 平均 RT > 200ms 时触发限流

// 仍需按机器规格和入口类型校准阈值
// 风险:正常流量波动可能触发误限,需仔细调试灵敏度

阈值是业务决策

限流阈值不是纯技术参数,而是一个业务决策。

它编码的是"我们愿意承受多大负载,以及拒绝超额流量的业务成本是什么"。

  • 面向消费者的核心交易链路:拒绝会影响转化,但超载也会损害已接受请求 → 采用优先级、容量预算和受控排队
  • 内部数据分析任务:晚执行几分钟无损失 → 阈值可严
  • 计算密集的报表接口:单个请求消耗大量资源 → 阈值必须严

阈值设定必须综合技术容量和业务容忍度,需要工程团队和产品团队协同决策。

6.2 被拒绝的请求去哪了

大多数限流讨论都集中在"如何拒绝",很少有人思考"拒绝之后怎么办"。而在真实业务中,后者往往更重要。

策略 做法 适用场景 风险
直接拒绝 返回 429 + Retry-After 开放 API、程序化调用方 用户体验差
排队等待 写入 MQ,消费者限速消费 异步操作(短信、邮件、报表) 队列积压导致延迟不可控
降级响应 返回缓存/兜底数据 推荐、搜索、详情页非核心模块 数据时效性降低
引流分担 导向备用路径(CDN/只读副本) 读多写少的场景 需要备用链路的维护成本

关键原则:限流策略和拒绝策略必须配套设计。

短信是否重试取决于用途、有效期和重复发送成本;排队需有上限及过期策略。秒杀限流应提示繁忙或排队,不能伪报售罄。商品详情可在业务允许的新鲜度范围内使用缓存,价格与库存操作仍需校验。

只设计了限流而没考虑拒绝后的处理,就像只安装了闸门却没修泄洪渠——水是拦住了,但迟早会溃坝。


七、金融场景:限流 ≠ 正确性

在金融、支付等对正确性有极高要求的领域,限流只是防御体系的一环。很多团队犯的错误是:觉得"加了限流就安全了"。现实是,限流解决的是流量问题,不是正确性问题。

三层防护:限流 + 并发控制 + 幂等

考虑一个支付场景:用户点了两次"付款"按钮。

防护层 解决的问题 如果只有这一层
限流 防止支付接口被高频调用打垮 两次点击间隔 100ms,速率限制 10/s → 都放行,扣两次款
并发控制 同一笔订单同一时刻只允许一个支付请求在处理 第二次被排队/拒绝,但如果第一次失败后重试呢?
幂等 同一笔支付操作无论执行几次,结果只生效一次 无论重试多少次、并发多少个,最终只扣一次款

三层必须配合使用:

  • 限流是外围护栏——挡住异常流量,保护系统不被打垮
  • 并发控制是执行调度——同一资源同一时刻只有一个操作在执行
  • 幂等是正确性保障——即使前两层被突破,最终结果仍然正确

支付保护要覆盖进程退出和结果未知

仅在执行支付后保存幂等结果是不够的:扣款成功、进程退出、结果尚未落库时,再次请求仍可能扣款。分布式锁也可能因租约到期或网络分区失去保护。

一个可执行的业务协议应包含:

  1. 在本地事务中创建持久化支付意图,以订单及业务操作建立唯一约束,保存金额、币种和请求指纹
  2. 同一幂等键但参数不同的请求明确拒绝;不同键也不能绕过订单已支付状态
  3. 调用支付渠道时使用渠道支持的稳定幂等标识;渠道不支持时,需要先设计查询、对账和人工处理路径
  4. 超时保存为结果未知,按渠道协议查询或使用同一标识重试,不能直接创建新扣款
  5. 通过状态条件更新记录结果,回调与查询重复到达时不重复发放权益
  6. 积压、未知状态和账务差异进入对账,保留可追溯记录

并发控制可以减少冲突,正确性最终依赖持久化约束、状态机与支付渠道契约。限流保护容量,不替代这些保证。


八、总结:限流是一种系统思维

限流从表面看是算法选择题,但真正落地到生产环境时,它是一个系统设计问题:

维度 核心问题
控制对象 你在控制什么?速率、并发、配额还是节奏?控制模型选错,算法再精妙也解决不了问题
容量 系统到底能承受多少?需要压测和监控,不是拍脑袋
优先级 必须拒绝时,拒绝谁?VIP vs 普通、核心 vs 边缘、写 vs 读
失败模式 限流触发后怎么办?报错、排队、降级还是引流
权衡 平滑性 vs 响应性、精确性 vs 性能、简单性 vs 灵活性

最好的限流系统是你感觉不到它存在的系统。流量平稳时安静旁观,突增时默默吸收合理突发,真正超限时优雅拒绝——确保已接受的请求仍能正常处理。它不是一堵墙,而是一个阀门:精确控制流量进出,让系统在极端压力下保持可控、可预测、可依赖。

限流的本质,是对系统能力边界的敬畏,以及在边界之内追求最大价值的工程智慧。

评论