## 目标与背景

  • 以低延迟、可扩展的方式实现“热门文章”排行,确保统计口径清晰、参数可验证、实现可复现。
  • 覆盖 PV(浏览量)、UV(独立访客)、停留时长三类指标,并引入时间衰减保证新内容具备竞争力。

## 技术栈与组件

  • `Nginx + PHP-FPM` 提供采集与查询 API;`phpredis` 扩展建议用于生产。
  • `Redis` 负责高并发计数与排行,核心结构:`String`、`HyperLogLog`、`Sorted Set`。

## 指标与权重(可验证参数)

  • PV 权重 `w_pv = 1`:`INCR` 线性叠加,O(1)。
  • UV 权重 `w_uv = 3`:`HyperLogLog` 估计误差约 `±0.81%`(Redis 官方实现的标准误差);
  • 键空间:`PFADD hot:uv:hll:{articleId} {userId}`,`PFCOUNT` 读取 UV。
  • HLL 在密集编码下约 `12 KB/键`,小基数下采用稀疏编码更省内存。
  • 停留时长权重 `w_dwell = 0.002`:以毫秒为单位参与得分,单次上报封顶 `180000ms`(3 分钟)防作弊。
  • 时间衰减半衰期 `half_life = 86400s`(24 小时):采用指数衰减,公式如下。

### 时间衰减公式(指数衰减,可复现)

  • 基础分:`base = w_pv * pv + w_uv * uv + w_dwell * avg_dwell_ms`
  • 衰减因子:`decay = exp(-ln(2) * age_seconds / half_life)`
  • 有效分:`score = base * decay`
  • 解释:当时间增加一个 `half_life`,有效分衰减到一半;参数直观且可调。

## 键设计与 TTL 策略

  • 计数键:
  • `hot:pv:{id}`、`hot:uv:hll:{id}`、`hot:dwell:sum:{id}`、`hot:dwell:cnt:{id}`、`hot:first_ts:{id}`
  • 排行键:`hot:rank`
  • TTL 建议:除排行键外,其它计数键设置 `EXPIRE 30d`,避免无限增长;`hot:rank`长期存在并随重新打分被更新。

## 采集接口(PHP + Redis,原子更新)

<?php
class HotArticleService {
    private Redis $r;
    private string $rankKey = 'hot:rank';

    public function __construct(Redis $r) { $this->r = $r; }

    public function track(string $articleId, string $userId, int $dwellMs, int $halfLifeSec = 86400): array {
        $script = <<<LUA
        local now = tonumber(ARGV[1])
        local id = ARGV[2]
        local user = ARGV[3]
        local dwell = tonumber(ARGV[4])
        local half = tonumber(ARGV[5])

        if dwell > 180000 then dwell = 180000 end

        local pvkey = 'hot:pv:'..id
        local uvkey = 'hot:uv:hll:'..id
        local sumkey = 'hot:dwell:sum:'..id
        local cntkey = 'hot:dwell:cnt:'..id
        local tskey = 'hot:first_ts:'..id
        local zkey  = 'hot:rank'

        redis.call('INCR', pvkey)
        redis.call('PFADD', uvkey, user)
        redis.call('INCRBY', sumkey, dwell)
        redis.call('INCR', cntkey)

        if redis.call('EXISTS', tskey) == 0 then
          redis.call('SET', tskey, now)
        end

        local pv = tonumber(redis.call('GET', pvkey)) or 0
        local uv = tonumber(redis.call('PFCOUNT', uvkey)) or 0
        local sum = tonumber(redis.call('GET', sumkey)) or 0
        local cnt = tonumber(redis.call('GET', cntkey)) or 0
        local avg = 0
        if cnt > 0 then avg = sum / cnt end

        local base = 1.0 * pv + 3.0 * uv + 0.002 * avg

        local first = tonumber(redis.call('GET', tskey)) or now
        local age = now - first
        local decay = math.exp(-math.log(2) * age / half)
        local score = base * decay

        redis.call('ZADD', zkey, score, id)

        -- 合理的 TTL,防止键膨胀
        local ttl = 30 * 24 * 3600
        redis.call('EXPIRE', pvkey, ttl)
        redis.call('EXPIRE', uvkey, ttl)
        redis.call('EXPIRE', sumkey, ttl)
        redis.call('EXPIRE', cntkey, ttl)
        redis.call('EXPIRE', tskey, ttl)

        return { pv, uv, avg, score }
LUA;

        $sha = $this->r->script('load', $script);
        $res = $this->r->evalSha($sha, [time(), $articleId, $userId, $dwellMs, $halfLifeSec], 0);
        return [
            'pv' => (int)$res[1],
            'uv' => (int)$res[2],
            'avg_dwell_ms' => (float)$res[3],
            'score' => (float)$res[4],
        ];
    }

    public function top(int $limit = 20): array {
        // ZREVRANGE with scores
        $ids = $this->r->zRevRange($this->rankKey, 0, $limit - 1, true);
        $out = [];
        foreach ($ids as $id => $score) { $out[] = ['id' => $id, 'score' => (float)$score]; }
        return $out;
    }
}

### 采集 API 示例

  • `POST /api/track`
  • Body:`{ articleId, userId, dwellMs }`
  • 成功返回:`{ pv, uv, avg_dwell_ms, score }`

## 查询与展示

  • Top N:`ZREVRANGE hot:rank 0 19 WITHSCORES`;页面按分值排序展示。
  • 明细诊断:可同时读 `hot:pv:{id}`、`hot:uv:hll:{id} -> PFCOUNT`、`hot:dwell:sum/cnt:{id}`。

## 参数验证与性能画像

  • 复杂度:`INCR/O(1)`、`PFADD/PFCOUNT ~ O(1)`、`ZADD O(logN)`、`ZREVRANGE O(M + logN)`。
  • HLL 误差:标准误差约 `±0.81%`,适合 UV 估算;对结算类场景不建议使用。
  • 压测方法:
  • 采集端:`wrk -t4 -c100 -d30s --latency http://host/api/track`
  • Redis 侧:`redis-benchmark -t INCR,PFADD,ZADD -n 100000 -q`
  • 观察:P99 时延、错误率、CPU 与内存占用(`INFO`, `LATENCY DOCTOR`)。

## 防作弊与数据治理

  • UV 去重以 `userId` 为主,必要时引入 `fingerprint + User-Agent + IP` 组合键。
  • 停留时长封顶与上报去抖:同会话短周期内按最大值取样;后端再次封顶。
  • 机器人排除:UA 黑名单、速率限制(`Nginx limit_req` 或应用层令牌桶)。

## 可复现实操清单

  • 安装 `phpredis`,连接 Redis 并启用上述服务类。
  • 前端在文章详情页埋点:进入时记录 `t0`,离开或心跳上报 `dwellMs`。
  • 后端路由:`/api/track` 调用 `track()`,列表页调用 `top()`。
  • 调参:根据业务目标微调 `w_pv/w_uv/w_dwell` 与 `half_life`。

## 结论

  • 方案在高并发下保持低开销与可扩展性,参数可控、口径清晰,可用于新闻门户、技术博客与知识库的“热门文章”能力落地。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部