真正能落地的PV/UV统计方案需拆解为采集、防重、落库三环节,缓存键带维度并设1小时过期,定时批量同步至含联合唯一索引的数据库,UV须用设备指纹而非纯IP,字段长度与安全转义必须严格规范。

直接在控制器里写 $model->setInc('pv') 统计访问量,90% 的项目会出问题:重复计数、数据库写爆、UV/PV 混淆。真正能落地的方案,必须拆开「采集」「防重」「落库」三个环节,且默认不依赖 Redis 也能跑通。
中间件里用缓存做原子递增,别碰数据库直写
所有请求入口统一走中间件,避免漏统计或重复统计。缓存键要带维度,不能只用一个全局 pv_total:
- 总 PV:
cache('site_pv', 0, 3600)→cache('site_pv', cache('site_pv') + 1, 3600) - 单页 PV:
$urlHash = md5($request->url()); cache("page_pv_{$urlHash}", 0, 3600),防止带参数 URL 被当成不同页面 - UV(按设备):
$fingerprint = md5($_SERVER['HTTP_USER_AGENT'] . $_SERVER['REMOTE_ADDR']); cache("uv_{$fingerprint}", 1, 86400),注意不能只靠 IP
缓存过期时间设为 1 小时(3600),不是永久,否则重启后数据丢失;也不设太短,否则频繁穿透到 DB。
定时任务批量刷库,别用 file_get_contents 写文件
缓存只是中转站,最终要进数据库做持久化和分析。起一个命令行任务(如 php think count:sync),每 5 分钟执行一次:
立即学习“PHP免费学习笔记(深入)”;
- 用
Cache::getKeys('page_pv_*')批量捞出所有页面缓存键 - 用
INSERT ... ON DUPLICATE KEY UPDATE count = count + VALUES(count)一次性合并写入,避免逐条INSERT压垮 MySQL - 表结构必须含联合唯一索引:
UNIQUE KEY `uk_url_date` (`url_hash`, `date`),url_hash是VARCHAR(32),date是DATE类型
别用 file_get_contents + flock 模拟原子写——PHP-FPM 多进程下 flock 基本无效,高并发时必然丢数。
数据库字段长度和类型容易翻车
很多项目上线后发现 UV 数不准、UA 截断、Referer 乱码,全是字段设计埋的坑:
-
user_agent字段必须是TEXT或至少VARCHAR(512),Chrome UA 已超 400 字符 -
referer同样不能VARCHAR(255),某些营销链接带长参数,直接被 MySQL 静默截断 -
ip字段用VARCHAR(45),兼容 IPv6;别用INT UNSIGNED存 IPv4,IPv6 无解 - 所有字符串字段入库前必须过
htmlspecialchars()或交由 ThinkPHP 的Db::name()->insert()自动转义,否则 SQL 注入风险真实存在
UV 统计必须放弃纯 IP 方案
NAT 网关、校园网、运营商共享出口下,成百上千用户共一个公网 IP 很常见。只靠 $_SERVER['REMOTE_ADDR'] 统计 UV,结果会比真实值低 1–2 个数量级:
- 基础指纹:拼接
$_SERVER['HTTP_USER_AGENT']+$_SERVER['REMOTE_ADDR']+ (可选)$_SERVER['HTTP_ACCEPT_LANGUAGE'] - 进阶方案:前端用 JS 生成
canvas.fingerprint或调用navigator.deviceMemory等 API,后端接收并哈希存储 - 注意:GDPR/个人信息保护法要求明确告知并获取用户同意,未授权收集设备指纹可能违规
最常被忽略的一点:UV 和 PV 的统计周期必须一致。比如 PV 按自然日归档,UV 也得按 date 字段去重,而不是简单查 COUNT(DISTINCT fingerprint) 跨月数据——那样会把老访客反复计入新日活。



















