本文目录导读:

- 方案一:简单文件/数据库计数器(适合低并发、内部小工具)
- 方案二:基于 Redis 的高速计数器(适合高并发、实时统计)
- 方案三:高并发 + 持久化(Redis + 异步入库)
- 方案四:基于文件记录(最简单,适合极低并发)
- 总结 & 选择建议
设计一个PHP API计数器,主要需要考虑存储方式、性能、以及对不同维度的统计需求,以下是几种常见的设计方案,从简单到复杂,你可以根据项目实际情况选择。
核心通常是:谁调用了 + 哪个端点 + 调用时间 + 调用结果。
简单文件/数据库计数器(适合低并发、内部小工具)
如果API流量不大(日均几百到几千次),直接用数据库或文件记录是最简单的。
数据库表设计 (MySQL/PostgreSQL)
-- 1. 总调用次数表
CREATE TABLE api_call_counter (
id INT AUTO_INCREMENT PRIMARY KEY,
endpoint VARCHAR(255) NOT NULL, -- API路径,如 /api/user/login
request_method VARCHAR(10) NOT NULL, -- GET, POST, PUT, DELETE
call_date DATE NOT NULL, -- 统计日期
call_count INT UNSIGNED DEFAULT 1, -- 当日调用次数
success_count INT UNSIGNED DEFAULT 0, -- 成功次数
failure_count INT UNSIGNED DEFAULT 0, -- 失败次数
total_duration_ms INT UNSIGNED DEFAULT 0, -- 总耗时(毫秒)
-- 为了快速查询某日的某个端点,建立唯一索引
UNIQUE KEY uk_endpoint_date (endpoint, request_method, call_date)
);
-- 2. 详细日志表 (可选,用于分析调用来源、IP、用户)
CREATE TABLE api_call_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
endpoint VARCHAR(255) NOT NULL,
request_method VARCHAR(10) NOT NULL,
caller_ip VARCHAR(45),
user_id INT UNSIGNED DEFAULT NULL, -- 如果是登录用户
status_code INT NOT NULL, -- HTTP状态码 200, 500等
duration_ms INT UNSIGNED DEFAULT 0, -- 本次请求耗时
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 按天分表或定期清理,避免日志表无限增长
INDEX idx_created_at (created_at),
INDEX idx_endpoint (endpoint)
);
PHP 实现代码 (中间件模式)
<?php
class ApiCounterMiddleware
{
private $pdo;
private $logTable;
public function __construct(PDO $pdo, string $logTable = 'api_call_log')
{
$this->pdo = $pdo;
$this->logTable = $logTable;
}
/**
* 在API请求处理结束后调用
*
* @param string $endpoint 当前API路径
* @param string $method 请求方法
* @param int $statusCode HTTP状态码
* @param int $durationMs 耗时(毫秒)
* @param array $extra 额外信息,如user_id, ip
*/
public function record(string $endpoint, string $method, int $statusCode, int $durationMs, array $extra = []): void
{
$date = date('Y-m-d');
$success = ($statusCode >= 200 && $statusCode < 300) ? 1 : 0;
$failure = $success ? 0 : 1;
// 1. 更新每日汇总表 (使用 ON DUPLICATE KEY)
$sql = "INSERT INTO api_call_counter (endpoint, request_method, call_date, call_count, success_count, failure_count, total_duration_ms)
VALUES (:endpoint, :method, :date, 1, :success, :failure, :duration)
ON DUPLICATE KEY UPDATE
call_count = call_count + 1,
success_count = success_count + VALUES(success_count),
failure_count = failure_count + VALUES(failure_count),
total_duration_ms = total_duration_ms + VALUES(total_duration_ms)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':endpoint' => $endpoint,
':method' => $method,
':date' => $date,
':success' => $success,
':failure' => $failure,
':duration' => $durationMs,
]);
// 2. 记录详细日志 (异步/批量会更好,但简单实现直接插入)
$logSql = "INSERT INTO {$this->logTable} (endpoint, request_method, caller_ip, user_id, status_code, duration_ms)
VALUES (:endpoint, :method, :ip, :userId, :statusCode, :duration)";
$logStmt = $this->pdo->prepare($logSql);
$logStmt->execute([
':endpoint' => $endpoint,
':method' => $method,
':ip' => $extra['ip'] ?? $_SERVER['REMOTE_ADDR'] ?? 'unknown',
':userId' => $extra['user_id'] ?? null,
':statusCode' => $statusCode,
':duration' => $durationMs,
]);
}
}
优点:结构清晰,数据持久化,能直接做各种SQL查询统计。
缺点:高并发下数据库写压力大,日志表增长快。
基于 Redis 的高速计数器(适合高并发、实时统计)
对于高并发场景(每秒几百次以上),数据库插入会成为瓶颈,利用 Redis 的内存操作和原子特性,可以扛住很高的 QPS。
Redis 数据结构设计
利用 Redis 的 Sorted Set(排序集合) 和 Hash(哈希)。
- Hash: 存储每日或每小时的各 API 调用次数。
- Sorted Set: 按时间排序的详细日志(便于最近查看,同时可以利用过期时间自动清理)。
# 1. 当日总调用次数(Hash)
# Key: api:counter:{date}
# Field: {method}:{endpoint}
# Value: 调用次数
HSET api:counter:20251028 "GET:/api/users" 100
HSET api:counter:20251028 "POST:/api/orders" 50
# 2. 最近N条调用日志(Sorted Set,用于监控和调试)
# Key: api:log:recent
# Member: 序列化的JSON或ID
# Score: 时间戳(微秒)
ZADD api:log:recent 1698472800000 "{'endpoint':'/api/users','method':'GET','status':200}"
PHP 实现代码 (使用 Predis/PhpRedis)
<?php
class RedisApiCounter
{
private $redis;
private $recentLogLimit = 10000; // 只保留最近1万条日志
public function __construct(\Redis $redis)
{
$this->redis = $redis;
}
/**
* 记录一次API调用(原子操作,高性能)
*/
public function record(string $endpoint, string $method, int $statusCode, int $durationMs, array $extra = []): void
{
$date = date('Y-m-d');
$timestamp = (int)(microtime(true) * 1000); // 毫秒时间戳
// 1. 累加当日Counter (Hash)
$hashKey = "api:counter:{$date}";
$field = "{$method}:{$endpoint}";
$this->redis->hIncrBy($hashKey, $field, 1);
// 设置过期时间,避免历史数据一直占用内存 (保留30天)
$this->redis->expire($hashKey, 30 * 86400);
// 2. 记录到最近日志 (Sorted Set)
$logKey = "api:log:recent";
$logData = json_encode([
'endpoint' => $endpoint,
'method' => $method,
'status' => $statusCode,
'duration' => $durationMs,
'ip' => $extra['ip'] ?? $_SERVER['REMOTE_ADDR'] ?? '',
'user_id' => $extra['user_id'] ?? 0,
'time' => date('Y-m-d H:i:s'),
]);
// 使用毫秒时间戳作为score,确保唯一性(如果并发极高,可以加随机后缀)
$this->redis->zAdd($logKey, $timestamp, $logData);
// 3. 控制日志集合大小,只保留最近N条
$this->redis->zRemRangeByRank($logKey, 0, -($this->recentLogLimit + 1));
// 4. (可选) 记录成功/失败总数 (另开一个Hash分别统计成功/失败)
// 成功后累加: $this->redis->hIncrBy("api:success:{$date}", $field, 1);
// 失败后累加: $this->redis->hIncrBy("api:failure:{$date}", $field, 1);
}
/**
* 获取某个API今日的调用次数
*/
public function getTodayCount(string $endpoint, string $method = 'GET'): int
{
$date = date('Y-m-d');
$field = "{$method}:{$endpoint}";
return (int)$this->redis->hGet("api:counter:{$date}", $field);
}
/**
* 获取今日所有API的TopN排行
*/
public function getTopEndpoints(string $date, int $topN = 10): array
{
$hashKey = "api:counter:{$date}";
$allFields = $this->redis->hGetAll($hashKey);
arsort($allFields);
return array_slice($allFields, 0, $topN);
}
}
优点:性能极高(单机10万+ QPS),支持实时排行榜,数据自动过期。
缺点:数据存储在内存中(成本高),断电或宕机可能丢失近期数据(Redis持久化配置可缓解),不支持复杂的跨天关联查询(需要定期同步到数据库)。
高并发 + 持久化(Redis + 异步入库)
这是生产环境最推荐的方案,结合方案二的高性能写入和方案一的持久化查询需求。
核心流程:
请求到达 -> [内存/Redis计数] -> 返回响应给客户端
-> 异步进程/定时任务 -> 批量写入MySQL
写入端 (PHP 继续使用 Redis + 将日志推入消息队列)
// 修改 record 方法,不直接操作日志表,而是将日志发送到消息队列
public function recordToQueue(string $endpoint, $method, $statusCode, $durationMs, $extra = []): void
{
// --- 仍然先更新Redis的实时Counter (保证毫秒级响应) ---
$date = date('Y-m-d');
$hashKey = "api:counter:{$date}";
$field = "{$method}:{$endpoint}";
$this->redis->hIncrBy($hashKey, $field, 1);
// --- 将详细日志推入 Redis List 或 Kafka/RabbitMQ ---
$logEntry = [
'endpoint' => $endpoint,
'method' => $method,
'status' => $statusCode,
'duration' => $durationMs,
'ip' => $extra['ip'] ?? '',
'user_id' => $extra['user_id'] ?? 0,
'created_at' => date('Y-m-d H:i:s'),
];
// 使用 Redis List 作为简易队列 (注意:Redis List数据量大时注意内存)
$this->redis->rPush('api:log:queue', json_encode($logEntry));
// 或者使用高吞吐的消息队列中间件
// $this->kafkaProducer->send('api-logs', $logEntry);
}
消费端 (另一个PHP脚本/定时任务,或使用队列工作进程)
<?php
// consumer.php (例如每分钟跑一次,或者作为守护进程)
while (true) {
// 从队列中取出最多100条日志
$batch = $this->redis->lRange('api:log:queue', 0, 99); // 注意防止阻塞
if (empty($batch)) {
sleep(1);
continue;
}
$this->redis->lTrim('api:log:queue', count($batch), -1); // 移除已取出的数据
// 批量插入数据库
$pdo->beginTransaction();
foreach ($batch as $logJson) {
$log = json_decode($logJson, true);
// 插入到 api_call_log 表
// 更新 api_call_counter 表的汇总数据
}
$pdo->commit();
}
优点:
- 写入极快:API响应时间只受Redis的短操作(微秒级)影响。
- 数据不丢:Redis持久化 + 队列机制确保数据最终能写入MySQL。
- 查询灵活:MySQL存放全量历史数据,支持复杂统计分析。
缺点:
- 架构复杂:需要维护消息队列和消费进程。
- 数据一致性问题:一旦Redis或消费者崩溃,可能会有短暂数据丢失窗口。
基于文件记录(最简单,适合极低并发)
如果项目极小,甚至不需要数据库,可以直接写日志文件。
function countApiCall($endpoint, $statusCode) {
$logFile = __DIR__ . '/api_counter.log';
$data = date('Y-m-d H:i:s') . "|$endpoint|$statusCode\n";
file_put_contents($logFile, $data, FILE_APPEND | LOCK_EX);
}
分析:并发高时LOCK_EX会导致大量进程排队,性能极差,只适合内部调试或日均千次以下。
& 选择建议
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 方案一 (MySQL) | 低并发 (< 100 QPS),需要历史分析和报表 | 结构清晰,查询方便,持久化稳定 | 高并发写入慢,日志表会非常大 |
| 方案二 (Redis only) | 高并发 (>1000 QPS),实时排行榜/大屏 | 极高性能,实时性好,自动过期 | 数据量大时内存成本高,断电可能丢部分数据 |
| 方案三 (Redis + 异步入库) | 生产环境推荐,高并发+需要持久化 | 兼顾性能与持久性,可扩展性好 | 架构复杂,需要维护队列和消费者 |
| 方案四 (文件日志) | 极低并发,开发调试或单机小项目 | 最简单,零依赖 | 性能差,不能直接统计分析 |
额外建议:
- 一定要加缓存:如果前端需要频繁调用查询统计接口(如实时PV),使用
Redis缓存统计结果,设置5-10秒过期。 - 降级策略:如果Redis挂了,可以降级为直接写MySQL(或就放弃记录此次调用,以保证API正常),不要因为计数而影响主要业务逻辑。
- 限流联动:计数器数据可以作为限流(Rate Limiting)的数据源,使用滑动窗口或令牌桶(Redis实现)限制恶意请求。
在设计时,先把记录和查询分离(CQRS思想),写入路径尽量轻量(Redis/队列),查询路径可以批量从数据库计算,这样比较优雅。