返回博客列表
技术文章Next.jsUpstashRedis全栈开发

给博客加个阅读量,我把选型、防刷和踩过的坑全记下来了

2026年06月🇨🇳 中文

阅读量这个功能,很多博客都有,实现起来看上去很简单——不就是一个计数器吗?

写起来才发现,一个计数器会引出好几层问题:数据存哪、本地开发会不会污染线上数据、刷新页面会不会一直涨、接口有没有被滥用。每一层都得想清楚。

先看一张总览图——一条请求进来之后,完整链路长这样:

六个判断节点,五层拦截。下面逐个讲每一层在防什么、怎么实现。

选型

最先想到的方案是 Supabase——这个博客已经有好几篇写 Supabase 的文章,我对它很熟悉,直接加一张 page_views 表就能解决。

但问题是:Supabase 免费计划只允许 2 个项目,我已经用完了。 再创建第三个就要付费,为了一个计数器不值得。

然后看了几个备选方案:

  • Vercel Analytics:已经接入,但数据只在 Vercel 控制台可见,无法在页面上展示
  • Vercel Edge Config:读优化,不适合高频写入
  • Upstash Redis:专门做 Serverless 场景的 Redis,免费计划 10,000 次请求/天,对个人博客完全够用,不需要绑卡。超额只是请求被拒绝,不会自动扣费

Upstash 的接入方式也很干净:一个 REST URL,一个 Token,加一个 @upstash/redis 包,完成。

import { Redis } from "@upstash/redis";

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

环境变量就这两个,和常见的 Redis 客户端用法一样。后面所有读写操作都通过 redis 这个实例来调。

核心实现

数据结构很简单,每篇文章对应 Redis 里的一个 key:

views:agent-types-overview          → 342
views:supabase/00-why-supabase      → 1204
views:python-agent-guide            → 89

API 分两个接口:

  • POST /api/views/[slug]:用户打开文章时触发,计数 +1,返回最新数值
  • GET /api/views/[slug]:只读,用于列表页展示,不写入

前端调用也很简单。文章详情页在页面加载时调一次 POST,拿到最新计数后直接渲染:

// 文章详情页:计数 +1 并展示
async function DetailPage({ slug }: { slug: string }) {
  const res = await fetch(`/api/views/${slug}`, { method: "POST" });
  const { count } = await res.json();

  return <span>{formatCount(count)}</span>;
}

列表页只需要展示数字、不需要写入,调 GET 就行:

// 文章列表:只读展示
async function ListPage() {
  const res = await fetch("/api/views");
  const data = await res.json();

  return data.map((post) => (
    <span>{formatCount(post.count)}</span>
  ));
}

列表页的汇总接口用 redis.mget() 一次拿回所有文章的计数,比逐个请求高效。mget 是原子操作,避免了并发数量太多的问题。

本地开发会污染线上数据

接入完成后意识到一个问题:本地 bun dev 和线上 Vercel 部署用的是同一个 Upstash 数据库。每次我本地调试,打开一篇文章,计数就会涨一次,和真实读者的数据混在一起。

解决方法很直接——在开发环境跳过写入

export async function POST(req, { params }) {
  if (process.env.NODE_ENV === "development") {
    return Response.json({ count: 0 });
  }
  // ...
}

本地 POST 直接返回 0,不碰 Redis。列表页的 GET 请求仍然正常读取线上真实数据——所以你本地调试时能看到真实的数字展示效果,只是不会因为你反复刷新而涨数。

刷新页面会一直涨

每次页面加载都触发 POST,意味着用户反复刷新可以无限制地增加计数。

解决方案是用 HttpOnly cookie 记录「已读」状态:

const cookieKey = `viewed:${slugStr}`;
const cookieStore = await cookies();

if (cookieStore.has(cookieKey)) {
  // 24 小时内读过,只返回当前数值,不写入
  const count = await redis.get(`views:${slugStr}`) ?? 0;
  return Response.json({ count });
}

// 首次读,写入并种 cookie
const count = await redis.incr(`views:${slugStr}`);
res.headers.set("Set-Cookie", `${cookieKey}=1; HttpOnly; ...Expires=24h`);

同一浏览器 24 小时内访问同一篇文章只计一次。HttpOnly 保证 cookie 无法被 JavaScript 读取,不存在前端绕过的风险。

这里用 cookie 而不是 localStorage,原因很简单:防刷判断在服务端做的POST /api/views/[slug] 这个 API route 执行的时候,cookies().has(cookieKey) 直接就能读到。用 localStorage 的话,API 这边完全感知不到——它只存在于浏览器里,请求不会自动带过去。

如果换成前端先判 localStorage、没读过才调 POST,那 POST 接口本身就成了一个"无条件 +1"的入口。任何人用 curl 直接请求就能绕过。HttpOnly cookie 让这一整层判断闭环在服务端,不依赖前端的任何一行代码。

换浏览器或无痕模式会重新计一次——这是合理的,本来就算不同的访问。

Cookie 解决的是同一个真实用户反复刷新的问题。但 cookie 防不了另一类威胁——有人直接拿 curl 对着接口狂轰。这是两件性质不同的事,下面的 Origin 和白名单主要对付这一类。

接口被直接调用的风险

POST /api/views/xxx 是公开接口,任何人都可以用 curl 直接请求:

curl -X POST https://yoursite.com/api/views/agent-types-overview
# 每次 +1,Upstash 免费额度很快耗尽

针对外部调用的风险,第一层防护是 Origin 校验:

浏览器发出请求时会自动携带 Origin header,值是当前域名。curl 默认不带这个 header。在服务端检查它:

function isValidOrigin(req: Request): boolean {
  const origin = req.headers.get("origin");
  const appUrl = process.env.APP_URL?.replace(/\/$/, "");
  return !!origin && !!appUrl && origin === appUrl;
}

Origin 可以被手动伪造,但这已经把绝大多数脚本攻击挡在门外了。

Slug 白名单

即使绕过了 Origin 校验,攻击者还可以构造不存在的文章路径:

curl -X POST https://yoursite.com/api/views/fake/nonexistent
# Redis 里会创建垃圾 key

处理方式是在接受请求前验证 slug 是否对应真实存在的文章:

const validSlugs = getPostSlugs();
if (!validSlugs.includes(slugStr)) {
  return Response.json({ error: "Not Found" }, { status: 404 });
}

getPostSlugs() 拿到了所有博客的 slug,这个函数本来就是博客系统的一部分——不需要额外维护一份独立的白名单文件。

这个设计的好处是:每次新增或删除文章、重新部署后,白名单自动更新。 用现有的数据源作为唯一真相,零手动维护。

超过 1000 就显示 xxk

阅读量数字在三个地方展示:列表页卡片徽章、详情页 header、内容概览统计格。统一用一个工具函数处理格式:

export function formatCount(n: number): string {
  if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
  return n.toLocaleString();
}

12001.2k1500015k10001k.replace(/\.0$/, "") 去掉整数时的 .0 尾巴。

请求进来之后

把所有校验串起来,POST 接口的完整逻辑是:

请求进来
  → 开发环境?→ 直接返回 0
  → Origin 不匹配?→ 403
  → slug 不在文章列表?→ 404
  → 24h 内访过?→ 只读返回当前数值
  → 全部通过 → Redis +1,种 cookie,返回最新数值

每一层拦截都有明确的职责,不会互相耦合。

这套方案上线之后,开发环境的误刷、同一个用户的重复刷新、外部脚本的 curl 请求,三层拦截下来已经挡住了绝大多数无效计数。更深层的东西——代理 IP 池、分布式刷量——不是现在该操心的事。

暂时想到的就这么多了。

这个功能核心代码 150 行左右,分布在 API routes、工具函数和页面组件里。真正花时间的是把每个坑想清楚了再动手。