成为全栈·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();
思路很巧妙:
baseKey标识"谁在看" :登录用户直接用userId(u:123);匿名用户没有稳定 id,退而用fnv1a(ip|ua)哈希(a:<hash>)------fnv1a是个非加密的轻量哈希,足够把"同一浏览器"映射成稳定字符串,又不会暴露原始 ip。bucket是时间桶 :floor(当前毫秒 / VIEW_DEDUP_MS),每 24 小时(24 × 3600 × 1000 毫秒)归一个桶号。今天和明天的桶号不同。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里完成,避免"先SELECT再UPDATE`"的竞态(那种写法在高并发下会丢计数)。 - 可独立扩展 :将来要分析"哪些文章被去重挡掉最多""真实 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.ts 的 POST /:id/view 用 optionalAuthMiddleware------匿名也能访问(返回 200 计数),但内部用 me?.id 判断有没有 userId,对应上面 baseKey 的分支。
七、和"阅读历史"是两回事
别把"阅读量"和"阅读历史"搞混。我们在 M1 会员中心那篇讲过 POST /me/history(B6 阅读历史):那是登录会员的个人进度 (看到第几段、上次读到哪),用 viewHistory 表 upsert,只本人可见,是会员中心功能。而本篇的 viewCount 是全站公开的文章热度指标 ,匿名也能贡献,走 articleViewDedup 防刷。两个概念:
| 阅读量 viewCount | 阅读历史 viewHistory | |
|---|---|---|
| 目的 | 全站热度指标 | 个人续读进度 |
| 身份 | 登录精确 / 匿名尽力 | 必须登录 |
| 去重 | 24h 时间桶 | 按 (user, article) upsert |
| 可见性 | 公开 | 仅本人 |
一个对外展示、一个对内服务,存储和逻辑都分开,这才是清晰的边界。
八、全站累计:coalesce(sum(viewCount))
阅读量还能聚合成全站指标。stats.ts 的 getSiteStats 用一条 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。
排障三板斧(计数"不涨"时按顺序查):
- 是不是
articleViewDedup里已有同桶记录?有就说明"本窗口已计过",返回原值不 +1,这是预期行为,不是 bug。 - 文章
status是不是published?不是就 404,根本不进计数逻辑。 - 是不是并发兜底命中了?同窗口两请求撞唯一索引,后者返回 200 但不 +1,日志里能看到(但通常无需处理)。
另外新文章 viewCount 默认 0(schema default(0)),符合直觉;冷启动不会凭空冒出虚假阅读量。
十一、小结
阅读量防刷的本质,是把"真实信号"从"噪声"里筛出来:
- 朴素做法失效:不防刷→注水,永久去重→低估回访,都不行。
- P-43 时间桶 :
dedupKey = baseKey#bucket,bucket = floor(now/24h);登录用userId,匿名用fnv1a(ip|ua),同人同日只计一次、隔日再计。 - P-44 计数写分离 :先插
articleViewDedup判据、再UPDATE viewCount+1原子自增,职责与写法都解耦。 - 并发兜底:唯一索引当锁,撞冲突即"已计过",返回 200 不重复 +1、不抛 500。
- 匿名局限:ip|ua 去重是尽力而为,挡明显作弊即可,不为理论精确上重型系统。
- 公开铁律 :仅
published计阅读量,否则 404(隐瞒未发布存在性)。 - 与阅读历史区分:viewCount 是公开热度、viewHistory 是本人进度,存储/逻辑分离。
- 全站累计 :
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
