使用 Memcached 存儲每個 IP 的請求計數器與時間窗口,採用**滑動窗口**策略:
ratelimit:{IP}[count, window_start, last_request_time, status]status = 'BLOCKED' 並記錄封禁開始時間<?php
/**
* Memcached 基礎頻率限制器
* 適用於單機或小規模集群,高併發建議使用 Redis + Lua 腳本保證原子性
*/
$config = [
'memcached_host' => '127.0.0.1',
'memcached_port' => 11211,
'max_requests' => 50, // 時間窗口內最大請求數
'window_seconds' => 60, // 時間窗口 (秒)
'block_seconds' => 300, // 封禁時長 (秒)
];
$mem = new Memcached();
$mem->addServer($config['memcached_host'], $config['memcached_port']);
$ip = $_SERVER['REMOTE_ADDR'];
$key = "ratelimit:$ip";
$data = $mem->get($key);
$now = time();
if ($data === false) {
// 首次訪問
$data = [
'count' => 1,
'window_start' => $now,
'last_request' => $now,
'status' => 'OK'
];
} else {
// 解包數據
[$count, $windowStart, $lastRequest, $status] = $data;
// 1. 處理封禁狀態
if ($status === 'BLOCKED') {
$blockedElapsed = $now - $windowStart; // 這裡 window_start 存的是封禁開始時間
if ($blockedElapsed < $config['block_seconds']) {
$wait = $config['block_seconds'] - $blockedElapsed;
http_response_code(429);
die("請求過於頻繁,已暫時封禁。請等待 $wait 秒後重試。");
} else {
// 封禁期結束,重置
$count = 0;
$windowStart = $now;
$status = 'OK';
}
}
// 2. 滑動窗口檢查
$elapsed = $now - $windowStart;
if ($elapsed >= $config['window_seconds']) {
// 窗口已過期,重置計數器
$count = 1;
$windowStart = $now;
} else {
// 窗口內,增加計數
$count++;
if ($count > $config['max_requests']) {
// 觸發封禁
$status = 'BLOCKED';
$windowStart = $now; // 重用字段存封禁開始時間
$mem->set($key, [$count, $windowStart, $now, $status], $config['block_seconds'] + 10);
http_response_code(429);
die("請求超過限制 (${config['max_requests']} 次/${config['window_seconds']} 秒),IP 已封禁 ${config['block_seconds']} 秒。");
}
}
$data = [$count, $windowStart, $now, $status];
}
// 3. 寫回 Memcached (TTL 略大於窗口/封禁時間)
$ttl = max($config['window_seconds'], $config['block_seconds']) + 10;
$mem->set($key, $data, $ttl);
// 正常業務邏輯繼續...
echo "請求通過。當前窗口請求數: $count";
?>
| 環節 | 說明 |
|---|---|
| 鍵設計 | ratelimit:{IP} 簡單直觀,集群環境自動分片 |
| 數據結構 | 數組 [count, window_start, last_request, status] 緊湊高效 |
| 滑動窗口 | 每次請求檢查 now - window_start,過期則重置,避免固定窗口邊界突發 |
| 封禁機制 | 狀態機:OK → BLOCKED → OK,封禁期內直接拒絕,不消耗應用資源 |
| TTL 設置 | 略大於最大窗口/封禁時間,自動清理過期鍵,防止內存洩漏 |
| 并發安全 | Memcached get/set 非原子,**高併發下可能漏計/重複計數**。嚴格場景請用 Redis + Lua 或 Memcached CAS |
<?php
$mem = new Memcached();
$mem->setOption(Memcached::OPT_COMPRESSION, false);
$key = "ratelimit:$ip";
$maxAttempts = 3;
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
$data = $mem->get($key, null, $casToken);
if ($data === false) {
$data = ['count'=>1, 'window'=>$now, 'status'=>'OK'];
if ($mem->set($key, $data, $ttl)) break;
continue;
}
[$count, $windowStart, $status] = $data;
// ... 邏輯判斷更新 $count, $windowStart, $status ...
$data = [$count, $windowStart, $status];
if ($mem->cas($casToken, $key, $data, $ttl)) {
break; // 成功
}
// CAS 失敗,說明數據被其他進程修改,重試
usleep(10000); // 10ms
}
?>
Redis 單線程模型 + Lua 腳本原子執行,完美解決併發競爭,性能更強:
-- Redis Lua 腳本 (ATOMIC)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local block = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'count', 'window_start', 'status', 'block_start')
local count = tonumber(data[1]) or 0
local windowStart = tonumber(data[2]) or now
local status = data[3] or 'OK'
local blockStart = tonumber(data[4]) or 0
if status == 'BLOCKED' then
if now - blockStart < block then
return {0, block - (now - blockStart)} -- 拒絕,返回剩餘封禁秒數
else
status = 'OK'
count = 0
windowStart = now
end
end
if now - windowStart >= window then
count = 1
windowStart = now
else
count = count + 1
if count > limit then
redis.call('HMSET', key, 'count', count, 'window_start', windowStart, 'status', 'BLOCKED', 'block_start', now)
redis.call('EXPIRE', key, block + 10)
return {0, block} -- 拒絕,返回封禁時長
end
end
redis.call('HMSET', key, 'count', count, 'window_start', windowStart, 'status', status)
redis.call('EXPIRE', key, window + 10)
return {1, count} -- 允許
PHP 調用:$redis->eval($luaScript, 1, $key, $limit, $window, $block, time())
limit_req 形成**雙層防護**:Nginx 擋住大流量洪峰,應用層精細控制業務邏輯