## 目标概述

  • 构建可复现、可验证的热门文章功能: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 的离线汇总,可满足高并发场景下的稳定排行需求,并提供明确的参数与验证路径以保证专业与真实。



点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部