成为全栈·Node 后端篇·阅读量防刷:去重、冷却与计数写分离

成为全栈·Node 后端篇·阅读量防刷:去重、冷却与计数写分离

各位看官,"阅读量 10086"这种数字,到底是人看的还是脚本刷的?如果一个接口每次被调用就 +1,那这个数字毫无意义------爬虫、刷新、自己点几十下都能注水,做得再花哨也是自欺欺人。

这一篇对照真实的 incrementViewCount,讲清三件事:怎么用"时间桶"做去重与 24 小时冷却、为什么计数要和去重表分开写(避开主表行锁竞争)、以及匿名身份下去重注定是"尽力而为"------边界在哪,我们如实交代。

一、朴素做法的两个极端

先看清大多数项目的错误解法:

  • 不防刷UPDATE articles SET view_count = view_count + 1 每次请求都执行。结果是刷新狂魔一个人能刷出几千阅读,数据完全失真,运营决策被带偏。
  • 永久去重 :给 (article_id, user_id) 加唯一约束,同一个人一辈子只看一次算一次。这又矫枉过正------读者周一看了、周五又回来复习,理应算两次,却永远只记一次,低估真实热度。

正确的中间态是:同一身份在一段窗口内(比如 24 小时)只计一次,过了窗口再来看就再计一次。这既挡住了"连点刷量",又保留了"隔天回访"的真实信号。

二、P-43:去重 + 24h 冷却,用"时间桶"实现

incrementViewCount 的真实代码,去重核心是这样的:

ts 复制代码
// src/services/article.ts --- incrementViewCount 去重核心
  // 去重采用「24h 时间桶」:dedupKey = baseKey#bucket,bucket = floor(now/WINDOW)。
  // 冷却过后桶号自然变化 → 不再撞旧记录,根除「永久唯一约束 vs 24h 冷却」的 500;
  // 同窗口并发插入撞唯一约束 → isUniqueConstraintError 兜底,跳过增量、返回 200,不重复计数。
  const baseKey = userId != null ? `u:${userId}` : `a:${fnv1a(`${ip}|${ua}`)}`;
  const bucket = Math.floor(Date.now() / VIEW_DEDUP_MS); // 每 24h 一个桶
  const dedupKey = `${baseKey}#${bucket}`;
  const recent = await db
    .select({ id: articleViewDedup.id })
    .from(articleViewDedup)
    .where(and(eq(articleViewDedup.articleId, id), eq(articleViewDedup.dedupKey, dedupKey)))
    .limit(1)
    .all();

思路很巧妙:

  1. baseKey 标识"谁在看" :登录用户直接用 userIdu:123);匿名用户没有稳定 id,退而用 fnv1a(ip|ua) 哈希(a:<hash>)------fnv1a 是个非加密的轻量哈希,足够把"同一浏览器"映射成稳定字符串,又不会暴露原始 ip。
  2. bucket 是时间桶floor(当前毫秒 / VIEW_DEDUP_MS),每 24 小时(24 × 3600 × 1000 毫秒)归一个桶号。今天和明天的桶号不同。
  3. dedupKey = baseKey#bucket :拼接后,含义是"某身份在某天对某文章"。同一人同一天狂刷,桶号不变、baseKey 不变,dedupKey 永远相同;过了午夜,桶号 +1,dedupKey 变了,又能计一次。

落库到 articleViewDedup 表(含 articleId + dedupKey,并有 (articleId, dedupKey) 唯一索引)。查询 recent.length === 0 表示"这个桶还没计过",才继续;否则直接返回当前计数,不重复 +1

这个"时间桶"设计的妙处在于:它用一次字符串拼接,同时解决了"去重"和"冷却"两个问题,不需要定时任务去清理过期记录,也不需要"永久唯一 vs 永远可刷"的二选一。桶号随时间自然滚动,旧桶记录成了无害的历史数据(真要瘦身,后台定时 DELETE 旧桶即可,但不影响正确性)。

三、P-44:计数写分离,去重与计数解耦

注意代码里先插去重记录、再单独 UPDATE 计数,两步是分开的:

ts 复制代码
// src/services/article.ts --- 计数写分离
  if (recent.length === 0) {
    try {
      await db
        .insert(articleViewDedup)
        .values({ articleId: id, dedupKey, createdAt: new Date() })
        .run();
    } catch (err) {
      if (isUniqueConstraintError(err)) {
        // 同窗口并发重复插入:视作已计数,跳过增量(不重复计数、不抛 500)
        const cur = (
          await db
            .select({ viewCount: articles.viewCount })
            .from(articles)
            .where(eq(articles.id, id))
            .limit(1)
            .all()
        )[0];
        return { viewCount: cur?.viewCount ?? existing.viewCount };
      }
      throw err;
    }
    await db
      .update(articles)
      .set({ viewCount: sql`${articles.viewCount} + 1` })
      .where(eq(articles.id, id))
      .run();
  }

为什么把"去重标记"和"计数"拆成两张表、两次写?

  • 职责不同articleViewDedup 是"防刷判据",只关心"这个桶计过没";articles.viewCount 是"展示数字",只关心累计。两者生命周期、查询模式完全不同,分开更清晰。
  • 计数用原子自增viewCount + 1 走 SQL 表达式(sql\${articles.viewCount} + 1`),由数据库在单条 UPDATE里完成,避免"先SELECTUPDATE`"的竞态(那种写法在高并发下会丢计数)。
  • 可独立扩展 :将来要分析"哪些文章被去重挡掉最多""真实 UV vs PV 比",articleViewDedup 直接能算 UV,而 viewCount 保持 PV 语义,互不干扰。

这就是"计数写分离"------把"要不要 +1 的判断"和"+1 的动作"解耦,各自用最合适的存储和写法。

四、并发兜底:唯一约束当锁用

高并发下,同一身份同一桶可能同时发来两个请求,都查到 recent.length === 0,都去 INSERT。这时 (articleId, dedupKey) 唯一索引会拦下第二个,抛唯一约束冲突。代码怎么处理?

ts 复制代码
// src/services/article.ts --- 并发兜底(catch 分支)
    } catch (err) {
      if (isUniqueConstraintError(err)) {
        // 同窗口并发重复插入:视作已计数,跳过增量(不重复计数、不抛 500)
        const cur = (
          await db
            .select({ viewCount: articles.viewCount })
            .from(articles)
            .where(eq(articles.id, id))
            .limit(1)
            .all()
        )[0];
        return { viewCount: cur?.viewCount ?? existing.viewCount };
      }
      throw err;
    }

关键点:撞唯一约束不算错误,而是"另一个请求已经计过了"的信号 。此时直接读当前 viewCount 返回,不抛 500、不重复 +1。这等于把数据库唯一索引当成一把"并发去重锁"用------既保证了不重复计数,又不会因为并发而返回 5xx 让前端报错。这是"用数据库特性兜底并发"的典范,比自己加分布式锁轻量得多。

五、匿名身份:去重是"尽力而为"

匿名用户没有 userId,只能用 ip + ua 哈希。这里必须诚实说明它的局限(P-43 延伸):

  • 共享 IP:公司 / 学校 / 小区 NAT 出口同一个公网 IP,几百号人看起来是"同一个匿名身份",互相的去重会串味------一人看了,同网段其他人当天就不计了。
  • UA 易变 :浏览器升级、伪装 UA,会让同一人的 baseKey 变化,去重失效(变成重复计数,偏宽松)。
  • 代理 / VPN:出口 IP 跳变,同理。

所以匿名去重是**"尽力而为"的近似**,不是精确 UV。要更准就得强制登录(但会牺牲匿名可读性)。我们的取舍是:登录用户精确(userId 稳定),匿名用户尽力(ip|ua 哈希),整体把"最恶意的连点刷量"挡住即可------防刷的目标是"挡住明显作弊",不是"精确统计到每个人",这两者成本差几个数量级。别为了追求理论精确,把简单问题搞成必须上 Redis + 设备指纹的巨系统。

六、公开可见性铁律:只计已发布

incrementViewCount 第一行就做了存在性 + 状态校验:

ts 复制代码
const existing = await db.select().from(articles)
  .where(and(eq(articles.id, id), isNull(articles.deletedAt))).limit(1).all();
if (existing?.status !== 'published') throw new AppError(ErrCode.NOT_FOUND, 404);

只有 published 的文章才计阅读量,否则 404 。这呼应全站"公开可见性铁律":草稿、待审、已软删的文章,对公开接口应当"不存在"。既防止有人靠刷草稿/别人的待审文章制造噪音,也避免把未发布内容的阅读行为暴露出去。路由层 articles-read.tsPOST /:id/viewoptionalAuthMiddleware------匿名也能访问(返回 200 计数),但内部用 me?.id 判断有没有 userId,对应上面 baseKey 的分支。

七、和"阅读历史"是两回事

别把"阅读量"和"阅读历史"搞混。我们在 M1 会员中心那篇讲过 POST /me/history(B6 阅读历史):那是登录会员的个人进度 (看到第几段、上次读到哪),用 viewHistory 表 upsert,只本人可见,是会员中心功能。而本篇的 viewCount全站公开的文章热度指标 ,匿名也能贡献,走 articleViewDedup 防刷。两个概念:

阅读量 viewCount 阅读历史 viewHistory
目的 全站热度指标 个人续读进度
身份 登录精确 / 匿名尽力 必须登录
去重 24h 时间桶 按 (user, article) upsert
可见性 公开 仅本人

一个对外展示、一个对内服务,存储和逻辑都分开,这才是清晰的边界。

八、全站累计:coalesce(sum(viewCount))

阅读量还能聚合成全站指标。stats.tsgetSiteStats 用一条 SQL 汇总:

ts 复制代码
db.select({ s: sql<number>`coalesce(sum(${articles.viewCount}), 0)` })
  .from(articles).where(published).all();

coalesce(sum(...), 0) 保证"一篇都没有"时返回 0 而非 NULL,前端不用做空值兜底。注意它只 sum 已发布(published)且未软删的文章------和全站统计的其他指标(已发布文章数、已通过评论数、活跃用户数)口径一致,都是"对外可见的真实状态"。

十、为什么去重必须放服务端,以及可调与排障

最后点两个容易被忽略的工程决策。

去重绝不能靠前端 。有人想"前端设个 cookie / localStorage,标记看过了就不再发请求"------这等于把防刷的钥匙交给用户:清 cookie、隐私模式、无头浏览器、写个脚本,全都能绕过。前端只负责"展示",服务端才是计数的唯一真相源 。所以我们把去重判据落库到 articleViewDedup,无论前端怎么折腾,服务端每次都重新裁决"这个桶计过没"。

VIEW_DEDUP_MS 是个可调旋钮 。24 小时是默认窗口,但它被收敛成一个常量,想更严格(比如改成 1 小时防刷得更狠)只需改这个值,桶粒度随之变细,整套逻辑不动。这体现"业务参数收敛成常量、不要散落在字面量里"的纪律------将来调策略,改一处即可,不用满代码搜 86400000

排障三板斧(计数"不涨"时按顺序查):

  1. 是不是 articleViewDedup 里已有同桶记录?有就说明"本窗口已计过",返回原值不 +1,这是预期行为,不是 bug。
  2. 文章 status 是不是 published?不是就 404,根本不进计数逻辑。
  3. 是不是并发兜底命中了?同窗口两请求撞唯一索引,后者返回 200 但不 +1,日志里能看到(但通常无需处理)。

另外新文章 viewCount 默认 0(schema default(0)),符合直觉;冷启动不会凭空冒出虚假阅读量。

十一、小结

阅读量防刷的本质,是把"真实信号"从"噪声"里筛出来

  1. 朴素做法失效:不防刷→注水,永久去重→低估回访,都不行。
  2. P-43 时间桶dedupKey = baseKey#bucketbucket = floor(now/24h);登录用 userId,匿名用 fnv1a(ip|ua),同人同日只计一次、隔日再计。
  3. P-44 计数写分离 :先插 articleViewDedup 判据、再 UPDATE viewCount+1 原子自增,职责与写法都解耦。
  4. 并发兜底:唯一索引当锁,撞冲突即"已计过",返回 200 不重复 +1、不抛 500。
  5. 匿名局限:ip|ua 去重是尽力而为,挡明显作弊即可,不为理论精确上重型系统。
  6. 公开铁律 :仅 published 计阅读量,否则 404(隐瞒未发布存在性)。
  7. 与阅读历史区分:viewCount 是公开热度、viewHistory 是本人进度,存储/逻辑分离。
  8. 全站累计coalesce(sum(viewCount),0) 只统计已发布未删。

下一篇({{LINK:M1-27}})我们聊"评论内容安全":敏感词过滤、评论三态审核、以及 SVG / 外链这类内容层面的坑。


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
脉动数据行情115 小时前
Node.js WebSocket 实现贵金属实时行情监听 伦敦金 / 伦敦银自动重连方案
websocket·node.js·vim
不老刘16 小时前
一行命令解决 Node.js 版本兼容问题:`--openssl-legacy-provider` 深度解析
node.js
万敏1 天前
Vue3 全栈实战:第一阶段复盘(第1-8周)
vue.js·node.js·全栈
濮水大叔1 天前
舒服了,CabloyJS 的 AI Spec 驱动开发会自动生成甘特图和燃尽图
typescript·node.js·vibecoding
FungLeo1 天前
成为全栈·Node 后端篇·部署上线:从本地起服到真正对外服务
node.js·后端部署·成为全栈
妙码生花2 天前
golang 应用服务端部署(使用 systemd 服务)
开发语言·人工智能·后端·golang·node.js·php·gin
柚稚姐姐2 天前
npm install pnpm -g npm error code EACCES npm error syscall symlink
前端·npm·node.js
FungLeo2 天前
成为全栈·Node 后端篇·容器化:给 Node 应用写一个像样的 Dockerfile
docker·node.js·dockerfile·成为全栈·后端服务容器·docker 容器
WeiXin_DZbishe3 天前
基于springboot大学生提问箱系统-计算机毕设【课程设计】72593
javascript·vue.js·spring boot·vscode·python·node.js·php