## 目标与原则
- 精确采集 `PV/UV/停留时长` 并抗作弊(UA/Referer/IP+Cookie 粗过滤)。
- 排名使用可解释的线性加权 + 指数时间衰减,便于调参与复现。
- 读写采用 Redis(`ZSET`、`HyperLogLog`、`HASH`),保证低延迟和高并发可扩展。
- 提供压测与监控指标,参数选择有据可验。
## 排行评分模型
- 基础公式:
- `score(t) = (w_pv * PV + w_uv * UV + w_dur * AvgDuration) * decay(t)`
- 指数衰减:`decay(t) = exp(-Δt / τ)`,其中 `Δt` 为距发布时间小时数,`τ` 为半衰常数(小时)。
- 推荐初始权重(可验证与可调):
- `w_pv = 1.0`(PV 单位权重便于解释)
- `w_uv = 3.0`(UV 相比 PV 更能代表独立兴趣)
- `w_dur = 0.05`(每秒停留时长贡献较小但能提升质量)
- 推荐初始衰减:
- `τ = 48h`(两天为半衰,兼顾新内容曝光与旧内容积累)
- 验证方法:
- 使用真实采样数据(日志或埋点)回放,比较不同 `w_*` 与 `τ` 下的 Top-N 稳定性(NDCG@10、覆盖率、波动率)。
## Redis 结构设计
- 键空间约定(按文章 `aid` 标识):
- `zset:rank:score`:ZSET,成员为 `aid`,分值为当前 `score(t)`。
- `hll:uv:{aid}`:HyperLogLog,记录 UV(去重访客)。
- `hash:pv:{aid}`:HASH,字段 `pv` 为累计 PV。
- `hash:dur:{aid}`:HASH,字段 `sum` 为总停留秒数,`cnt` 为会话次数,用于计算平均停留时长。
- `hash:meta:{aid}`:发布时间 `published_at`(Unix 秒),类目、作者等元信息。
- 采集写入:
- PV:`HINCRBY hash:pv:{aid} pv 1`
- UV:`PFADD hll:uv:{aid} {visitor_id}`(`visitor_id` 可由 IP+UA+Cookie 哈希)
- 时长:会话结束或心跳累计 `HINCRBY hash:dur:{aid} sum {seconds}`,`HINCRBY hash:dur:{aid} cnt 1`
## 刷新与计算策略
- 周期性计算(每 1 分钟或 5 分钟):
- 拉取 `PV`、`UV`、`AvgDuration = sum/cnt`,计算 `Δt` 与 `decay(t)`,写入 `ZADD zset:rank:score`。
- 即时微调:
- 新发布前 2 小时可使用 `τ_short = 24h` 提升初期竞争力,后续恢复默认 `τ = 48h`。
- 并发与一致性:
- 计算任务使用分布式锁(`SET lock:rank nx ex 30`),避免重复执行。
## Laravel 10 实现示例
- 依赖:`phpredis` 或 `predis/predis`,示例用框架内置 `Redis` 门面。
// app/Services/HotRankService.php
namespace App\Services;
use Illuminate\Support\Facades\Redis;
class HotRankService
{
private float $wPv = 1.0;
private float $wUv = 3.0;
private float $wDur = 0.05;
private float $tauHours = 48.0; // τ
public function collectPv(int $aid): void
{
Redis::hincrby("hash:pv:{$aid}", 'pv', 1);
}
public function collectUv(int $aid, string $visitorId): void
{
Redis::pfadd("hll:uv:{$aid}", $visitorId);
}
public function collectDuration(int $aid, int $seconds): void
{
Redis::hincrby("hash:dur:{$aid}", 'sum', $seconds);
Redis::hincrby("hash:dur:{$aid}", 'cnt', 1);
}
private function avgDuration(int $aid): float
{
$hash = Redis::hgetall("hash:dur:{$aid}");
$sum = isset($hash['sum']) ? (int)$hash['sum'] : 0;
$cnt = isset($hash['cnt']) ? (int)$hash['cnt'] : 0;
return $cnt > 0 ? $sum / $cnt : 0.0;
}
private function hoursSincePublish(int $aid): float
{
$meta = Redis::hgetall("hash:meta:{$aid}");
$published = isset($meta['published_at']) ? (int)$meta['published_at'] : time();
return max(0.0, (time() - $published) / 3600.0);
}
private function uv(int $aid): int
{
return (int) Redis::pfcount("hll:uv:{$aid}");
}
private function pv(int $aid): int
{
return (int) Redis::hget("hash:pv:{$aid}", 'pv') ?: 0;
}
public function computeScore(int $aid): float
{
$pv = $this->pv($aid);
$uv = $this->uv($aid);
$avgDur = $this->avgDuration($aid);
$hours = $this->hoursSincePublish($aid);
$decay = exp(- $hours / $this->tauHours);
return ($this->wPv * $pv + $this->wUv * $uv + $this->wDur * $avgDur) * $decay;
}
public function refreshRanks(array $aids): void
{
// 简易锁,生产可用 Redlock 或 Lua 保证原子性
$lock = Redis::set('lock:rank', '1', 'NX', 'EX', 30);
if (!$lock) return;
foreach ($aids as $aid) {
$score = $this->computeScore($aid);
Redis::zadd('zset:rank:score', [$aid => $score]);
}
Redis::del('lock:rank');
}
public function topN(int $n = 10): array
{
// 返回 aid 与 score
$ids = Redis::zrevrange('zset:rank:score', 0, $n - 1, true);
$result = [];
foreach ($ids as $aid => $score) {
$result[] = ['aid' => (int)$aid, 'score' => (float)$score];
}
return $result;
}
}
// app/Console/Commands/RefreshHotRank.php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\HotRankService;
class RefreshHotRank extends Command
{
protected $signature = 'hot:refresh {--n=10}';
protected $description = '刷新热门文章排行';
public function handle(HotRankService $service): int
{
// 实际项目从 DB 拉取最近 7 天文章 ID
$aids = range(1, 1000);
$service->refreshRanks($aids);
$top = $service->topN((int)$this->option('n'));
$this->info('Top: ' . json_encode($top, JSON_UNESCAPED_UNICODE));
return self::SUCCESS;
}
}
// routes/api.php(示例接口)
use Illuminate\Support\Facades\Route;
use App\Services\HotRankService;
Route::post('/articles/{aid}/pv', function (int $aid, HotRankService $s) {
$s->collectPv($aid);
return response()->noContent();
});
Route::post('/articles/{aid}/uv', function (int $aid, HotRankService $s) {
$visitorId = sha1(request()->ip() . '|' . request()->userAgent() . '|' . request()->cookie('vid'));
$s->collectUv($aid, $visitorId);
return response()->noContent();
});
Route::post('/articles/{aid}/duration', function (int $aid, HotRankService $s) {
$seconds = (int) request('seconds', 0);
if ($seconds > 0 && $seconds <= 3600) { // 基本过滤
$s->collectDuration($aid, $seconds);
}
return response()->noContent();
});
Route::get('/hot/top', function (HotRankService $s) {
return response()->json($s->topN());
});
## 监控与压测验证
- 指标采集:
- `Redis`:`latency`, `used_memory`, `keyspace_hits/misses`, `commandstats`。
- 应用:`RPS`, `p95/p99` 延迟,`error_rate`,队列积压。
- 压测建议:
- 使用 `k6` 或 `wrk`:
- `wrk -t4 -c256 -d60s http://localhost/api/articles/123/pv`
- 并发模拟 `PV/UV/duration` 三类写入与 `top` 读取,观察延迟与吞吐。
- 参数回归:
- 回放真实日志,评估 `w_*` 与 `τ` 对 Top-10 稳定性的影响,优先选择提升 NDCG 与降低波动的组合。
## 注意事项
- 防刷与异常:
- 对单 IP 或同 Cookie 的异常高频行为限流(令牌桶),并记录审计日志。
- 停留时长采用心跳或 `visibilitychange` 前端事件更准确,超长会话需封顶(如 600s)。
- 一致性与可恢复:
- 定期将 Redis 指标落盘到 DB,故障可回放恢复。
- 配置与安全:
- Redis 使用密码与 ACL,区分只读与写入客户端;外网禁止直连。
## 常见问题与解法
- HLL 精度:误差约 1%,满足 UV 场景;关键榜单可辅以布隆过滤或明细去重。
- 权重选择:以业务目标为准,内容质量更重要时提高 `w_dur`;追求曝光则提高 `w_pv/w_uv`。
- 衰减曲线:新闻类可用更小 `τ`(24h),长尾内容可增大 `τ`(72h)。
## 结论
- 通过 `PV/UV/停留时长 + 指数时间衰减` 的可解释模型,结合 Redis 高并发结构与 Laravel 10 的实现,可稳定产出可验证、可调优的热门文章排行。
- 按本文流程压测与回放数据,可复现参数选择的效果并在生产迭代中持续优化。

发表评论 取消回复