## 目标与背景
- 以低延迟、可扩展的方式实现“热门文章”排行,确保统计口径清晰、参数可验证、实现可复现。
- 覆盖 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`。
## 结论
- 方案在高并发下保持低开销与可扩展性,参数可控、口径清晰,可用于新闻门户、技术博客与知识库的“热门文章”能力落地。

发表评论 取消回复