## 场景与目标

  • 在不改动栏目结构的前提下,增加“热门文章”榜单。
  • 真实度优先:同会话与短时间窗口去重、区分 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`。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部