session_start()在CLI或Nginx FastCGI下失效,因CLI无HTTP上下文导致Cookie机制不可用,而PHP-FPM默认不自动启动会话且文件存储存在锁竞争;可靠统计应绕过session,改用IP+UA+URI+小时拼接指纹,通过Redis SETNX原子写入与定时落库实现幂等计数。

为什么 session_start() 在 CLI 或 Nginx FastCGI 下会失效
ThinkPHP 请求统计若依赖 session_id() 或 $_SESSION 去判重,线上高并发时大概率漏统计或重复统计——因为 CLI 环境无 session,Nginx + PHP-FPM 默认不为每个请求分配独立 session 文件(尤其启用了 session.save_handler = files 且未显式 session_start()),更别说多进程/多线程下 session 文件锁竞争导致阻塞甚至超时。
真正可靠的方式是绕过 session,用请求指纹 + 原子写入做幂等控制:
- 指纹由
$_SERVER['REMOTE_ADDR']+$_SERVER['HTTP_USER_AGENT']+$_SERVER['REQUEST_URI']+ 当前小时(如date('Y-m-d-H'))拼接后md5(),避免单 IP 多设备或 UA 变动误判 - 不写数据库主表,改用 Redis 的
SETNX(setnx命令)做分布式锁,key 过期时间设为 3600 秒,value 存毫秒级时间戳用于调试 - 失败时不重试,直接跳过统计——高并发下“宁可少计,不可多计”
如何用 Redis 实现每小时单 IP-UA-URI 组合仅统计一次
在 ThinkPHP 的中间件(如 app/middleware/StatMiddleware.php)中插入如下逻辑,确保它在路由解析后、控制器执行前运行:
use think\facade\Cache;
// 构造唯一指纹
$fp = md5(
$_SERVER['REMOTE_ADDR'] .
($_SERVER['HTTP_USER_AGENT'] ?? '') .
$_SERVER['REQUEST_URI'] .
date('Y-m-d-H')
);
$redisKey = 'stat:hit:' . $fp;
$ttl = 3600;
// 尝试设置 key,仅当不存在时成功(原子操作)
$result = Cache::store('redis')->handler()->set($redisKey, time(), ['nx', 'ex' => $ttl]);
if ($result === true) {
// 成功写入,说明是首次命中,执行统计
$countKey = 'stat:hourly:' . date('Y-m-d-H');
Cache::store('redis')->inc($countKey);
}
注意:Cache::store('redis') 要求已在 config/cache.php 中正确配置 redis 驱动;inc() 是原子自增,无需额外加锁。
立即学习“PHP免费学习笔记(深入)”;
常见坑:
- 没配 Redis 或连接失败时,
Cache::store('redis')->handler()会抛异常,需包裹try/catch并静默失败(否则影响主业务) - 用
Cache::set($key, $val, $ttl)替代原生命令会丢失nx语义,导致重复写入 - 指纹里漏掉
date('Y-m-d-H'),会导致全天只计一次,失去“每小时”维度
如何安全落地到数据库并支持按天/小时查报表
Redis 只存临时指纹和小时计数,最终数据要定时落库(例如用 ThinkPHP 的命令行任务,每小时执行一次):
// app/command/FlushStat.php
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\facade\Cache;
class FlushStat extends Command
{
protected function configure()
{
$this->setName('stat:flush')->setDescription('Flush hourly stats to db');
}
protected function execute(Input $input, Output $output)
{
$hourKey = 'stat:hourly:' . date('Y-m-d-H', strtotime('-1 hour'));
$count = Cache::store('redis')->get($hourKey, 0);
if ($count > 0) {
// 写入数据库(假设表 stat_log 有 date_hour、count 字段)
\think\Db::name('stat_log')->insert([
'date_hour' => date('Y-m-d-H', strtotime('-1 hour')),
'count' => (int)$count,
'created_at'=> date('Y-m-d H:i:s'),
]);
Cache::store('redis')->delete($hourKey);
$output->writeln("Flushed {$count} hits for " . date('Y-m-d-H', strtotime('-1 hour')));
}
}
}
关键点:
- 读取的是「上一小时」的 key,避免正在写的小时被截断
- 写库失败不重试,但要在日志中记录错误,否则数据丢失无感知
- 不要在每次请求里直接写库——MySQL 插入在 1000+ QPS 下会成瓶颈,必须异步批量落库
漏掉 Referer 和 QueryString 会导致什么偏差
如果统计目标是“真实页面访问量”,而指纹只用 REQUEST_URI,那带不同参数的链接(如 /article?id=123 和 /article?id=456)会被视为同一请求,造成聚合过度;但如果目标是“接口调用量”,又需要忽略 QueryString 防止缓存穿透类攻击刷量。
务实做法是提供开关:
- 默认指纹含
parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),剔除 query 和 fragment - 对特定路径(如
/api/v1/order/status)单独启用全 URI 指纹,通过白名单数组控制 - 永远不把
Referer加入指纹——它极易被伪造、跨域缺失、CDN 清洗,稳定性远低于 UA+IP+PATH
最易被忽略的是:指纹生成时没 trim() 用户输入字段,比如 UA 末尾带空格或换行,导致相同设备生成不同 md5——务必对所有拼接字段调用 trim()。



















