## 目标与原则

  • 精确采集 `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 的实现,可稳定产出可验证、可调优的热门文章排行。
  • 按本文流程压测与回放数据,可复现参数选择的效果并在生产迭代中持续优化。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部