PHP如何实现基于令牌桶算法的精准接口限流系统架构

代码的艺术与守护:如何用 PHP 搭建一个“任性”的接口限流系统

各位同学,大家好!

欢迎来到今天的讲座,主题是——《如何防止你的 API 服务器在双十一当晚变成一堆废铁,以及如何在别人疯狂刷接口时优雅地喝一杯咖啡》

我是你们的老朋友,一名在这个代码江湖摸爬滚打了十几年的资深架构师。今天我们不聊那些虚头巴脑的架构图,也不谈什么高深莫测的设计模式,我们就来聊聊一个在分布式系统里能救命,在单体系统里能省钱的“守护神”——令牌桶算法

很多刚入行的 PHP 开发者看到“限流”这两个字,脑子里蹦出来的第一个词可能是:if ($user->count > 10) return false;

朋友,醒醒。如果你的系统部署了 3 台服务器,而且还在用 Nginx 做负载均衡,那么这三个 if 条件就像是三个聋子的对话——他们谁也听不见谁。一个用户在服务器 A 拿到了令牌,服务器 B 和 C 还没反应过来,用户紧接着在 B 和 C 上又发了三个请求。结果?你的数据库在午夜两点因为连接数爆满而挂了,老板在凌晨三点把你叫到办公室,问你为什么 App 提示“服务繁忙”。

别慌,今天我就带大家用 PHP,通过 Redis + Lua 脚本,搭建一个精准、高效、分布式的令牌桶限流系统。哪怕全世界都在给你发请求,你的服务器也能稳如老狗。


第一部分:为什么我们需要“门神”?(动机篇)

首先,我们要搞清楚为什么要限流。

想象一下,你开了一家大排档。生意好的时候,门口排队的人能从门口排到两公里外。这时候,如果你的厨房(服务器)只有一张桌子(CPU),而服务员(用户请求)像疯了一样把菜盘(数据包)砸在桌子上。很快,厨房就炸了,做出来的菜全是生的,甚至厨具都碎了。

这就是过载。限流,本质上就是“劝退”或者“排队”。它不是要拒绝所有的请求,而是拒绝那些恶意的、过多的、不配的请求。

而令牌桶算法,是目前业界最流行、最优雅的一种限流方式。它的核心思想简单得令人发指:“只有桶里有硬币,你才能买饮料;桶里没钱,你就只能在旁边等着。”


第二部分:令牌桶的“浪漫”数学(原理篇)

在写代码之前,我们必须理解这个算法的数学之美。

1. 桶与令牌

想象一个桶,桶有一个固定的容量。这个容量决定了系统在正常情况下,能承受多大的突发流量。比如,桶容量是 100,意味着最多允许你在 100 毫秒内连续发起 100 个请求,不用等令牌慢慢生成。

桶还有一个机制,就是匀速生成令牌。比如每秒生成 10 个令牌(QPS = 10)。

2. 取令牌逻辑

当一个请求进来时,它会做两件事:

  1. 看桶里有多少令牌?
  2. 如果够用,拿走一个,请求通过。
  3. 如果不够用,拒绝请求,返回 429 (Too Many Requests)。

关键点来了:
如果桶是满的,多余的令牌会怎么样?——销毁。它们不会累积到明天。这是为了防止“历史积压”导致瞬间流量爆炸。桶永远保持“新鲜”。


第三部分:架构设计的“灵魂”拷问(架构篇)

在 PHP 世界里,我们面临一个尴尬的局面:PHP 本身是无状态的,进程重启了,内存里的数据就没了。

如果你想在单机 PHP 里用内存变量(比如 $bucket = 50)来存令牌,一旦你开启了 PHP-FPM 的进程池(比如有 10 个进程),每个进程都有自己的桶。那么,限流就会失效。因为用户可以随机连到任意一个进程,绕过限流。

所以,我们的架构必须是分布式的。我们必须引入一个全局的、共享的存储介质。在这个领域,Redis 就是那个绝对的王者。它既是数据库,又是缓存,还是我们限流算法的“中央银行”。

架构图解(脑补版):
用户请求 -> Nginx -> PHP-FPM -> 限流中间件 -> 业务逻辑 -> Redis (原子操作)

这里的核心难点在于并发。如果 1000 个请求同时到达,它们都会去 Redis 里读“当前令牌数”,然后各自算出一个数,写回 Redis。这就像 1000 个人去银行取钱,都以为柜台里还有 100 块钱,结果取出来 1 块,然后大家再写回 99 块……数据早就乱了。

为了解决这个问题,我们必须祭出大杀器——Lua 脚本。在 Redis 中执行 Lua 脚本是原子的,意味着这 1000 个人必须排队,一次只能有一个人操作 Redis。


第四部分:实战代码(核心篇)

好了,口水擦干,我们开始撸代码。

我们将构建一个 RateLimiter 类,它负责与 Redis 交互,并封装核心算法。

1. 定义配置与接口

首先,我们需要定义一个接口,这是面向对象编程的基本素养。

<?php

namespace AppService;

interface RateLimiterInterface
{
    /**
     * 检查是否允许请求通过
     * 
     * @param string $key 限流的 Key,可以是 IP,可以是 User ID
     * @param int    $limit 限制的请求数
     * @param int    $period 时间窗口(秒)
     * @return bool
     */
    public function allow(string $key, int $limit, int $period): bool;

    /**
     * 获取限流剩余次数
     */
    public function remaining(string $key, int $limit, int $period): int;
}

2. 核心实现:Redis + Lua 脚本

这是最关键的部分。我们不能在 PHP 里写复杂的逻辑,然后调用 Redis 命令,因为那样会有网络延迟和并发问题。我们要把逻辑封装在 Lua 脚本里,一次性发给 Redis 执行。

让我们来写这个 Lua 脚本。把它保存为一个字符串变量,或者 .lua 文件。

-- rate_limit.lua
-- KEYS[1]: 限流器的 key (例如: ip:127.0.0.1)
-- ARGV[1]: 时间窗口内的最大请求数 (limit)
-- ARGV[2]: 时间窗口的大小 (period, 秒)
-- ARGV[3]: 当前时间戳 (用于计算生成令牌)
-- ARGV[4]: 桶的容量 (capacity, 突发流量上限)

local key = KEYS[1]
local limit = tonumber(ARGV[1])
local period = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local capacity = tonumber(ARGV[4])

-- 1. 获取当前剩余令牌数和上次填充时间
-- 如果 key 不存在,则返回 0 和 0
local current = tonumber(redis.call('get', key)) or 0
local last_fill = tonumber(redis.call('hget', key, 'last_fill')) or now

-- 2. 计算过去的时间里,应该生成多少个新令牌
-- 简单的数学题:(现在时间 - 上次时间) / 窗口时间 * 速率
-- 这里的速率就是 limit / period
local delta = math.max(0, now - last_fill)
local allowed_fill = math.floor(delta / period) * limit

-- 3. 更新桶里的令牌数
-- 新的令牌数 = 旧的令牌数 + 生成的令牌数
-- 但是,桶不能溢出!最多只能装满 capacity 个
local new_token = math.min(current + allowed_fill, capacity)

-- 4. 尝试消耗一个令牌
if new_token > 0 then
    -- 有令牌,消耗一个,写回 Redis
    redis.call('set', key, new_token)
    redis.call('hset', key, 'last_fill', now)

    -- 计算剩余令牌数,用于后续返回
    local remaining = new_token - 1
    if remaining < 0 then remaining = 0 end

    -- 返回 JSON 字符串: {"allowed": true, "remaining": 10, "reset_time": ...}
    -- reset_time 简单计算一下:如果当前令牌少于 limit,说明下一秒就会恢复到 limit
    local reset_time = 0
    if new_token <= limit then
        reset_time = now + period
    end

    return cjson.encode({allowed = true, remaining = remaining, reset_time = reset_time})
else
    -- 没令牌了,拒绝!
    return cjson.encode({allowed = false, remaining = 0})
end

为什么要这么写?
这段脚本不仅处理了“令牌生成”,还处理了“桶容量限制”。它完美实现了令牌桶的核心逻辑:平滑突发流量。如果你在桶空的时候发请求,你会被拒绝;如果你在桶满的时候发请求,你可以瞬间发完所有请求。

3. PHP 封装类

接下来,我们把 Lua 脚本塞进 PHP 类里。

<?php

namespace AppService;

use PredisClient as RedisClient;
use IlluminateSupportFacadesLog;

class RedisRateLimiter implements RateLimiterInterface
{
    protected RedisClient $redis;
    protected string $script;

    public function __construct(RedisClient $redis)
    {
        $this->redis = $redis;

        // 读取上面的 Lua 脚本内容
        // 在实际生产中,建议把这个脚本放到 Redis 中用 EVALSHA 缓存,减少网络传输
        $this->script = <<<LUA
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local period = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local capacity = tonumber(ARGV[4])

local current = tonumber(redis.call('get', key)) or 0
local last_fill = tonumber(redis.call('hget', key, 'last_fill')) or now

local delta = math.max(0, now - last_fill)
local allowed_fill = math.floor(delta / period) * limit
local new_token = math.min(current + allowed_fill, capacity)

if new_token > 0 then
    redis.call('set', key, new_token)
    redis.call('hset', key, 'last_fill', now)
    local remaining = new_token - 1
    if remaining < 0 then remaining = 0 end

    local reset_time = 0
    if new_token <= limit then
        reset_time = now + period
    end

    return cjson.encode({allowed = true, remaining = remaining, reset_time = reset_time})
else
    return cjson.encode({allowed = false, remaining = 0})
end
LUA;
    }

    public function allow(string $key, int $limit, int $period): bool
    {
        $now = time();
        // 默认桶容量等于限制,防止突发过大,或者你可以设置一个更大的值,比如 limit * 5
        $capacity = $limit; 

        // Lua 脚本接收 3 个参数:limit, period, now, capacity
        // KEYS 只有 1 个:$key
        $result = $this->redis->eval($this->script, 1, $key, $limit, $period, $now, $capacity);

        $data = json_decode($result, true);

        Log::info("Rate Limit Check", [
            'key' => $key,
            'result' => $data
        ]);

        return $data['allowed'];
    }

    public function remaining(string $key, int $limit, int $period): int
    {
        // 复用上面的逻辑,或者你可以优化一下,直接读取剩余数(但这需要 Lua 脚本稍微改改,这里简单起见复用)
        // 实际上,为了性能,我们可以在允许通过的接口里,直接把 remaining 传出来给前端展示
        return 0; // 简化演示
    }
}

第五部分:如何优雅地“劝退”用户?(中间件篇)

有了限流器,我们怎么在 Laravel 或 Symfony 里用到它呢?答案就是:中间件

中间件就像是流水线上的质检员。每一个数据包过来,先经过质检员,合格了才能流向生产线(Controller)。

1. 创建中间件

<?php

namespace AppHttpMiddleware;

use Closure;
use IlluminateHttpRequest;
use AppServiceRedisRateLimiter;

class ThrottleRequests
{
    protected $limiter;

    public function __construct(RedisRateLimiter $limiter)
    {
        $this->limiter = $limiter;
    }

    public function handle(Request $request, Closure $next)
    {
        // 获取限流 Key:优先使用 IP,如果没有 IP 则用 Token 或者随机字符串
        // 这里为了演示,直接用 IP
        $key = 'ip:' . $request->ip();

        // 配置参数:每秒 5 个请求 (QPS=5)
        $limit = 5; 
        $period = 1;

        if (!$this->limiter->allow($key, $limit, $period)) {
            // 限流了!我们要怎么处理?
            // 最好的方式是返回 429 状态码,并带上 Retry-After 响应头,告诉用户什么时候再试
            return response()->json([
                'error' => 'Too Many Requests',
                'message' => '您访问的频率太快了,请稍后再试。',
                'retry_after' => time() + 1
            ], 429);
        }

        // 通过了!继续执行
        return $next($request);
    }
}

2. 注册中间件

bootstrap/app.php 或者 app/Http/Kernel.php 里,把这个中间件挂载到你的路由上。

// Laravel 示例
Route::middleware([ThrottleRequests::class])->group(function () {
    Route::get('/api/user', [UserController::class, 'profile']);
    Route::post('/api/login', [AuthController::class, 'login']);
});

第六部分:进阶技巧(高级篇)

作为一个“资深专家”,仅仅实现一个能跑的限流器是远远不够的。我们要考虑更复杂的情况。

1. 动态限流配置

很多运营同学会问:“能不能根据用户的等级调整限流?”
当然可以。你的 key 不应该只是 ip:xxx,可以是 vip:1:ip:xxx 或者 vip:1:uid:xxx

// 在中间件里加个判断
$level = $request->user()?->level ?? 1;
$key = "vip:{$level}:ip:" . $request->ip();

// 或者针对不同接口设置不同限流
if ($request->routeIs('api.admin.*')) {
    $limit = 100; // 管理员接口限流宽松
    $period = 1;
} else {
    $limit = 5; // 普通接口限流严格
    $period = 1;
}

2. 黑名单机制

如果检测到有人在疯狂刷接口,你想直接封禁他,怎么做?
Redis 的 ZSet (有序集合) 非常适合做黑名单。把 IP 和时间戳放进去,设置一个过期时间。

// 封禁 IP
public function ban(string $ip, int $duration): void
{
    $key = 'banlist';
    // 添加到集合,score 是过期时间戳
    $this->redis->zadd($key, time() + $duration, $ip);
}

// 检查 IP 是否被封
public function isBanned(string $ip): bool
{
    $key = 'banlist';
    // 获取当前时间
    $now = time();
    // 移除过期的 IP
    $this->redis->zremrangebyscore($key, 0, $now);
    // 检查 IP 是否在集合中
    return (bool) $this->redis->zscore($key, $ip);
}

在中间件里,先检查黑名单,再检查令牌桶。

3. 多级限流(宏观与微观)

有时候我们需要两层限流:

  • 宏观(Nginx 层): 比如 IP 层面每秒只能发 50 个请求。这是为了挡住 DDoS 攻击,保护 PHP 后端。
  • 微观(PHP 层): 比如 API 接口层面,同一个用户 ID 每秒只能调 5 次。

这需要结合 Nginx 的 limit_req_zone 和我们的 Redis 限流器。


第七部分:避坑指南(调试与性能篇)

写代码容易,写出健壮的代码难。在部署这个系统时,你可能会遇到几个坑。

坑 1:Redis 挂了怎么办?

如果你的 Redis 崩溃了,那么这个限流器就失效了。这意味着你的系统变成了“裸奔”状态。恶意攻击可以瞬间打挂你的数据库。

解决方案: 引入降级策略
在 PHP 中,如果 Redis 连接失败,我们可以采取保守策略:允许请求通过,但记录日志,或者临时放宽限流。不要直接抛出异常让用户报错。

try {
    $result = $this->redis->eval(...);
} catch (Exception $e) {
    Log::error('Redis Down, bypass rate limit', ['error' => $e->getMessage()]);
    return $next($request); // 硬着陆:放行
}

坑 2:时钟漂移

这个算法依赖 time()。如果你的 Redis 服务器时间和 PHP 服务器时间不一致,计算 delta(时间差)就会出错。

解决方案:

  • 生产环境必须保证 Redis 和 PHP 服务器的 NTP 时间同步。
  • 代码中可以使用 clock_gettime(CLOCK_MONOTONIC) 这种单调时钟,它不受系统时间修改的影响。

坑 3:代码性能

Lua 脚本虽然快,但毕竟是网络调用。如果每秒有 10 万个请求,每秒就要执行 10 万次 Redis 交互。这可能会给 Redis 带来压力。

优化方案:

  1. 本地缓存预热: 在 PHP 端维护一个本地缓存,比如 APCu。当请求 Redis 失败或者结果在缓存中,直接读缓存。只有缓存失效才去 Redis。
  2. Pipeline: 虽然我们这里用的是原子脚本,但如果是复杂的业务,可以考虑 Pipeline 批量操作。
  3. 降低频率: 没必要每个请求都去查限流。可以做一个“采样限流”,比如每 5 个请求检查 1 次,或者在 Lua 脚本里加个随机数,比如 if math.random() > 0.8 then return false end。这在保证安全的前提下能极大提升性能。

结语:守护你的服务器

好了,同学们,今天的讲座就要结束了。

我们讲了从“为什么要限流”到“如何用 Lua 脚本实现分布式令牌桶”,再到“如何封装成 Laravel 中间件”的全过程。

记住,架构设计不是为了让系统变得复杂,而是为了让系统在混乱中保持秩序。令牌桶算法就像是一个冷静的管家,它看着源源不断的流量,微笑着把那些不守规矩的请求挡在门外,只让那些真正重要的请求走进你的大厅。

在你的下一个项目中,别吝啬这个限流中间件。它可能是你深夜加班时,唯一能救你命的东西。

现在,去把你的代码改了,然后给你的老板展示一下,看看他的反应。如果有疑问,欢迎在评论区扔代码,或者去我的 GitHub 上找找看(虽然我还没建,但这儿是讲座,不是简历)。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注