UV统计必须用Redis的Set结构,因为SADD自动去重,避免手动拼接字符串导致性能瓶颈和并发丢失;PV用INCR原子自增,高效安全;统一通过中间件拦截记录,并设置7天过期防止内存堆积。

UV 统计为什么必须用 Redis 的 Set 结构
因为 UV 要去重,同一用户一天内多次访问只算 1 次。用 SET 最直接:每次请求把用户标识(如 ip 或 device_id)塞进一个带日期后缀的 key,比如 uv:20240615,SADD 自动去重。
别用 String 自己拼接字符串再 explode 去重——数据量一上来就卡死,且并发写入会丢数据。
ThinkPHP 中操作示例:
$date = date('Ymd');
$uvKey = "uv:{$date}";
$userId = $request->ip(); // 或从 cookie / token 解析 device_id
\think\Cache::store('redis')->handler()->sAdd($uvKey, $userId);
// 注意:store('redis') 要确保已配置 Redis 缓存驱动,不是默认 file
- 务必设置过期时间(如 7 天),否则 key 永久堆积:
EXPIRE $uvKey 604800 - 不要用
session_id()做 UV 标识——用户关浏览器就变,导致虚高 - 如果用
ip,需注意局域网出口 IP 相同的问题;更稳的是前端生成并持久化device_id到 localStorage + 后端校验
PV 统计用 Redis 的 String 自增最省资源
INCR 是原子操作,高并发下安全又快。每天一个 key,比如 pv:20240615,每次请求执行一次 INCR 即可。
ThinkPHP 写法:
立即学习“PHP免费学习笔记(深入)”;
$date = date('Ymd');
$pvKey = "pv:{$date}";
\think\Cache::store('redis')->handler()->incr($pvKey);
// 首次 incr 会自动初始化为 0,无需提前 set
- 不用自己读出来 +1+写回(
GET+SET),那是并发灾难 - 如果要统计到小时粒度,key 改成
pv:20240615_14即可,逻辑不变 - 注意:Redis 默认不支持带过期的自增,所以
INCR后得单独EXPIRE,否则 key 永存
如何在 ThinkPHP 中统一拦截并记录 PV/UV
别在每个控制器里手动调 incr/sAdd,容易漏、难维护。推荐用中间件,在请求入口处集中处理。
新建中间件 App\Middleware\StatMiddleware:
public function handle($request, \Closure $next)
{
$date = date('Ymd');
$pvKey = "pv:{$date}";
$uvKey = "uv:{$date}";
// PV:无条件自增
\think\Cache::store('redis')->handler()->incr($pvKey);
// UV:基于设备标识去重(这里用 IP 简化示意)
$ip = $request->ip();
\think\Cache::store('redis')->handler()->sAdd($uvKey, $ip);
// 设置过期(只设一次,避免重复调用开销)
if (!\think\Cache::store('redis')->handler()->exists("expire_mark:{$date}")) {
\think\Cache::store('redis')->handler()->expire($pvKey, 604800);
\think\Cache::store('redis')->handler()->expire($uvKey, 604800);
\think\Cache::store('redis')->handler()->set("expire_mark:{$date}", 1, 604800);
}
return $next($request);
}
- 用
expire_mark防止每请求都调EXPIRE,减少 Redis 命令次数 - 中间件要在
app/middleware.php中注册,并放在全局中间件数组靠前位置 - 如果项目有 API 和 Web 共存,建议按路由分组启用,避免静态资源也被统计
查 UV/PV 数据时要注意 key 生命周期和类型误用
查当天 PV 很简单:GET pv:20240615;查 UV 要用 SCARD,不是 GET —— 因为 uv:xxx 是 Set,GET 返回 nil。
ThinkPHP 查询示例:
$date = '20240615';
$pv = \think\Cache::store('redis')->handler()->get("pv:{$date}") ?: 0;
$uv = \think\Cache::store('redis')->handler()->scard("uv:{$date}"); // 必须用 scard
- 如果某天没流量,
pv:xxxkey 可能不存在,get返回false,记得?: 0 - 千万别对
uv:xxx执行DEL后再查SCARD——删了就没了,而且没过期的旧 key 会一直占内存 - 线上发现 UV 突降?先检查是否误用了
ip作为标识,又开了 CDN 导致所有请求 IP 变成 CDN 节点 IP
真实场景里,UV/PV 统计只是起点,后续常要关联用户行为、来源渠道或页面路径,这时候 key 设计就得预留结构,比如 uv:20240615:home、pv:20240615:product/123,但基础逻辑不变:Set 去重,String 自增,中间件兜底,过期必设。



















