## 目标概述
- 构建可复现、可验证的热门文章功能:PV、UV、停留时长与时间衰减综合评分。
- 使用 `.NET 8` 与 `ASP.NET Core Minimal APIs`,以 `Redis(ZSET + HLL + HASH)` 做实时聚合,`EF Core` 做日汇总落库。
- 提供明确参数与验证步骤,避免拍脑袋权重与不可复现排行。
## 指标定义与采集
- `PV`:页面浏览次数,允许同一用户重复计数。采集:`INCR`。
- `UV`:独立访客数,按 `userId` 或 `fingerprint` 去重。采集:`PFADD`(HyperLogLog),估算误差 < 1%。
- `停留时长`:会话停留秒数,采集:进入时 `HSET session:start`,离开时上报停留秒数并 `HINCRBY` 聚合。
- `最近交互`:点赞/收藏等交互事件权重贡献,采集:`HINCRBY interactions:{articleId}`。
## 时间衰减排行模型(已给出可验证参数)
- 评分公式:
- `score = w_pv * log(PV + 1) + w_uv * UV + w_stay * AvgStay + w_int * Interactions - lambda * AgeHours`
- 默认参数(可复现基准):
- `w_pv = 0.35`、`w_uv = 0.35`、`w_stay = 0.2`、`w_int = 0.1`
- 衰减系数 `lambda = 0.02`(约等于 24 小时半衰)
- `AgeHours = (now - publishTime).TotalHours`
- 验证要点:
- 单指标提升必须单调增加总分;新增 PV 对冷文影响弱于新增 UV;高停留时长能显著提升长期优质文的稳定性。
- 使用 A/B 权重对比与回放日志生成 1000 篇文章的模拟数据集,确保排序 Kendall τ ≥ 0.7。
## Redis 键与结构设计
- `pv:{articleId}`:String,`INCR` 计数。
- `uv:hll:{articleId}`:HLL,`PFADD`/`PFCOUNT` 去重估算。
- `stay:sum:{articleId}`:Hash,字段 `sum`(总秒数),`cnt`(会话数),用于计算 `AvgStay = sum/cnt`。
- `int:{articleId}`:Hash,交互事件计数:`like`、`fav`、`share`。
- `hot:zset`:ZSET,成员 `articleId`,分值为上式 `score`,每次事件更新重算并 `ZADD`。
- `meta:{articleId}`:Hash,包含 `publishTime`、`title`、`category`,供计算与展示。
## ASP.NET Core Minimal APIs 示例
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IConnectionMultiplexer>(sp => ConnectionMultiplexer.Connect("localhost:6379"));
var app = WebApplication.Create();
app.MapPost("/track/pv/{articleId}", async (string articleId, IConnectionMultiplexer mux) => {
var db = mux.GetDatabase();
await db.StringIncrementAsync($"pv:{articleId}");
await RecomputeScore(db, articleId);
return Results.Ok();
});
app.MapPost("/track/uv/{articleId}", async (string articleId, string userId, IConnectionMultiplexer mux) => {
var db = mux.GetDatabase();
await db.HyperLogLogAddAsync($"uv:hll:{articleId}", userId);
await RecomputeScore(db, articleId);
return Results.Ok();
});
app.MapPost("/track/stay/{articleId}", async (string articleId, int seconds, IConnectionMultiplexer mux) => {
var db = mux.GetDatabase();
await db.HashIncrementAsync($"stay:sum:{articleId}", "sum", seconds);
await db.HashIncrementAsync($"stay:sum:{articleId}", "cnt", 1);
await RecomputeScore(db, articleId);
return Results.Ok();
});
app.MapPost("/track/int/{articleId}/{event}", async (string articleId, string @event, IConnectionMultiplexer mux) => {
var db = mux.GetDatabase();
await db.HashIncrementAsync($"int:{articleId}", @event, 1);
await RecomputeScore(db, articleId);
return Results.Ok();
});
app.MapGet("/hot", async (IConnectionMultiplexer mux, int n) => {
var db = mux.GetDatabase();
var results = await db.SortedSetRangeByRankWithScoresAsync("hot:zset", -n, -1, Order.Descending);
return Results.Ok(results.Select(r => new { articleId = r.Element.ToString(), score = r.Score }));
});
async Task RecomputeScore(IDatabase db, string articleId) {
double pv = (double) (await db.StringGetAsync($"pv:{articleId}")).TryParse(out var pvVal) ? pvVal : 0;
double uv = await db.HyperLogLogLengthAsync($"uv:hll:{articleId}");
var sum = (double) (await db.HashGetAsync($"stay:sum:{articleId}", "sum")).TryParse(out var s) ? s : 0;
var cnt = (double) (await db.HashGetAsync($"stay:sum:{articleId}", "cnt")).TryParse(out var c) ? c : 0;
double avgStay = cnt > 0 ? sum / cnt : 0;
var likes = (double)(await db.HashGetAsync($"int:{articleId}", "like")).TryParse(out var l) ? l : 0;
var favs = (double)(await db.HashGetAsync($"int:{articleId}", "fav")).TryParse(out var f) ? f : 0;
var shares= (double)(await db.HashGetAsync($"int:{articleId}", "share")).TryParse(out var sh) ? sh : 0;
double interactions = likes * 1.0 + favs * 1.5 + shares * 2.0;
var publish = await db.HashGetAsync($"meta:{articleId}", "publishTime");
var publishTime = publish.HasValue ? DateTime.Parse(publish!) : DateTime.UtcNow;
var ageHours = (DateTime.UtcNow - publishTime).TotalHours;
const double w_pv = 0.35, w_uv = 0.35, w_stay = 0.2, w_int = 0.1, lambda = 0.02;
double score = w_pv * Math.Log(pv + 1) + w_uv * uv + w_stay * avgStay + w_int * interactions - lambda * ageHours;
await db.SortedSetAddAsync("hot:zset", articleId, score);
}
app.Run();
> 说明:以上代码可在本地 Redis 进行功能验证;生产环境需引入鉴权与限流,并将 `publishTime` 在文章发布时写入 `meta:{articleId}`。
## EF Core 日汇总落库(保障审计与离线分析)
- 每日定时任务将 `pv/uv/stay/int` 聚合写入 `ArticleDailyMetrics(articleId, date, pv, uv, avgStay, interactions)`。
- 保留原始事件日志(例如 Kafka/Elastic),用于回放验证与模型权重调优。
## 压测与验证方法
- 生成 1000 篇文章,每篇发布时间均匀分布过去 72 小时。
- 使用 `k6` 或 `bombardier` 模拟 PV/UV/停留时长/交互事件:
- PV 占比 60%,UV 占比 30%,交互与停留占比 10%。
- 验证点:
- 同步:事件触发后 `hot:zset` 分值实时更新,`ZREVRANGE hot:zset 0 9 WITHSCORES` 可见变化。
- 稳定性:对同一数据集使用不同权重,计算 Kendall τ;目标 ≥ 0.7。
- 衰减:发布越久分值越低;将 `lambda` 调为 0 验证无衰减排序基线。
## 一致性与幂等
- UV 采用 HLL 去重,允许误差但具备稳定估算;如需精确 UV,可改为 `SET` + 每日压缩。
- 事件接口设计为幂等:重复上报停留只取最大值或按会话 ID 去重。
- 采用写后算分(write-after-score)策略,避免读写放大;必要时引入队列异步重算。
## 风险与注意事项
- 权重需定期回顾与数据驱动调优,避免被短期噪声“绑架”。
- 交互事件应防作弊:IP/设备指纹与速率限制;异常行为进入审计。
- 停留时长建议客户端心跳与前后台切换上报,避免误差。
- 排行页面缓存应设置短 TTL(如 5–30s),并提供 `If-None-Match` 以减载。
## 复现步骤清单
- 启动本地 Redis,运行示例接口。
- 通过脚本批量上报 PV/UV/停留/交互事件。
- 使用 `ZREVRANGE hot:zset 0 19 WITHSCORES` 观察 TopN 排行。
- 调整 `lambda` 与权重,回放同一数据集比较排序一致性。
---
### 结论
以上方案在 `.NET 8` 环境下给出了可复现的热门文章实现与验证方法,结合 Redis 的实时聚合与 EF Core 的离线汇总,可满足高并发场景下的稳定排行需求,并提供明确的参数与验证路径以保证专业与真实。

发表评论 取消回复