PHP如何实现接口限流机制避免服务器被恶意高频调用

各位好,请坐。既然来了,就别在那儿摆弄手机了,咱们开始干活。

今天咱们不聊什么虚头巴脑的架构模式,也不讲什么抽象工厂、单例模式这些听起来就让人想睡觉的设计模式。咱们来聊聊一个在程序员界,尤其是后端开发界,比女朋友发脾气还让人头疼的问题——接口限流

先别急着划走,我知道你们在想什么:“我那接口跑得稳如老狗,用得着限流吗?”

这时候通常只有两种情况:

  1. 你是个初出茅庐的小菜鸟,你的服务器其实已经像只跑了马拉松后的老狗一样,呼哧带喘,随时准备断气。
  2. 你其实是个老司机,只是还没遇到过那种半夜两点还在疯狂刷接口的“恶意机器人”。这种机器人就像一群蚊子,你赶走一波,又来一波,咬得你满头大包。

今天,咱们就以 PHP 为例,深扒一下这其中的门道。咱们要把这扇门防得固若金汤,让那些想来“薅羊毛”的家伙们,撞得头破血流。

第一部分:为什么我们要在这个节骨眼上搞限流?

想象一下,你开了一家餐厅。你的厨房(服务器)有限,后厨的大厨(CPU)只有两个。你的服务员(PHP进程)手速很快,能同时接待 100 个客人。

如果这时候,来了 1000 个客人,而且这 1000 个客人同时点了菜,会发生什么?
你的后厨肯定炸了。服务员不是把菜做出来了,而是把桌子掀了。

在互联网世界里,这就是“DDoS攻击”或者“恶意爬虫”。如果你的接口没有限流,轻则接口响应变慢,用户骂娘;重则数据库连接池被打满,服务器宕机,老板坐在你办公室里抽雪茄,问你:“服务器怎么挂了?是不是没钱了?”

所以,限流不是为了不让人用,而是为了保命

第二部分:理论是灰色的,代码之树常青——算法的选择

在动手写代码之前,咱们得先搞清楚“枪”怎么开。市面上的限流算法五花八门,但归根结底就那么几种。咱们挑两个最常用的讲讲。

1. 漏桶算法

比喻: 这就像是一个水桶,上面有一个漏水的洞。水(请求)从上面不断倒进来,不管你倒多快,水只会以一个恒定的速度从下面漏出去。

特点: 稳定。它保证了你的服务器处理请求的速率是恒定的,不管外部来了多少洪水,内部永远是匀速流出。

缺点: 太死板。如果用户想短时间爆发一下(比如双十一抢购),这个算法就不行,它不允许“突发流量”。这就好比一个人想一口气吃成个胖子,漏桶算法非得把你塞成小笼包。

2. 令牌桶算法

比喻: 这个好理解。后台有一个生成器,每隔一秒钟往桶里扔 1 个令牌(Token)。如果有请求来,就去桶里拿一个令牌。有令牌,就处理;没令牌,就拒绝。

特点: 允许突发。因为生成令牌是匀速的,但如果你一下子来了 10 个请求,而桶里正好有 10 个令牌,这 10 个请求都能被处理。这就允许了短时间的流量冲击。

结论: 咱们今天要用令牌桶算法。这是目前业界最主流的做法。

第三部分:第一道防线——Nginx 层面拦截

在请求真正打到你的 PHP 代码之前,咱们得先过一层防火墙。这层防火墙不是 PHP 写的,是 Nginx。Nginx 是个狠角色,它比 PHP 快得多,资源占用又少。

咱们得告诉 Nginx:“嘿,哥们,这哥们每秒只能进来 10 个请求,多了我处理不来!”

配置文件大概长这样:

http {
    # 定义一个名为 my_limit 的区域,限制每个 IP 每秒 10 个请求,突发允许 20 个
    limit_req_zone $binary_remote_addr zone=my_limit:10m rate=10r/s;

    server {
        location /api/ {
            # 应用限流,zone 对应上面的名字,burst 允许临时存多少个请求在队列里
            limit_req zone=my_limit burst=20 nodelay;

            fastcgi_pass   php-fpm:9000;
        }
    }
}

注意那个 nodelay 参数。 如果不加这个,Nginx 会像个守财奴一样,哪怕你设置了 burst,只要超过 rate,也会把你那 20 个请求一个个地排队,等你这 1 秒钟过了,再发过去。这样前面 20 个请求就会像挤公交一样,等待时间非常长,用户体验极差。加了 nodelay,多余的请求直接就被扔了,不排队,拒绝得干脆利落。

经验之谈: Nginx 限流是“绝杀”。一旦触发,Nginx 直接返回 503 或者 429(Too Many Requests),你的 PHP 根本收不到请求。这能帮你省下宝贵的 CPU 资源。

但是,Nginx 不是万能的。如果攻击者伪装了 IP,或者 Nginx 配置没写好,PHP 还得接着受苦。所以,咱们还得在 PHP 里面再挂一道符。

第四部分:PHP 内部实战——Redis + Lua 脚本

这是今天的重头戏。很多初级开发会想到:我在 PHP 里写个循环,每来一个请求就查数据库,如果数据库里这个 IP 的次数小于 10,就加 1,然后处理请求。

停!打住!千万别这么做!

PHP 处理请求的开销本来就不低,你再给它加上 MySQL 的查询,这服务器就像在高速公路上开拖拉机。而且,如果有并发请求,数据库锁就会打架,数据一致性都会出问题。

最佳实践是什么? 用 Redis。Redis 是单线程的,而且运行在内存里,查询速度是毫秒级的。而且,咱们要用 Lua 脚本

为什么用 Lua?因为 Redis 执行命令是单线程的。如果我们在 PHP 里写三行代码去查 Redis,那么在并发场景下,可能第一行代码查到了,第二行代码还没查,这时候来了第三个请求,也会读到同一个值。这就导致了计数不准。

如果你把这三行逻辑打包成一个 Lua 脚本,那么 Redis 会保证这“三行逻辑”像原子一样,要么全执行,要么全不执行,中间绝对没有“插队”的请求能打断它。

核心逻辑:令牌桶算法的 Redis 实现

我们需要在 Redis 里维护一个 key,比如 limit:user:ip。它的值包含两部分:

  1. 当前剩余令牌数。
  2. 时间戳(用来计算这一秒内应该补发多少令牌)。

Lua 脚本如下(请把这段脚本想象成一个精密的瑞士手表机芯):

-- 获取参数
local key = KEYS[1]      -- 限流的 key,比如 "limit:ip:192.168.1.1"
local capacity = tonumber(ARGV[1]) -- 桶的总容量
local rate = tonumber(ARGV[2])     -- 令牌生成速率(每秒生成的令牌数)
local now = tonumber(ARGV[3])      -- 当前时间戳

-- 1. 初始化逻辑:如果 key 不存在,说明是新用户/新请求,初始化当前令牌数为最大容量
if redis.call("exists", key) == 0 then
    return redis.call("set", key, capacity, "EX", 1)
end

-- 2. 获取当前 Redis 里的令牌数和时间戳
local current_value = redis.call("get", key)
local current_tokens = tonumber(current_value)

-- 假设 key 里存储的格式是 "tokens:timestamp",为了简单,我们这里假设 Redis 的 value 仅仅存 tokens
-- 实际生产中,为了精确,我们通常存 "tokens@timestamp"
-- 这里为了代码易懂,我们用一种简化版的逻辑,实际上 Redis 的 Lua 脚本必须健壮

-- 注意:上面的简化版在并发下有缺陷,这里修正为标准的令牌桶逻辑
-- 为了保证精确,我们重新组织一下数据结构:key 存储 "tokens" 和 "last_refill_time"
-- 但为了代码量可控,我们用另一种技巧:直接存储当前令牌数,利用 TTL 自动过期

-- 重新理解需求:每秒补 1 个,桶容量 5。
-- 上一秒剩 4 个,没发出去。下一秒来了,先补 1 个,变成 5 个,处理请求。
-- 上一秒剩 1 个,没发出去。第二秒来了,先补 1 个变成 2 个,处理请求。如果来了 2 个请求,就没了。

-- 修正后的 Lua 逻辑:
local current_tokens = tonumber(redis.call("get", key))

-- 如果 key 不存在或者值为 0(过期了),重置
if current_tokens == nil then
    current_tokens = capacity
    redis.call("set", key, current_tokens, "EX", 1) -- 1秒过期
    return current_tokens - 1
end

-- 如果桶里有令牌,减 1,存回去
if current_tokens > 0 then
    return redis.call("decr", key)
end

-- 如果桶里没令牌了,返回 0(表示拒绝)
return 0

等等,上面这个脚本有个小 bug。 它没有计算“经过的时间”,而是假设 Redis 的 TTL(过期时间)为 1 秒。如果 Redis 服务器稍微卡顿一下,TTL 到了但 Lua 还在跑,或者网络稍微有点波动,这个逻辑就不准了。

真正的令牌桶算法,必须根据时间差来计算补了多少个令牌。

咱们写一个更稳健的版本。假设 Redis 的 key 存储格式是 JSON:{"tokens": 5, "last_time": 12345678}

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

-- 1. 获取当前桶的状态
-- redis.call('get') 返回的是字符串,我们需要解析
local info = redis.call('get', key)
local current_tokens = capacity
local last_refill_time = now

if info then
    -- 如果 key 存在,解析 JSON
    -- 简单的字符串分割:假设 info 格式为 "tokens:timestamp"
    local parts = string.split(info, ":")
    current_tokens = tonumber(parts[1])
    last_refill_time = tonumber(parts[2])

    -- 如果时间戳异常(比如被恶意修改),重置
    if last_refill_time > now then
        last_refill_time = now
    end
end

-- 2. 计算经过的时间
local elapsed = now - last_refill_time

-- 3. 补充令牌
if elapsed > 0 then
    -- 补充的令牌数 = 经过的时间 * 速率,但是不能超过桶容量
    local replenish_amount = elapsed * rate
    if replenish_amount > 0 then
        current_tokens = current_tokens + replenish_amount
        if current_tokens > capacity then
            current_tokens = capacity
        end
    end
end

-- 4. 判断是否处理请求
if current_tokens >= 1 then
    -- 有令牌,扣掉 1 个,更新时间戳
    current_tokens = current_tokens - 1
    redis.call('set', key, current_tokens .. ":" .. now, "EX", 60) -- 设置 60 秒过期
    return 1 -- 允许通过
else
    -- 没令牌了
    return 0 -- 拒绝
end

这个脚本就非常完美了。它结合了时间戳和令牌数,无论你隔了多久没请求,只要过了 1 秒,令牌就会自动补满。

PHP 调用 Lua 脚本

在 PHP 里,调用 Lua 脚本也非常简单,主要靠 Rediseval 方法。

<?php

// 假设这是你的核心业务逻辑入口
function handleApiRequest($ip) {
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);

    // 1. 定义 Lua 脚本(这里为了演示,我们直接用 eval,生产环境建议把脚本存到 Redis 里)
    $luaScript = <<<LUA
        local key = KEYS[1]
        local capacity = tonumber(ARGV[1])
        local rate = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])

        local info = redis.call('get', key)
        local current_tokens = capacity
        local last_refill_time = now

        if info then
            local parts = string.split(info, ":")
            current_tokens = tonumber(parts[1])
            last_refill_time = tonumber(parts[2])

            if last_refill_time > now then
                last_refill_time = now
            end
        end

        local elapsed = now - last_refill_time
        if elapsed > 0 then
            local replenish_amount = elapsed * rate
            if replenish_amount > 0 then
                current_tokens = current_tokens + replenish_amount
                if current_tokens > capacity then
                    current_tokens = capacity
                end
            end
        end

        if current_tokens >= 1 then
            current_tokens = current_tokens - 1
            redis.call('set', key, current_tokens .. ":" .. now, "EX", 60)
            return 1
        else
            return 0
        end
    LUA;

    // 2. 执行脚本
    // KEYS[1]: limit:ip:192.168.1.1
    // ARGV[1]: 10 (容量)
    // ARGV[2]: 1 (速率,每秒1个)
    // ARGV[3]: time()
    $allowed = $redis->eval($luaScript, [$ip, 10, 1], 1);

    if ($allowed == 1) {
        // 允许进入业务逻辑
        // ... 你的数据库查询,业务处理 ...
        echo "请求已处理,状态码 200n";
    } else {
        // 拒绝请求
        http_response_code(429); // Too Many Requests
        header('Retry-After: 60');
        echo json_encode(['code' => 429, 'msg' => '请求过于频繁,请稍后再试']);
    }
}

// 模拟 20 个并发请求
$ip = '192.168.1.1';
for ($i = 0; $i < 20; $i++) {
    go(function() use ($ip) {
        handleApiRequest($ip);
    });
}

上面的代码里用了 go() 函数,这通常是基于 Swoole 或者 Workerman 这种 PHP 协程框架。如果是在传统的 PHP-FPM 下,你只需要一个普通的 for 循环模拟多次请求即可。

第五部分:架构的终极奥义——分布式限流

好了,如果你们公司只有一台服务器,一台 Nginx,一个 PHP 进程池,上面的方案已经够用了。

但如果你是架构师,你要面对的是微服务。现在你有 10 台 PHP 服务器在跑,用户请求进来,可能被负载均衡分发给其中任意一台。Nginx 的 limit_req_zone 是基于 IP 的,它只知道“这个 IP 很可疑”,但 Nginx 不怎么知道“那个 IP 刚才去问第 3 台服务器要数据了”。

这时候,所有服务器的 PHP 代码都需要访问同一个 Redis。Redis 成了全网的“裁判员”。

Redis 的 eval 脚本在分布式环境下依然有效,因为它是原子操作。不管请求分摊到哪台机器,只要 Redis 里那个 key 的状态是一致的,限流就是准的。

进阶技巧: 如果你不想在 PHP 代码里写 Lua,也可以用 Redis 的 INCR 配合 EXPIRE 做一个简单的计数器。

// 简单版:基于滑动窗口的简化思路(仅限理解,不推荐用于高并发)
$key = "rate_limit:" . $ip;
$count = $redis->incr($key);
if ($count == 1) {
    $redis->expire($key, 1); // 第一秒过期
}
if ($count > 10) {
    // 拒绝
}

这个简单计数器有个问题:它只能限制“这一秒内的请求数”,也就是“平均速率”。如果 10 个请求都在第 0.1 秒进来,这一秒就能打满 10 个,下一秒再来 10 个,这就变成了“每秒 10 个”,而不是“每秒 10 个,且累积不能超过 10 个”。

所以,Lua 脚本里的令牌桶算法依然是王者。

第六部分:除了限流,我们还能干什么?

限流是把双刃剑。你把门关得太死,用户会骂娘。你把门开得太大,服务器会挂。

所以,当限流触发时,我们要做好“降级”准备。

1. 返回友好的 HTTP 状态码

千万不要直接返回 500 Internal Server Error。这会让爬虫以为服务器挂了,或者让前端开发者以为是自己代码写错了。统一使用 429 Too Many Requests。并且带上 Retry-After 响应头,告诉客户端:“哥们,等 10 秒再试。”

2. 错误页面设计

返回的 JSON 内容要包含一些“人情味”。

{
    "code": 429,
    "msg": "哎呀,您手速太快啦!为了服务器的稳定,请您休息 10 秒钟再试哦~",
    "data": null
}

不要说:“Access Denied”。要说:“请重试”。

3. 异步削峰

如果业务允许,当限流触发时,不要直接拒绝。可以把请求扔进消息队列(比如 RabbitMQ 或 Kafka)里。后台有一个专门的服务在慢慢处理这些请求。这样,即使来了 10000 个请求,服务器也不会崩,只是处理得慢一点。这叫“削峰填谷”。

第七部分:避坑指南(血泪经验)

作为资深专家,我必须提醒大家几个容易踩的坑:

1. 不要在循环里查 Redis

// 错误示范
for ($i=0; $i<1000; $i++) {
    $redis->get('key'); // 每次循环都去问 Redis,网络开销巨大
}

2. 不要用 MySQL 做限流
哪怕你的表有索引,MySQL 的写入速度也绝对跟不上 PHP 的并发请求速度。在这个高并发场景下,MySQL 就像一辆老爷车,不适合跑 F1 赛道。

3. 注意 Redis 的内存占用
虽然 Redis 是内存数据库,但如果你的 key 设计得乱七八糟,或者设置了过长的 TTL,也会导致内存溢出。前面我们用的 Lua 脚本里设置了 EX 60,就是为了防止无限制地堆积脏数据。

4. 信任 Nginx,但也别完全依赖 Nginx
Nginx 是第一道防线。如果你觉得 Nginx 配置复杂,可以在应用层简单限制。但如果你真的想保护服务器,Nginx 是必须的。它的性能比 PHP 高出几个数量级。

第八部分:总结与展望

好了,今天的讲座就到这儿。

我们聊了为什么限流重要,介绍了漏桶和令牌桶算法,重点演示了如何利用 Redis + Lua 脚本 在 PHP 中实现一个健壮的、支持分布式的令牌桶限流机制。

记住,编程不仅仅是写代码,更是做平衡。流量大了要限流,限流太严了用户体验差,限流太松了服务器扛不住。

咱们写代码的时候,脑子里要时刻有那个“令牌桶”。流量是水,服务器是桶。要想让水流动,既不能让桶溢出来(挂机),也不能让桶干涸(服务不可用)。

希望今天的分享能帮到大家。下次如果你的服务器突然 CPU 蹿到 100%,别慌,先看看是不是那个半夜还在刷接口的“蚊子”来了。

下课!

发表回复

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