阅读数是衡量内容传播效果的重要指标之一。但它背后远不止“+1”这么简单——如何保证统计准确?防止恶意刷量?保证系统高性能?
一、为什么阅读数统计很难做对?
你可能会直接写个接口:
@GetMapping("/article/read")
public void addReadCount(Long articleId) {
articleRepository.incrementReadCount(articleId);
}
但你会很快遇到这些问题:
- 用户 F5 连续刷新,阅读数暴涨
- 攻击者写脚本刷量(伪造请求)
- 游客重复计算(没有身份标识)
- 短暂停留也被记为有效阅读
- 高并发导致数据库压力大
👆 所以,想做“真实阅读数”,必须具备以下能力:
- ✅ 唯一用户识别(用户 or 设备)
- ✅ 时间窗口去重
- ✅ 行为过滤(短时间退出不计数)
- ✅ 防刷机制
- ✅ 高并发优化
二、系统目标与架构设计
目标
| 需求 | 说明 |
|---|---|
| 实时性 | 用户访问后 5 秒内阅读数更新 |
| 防刷 | IP、设备去重,限制频率 |
| 游客识别 | 使用 deviceId 或浏览器指纹 |
| 高性能 | Redis 缓存计数,异步持久化到数据库 |
架构图
[浏览文章]
↓
[前端延迟上报] —— 加上 userId/deviceId
↓
[后端接收] —— 判断是否去重 + 防刷判断
↓
[Redis 计数]
↓
[定时批量持久化到数据库]
三、前端方案
延迟上报行为,过滤秒退用户
mounted() {
this.timer = setTimeout(() => {
this.reportRead();
}, 5000); // 停留5秒再上报
},
methods: {
reportRead() {
this.$axios.post('/api/article/read', {
articleId: this.articleId,
deviceId: this.getDeviceId(),
});
},
getDeviceId() {
let id = localStorage.getItem('deviceId');
if (!id) {
id = 'd-' + Math.random().toString(36).substring(2) + Date.now();
localStorage.setItem('deviceId', id);
}
return id;
}
},
beforeDestroy() {
clearTimeout(this.timer);
}
可选:引入浏览器指纹
<script src="https://cdn.jsdelivr.net/npm/@fingerprintjs/fingerprintjs@3/dist/fp.min.js"></script>
<script>
FingerprintJS.load().then(fp => {
fp.get().then(result => {
const visitorId = result.visitorId;
localStorage.setItem('fingerprint', visitorId);
});
});
</script>
四、后端方案
接口设计
@PostMapping("/api/article/read")
public ResponseEntity<?> reportRead(@RequestBody ReadDTO dto, HttpServletRequest request) {
String ip = request.getRemoteAddr();
String deviceId = dto.getDeviceId();
Long userId = getLoginUserId();
articleReadService.recordRead(dto.getArticleId(), userId, deviceId, ip);
return ResponseEntity.ok().build();
}
Redis 去重 + 阅读计数
public void recordRead(Long articleId, Long userId, String deviceId, String ip) {
String uniqueKey = "read:" + articleId + ":" + (userId != null ? "u:" + userId : "d:" + deviceId);
if (!redisTemplate.hasKey(uniqueKey)) {
redisTemplate.opsForValue().set(uniqueKey, "1", 1, TimeUnit.HOURS); // 1小时内去重
if (passRiskControl(ip, deviceId)) {
redisTemplate.opsForValue().increment("article:read:count:" + articleId);
}
}
}
防刷策略实现(简单 IP 防护)
public boolean passRiskControl(String ip, String deviceId) {
String ipKey = "read:ip:" + ip;
Long count = redisTemplate.opsForValue().increment(ipKey);
redisTemplate.expire(ipKey, Duration.ofHours(1));
if (count != null && count > 1000) {
log.warn("IP 访问过于频繁:{}", ip);
return false;
}
return true;
}
可扩展更强防护策略:
- 限速算法(令牌桶)
- 黑名单 IP
- 接入滑动验证码验证可疑请求
- UA + Referer 白名单
批量写入数据库(定时任务)
@Scheduled(fixedRate = 60000)
public void persistReadCount() {
Set<String> keys = redisTemplate.keys("article:read:count:*");
for (String key : keys) {
Long articleId = Long.valueOf(key.split(":")[3]);
Long count = redisTemplate.opsForValue().get(key);
if (count != null && count > 0) {
articleMapper.incrementReadCount(articleId, count);
redisTemplate.delete(key);
}
}
}
SQL 示例:
UPDATE article SET read_count = read_count + #{count} WHERE id = #{articleId}
五、展示阅读数(前端)
<div class="meta">
阅读 {{ article.readCount }} 次
</div>
后端接口返回文章详情时附带 readCount 字段。
六、常见问题与优化建议
| 问题 | 解决方案 |
|---|---|
| 游客刷新页面反复记数 | deviceId 去重 |
| 短暂停留也被计数 | 延迟上报 |
| 攻击者伪造请求 | 防刷校验(IP、UA) |
| Redis 持久化丢失 | 加入持久化队列/异步写库机制 |
| 并发写库压力大 | Redis 批量合并写入 |
七、总结
阅读数看似简单,但在真实环境中要做好却并不容易。需要综合考虑:
- 用户行为识别
- 数据去重机制
- 防刷策略
- 缓存与数据库双写
- 系统稳定性与性能优化
通过本文设计的这套方案,我们可以兼顾准确性、防刷能力、系统性能与用户体验,构建一套可持续、可扩展的文章阅读统计系统。
👋 最后问一句:你网站的阅读数,可信吗?
最后
如果文章对你有帮助,点个免费的赞鼓励一下吧!关注公众号:加瓦点灯, 每天推送干货知识!