## 适用范围与版本
- 适用:内容站点/博客/资讯类服务的「热门文章」模块。
- 基线版本:.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 与互动信号,配合温和的时间衰减与小时分桶重算,可稳定产出高质量的热门榜单;参数需结合站点实际分布做回归与迭代。

发表评论 取消回复