# 短视频全局推荐环形队列——后端技术方案 ## 1. 文档范围 本文仅覆盖《91porn迭代需求》第一项“短视频推荐逻辑修改”,以当前代码、测试H5和测试管理后台为准。 已确认的产品口径: - 只推荐审核通过的短视频; - VIP、金币、广告和特殊视频均可参与推荐,其他状态和内容类型不参与; - 互动分为 `点赞×1 + 收藏×2 + 评论×3 + 转发×5`; - 使用全部累计真实互动,不使用运营假数据; - 取消点赞、取消收藏、删除评论等行为不扣推荐累计分; - 互动分和全局队列每日更新一次; - 每批固定20条,严格按17条高分视频加3条新视频组织; - 新视频按审核通过时间24小时计算,并从高分池排除; - 新视频不足3条时用高分视频补齐; - 用户偏移存Redis Hash; - 每日新队列切换后所有用户从新队列起点重新开始; - 队尾通过取模回到队首。 ## 2. 现状核对 ### 2.1 H5实际调用 测试H5短视频Swiper实际调用: ```http GET /api/app/recommend/vid/list?pageNumber={n}&pageSize={size} ``` 当前响应: ```json { "vInfos": [], "totalPages": 10 } ``` H5还单独调用: ```http GET /api/app/recommend/vid/ad GET /api/app/vid/user/count?vid={videoId} ``` 广告插入和免费观看次数不并入本次推荐队列算法。 ### 2.2 后端现状 主接口位于: - `app/api/recommctrl/recommctrl.go` - `app/service/recommser/recommser.go` 当前算法: ```text Redis Set(short-videos-all-ids-list) -> SRANDMEMBER N -> 查询视频详情 -> 返回 ``` 另有遗留接口: ```http GET /api/app/vid/module/short/all ``` 该接口使用另一个Redis Set `shortVideosRecoCache`,并存在5分钟全局响应缓存。两条接口目前算法和Redis Key均不统一。 ### 2.3 管理后台现状 后台已有“视频热度配置”: ```http GET /api/web/admin/vid/popularity/config POST /api/web/admin/vid/popularity/config ``` 该配置计算播放量、有效播放量、点赞量和审核时间衰减,是通用视频热度,不符合本次固定的互动分算法。因此: - 本次推荐不能复用现有 `VideoPopularityConfig`; - 不修改现有热度配置页面; - 一期不新增运营配置页面; - 推荐权重按产品确认值作为后端常量; - 灰度开关和任务时间使用服务配置。 ## 3. 总体架构 ```text 真实互动事件 ↓ 视频推荐累计字段(只增不减) ↓ 每日SKD任务计算recommendScore ↓ 生成17+3全局有序队列 ↓ 完整写入Redis版本Key ↓ 原子切换current版本 ↓ 推荐接口按用户Hash偏移环形读取 ``` 不在请求时实时计算分数,不为每个用户生成独立队列。 ## 4. 数据模型 在视频模型增加: ```go RecommendLikeCount int64 `json:"-" bson:"recommendLikeCount"` RecommendCollectCount int64 `json:"-" bson:"recommendCollectCount"` RecommendCommentCount int64 `json:"-" bson:"recommendCommentCount"` RecommendShareCount int64 `json:"-" bson:"recommendShareCount"` RecommendScore int64 `json:"-" bson:"recommendScore"` RecommendScoreAt time.Time `json:"-" bson:"recommendScoreAt"` ``` 计算公式: ```text recommendScore = recommendLikeCount + recommendCollectCount × 2 + recommendCommentCount × 3 + recommendShareCount × 5 ``` ### 4.1 为什么不能直接使用现有字段 现有 `likeCount/collectCount/commentCount/shareCount` 会在取消或删除时下降,而产品已确认取消行为不扣推荐分。 现有 `fakeLikeCount/fakeCommentCount/fakeShareCount` 包含运营假数据,禁止参与推荐分。 因此需要独立的单调递增推荐累计字段。 ### 4.2 互动写入规则 仅在真实行为首次成功时递增: - 点赞成功:`recommendLikeCount + 1` - 收藏成功:`recommendCollectCount + 1` - 评论发布成功:`recommendCommentCount + 1` - 转发首次有效记录:`recommendShareCount + 1` 现有 `POST /api/app/share/output` 增加可选参数 `videoID`。新客户端分享短视频时必须传入,服务端仅在ID合法且视频当前为审核通过的短视频时累计;旧客户端不传保持原分享能力,但无法归属到具体视频,因此不累计推荐分享分。 以下行为不修改推荐累计字段: - 取消点赞; - 取消收藏; - 删除评论; - 运营修改假互动; - 重复请求和重复事件。 每个互动入口必须先利用现有行为唯一性或幂等逻辑确认“首次成功”,再累计推荐字段。 ## 5. 历史数据初始化 上线前执行一次幂等脚本: ```text recommendLikeCount = max(likeCount, 0) recommendCollectCount = max(collectCount, 0) recommendCommentCount = max(commentCount, 0) recommendShareCount = max(shareCount, 0) ``` 不读取任何 `fake*` 字段。 脚本增加初始化版本标记,重复执行不得覆盖已经由线上事件增长的新累计值。 ## 6. 每日队列生成 任务放在 `skd/job`,使用现有Cron框架。默认按北京时间凌晨低峰运行,具体时间由部署配置确定。 ### 6.1 候选条件 ```text status = 审核通过 newsType = 短视频 未删除 ``` VIP、金币、广告、特殊视频不作为排除条件。 ### 6.2 新视频池 ```text reviewAt >= generatedAt - 24h reviewAt <= generatedAt ``` 排序: ```text reviewAt desc, _id desc ``` 进入新视频池的视频必须从高分池排除,当天队列中只使用一次。 ### 6.3 高分池 排序: ```text recommendScore desc, reviewAt desc, _id desc ``` 排除当日新视频池全部ID。 ### 6.4 组装算法 每个推荐块: ```text high = 最多17条 new = 最多3条 缺少的新视频名额由high补齐 块内确定性洗牌 ``` 要求: - 同一视频在当日全局队列只出现一次; - 新视频消费完后,后续块全部由高分视频组成; - 不复制视频凑足队列长度; - 尾部不足20条时保留实际数量; - 洗牌使用日期版本作为种子,便于问题复现。 ## 7. Redis设计 ```text recommend:short:current recommend:short:queue:{version} recommend:short:offset:{version} recommend:short:meta:{version} recommend:short:build-lock:{version} ``` 示例: ```text recommend:short:current -> 20260724 recommend:short:queue:20260724 -> LIST(videoId...) recommend:short:offset:20260724 -> HASH(uid => nextOffset) recommend:short:meta:20260724 -> HASH(length, generatedAt, startOffset) ``` 规则: - `queue` 使用Redis List,保持顺序; - `offset` 使用一个Hash存放全部用户偏移; - `startOffset` 第一期固定为0; - 新版本先完整写入,再原子切换 `current`; - 新版本Key设置72小时TTL; - 版本切换天然实现每日偏移重置,不扫描删除旧Hash; - 构建锁避免多个SKD实例同时生成同一版本。 ## 8. 环形读取 `offset` 表示下一次读取位置: ```text newOffset = (offset + scannedCount) % queueLength ``` 使用Lua脚本原子完成: 1. 读取当前版本; 2. 读取队列长度; 3. `HGET`用户偏移; 4. 无偏移时使用0; 5. 计算本次候选索引; 6. 推进并保存新偏移; 7. 返回版本、旧偏移和候选视频ID。 接口层查询Mongo并再次校验视频状态。发现失效视频时继续向后扫描补位,最多扫描一整圈。 约束: - 同一响应内同一视频最多出现一次; - 队列长度小于20时只返回一圈; - Mongo返回结果必须按Redis ID顺序重新排列; - 多设备和并发请求共享同一用户偏移。 ## 9. App接口改造 ### 9.1 H5主接口 保留现有地址: ```http GET /api/app/recommend/vid/list ``` 修改 `recommser.GetVidList`,由随机Set切换到环形队列服务。 ### 9.2 兼容接口 保留: ```http GET /api/app/vid/module/short/all ``` 该接口删除5分钟全局响应缓存,并委托同一个环形队列服务读取,避免两个客户端获得不同推荐逻辑。 ### 9.3 广告接口 保持不变: ```http GET /api/app/recommend/vid/ad ``` 广告仍由H5在推荐内容之外按现有规则插入,不占17+3名额。 ### 9.4 免费观看次数接口关联改动 现有接口: ```http GET /api/app/vid/user/count ``` 传或不传 `vid` 的两个响应分支都增加: ```json { "totalWatchCount": 3 } ``` 该字段读取现有 `appg.Conf.Base.TotalWatch`,表示系统配置的免费观看总次数;现有 `watchCount` 继续表示用户剩余次数。不新增数据库字段,不修改扣减逻辑。此项已纳入接口设计,但代码尚未修改。 ## 10. 响应兼容 H5主接口保留: ```json { "vInfos": [], "totalPages": 100 } ``` 新增: ```json { "hasNext": true, "queueVersion": "20260724" } ``` 说明: - `vInfos`结构不变; - `totalPages`继续返回 `ceil(queueLength / 20)`,仅兼容旧客户端; - 环形队列非空时 `hasNext=true`; - 前端不得用 `pageNumber`计算服务端offset; - `queueVersion`仅用于日志排查。 ## 11. 灰度、降级和监控 服务配置: ```text shortRecommendV2Enabled shortRecommendCron shortRecommendKeyTTLHours = 72 ``` 降级顺序: 1. 当日队列不可用时使用上一有效版本; 2. 没有任何版本或Redis异常时,回退现有 `ShortVideosKey` 随机逻辑; 3. 降级必须记录日志和指标。 监控: - 生成耗时和任务状态; - 候选、高分、新视频和最终队列数量; - 每块17+3比例; - 重复率; - 接口耗时和空结果率; - 失效视频跳过数; - Redis/Lua失败数; - 版本切换时间; - 降级次数。 ## 12. 索引 建议Mongo索引: ```text status + newsType + reviewAt status + newsType + recommendScore + reviewAt ``` 根据生产数据量通过 `explain` 确认最终字段顺序。 ## 13. 测试 必须覆盖: - 正常17+3; - 新视频0/1/2/3条; - 新视频池耗尽; - 高分池不足; - 队列不足20条; - 尾部跨界读取; - 用户首次进入; - 不同用户偏移隔离; - 同用户并发; - 每日版本重置; - 队列中视频下架后的补位; - 假数据不计分; - 取消互动不扣分; - 历史初始化幂等; - SKD重复执行; - 生成失败继续使用旧队列; - H5主接口和兼容接口响应结构。 ## 14. 发布步骤 1. 发布新增字段和索引; 2. 执行历史累计初始化; 3. 发布互动累计逻辑; 4. 发布SKD任务,影子生成队列但不切流; 5. 核对数量、分数、17+3比例和重复率; 6. 发布App接口兼容字段; 7. 小流量开启 `shortRecommendV2Enabled`; 8. 全量切换; 9. 保留旧随机Set作为短期降级路径。