基于远程 Redis 的博客页面点击量统计方案
一、功能目标
为基于 VuePress 的静态博客增加页面点击量(Page View)统计功能。每个博客页面在加载时自动 +1,并在页面上展示阅读量,例如:
👁 1,234 次阅读
数据存储在远程 Redis 服务中,由后端 API 作为中间层与 Redis 交互。
二、整体架构
VuePress 是纯静态站点,浏览器无法直接连接 Redis,因此需要引入一个轻量后端 API:
访客浏览器
│ HTTP/HTTPS
▼
Nginx(80 端口)
│ location /blog-api/pageview 反代
▼
Node.js API(localhost:3000)
│
▼
Redis(远程/本地)2.1 后端 API 设计
- 框架:Express
- Redis 客户端:
ioredis - 监听端口:
3000 - 部署方式:服务器上用 PM2 常驻运行
2.2 接口定义
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /blog-api/pageview?path=/blog/xxx/ | 查询指定页面当前点击量 |
POST | /blog-api/pageview | 对指定页面点击量 +1,并返回最新值 |
POST 请求体:
{
"path": "/blog/xxx/"
}响应格式:
{
"path": "/blog/xxx/",
"count": 100
}2.3 Redis Key 设计
每个页面对应一个 Key:
blog:pv:{path}例如:
blog:pv:/blog/redis-pv-design/注意:Key 中应只保留规范化后的
pathname,去掉协议、域名、查询参数、hash 以及末尾斜杠。
三、前端组件设计
3.1 组件职责
在 docs/.vuepress/theme/components/PageView.vue 中实现一个全局组件:
- 通过
vue-router获取当前页面路径route.path。 - 页面加载时先调用
GET接口读取当前阅读量并展示。 - 检查
sessionStorage,若当前会话尚未统计过该页面,则调用POST接口 +1。 - 监听路由变化,切换文章时自动刷新阅读量。
- 接口失败时优雅降级,不显示或显示占位符,不影响正文阅读。
3.2 为什么用 sessionStorage
| 方案 | 优点 | 缺点 |
|---|---|---|
| 不做控制 | 实现最简单 | 每次刷新都 +1,调试时数字暴涨 |
localStorage | 永久去重 | 会严重低估真实阅读量 |
sessionStorage | 同一次浏览会话只计一次,关闭标签页后失效 | 兼顾防刷与真实性 |
因此选择 sessionStorage。
3.3 组件交互流程
用户打开 /blog/my-post/
│
▼
┌─────────────────────────┐
│ 组件显示 "加载中..." │
└─────────────────────────┘
│
▼
GET /blog-api/pageview?path=/blog/my-post/
│
▼
┌─────────────────────────┐
│ 返回当前点击量,例如 99 │
└─────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ 检查 sessionStorage │
│ 是否已标记 /blog/my-post/ 已统计? │
│ │
│ 否 ──► POST /blog-api/pageview │
│ body: { path } │
│ 返回 100 │
│ 写入 sessionStorage │
│ │
│ 是 ──► 保持 99,不再加 1 │
└─────────────────────────────────────┘
│
▼
显示最终数字3.4 代码流程示例
const API_BASE = import.meta.env.VITE_PV_API || '/blog-api/pageview'
async function getViews(path: string): Promise<number | null> {
try {
const res = await fetch(`${API_BASE}?path=${encodeURIComponent(path)}`)
if (!res.ok) return null
const data = await res.json()
return data.count
} catch {
return null
}
}
async function incrementViews(path: string): Promise<number | null> {
try {
const res = await fetch(API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path }),
})
if (!res.ok) return null
const data = await res.json()
return data.count
} catch {
return null
}
}
onMounted(async () => {
const path = route.path
const current = await getViews(path)
const key = `pv:${path}`
if (!sessionStorage.getItem(key)) {
const updated = await incrementViews(path)
if (updated !== null) {
count.value = updated
sessionStorage.setItem(key, '1')
return
}
}
count.value = current
})四、并发控制
4.1 Redis INCR 是原子操作
计数的核心命令是 Redis 的 INCR:
const newCount = await redis.incr(`blog:pv:${path}`)Redis 是单线程模型,INCR 在服务端原子执行。无论多少请求同时到达,最终计数都是准确的,不会出现读写覆盖或丢失。
4.2 避免读-改-写
错误做法:
const count = await redis.get(key) // 100
count += 1 // 101
await redis.set(key, count) // 可能覆盖掉别人的 103正确做法:直接 INCR。
4.3 后端限流(可选)
为防止恶意刷量,可对 POST 接口做简单 IP 限流:
const ipMap = new Map()
function isRateLimited(ip) {
const now = Date.now()
const last = ipMap.get(ip) || 0
if (now - last < 1000) return true // 同一 IP 1 秒内限 1 次
ipMap.set(ip, now)
return false
}
GET查询接口不需要限流。
五、服务器部署方案
5.1 当前服务器环境
- 服务器:
82.157.2.25,OpenCloudOS 9.4 + 宝塔面板 - Nginx 监听 80 端口
- 博客静态目录:
/www/wwwroot/jasonzhang.net.cn/ - 失物招领系统已占用前缀:
/admin/、/api/、/laf/、/wx/、/user/、/sms/、/image/、/ocr/、/ws/、/uploads/、/images/、/laf-admin/
5.2 后端部署
- 将后端代码放到服务器,例如
/www/wwwroot/blog-pv-api/。 - 使用 PM2 启动并常驻运行,监听
127.0.0.1:3000。 - 配置
.env文件存放 Redis 连接信息(地址、端口、密码)。
5.3 Nginx 配置
在博客站点配置中增加:
location /blog-api/pageview {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}使用
/blog-api/pageview而不是/api/pageview,可以避免与失物招领系统的/api/*前缀冲突。
六、边界情况处理
| 场景 | 处理 |
|---|---|
URL 带 ?ref=xxx | 后端只取 pathname,忽略 query |
| 路径末尾有无斜杠 | 统一去掉末尾斜杠,/blog/a 与 /blog/a/ 视为同一页面 |
首页 / | Key 为 blog:pv:/ |
| 重复快速刷新 | sessionStorage 保证同一次会话只 +1 |
| 多标签页同时打开 | 各自算一次会话,会多计几次,这符合 PV 常规定义 |
| 爬虫/机器人 | 若执行 JS 并调用接口会被计入,可简单过滤 User-Agent,但个人博客通常无需处理 |
英文站点 /en/blog/xxx/ | 按实际路径分别统计 |
| 后端或 Redis 故障 | 组件隐藏或显示占位符,不影响页面正常打开 |
七、项目文件变更计划
新增文件
server/
└── pageview-api/
├── package.json
├── index.js
├── .env.example
└── ecosystem.config.js # PM2 配置
docs/.vuepress/theme/components/PageView.vue修改文件
docs/.vuepress/client.ts # 注册 PageView 组件
docs/.vuepress/theme/layouts/Layout.vue # 在文章页挂载组件
docs/.vuepress/theme/styles/custom.css # 增加阅读量样式
deploy.sh # 可选:增加后端同步/重启步骤八、待确认事项
实施前还需要确认:
- Redis 连接信息:地址、端口、密码(写入
.env,不提交仓库)。 - API 路径:建议使用
/blog-api/pageview,避免与现有/api/*冲突。 - 显示位置:文章标题下方自动显示,或仅博客文章页显示。
- 部署方式:是否需要将后端同步与重启整合进
deploy.sh。
九、总结
本方案通过「VuePress 前端 + Node.js 后端 API + Redis」三层架构,实现博客页面点击量统计:
- 前端:自动读取并展示阅读量,同一会话只 +1。
- 后端:提供查询和增量接口,利用 Redis
INCR原子性保证并发安全。 - 部署:后端用 PM2 常驻,Nginx 反代
/blog-api/pageview。
整体改动轻量,对现有博客侵入性小,失败时可优雅降级。