实战 | 如何设计一套真实、抗刷的文章阅读数统计系统?

275 阅读3分钟

阅读数是衡量内容传播效果的重要指标之一。但它背后远不止“+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 批量合并写入

七、总结

阅读数看似简单,但在真实环境中要做好却并不容易。需要综合考虑:

  • 用户行为识别
  • 数据去重机制
  • 防刷策略
  • 缓存与数据库双写
  • 系统稳定性与性能优化

通过本文设计的这套方案,我们可以兼顾准确性、防刷能力、系统性能与用户体验,构建一套可持续、可扩展的文章阅读统计系统。

👋 最后问一句:你网站的阅读数,可信吗?


最后

如果文章对你有帮助,点个免费的赞鼓励一下吧!关注公众号:加瓦点灯, 每天推送干货知识!