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

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

每个系统都有容量边界。缓存解决读的问题,降级解决非核心链路的问题,但当写操作高并发、稀缺资源被争抢、昂贵查询集中涌入时——只有限流能保护你。

手段 解决的问题 核心机制 局限性
缓存 提速 将高频数据放入更快的存储层 对写操作无能为力
降级 止损 放弃非核心功能保核心链路 前提是有东西可降,秒杀场景无法降级
限流 控流 主动丢弃/延迟超量请求 需要准确的容量评估,否则误杀或漏放

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


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

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

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

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

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

典型场景: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

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

下面给出五种经典限流算法的 Java 实现——纯 JDK、per-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;
        this.windowNanos = windowMillis * 1_000_000L;
    }

    @Override
    public boolean allow(String key) {
        long now = System.nanoTime();
        Window w = map.computeIfAbsent(key, _ -> new Window(now));
        synchronized (w) {
            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;
        this.windowNanos = windowMillis * 1_000_000L;
    }

    @Override
    public boolean allow(String key) {
        long now = System.nanoTime();
        Window w = map.computeIfAbsent(key, _ -> new Window(now));
        synchronized (w) {
            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 < 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) {
        this.capacity = burst;
        this.refillPerNano = permitsPerSecond / 1_000_000_000.0;
        this.warmUp = warmUp;
    }

    @Override
    public boolean allow(String key) {
        long now = System.nanoTime();
        Bucket b = map.computeIfAbsent(key, _ -> {
            Bucket bucket = new Bucket();
            bucket.tokens = warmUp ? 0 : capacity;  // 冷启动 vs 满桶启动
            bucket.lastRefillTime = now;
            return bucket;
        });
        synchronized (b) {
            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 参数控制新 key 是从满桶开始(适合 API 限流)还是从空桶开始(适合缓存预热)。

适用场景

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

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

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

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

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

// 场景:数据库连接池、缓存冷启动
// 特点:系统刚启动时不会全速放量,给下游一个"热身"时间
// 预热期内速率从低到高线性增长,避免冷系统被瞬时流量打垮

SmoothBursty vs SmoothWarmingUp 的选择

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

关键注意:Guava RateLimiter 是单机限流。它只能控制当前 JVM 进程的流量,在分布式环境下需要配合 Redis 方案使用。


GCRA(Generic Cell Rate Algorithm)

核心原理

GCRA 是令牌桶的数学等价形式,但实现更精简。它不维护"当前令牌数",而是维护一个"理论到达时间"(TAT,Theoretical Arrival Time)——下一个请求最早应该在什么时候到达。

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

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

判断逻辑:
  TAT = max(TAT, now) + emission_interval
  如果 TAT - now > burst_tolerance → 拒绝(超前太多,突发已耗尽)
  否则 → 放行,更新 TAT

Java 实现

与前面的算法不同,GCRA 使用 AtomicLong + CAS 实现无锁设计,天然适合高并发场景:

class GcraRateLimiter implements RateLimiter {

    private final long T;    // 请求间隔 (ns)
    private final long tau;  // 突发容忍窗口 (ns) = burstPermits × T

    // 无锁设计:CAS 自旋
    private final ConcurrentHashMap<String, AtomicLong> tatByKey = new ConcurrentHashMap<>();

    GcraRateLimiter(double permitsPerSecond, int burstPermits) {
        this.T = (long) (TimeUnit.SECONDS.toNanos(1) / permitsPerSecond);
        this.tau = burstPermits * T;
    }

    @Override
    public boolean allow(String key) {
        long now = System.nanoTime();
        AtomicLong tatRef = tatByKey.computeIfAbsent(key, _ -> new AtomicLong(now));
        while (true) {
            long tat = tatRef.get();
            if (now < tat - tau) {
                return false;  // 请求来得太早,拒绝
            }
            long newTat = Math.max(now, tat) + T;
            if (tatRef.compareAndSet(tat, newTat)) {
                return true;
            }
            Thread.onSpinWait();  // CAS 失败,降低自旋 CPU 开销
        }
    }
}

判断逻辑先于更新执行:now < tat - tau 直接拒绝,避免了"先更新 TAT 再判断是否超限"的回滚问题。Thread.onSpinWait()(Java 9+)在 CAS 失败时降低 CPU 空转开销。

为什么 GCRA 值得关注

优势 说明
状态极简 只需存储一个值(TAT),对比令牌桶需要存 tokens + last_refill
天然适合分布式 一个 Redis key 存一个时间戳,原子 CAS 即可更新
数学精确 与令牌桶行为完全等价,但无浮点累积误差

工程实践:GCRA 广泛用于网络设备的 ATM 流量控制(这也是它名字中"Cell Rate"的来源),在互联网领域被 Cloudflare、Stripe 等公司采用作为 API 限流的核心算法。


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

漏桶算法(Leaky Bucket)

核心原理

漏桶的逻辑可以用一句话概括:无论流入多快,流出永远恒定。

请求流入 → [  桶(有容量上限)  ] → 恒定速率流出 → 下游处理
                    ↓
              桶满则丢弃
  • 请求以任意速率流入桶中
  • 桶底以固定速率流出(处理请求)
  • 桶有容量上限,溢出的请求被直接丢弃

核心参数

参数 含义 设计考量
流出速率 下游能承受的恒定处理能力 取决于下游系统的稳态吞吐上限
桶容量 允许暂存的最大请求数 过大导致延迟积累,过小导致突发流量全被丢弃

Java 实现

下面的实现用"下一次允许通过的时间"来建模漏桶的恒定流出:每放行一个请求,nextAllowedTime 就往后推一个 intervalNanos。如果请求到达时已经超前太多(超出 burst 容忍量),直接拒绝。

class LeakyBucketRateLimiter implements RateLimiter {

    private final long intervalNanos;  // 每个请求的理论间隔
    private final long burstNanos;     // 突发容忍量(纳秒)

    private static class Bucket {
        long nextAllowedTime;
        Bucket(long t) { this.nextAllowedTime = t; }
    }

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

    LeakyBucketRateLimiter(double permitsPerSecond, int burstPermits) {
        this.intervalNanos = (long) (TimeUnit.SECONDS.toNanos(1) / permitsPerSecond);
        this.burstNanos = burstPermits * intervalNanos;
    }

    @Override
    public boolean allow(String key) {
        long now = System.nanoTime();
        Bucket b = map.computeIfAbsent(key, _ -> new Bucket(now));
        synchronized (b) {
            long allowAt = b.nextAllowedTime - burstNanos;
            if (now < allowAt) {
                return false;  // 桶满了,拒绝
            }
            b.nextAllowedTime = Math.max(now, b.nextAllowedTime) + intervalNanos;
            return true;
        }
    }
}

burstPermits = 0 时,漏桶不允许任何突发,请求严格按 intervalNanos 的间隔逐个放行——这正是"恒定速率流出"的语义。

与令牌桶的本质区别

令牌桶控制的是"允许进入的速率"(入口),漏桶控制的是"实际处理的速率"(出口)。令牌桶在空闲后可以突发放行一批请求,漏桶永远不会——即使桶里积攒了很多请求,也只能按固定速率一个个出去。

适用场景

  • 对接物理设备或硬件接口(严格不允许任何突发)
  • 需要绝对平滑的输出流量(如音视频流的恒定码率传输)
  • 流量整形(traffic shaping)场景

不适用场景

  • 互联网业务的 API 限流(真实流量天然是突发的,漏桶的死板会浪费系统空闲容量)
  • 需要快速响应突发请求的场景

工程实践:Nginx 的 limit_req 就是漏桶实现

# 定义限流区域:10MB 共享内存,每个 IP 每秒 10 个请求
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
    location /api/ {
        # burst=20:桶容量为 20,超出的排队
        # nodelay:排队请求不延迟,立即处理(占用 burst 配额)
        limit_req zone=api burst=20 nodelay;

        # 超限返回 429 而非默认的 503
        limit_req_status 429;
    }
}

这里有个常见误区:burst=20 nodelay 不是"允许突发 20 个请求"那么简单。nodelay 的含义是突发请求立即转发(不排队等待),但每个突发请求会"占用"一个 burst 槽位,槽位按 rate 的速率恢复。实际效果是:瞬间可以通过 30 个请求(rate + burst),但之后必须等槽位恢复。


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 限制并发连接数
  • Hystrix/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)

工程要点

  • 配额通常需要返回剩余量(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)
  → 系统实际吞吐远低于理论上限

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

标准答案:将限流状态提升到共享的集中存储中。

分布式限流的核心原则

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

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

Redis 分布式限流:为什么是标准答案

Redis 之所以成为分布式限流的事实标准,是因为它的特性精确匹配了限流的每一个核心需求:

限流需求 Redis 特性 为什么匹配
原子性:"读取-判断-递增"必须原子 INCR 原子命令 + Lua 脚本 单线程模型,天然无并发冲突
极致性能:每个请求都要过限流 内存操作,亚毫秒级延迟 不成为业务瓶颈
共享状态:所有节点看到同一个计数器 独立服务,集群可访问 分布式协调问题消失
自动过期:时间窗口结束后计数器清零 Key 级别 TTL 无需额外清理逻辑

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

为什么必须用 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 设计规范

格式: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

两种 Redis 方案的对比

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

工程建议:优先用固定窗口方案。只有当阈值非常接近系统极限(余量 < 20%)时,才需要滑动窗口的精度。

关于时钟同步

分布式系统中,各节点用本地时间计算 Redis key 中的时间窗口标识,时钟偏移可能导致不同节点在不同窗口中计数。严格做法是用 Redis 服务端时间 redis.call('TIME')。但现代服务器通过 NTP 同步后的时钟偏差通常在毫秒级,对秒级窗口几乎无影响。

工程判断:对于秒级窗口,使用本地时间戳即可。对于百毫秒级窗口或对精度有极端要求的场景,使用 Redis 服务端时间。


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

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

                     请求流入
                        ↓
┌──────────────────────────────────────────┐
│  第一层:接入层(Nginx / CDN)            │  ← 挡住恶意流量和 DDoS
│  基于 IP 的连接数和请求速率限制            │
└──────────────────────────────────────────┘
                        ↓
┌──────────────────────────────────────────┐
│  第二层:API 网关(Gateway)              │  ← 业务感知型限流
│  基于用户/租户/API 维度的差异化限流        │
└──────────────────────────────────────────┘
                        ↓
┌──────────────────────────────────────────┐
│  第三层:业务层                           │  ← 业务规则型限流
│  业务语义的频率控制(发帖/下单/发短信)     │
└──────────────────────────────────────────┘
                        ↓
┌──────────────────────────────────────────┐
│  第四层:数据层                           │  ← 最后一道防线
│  连接池 / 线程池隔离 / 熔断器             │
└──────────────────────────────────────────┘

各层详细对比

层级 保护对象 限流维度 典型工具 算法
接入层 基础设施 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;

    # 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=ip_rate burst=5;
            limit_req_status 429;
        }
    }
}

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);
    }

    // 反垃圾:新账号 24 小时内最多发 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. 安全系数 阈值 = 容量边界 × 70%~80% 留出余量应对突发波动
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);               // 平均 RT > 200ms 时触发限流

// 优点:省去人为猜测阈值
// 风险:正常流量波动可能触发误限,需仔细调试灵敏度

阈值是业务决策

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

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

  • 面向消费者的核心交易链路:拒绝一个请求 = 损失一笔订单 → 阈值宜宽
  • 内部数据分析任务:晚执行几分钟无损失 → 阈值可严
  • 计算密集的报表接口:单个请求消耗大量资源 → 阈值必须严

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

6.2 被拒绝的请求去哪了

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

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

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

回到短信发送事故:被限流的短信不能直接丢弃,必须进入重试队列。秒杀请求被限流?直接告知"已售罄"比让用户苦等体验更好。商品详情页被限流?返回缓存数据即可,用户感知的是"数据没那么新"而不是"服务挂了"。

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


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

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

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

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

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

三层必须配合使用:

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

伪代码:PaymentProtection 组合示例

class PaymentProtection:
    def __init__(self):
        self.rate_limiter = TokenBucket(rate=100, capacity=200)    # 限流
        self.locks = DistributedLockManager()                       # 并发控制
        self.idempotency = IdempotencyStore()                       # 幂等

    def process_payment(self, order_id, idempotency_key, amount):
        # 第一层:限流 —— 保护系统不被打垮
        if not self.rate_limiter.allow():
            return Error("RATE_LIMITED", "系统繁忙,请稍后重试")

        # 第二层:幂等检查 —— 如果这个操作已经成功过,直接返回之前的结果
        existing = self.idempotency.get(idempotency_key)
        if existing:
            return existing  # 重复请求,返回之前的结果

        # 第三层:并发控制 —— 同一订单同一时刻只处理一个支付请求
        lock = self.locks.acquire(f"payment:{order_id}", timeout=10)
        if not lock:
            return Error("CONCURRENT", "订单正在处理中")

        try:
            # 再次检查幂等(拿到锁之后的 double-check)
            existing = self.idempotency.get(idempotency_key)
            if existing:
                return existing

            # 执行实际支付
            result = do_payment(order_id, amount)

            # 记录幂等结果
            self.idempotency.store(idempotency_key, result)
            return result
        finally:
            lock.release()

核心认知:限流是流量层面的保护,幂等才是业务正确性的最后防线。在金融场景中,这三层缺一不可。


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

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

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

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

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

加载导航中...

评论