高并发系统设计-限流

一、什么是限流

限流(Rate Limiting)是指限制单位时间内允许进入系统的请求数量

简单来说,就是:

控制流量进入系统的速度,而不是等系统崩溃之后再处理。

例如:

某接口限制:1000 Request / Second

如果一秒钟来了:1500 Request

那么:1000正常处理,500拒绝访问

通常返回:HTTP 429 Too Many Requests

限流的本质就是:

宁可拒绝一部分请求,也不要让整个系统崩溃。

二、为什么需要限流

因为系统资源是有限的。

例如:

服务器配置:

CPU:8 Core
Memory:16GB
MySQL连接池:100
Redis连接池:200

这些资源都存在上限。

假设数据库最大只能处理: 5000 QPS

如果突然来了:30000 QPS

没有限流:30000全部访问MySQL->连接池耗尽->CPU100%->慢SQL->超时->…->系统崩溃

有了限流:30000->Gateway->允许5000->25000直接拒绝->系统稳定

虽然部分用户会收到"系统繁忙"的提示,但系统整体仍然保持可用。

这正是限流存在的意义

三、典型应用场景

限流几乎存在于所有互联网系统中。

1、登录接口

例如:POST /login

限制:每个IP 5次/分钟

防止:

  • 暴力破解密码
  • 恶意攻击
  • 短时间大量登录

2、短信验证码

3、支付接口

4、秒杀系统

5、开放API

四、限流的位置

限流通常不是只有一层。

一般有四层。

第一层:CDN

例如: Cloudflare->限制恶意流量

主要抵御:

  • CC攻击
  • DDoS
  • Bot

第二层:Nginx / Gateway

例如: Client->Gateway->Application

nginx示例

limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;

server {
    location /api/ {
        limit_req zone=api burst=20 nodelay;
    }
}

统一限制:

  • IP
  • Token
  • 用户
  • API

这是企业最常见的位置。

第三层:应用层

程序内部实现 如:

class RateLimitMiddleware
{
    public function process($request, $handler)
    {
        $key = "rate:" . $request->getUri()->getPath();

        $count = Redis::incr($key);

        if ($count == 1) {
            Redis::expire($key, 1);
        }

        if ($count > 100) {
            throw new HttpException(429, "Too Many Requests");
        }

        return $handler->handle($request);
    }
}

常用于:

  • 登录
  • 支付
  • 下单 等业务接口。

第四层:数据库保护

例如:

数据库连接数:100

如果:

连接100->拒绝新连接

虽然这不是严格意义上的限流,但属于最后一道保护措施。

五、限流维度

通常不会只有一种限流方式。

常见维度如下:

限流方式示例
IP限流一个IP一分钟100次
用户限流每个用户100次/分钟
Token限流每个AccessToken限制
接口限流/login 每秒100次
应用限流AppKey限制
地区限流海外地区单独限制
全局限流整个平台10000 QPS

六、限流算法概览

6.1 四种主流限流算法

目前最常见的四种算法如下:

算法是否推荐特点
固定窗口★★☆☆☆实现简单,存在流量突刺
滑动窗口★★★★☆精确统计,请求稍复杂
漏桶算法★★★★☆输出稳定,适合流量整形
令牌桶算法★★★★★企业使用最广,兼顾稳定与突发
其中:
  • 固定窗口:实现最简单,适合低并发系统。
  • 滑动窗口:统计更准确,适用于业务限流。
  • 漏桶算法:控制输出速率,适合网关和流量整形。
  • 令牌桶算法:支持突发流量,是现代网关和框架最常用的方案。

6.2 固定窗口(Fixed Window)

原理

时间:10:00:00 ~ 10:00:01
计数器:count++

超过限制:

count > limit → 拒绝

问题(非常重要)

窗口边界会产生突刺:

10:00:00.999 → 100次
10:00:01.000 → 100次

1ms 内 200 请求

实现

$key = "rate:login:ip:1.1.1.1";

$count = $redis->incr($key);

if ($count == 1) {
    $redis->expire($key, 1);
}

if ($count > 100) {
    throw new Exception("Rate Limited");
}

6.3 滑动窗口(Sliding Window)

原理

不再按“整秒”,而是统计:

最近 60 秒的请求数量

实现(Redis ZSet)

score = timestamp
value = request_id

php实现

$key = "rate:login:user:1";

$now = microtime(true);

$redis->zAdd($key, $now, uniqid());

$redis->zRemRangeByScore($key, 0, $now - 60);

$count = $redis->zCard($key);

if ($count > 100) {
    throw new Exception("Rate Limited");
}

优点

  • 精确控制
  • 无突刺问题

缺点

  • Redis开销较大
  • 写入成本高

6.4 漏桶算法(Leaky Bucket)

原理

请求进入桶
桶以固定速度流出

特点

  • 输出稳定
  • 不允许突发流量

适用场景

  • 消息队列消费
  • 流量整形
  • 下游保护

模拟实现(PHP)

class LeakyBucket
{
    private int $capacity = 100;
    private int $water = 0;
    private int $rate = 10; // 每秒流出

    private int $lastTime;

    public function __construct()
    {
        $this->lastTime = time();
    }

    public function allow(): bool
    {
        $now = time();
        $diff = $now - $this->lastTime;

        $this->water = max(0, $this->water - $diff * $this->rate);
        $this->lastTime = $now;

        if ($this->water < $this->capacity) {
            $this->water++;
            return true;
        }

        return false;
    }
}

6.5 令牌桶算法(Token Bucket) 主流选择

原理

系统持续生成 token:每秒生成 100 个 token

请求:拿 token 没有 token:拒绝

优势

  • 支持突发流量
  • 平滑控制
  • 企业标准方案

Redis实现(Lua)

local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local data = redis.call("HMGET", key, "tokens", "time")
local tokens = tonumber(data[1])
local last = tonumber(data[2])

if tokens == nil then
    tokens = capacity
    last = now
end

local delta = math.max(0, now - last)
tokens = math.min(capacity, tokens + delta * rate)

if tokens < 1 then
    return 0
else
    tokens = tokens - 1
    redis.call("HMSET", key, "tokens", tokens, "time", now)
    return 1
end

PHP调用

$result = $redis->eval($lua, [
    "rate:api:user:1",
    100,
    200,
    time()
], 1);

if ($result == 0) {
    throw new Exception("Rate Limited");
}