## 适用范围与版本

  • 适用:内容站点/博客/资讯类服务的「热门文章」模块。
  • 基线版本:.NET 8、ASP.NET Core Minimal API;Redis 6.2+(ZADD/ZINCRBY);主存储使用 PostgreSQL 15+/MySQL 8.0+。

## 指标体系与权重设计(可验证)

  • 采集指标:
  • PV:页面浏览次数(经基本抗爬过滤)。
  • UV:独立访客数(基于登录态或 IP+UA 去重)。
  • AvgDuration:平均停留时长(秒)。
  • Interactions:点赞、评论、收藏等互动信号。
  • AgeHours:文章发布后的小时数(用于时间衰减)。
  • 推荐评分函数(默认权重可回归验证后调整):
  • score = 0.35·log(PV+1) + 0.25·UV + 0.25·min(AvgDuration, 300)/60 + 0.10·Likes + 0.05·Comments − 0.02·AgeHours
  • 参数选择依据:
  • 使用 log(PV+1) 抑制早期流量峰值的长期霸榜效应。
  • UV 权重高于 PV,体现真实覆盖质量。
  • AvgDuration 上限 300s,避免异常长停留扭曲分数;按分钟归一更稳健。
  • 时间衰减 0.02/h 保证新文可上榜且旧文在无新增互动时逐步下滑。

## 数据采集与防作弊

  • 采集:前端埋点上报 PV/UV/停留时长与互动事件,通过 API 写入计数;高并发场景可采用队列/Kafka 异步聚合。
  • 防作弊:
  • 前端:基于 Page Visibility 仅在可见状态计时;滚动与切页不重复上报停留;节流上报(如 5–10s)。
  • 后端:`UV = COUNT(DISTINCT user_id || ip_hash || ua_hash)`;对异常高频 UA/IP 做速率限制与封禁。
  • 机器人识别:维护 UA 黑名单与访问速率阈值(PV/s、UV/s)。

## Redis 排行结构与更新策略

  • 使用 `ZSET` 存储热门分数:key `hot:articles`,member 为 `article_id`,score 为推荐分。
  • 计数分桶(按小时聚合便于回放与重算):
  • `cnt:pv:{article_id}:{yyyyMMddHH}`、`cnt:uv:{...}`、`cnt:dur:{...}`、`cnt:like:{...}`、`cnt:cmt:{...}`。
  • 更新策略:
  • 实时:事件到达即更新对应计数并触发增量重算(节流至每 5–10s)。
  • 批量:每分钟回放最近 24h 的小时桶,计算 AgeHours 与衰减并写 ZSET。
  • 过期与归档:小时桶 7 天过期;滚动窗口外数据周期归档到主库用于报表。

## C# 端实现示例(可运行)

using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
    ConnectionMultiplexer.Connect("localhost:6379"));
var app = builder.Build();

record Metrics(long PV, long UV, double AvgDuration, long Likes, long Comments, double AgeHours);

static double Score(Metrics m)
{
    var pv = 0.35 * Math.Log(m.PV + 1);
    var uv = 0.25 * m.UV;
    var dur = 0.25 * Math.Min(m.AvgDuration, 300) / 60.0;
    var likes = 0.10 * m.Likes;
    var cmts = 0.05 * m.Comments;
    var decay = 0.02 * m.AgeHours;
    return pv + uv + dur + likes + cmts - decay;
}

static double AgeHours(DateTimeOffset publishedAt, DateTimeOffset now) => (now - publishedAt).TotalHours;

app.MapPost("/events/{articleId}", async (string articleId, IConnectionMultiplexer mux, HttpRequest req) =>
{
    var db = mux.GetDatabase();
    var now = DateTimeOffset.UtcNow;
    var hour = now.ToString("yyyyMMddHH");
    // 示例:根据请求参数递增计数(真实场景请做参数校验与鉴权)
    var pvKey = $"cnt:pv:{articleId}:{hour}";
    var uvKey = $"cnt:uv:{articleId}:{hour}"; // 实际需去重后再写入
    var durKey = $"cnt:dur:{articleId}:{hour}"; // 秒累计,平均值在重算环节计算
    var likeKey = $"cnt:like:{articleId}:{hour}";
    var cmtKey = $"cnt:cmt:{articleId}:{hour}";

    var tasks = new Task[]
    {
        db.StringIncrementAsync(pvKey),
        db.StringIncrementAsync(uvKey),
        db.StringIncrementAsync(durKey, 5), // 示例:本次停留累计 5 秒
        db.StringIncrementAsync(likeKey),
        db.StringIncrementAsync(cmtKey)
    };
    await Task.WhenAll(tasks);
    return Results.Accepted();
});

app.MapPost("/rank/recompute/{articleId}", async (string articleId, IConnectionMultiplexer mux) =>
{
    var db = mux.GetDatabase();
    var now = DateTimeOffset.UtcNow;
    // 聚合最近 24h 计数
    long pv = 0, uv = 0, likes = 0, cmts = 0; double durTotal = 0;
    for (int i = 0; i < 24; i++)
    {
        var ts = now.AddHours(-i).ToString("yyyyMMddHH");
        pv += (long)(await db.StringGetAsync($"cnt:pv:{articleId}:{ts}")).TryParseLong();
        uv += (long)(await db.StringGetAsync($"cnt:uv:{articleId}:{ts}")).TryParseLong();
        durTotal += (double)(await db.StringGetAsync($"cnt:dur:{articleId}:{ts}")).TryParseDouble();
        likes += (long)(await db.StringGetAsync($"cnt:like:{articleId}:{ts}")).TryParseLong();
        cmts += (long)(await db.StringGetAsync($"cnt:cmt:{articleId}:{ts}")).TryParseLong();
    }
    var avgDur = pv > 0 ? durTotal / pv : 0;
    // 示例:从主库读取发布时间
    var publishedAt = DateTimeOffset.UtcNow.AddHours(-12); // 请替换为真实发布时间
    var age = AgeHours(publishedAt, now);
    var sc = Score(new Metrics(pv, uv, avgDur, likes, cmts, age));
    await db.SortedSetAddAsync("hot:articles", articleId, sc);
    return Results.Ok(new { articleId, score = sc });
});

app.MapGet("/rank/top/{n}", async (int n, IConnectionMultiplexer mux) =>
{
    var db = mux.GetDatabase();
    var res = await db.SortedSetRangeByRankAsync("hot:articles", 0, n - 1, Order.Descending);
    return Results.Ok(res.Select(x => x.ToString()));
});

app.Run();

static class RedisParseExt
{
    public static long TryParseLong(this RedisValue v) => long.TryParse(v, out var x) ? x : 0;
    public static double TryParseDouble(this RedisValue v) => double.TryParse(v, out var x) ? x : 0;
}

> 依赖安装:`dotnet add package StackExchange.Redis`。生产部署需配置连接池、重试与超时;示例为最小可运行实现。


## 定时任务与增量重算(伪代码)

// 每分钟回放最近 24h 小时桶,计算分数并写 ZSET
foreach (var aid in ActiveArticleIds())
{
    var (pv, uv, dur, likes, cmts) = SumLast24Hours(aid);
    var age = AgeHours(GetPublishedAt(aid), DateTimeOffset.UtcNow);
    var score = Score(new Metrics(pv, uv, dur, likes, cmts, age));
    await db.SortedSetAddAsync("hot:articles", aid, score);
}

## 验证方法与基线结果

  • 本地压测:
  • 生成 100 篇文章,模拟 10 分钟内不同 PV/UV/停留/互动分布;观察 TopN 稳定性与新文上榜速度。
  • 期望:UV 提升且停留较长的文章更易上榜;早期高 PV 但低停留的文章得分被抑制。
  • 回归验证:
  • 离线回放最近 7 天小时桶,比较新老权重的 TopN 差异与点击-转化提升(CTR/阅读完成率)。
  • 边界检查:
  • 极端高停留样本(>300s)不应导致异常霸榜(上限裁剪生效)。
  • 无新增互动的旧文在 24–72h 内逐步下滑(衰减生效)。

## 与前端的衔接与展示

  • API 返回:`article_id`、`title`、`score`、`summary`、`cover`、`published_at`。
  • 展示策略:在 TopN 中对相同作者/专题做去重,避免列表单一;支持按分类维度分桶 `hot:articles:{category}`。
  • 更新频率:建议 1–5 分钟;高流量时段可提升至 30–60 秒。

## 运维与监控

  • 指标:ZSET 大小、TopN 命中率、事件消费延迟、重算耗时、Redis 命令耗时(`INFO latency`)。
  • 预案:热点过高时,按分类或标签维度拆分排行榜;或改用滑动窗口加权结构(例如双 ZSET 合并)。

## 总结

  • 以 UV/停留为核心、结合 PV 与互动信号,配合温和的时间衰减与小时分桶重算,可稳定产出高质量的热门榜单;参数需结合站点实际分布做回归与迭代。


点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部