## 场景与目标
- 在不改动栏目结构的前提下,增加“热门文章”榜单。
- 真实度优先:同会话与短时间窗口去重、区分 PV 与 UV、防刷与速率限制。
- 排名可解释:时间衰减模型,突出近期增长同时兼顾常青内容。
## 方案总览
- 采集接口:`POST /api/views { articleId, publishedAt, fingerprint }`,后端进行会话与时间窗口去重。
- UV 统计:按自然日维护 `UV` 集合,区分首次访问与重复访问的权重。
- 排名算法:时间衰减权重累加,`score = (pv_w + uv_w*is_uv) / (hours + base)^gamma`。
- 排行与缓存:`ZSET` 存分数,定期读取生成榜单,服务端缓存 60–120 秒。
## 键设计与数据结构
- `hot:score`:`ZSET`,成员为 `articleId`,分数为时间衰减累计得分。
- `hot:uv:{articleId}:{yyyyMMdd}`:`SET`,记录当日唯一访问者指纹用于 UV 统计。
- `hot:dedup:{articleId}:{fingerprint}`:`STRING`,短窗口去重键,`TTL=600s`。
- `hot:list`:`STRING(JSON)`,热门榜单缓存,`TTL=60s`。
## 排名算法与已验证参数
- 公式:`score = inc / (hours_since_pub + base)^gamma`,其中 `inc = pv_w + uv_w*is_uv`。
- 推荐参数:`base = 2`、`gamma = 1.5`、`pv_w = 1.0`、`uv_w = 2.0`。
- 依据与验证:
- 指数衰减(`gamma ∈ [1.3, 1.8]`)在 24–72 小时窗口能突出近期热度且不过度压制常青内容。
- `base ∈ [1, 3]` 可避免新文分母过小造成排序极端波动;在站点规模下取 `2` 表现稳定。
- UV 权重高于 PV(如 2:1)可提升真实度与抗刷能力。
示例对比:
文章A:views=120,hours=12 => score≈(1*120)/(12+2)^1.5 ≈ 2.31
文章B:views=80, hours=4 => score≈(1*80)/(4+2)^1.5 ≈ 5.44
文章C:views=300,hours=72 => score≈(1*300)/(72+2)^1.5 ≈ 0.47
## Swift 实现示例(Vapor 4 + RediStack)
import Vapor
import RediStack
struct ViewPayload: Content {
let articleId: String
let publishedAt: String // ISO8601, UTC
let fingerprint: String
}
func utcYmd() -> String {
let now = Date()
let cal = Calendar(identifier: .iso8601)
let y = cal.component(.year, from: now)
let m = cal.component(.month, from: now)
let d = cal.component(.day, from: now)
return String(format: "%04d%02d%02d", y, m, d)
}
public func routes(_ app: Application) throws {
let pool = RedisConnectionPool(configuration: .init(address: try .makeAddressResolvingHost("127.0.0.1", port: 6379)), boundEventLoop: app.eventLoopGroup.next())
app.post("api", "views") { req -> EventLoopFuture<Response> in
let p = try req.content.decode(ViewPayload.self)
let dedupKey = "hot:dedup:\(p.articleId):\(p.fingerprint)"
let uvKey = "hot:uv:\(p.articleId):\(utcYmd())"
return pool.connection().flatMap { conn in
// NX EX 600 去重
let setNx = conn.send(command: "SET", with: [RESPValue.bulkString(dedupKey), .bulkString("1"), .bulkString("NX"), .bulkString("EX"), .bulkString("600")])
return setNx.flatMap { reply in
// 未返回 OK 视为重复
if reply.string == nil {
return req.eventLoop.makeSucceededFuture(Response(status: .ok, body: .init(stringLiteral: "{\"ok\":true,\"dedup\":true}")))
}
// UV 记录
let sadd = conn.sadd(uvKey, values: [RESPValue.bulkString(p.fingerprint)])
let expire = conn.expire(uvKey, after: .seconds(3*24*3600))
return sadd.and(expire).flatMap { res1, _ in
// 计算衰减分
let formatter = ISO8601DateFormatter()
let pub = formatter.date(from: p.publishedAt) ?? Date()
let hours = max(0.0, Date().timeIntervalSince(pub) / 3600.0)
let base = 2.0, gamma = 1.5, pv_w = 1.0, uv_w = 2.0
let inc = pv_w + (res1 == 1 ? uv_w : 0.0)
let decay = pow(hours + base, gamma)
let scoreInc = inc / decay
let zincr = conn.zincrby(scoreInc, in: "hot:score", for: p.articleId)
let top = conn.zrevrange(withScoresFrom: 0, to: 9, in: "hot:score")
return zincr.and(top).flatMap { _, items in
let list = items.compactMap { (m: RESPValue, s: RESPValue) -> [String: Any]? in
guard let id = m.string, let score = s.double else { return nil }
return ["id": id, "score": (score * 1e6).rounded() / 1e6]
}
let data = try! JSONSerialization.data(withJSONObject: list, options: [])
let set = conn.set("hot:list", to: data, expiration: .seconds(60))
return set.transform(to: Response(status: .ok, body: .init(stringLiteral: String(data: try! JSONSerialization.data(withJSONObject: ["ok": true, "uv": (res1 == 1), "scoreInc": ((scoreInc*1e6).rounded()/1e6)], options: []), encoding: .utf8)!)))
}
}
}
}
}
app.get("api", "hot") { req -> EventLoopFuture<Response> in
return pool.connection().flatMap { conn in
conn.get("hot:list").flatMap { cached in
if let data = cached?.data, !data.isEmpty {
return req.eventLoop.makeSucceededFuture(Response(status: .ok, body: .init(data: data)))
}
let top = conn.zrevrange(withScoresFrom: 0, to: 9, in: "hot:score")
return top.flatMap { items in
let list = items.compactMap { (m: RESPValue, s: RESPValue) -> [String: Any]? in
guard let id = m.string, let score = s.double else { return nil }
return ["id": id, "score": (score * 1e6).rounded() / 1e6]
}
let data = try! JSONSerialization.data(withJSONObject: list, options: [])
let set = conn.set("hot:list", to: data, expiration: .seconds(60))
return set.transform(to: Response(status: .ok, body: .init(data: data)))
}
}
}
}
}
## 排行接口与缓存策略
- 接口:`GET /api/hot?limit=10`,优先从 `hot:list` 返回;若不存在则回源 `ZSET` 生成并设置缓存。
- 服务端缓存:建议 60–120 秒,客户端可使用 `stale-while-revalidate` 30–60 秒。
- 渐进更新:高并发下采用固定时间片(如 30 秒)更新,避免抖动。
## 验证与运维注意事项
- 防刷:结合会话去重、短窗口限速(10 分钟)、UV 权重与 IP/UA 白黑名单。
- 时区与发布时刻:统一以 UTC 计算 `hours_since_pub`,避免跨时区误差。
- 数据清理:UV 集合设置过期(3 天),定期巡检并清理陈旧键。
- Redis 持久化:开启 AOF(`appendfsync everysec`)或合理 RDB 周期,保障排名数据可恢复。
- 监控:对 `hot:score` 的键规模、命中率与接口延迟设置告警。
## 总结
- 以 Redis ZSET + 时间衰减实现热门文章排行,参数范围与权重配置经过验证,可在 Swift/Vapor 环境下稳定落地。
- 方案兼顾真实度、可解释性与性能,适配现有分类 `软件/编程语言/Swift`。

发表评论 取消回复