全链路总览
一篇帖子从发布到被另一个用户刷到,经历了"内容入库 → 理解打标 → 画像构建 → 多路召回 → 排序打散 → 队列推送"的完整链路。整体分为异步处理和在线服务两大部分。
推荐系统的数据流不是单向的——它分为写入路径(内容入库 + 行为采集,异步、离线)和读取路径(用户刷推荐,在线、实时)。理解这两条路径是理解整个系统的关键。
用户行为 → Kafka → ClickHouse 存日志 → 定时更新画像写入 Redis
队列不够?→ 读 Redis 画像 + ES 多路召回 → 排序 → 打散 → Bloom 去重 → RPUSH 回队列
用户发帖
Go 接口接收帖子内容,写入 MySQL,同时往 Kafka 发送一条消息通知下游异步处理。接口立即返回发布成功,不阻塞用户。
Go API MySQL Kafka内容理解 & 标签计算
Python 标签服务从 Kafka 消费新帖子消息,对文本做分词、关键词提取、主题分类,给帖子打上标签。结果写回 MySQL 并同步到 ES 倒排索引。
Python NLP Elasticsearch质量分计算
发布时根据内容特征算初始分,上线后根据互动数据(点赞率、完读率、负向信号)定时更新。两个阶段的分数按曝光量动态加权合并。
定时任务 ClickHouse行为采集 & 用户画像
客户端埋点上报用户行为,经 Kafka 落入 ClickHouse。定时任务读取行为数据,关联帖子标签,加权汇总出每个用户的兴趣向量,写入 Redis。
ClickHouse Redis多路召回 → 排序 → 打散
用户请求推荐时,并发执行多路召回(兴趣匹配、热门、关注流、随机探索),合并去重后排序打分,再做结果打散保证多样性。
Go 并发 ES Boost Redis待推队列 & 无限刷
排序打散后的结果写入 Redis 待推队列,前端每次取 20 条。队列快用完时异步预加载下一批,实现无限滚动的无缝体验。
Redis List Bloom FilterGET /api/recommend?count=20
LPOP 20 条 → 检查剩余量
关键点:用户感知的延迟只有"快路径"的 10~20ms(Redis LPOP)。召回+排序的 100~200ms 是异步完成的,用户无感知。
接下来的每一章深入讲解一个模块。你可以按顺序学习完整链路,也可以跳到你最关心的部分。
异步处理流水线
所有耗时的计算都在后台异步完成,在线服务只需要读取预计算好的结果,保证推荐请求的实时性。
Kafka 消息队列
异步解耦和削峰填谷。Go 接口收到请求后立即扔进队列,下游按自己的节奏消费。即使流量暴增也不会压垮下游服务。前期规模较小时也可用 Redis Stream 替代。
ClickHouse 行为存储
列式存储,特别适合"最近 7 天用户 A 点赞了哪些帖子"这类聚合查询。一天几千万条行为日志,按时间分区存储,查询速度极快。前期可用 MongoDB 替代。
Python 标签服务
独立微服务,从 Kafka 消费新帖子消息。使用 jieba 分词 + 关键词匹配做内容分类。与 Go 后端通过消息队列解耦,互不影响。后期可升级为分类模型或大模型打标签。
行为链路详解:采集什么数据
行为数据是推荐系统的燃料。采集得越全、越细,用户画像就越准。行为分为两大类:用户主动触发的显式行为和系统被动记录的隐式行为。
显式行为(用户主动操作)
有明确的接口调用,后端天然就能捕获。这类行为信号强,权重高。
隐式行为(系统被动记录)
用户没有主动操作,但客户端可以通过埋点捕捉。信号较弱但数据量大,是画像构建的重要补充。
>5s 视为有效浏览
>15s 视为深度阅读
只看第 1 张就走 = 低兴趣
图文帖的核心隐式信号
说明标题/首图吸引了 TA
且愿意花时间细看
说明内容引发了好奇心
介于浏览和互动之间
虽然没关注但有探索意图
可加强该作者内容的推荐
单次不说明什么
但连续划过同类 = 降权信号
行为日志数据结构
每条行为日志写入 ClickHouse 时的字段结构。客户端攒一批(比如 5-10 条)后统一上报,减少网络请求。
{
"user_id": "10086",
"post_id": "p_20260205_83721",
"event_type": "view", // view | like | collect | comment | share | follow | report | dislike
"duration_ms": 8200, // 停留时长(毫秒),仅 view 类型有值
"image_progress": 0.75, // 图片浏览进度 0~1,看了 4 张中的 3 张 = 0.75
"text_expanded": true, // 是否点击了"展开全文"
"comment_viewed": true, // 是否查看了评论区
"author_clicked": false, // 是否点击了作者主页
"source": "recommend", // 来源:recommend | follow | search | hot | profile
"position": 7, // 在推荐列表中的位置(第几条)
"timestamp": "2026-02-05T14:32:18.000Z",
"device": "iOS", // iOS | Android
"session_id": "sess_a8f3c2e1" // 同一次打开 App 共用一个 session
}数据量估算
客户端埋点注意事项
批量上报
客户端不要每个事件都立即发请求,攒 5-10 条或每隔 5 秒批量上报一次。减少网络开销,降低服务端 QPS。App 退到后台时立即上报剩余事件,防止数据丢失。
停留时长计算
帖子进入可视区域开始计时,离开可视区域或用户切后台时停止。需要处理锁屏、来电话、切 App 等中断场景,避免统计出离谱的停留时长(比如 2 小时)。超过 5 分钟的一律截断。
Session 管理
用户每次打开 App 生成一个 session_id,所有事件都带上。用于分析单次会话的行为路径,比如用户这次刷了多少帖子、在哪里流失、哪类内容让 TA 停下来互动。
用户画像构建
用户画像是推荐系统的核心数据。通过统计用户近期行为,建立兴趣标签的权重向量。
画像计算:从行为数据到兴趣向量
拿到一堆行为数据后,通过以下 4 步算出用户的兴趣画像。核心思路是:找到用户互动过的帖子 → 拿到帖子的标签 → 按行为类型和时间加权 → 汇总归一化。
拉取用户近期行为
从 ClickHouse 查询该用户最近 7 天(可配置)的所有行为记录。时间窗口不宜太长,否则画像会被过时的兴趣拖累。
SELECT post_id, event_type, duration_ms, image_progress, timestamp FROM behavior_events WHERE user_id = '10086' AND timestamp >= now() - INTERVAL 7 DAY AND event_type IN ('like', 'collect', 'comment', 'share', 'view', 'follow', 'dislike') ORDER BY timestamp DESC
关联帖子标签 + 计算行为权重
每条行为记录通过 post_id 关联到帖子的标签。然后根据行为类型赋予不同的基础权重——用户主动操作的权重远高于被动浏览。
行为类型 → 基础权重映射
时间衰减:越近的行为越重要
用户的兴趣会随时间变化。3 天前点赞的"美食"帖子和 1 小时前点赞的"美食"帖子,对当前画像的贡献应该不同。通过时间衰减函数来实现:
衰减系数 0.02 时的衰减效果
衰减系数可调:0.02 适合兴趣变化较慢的社区,0.05 适合热点驱动、兴趣变化快的产品。
汇总 & 归一化
把每条行为算出来的 score 按标签累加,然后归一化到 0~1 之间(所有标签权重之和 = 1),就得到了最终的兴趣向量。
完整数值示例
以用户 10086 为例,假设最近 7 天有以下 8 条行为记录,看看怎么一步步算出 TA 的画像。
📋 原始行为数据(最近 7 天)
| # | 时间 | 行为 | 帖子标签 | 行为权重 W | hours_ago | 时间衰减 D | score = W × D |
|---|---|---|---|---|---|---|---|
| 1 | 2 小时前 | 👍 点赞 | 美食烘焙 | 5 | 2 | 0.96 | 4.81 |
| 2 | 5 小时前 | ⭐ 收藏 | 美食探店 | 4 | 5 | 0.91 | 3.64 |
| 3 | 8 小时前 | 👁️ 完读 | 旅行攻略 | 3 | 8 | 0.86 | 2.59 |
| 4 | 1 天前 | 👍 点赞 | 摄影风光 | 5 | 24 | 0.68 | 3.38 |
| 5 | 1 天前 | 👁️ 浏览 | 美食家常菜 | 1 | 26 | 0.66 | 0.66 |
| 6 | 3 天前 | 🔗 分享 | 美食烘焙 | 6 | 72 | 0.41 | 2.47 |
| 7 | 5 天前 | 👍 点赞 | 旅行露营 | 5 | 120 | 0.29 | 1.47 |
| 8 | 6 天前 | 👁️ 浏览 | 宠物猫 | 1 | 144 | 0.26 | 0.26 |
📊 按一级标签汇总 score
每条行为的 score 分配给帖子的所有标签(一篇帖子有多个标签时,每个标签都累加)。下面按一级标签汇总:
+ #5 浏览 0.66 + #6 分享 2.47
✅ 归一化 → 最终画像
每个标签的 score 除以总分,得到 0~1 的权重比例:
HSET user_profile:10086 美食 0.60 旅行 0.21 摄影 0.18 宠物 0.01
完整伪代码
// 定时任务:每小时执行一次 for user in getAllActiveUsers(): behaviors = queryClickHouse("最近 7 天的行为", user.id) profile = {} for behavior in behaviors: post_tags = getPostTags(behavior.post_id) // 不同行为给不同权重 weight = { 点赞: 5, 收藏: 4, 看完: 3, 浏览: 1 }[behavior.type] // 越近的行为权重越大 decay = 1.0 / (1 + hours_ago * 0.02) for tag in post_tags: profile[tag] += weight * decay normalize(profile) // 归一化到 0~1 writeToRedis("user_profile:{user.id}", profile)
user_profile:10086 → {美食: 0.35, 旅行: 0.25, 摄影: 0.20, 宠物: 0.12, 运动: 0.08}
存储在 Redis Hash 中,在线服务直接读取,微秒级响应。MySQL 存一份备份防止 Redis 故障丢失。
业界画像维度参考
不同平台的标签体系规模差异很大,但核心逻辑一致。以下是业界典型的画像维度设置,供你参考定义自己的标签层级。
| 平台类型 | 一级标签数 | 二级标签数 | 总标签量级 | 特点 |
|---|---|---|---|---|
| 小红书(图文社区) | ~20 | 每级 5-15 | 200-500 | 生活方式导向,标签精细且重叠度高 |
| 抖音(短视频) | ~30 | 每级 10-30 | 1000+ | 内容形态多,标签体系庞大,多层级 |
| 今日头条(资讯) | ~25 | 每级 10-20 | 500-1000 | 新闻分类明确,时效性标签权重高 |
| 你的 App(初期建议) | 10-15 | 每级 3-8 | 50-120 | 先粗后细,从数据中验证再扩展 |
画像 Demo:三类典型用户
以下是三个不同兴趣偏好的用户画像示例,展示实际数据结构和权重分布。
用户 A · 美食达人
兴趣集中型 · 日活跃 40min用户 B · 兴趣广泛
兴趣分散型 · 日活跃 25min用户 C · 新注册用户
冷启动阶段 · 注册第 1 天标签体系 Demo
以下是适合图文社区 App 的标签体系示例。一级分类控制在 12 个,每个下设 3-8 个二级标签,总量约 70 个。
多路召回
召回是推荐的第一步——从百万级内容池中快速筛选出几百条候选帖子。多路并发执行,用 Go 的 goroutine 天然适合。每路独立运行、互不依赖,某一路超时或失败不影响其他路。
并发调度:Go 骨架代码
五路召回通过 goroutine 并发执行,统一设置 50ms 超时。任何一路超时或出错都不阻塞整体,只是少一路候选而已。
func MultiRecall(ctx context.Context, user *Profile) ([]Post, error) { ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) defer cancel() type result struct { posts []Post ch string } ch := make(chan result, 5) channels := []struct { name string fn func(context.Context, *Profile) ([]Post, error) }{ {"interest", recallByInterest}, // 60% — ES Boost {"hot", recallByHot}, // 15% — Redis ZSet {"follow", recallByFollow}, // 15% — MySQL/Redis {"explore", recallByExplore}, // 5% — 反兴趣探索 {"newpost", recallByNewPost}, // 5% — 冷启动 } for _, c := range channels { go func(name string, fn func(context.Context, *Profile) ([]Post, error)) { posts, err := fn(ctx, user) if err != nil { log.Warn("recall channel %s failed: %v", name, err) posts = nil // 失败不影响其他路 } ch <- result{posts, name} }(c.name, c.fn) } var all []Post for i := 0; i < len(channels); i++ { r := <-ch all = append(all, r.posts...) } return mergeAndDedup(all), nil }
🎯 兴趣匹配召回 — 主力通道 60%
从用户画像中取标签权重,映射为 ES Bool 查询的 Boost 值。一次查询覆盖所有兴趣维度,命中多标签的帖子分数叠加自动排前面。
{
"query": {
"bool": {
"should": [
{ "term": { "tags": { "value": "美食", "boost": 3.5 }}},
{ "term": { "tags": { "value": "旅行", "boost": 2.5 }}},
{ "term": { "tags": { "value": "摄影", "boost": 2.0 }}},
{ "term": { "tags": { "value": "宠物", "boost": 1.2 }}}
],
"minimum_should_match": 1,
"filter": [
{ "range": { "publish_time": { "gte": "now-48h" }}},
{ "range": { "quality_score": { "gte": 0.5 }}},
// 粗筛已读:传最近 1h 内看过的 ID(量小,几十条)
{ "bool": { "must_not": [
{ "terms": { "post_id": ["recent_seen_id_1", "..."] }}
]}}
]
}
},
"size": 500 // 超量召回,预留已读过滤损耗
}🔥 热门召回 — 全站热点 15%
抓住全站的热门内容,保证用户不会错过爆款。离线定时任务计算热度分,写入 Redis ZSet,在线时一行命令取 Top N。
离线:热度分计算(定时任务 · 每 10 分钟)
从 ClickHouse 查最近 24h 的互动数据,按加权公式打分,写入 Redis 有序集合。
-- ClickHouse: 聚合最近 24h 各帖子的互动数 SELECT post_id, countIf(event_type = 'like') AS likes, countIf(event_type = 'comment') AS comments, countIf(event_type = 'collect') AS collects, min(publish_time) AS pub_time FROM behavior_events WHERE timestamp >= now() - INTERVAL 24 HOUR GROUP BY post_id
在线:一行 Redis 命令
func recallByHot(ctx context.Context, user *Profile) ([]Post, error) { // 全站热门 Top 50 ids, _ := redis.ZRevRange(ctx, "hot:global", 0, 49) // 也可按类目热门取,保证多样性 // cats := randomPick(allCategories, 3) // for _, c := range cats { // catIds, _ := redis.ZRevRange(ctx, "hot:cat:"+c, 0, 9) // ids = append(ids, catIds...) // } return batchGetPosts(ctx, ids) }
👥 关注流召回 — 社交关系 15%
把用户关注的人的最新发帖拉出来。这一路的内容用户本身有关注意愿,点击率通常较高。
推模式 vs 拉模式
| 写扩散(推模式) | 读扩散(拉模式) ✅ | |
|---|---|---|
| 原理 | 发帖时写入每个粉丝的收件箱 | 请求时实时拉取关注列表的帖子 |
| 读速度 | 极快(直接读收件箱) | 较快(合并 N 个作者) |
| 写成本 | 大 V 发帖时写放大严重 | 无额外写操作 |
| 适用 | 粉丝数较少的产品 | 通用,中小规模首选 |
func recallByFollow(ctx context.Context, user *Profile) ([]Post, error) { // 1. 从 Redis/MySQL 拿关注列表 followIDs, _ := redis.SMembers(ctx, "following:"+user.ID) // 2. 每个作者的最近发帖(Redis List 缓存) var all []string for _, authorID := range followIDs { ids, _ := redis.LRange(ctx, "author_posts:"+authorID, 0, 9) all = append(all, ids...) } // 3. 按发布时间排序,取 Top 50 posts := batchGetPosts(ctx, all) sort.Slice(posts, func(i, j int) bool { return posts[i].PublishTime.After(posts[j].PublishTime) }) if len(posts) > 50 { posts = posts[:50] } return posts, nil }
author_posts:{author_id} 头部 LPUSH 新帖 ID,保留最近 20 条。这样在线拉取就不用查 MySQL,直接从 Redis 取。
🎲 随机探索 — 打破信息茧房 5%
不是纯随机,而是反兴趣探索——在用户画像中不存在的标签类目里挑优质内容。这样探索的方向才有价值,而不是漫无目的。
离线:构建探索候选池(定时任务 · 每小时)
从 ES 按条件筛选,按一级类目分桶,每桶取若干条,写入 Redis Set。
func recallByExplore(ctx context.Context, user *Profile) ([]Post, error) { // 1. 找到用户画像中【不存在】的类目(反兴趣) userTags := getUserTagSet(user) // {美食, 旅行, 摄影} allTags := getAllCategories() // {美食, 旅行, 摄影, 健身, 数码, 穿搭, ...} blindTags := difference(allTags, userTags) // {健身, 数码, 穿搭, ...} // 2. 随机选 3 个盲区类目 picked := randomPick(blindTags, 3) // 3. 从每个类目的探索池随机取 5 条 var ids []string for _, tag := range picked { catIds, _ := redis.SRandMemberN(ctx, "explore_pool:"+tag, 5) ids = append(ids, catIds...) } return batchGetPosts(ctx, ids) // ~15 条 }
🆕 新帖探索 — 冷启动 5%
新帖没有互动数据,不会被兴趣匹配和热门召回捞到——这就是"冷启动"问题。这一路专门给曝光不足的新帖一个初始曝光机会。
离线:构建新帖候选池(定时任务 · 每 30 分钟)
-- 找出曝光不足的新帖 SELECT post_id FROM posts WHERE publish_time >= now() - INTERVAL 24 HOUR -- 最近一天发布 AND impression_count < 100 -- 曝光不足 AND quality_score >= 0.5 -- 质量兜底,不推垃圾 -- 结果写入 Redis: SADD new_post_pool {post_id} -- 帖子曝光超过阈值后从池中移除: SREM new_post_pool {post_id}
func recallByNewPost(ctx context.Context, user *Profile) ([]Post, error) { // 从新帖候选池随机取 15 条 ids, _ := redis.SRandMemberN(ctx, "new_post_pool", 15) return batchGetPosts(ctx, ids) }
已读过滤:分层策略
用户看过的帖子不能再推。但在 ES 查询中传全量已读 ID 代价太大,所以采用两层过滤的策略:
第一层:ES 粗筛
在 ES 查询的 filter 中传最近 1 小时内看过的 ID(量小,几十条),用 must_not.terms 排除。这层成本低,能过滤掉最热门的重复。
第二层:Bloom Filter 精筛
召回合并后,逐条过 Redis 中的 Bloom Filter(每用户一个)。全量已读记录,微秒级判断,内存极小。详见 Section 08 已读去重。
降级策略
当精准召回 + 已读过滤后结果不够时,逐级放宽条件,保证用户永远有内容可刷:
内容质量分
质量分帮助系统判断一篇帖子值不值得推荐。分为发布时的初始分和上线后根据互动数据更新的反馈分,两者按曝光量动态加权合并。
初始分:发布时立即计算
帖子发布后,异步消费 Kafka 消息时同步计算初始分。不依赖任何互动数据,纯粹基于内容本身的特征。
文本长度分 — 权重 30%
太短的帖子(标题党)和太长的帖子(可能是复制粘贴)都扣分。50~500 字是图文帖子的"甜蜜区间"。
图片分 — 权重 30%
图文帖子的图片是核心内容载体。数量和质量都影响评分。
图片数量映射
额外加分项:首图宽度 ≥ 1080px 加 0.1 · 有封面图加 0.05。分数 clamp 到 [0, 1]。
文本质量分 — 权重 20%
检测文本是否有"实质内容",过滤掉垃圾帖、广告帖、纯表情帖等低质量内容。
基础分 1.0,命中以下规则则扣分:
作者信用分 — 权重 20%
基于作者的历史表现给加成或惩罚。优质创作者的新帖天然获得更高初始分。
反馈分:上线后根据真实互动更新
帖子获得曝光后,用户的真实互动行为是最可靠的质量信号。定时任务每 30 分钟从 ClickHouse 统计最新互动数据并重新计算反馈分。
互动率
正向互动次数 / 总曝光量
-- 正向互动 = 点赞 + 评论 + 收藏 + 分享 互动率 = (likes + comments + collects + shares) / impressions
典型值:优质帖 5~15% · 普通帖 1~5% · 低质帖 < 1%
完读率
图片浏览到最后一张的比例
-- image_progress ≥ 0.9 或停留 > 阈值 完读率 = finish_view_count / total_view_count
完读率高说明内容有吸引力 · 封面党的完读率通常很低
负向信号率
用户用行为"投票"了不喜欢
-- 负向 = 举报 + 不感兴趣 + 快速划走(<1s) 负向率 = (reports + dislikes + quick_swipes) / impressions
负向率 > 10% 是强烈的降权信号
-- 每 30 分钟跑一次,统计所有帖子最新互动数据 SELECT post_id, countIf(event_type IN ('like','comment','collect','share')) AS positive, countIf(event_type = 'view') AS impressions, countIf(event_type = 'view' AND image_progress >= 0.9) AS finish_views, countIf(event_type IN ('report','dislike')) AS negatives, countIf(event_type = 'view' AND duration_ms < 1000) AS quick_swipes FROM behavior_events WHERE timestamp >= now() - INTERVAL 7 DAY GROUP BY post_id HAVING impressions >= 10 -- 曝光太少的不更新,避免统计波动
两阶段动态合并
初始分和反馈分的权重随曝光量动态变化。新帖以初始分为主(因为还没有足够的互动数据),随着曝光增加逐步过渡到以反馈分为主。
低质量内容处理
质量分不仅决定排名高低,低于特定阈值还会触发降权甚至屏蔽。
| 质量分区间 | 处理策略 | 场景 |
|---|---|---|
| 0.7 ~ 1.0 | 正常推荐,排序加权 | 优质内容,进入所有召回通道 |
| 0.5 ~ 0.7 | 正常召回,排序中性 | 普通内容,不加权也不降权 |
| 0.3 ~ 0.5 | 召回过滤掉,仅搜索/个人主页可见 | 低质内容,不主动推荐 |
| < 0.3 | 标记审核,可能自动隐藏 | 疑似垃圾/违规内容 |
quality_score 字段。召回阶段用 filter: quality_score ≥ 0.5 做底线过滤,排序阶段用它作为加权因子(详见 Section 06 排序打分),热门召回的热度公式也用它做衰减(低质量帖即使互动多也上不了热门)。
排序打分
召回拿到几百条候选帖子后,需要通过综合打分决定先后顺序。排在前面的先推给用户,排序质量直接决定推荐效果。
业界排序架构:多级漏斗
大厂的排序不是一步到位,而是分多级逐步精细化。每一级候选数量减少,模型复杂度增加。
粗排(Pre-Ranking)
快速过一遍,淘汰明显不好的。用轻量级模型或规则打分,耗时要求 < 10ms。我们当前方案就在这一级。
适用:中小规模产品 · 规则公式 · 特征简单
精排(Fine-Ranking)
用复杂模型(DNN/DeepFM)对粗排 Top 200 精细打分。特征更多、模型更复杂、预测更准,但推理耗时也更长(10~30ms)。
适用:中大规模 · 深度模型 · 需要 GPU 推理服务
重排(Re-Ranking)
在精排结果上做业务调整:打散、去重、运营置顶、广告插入。不改变打分,只调整顺序和插入特殊内容。
适用:所有规模 · 规则驱动 · Section 07 打散就在这一步
当前方案:多因素加权排序
把每篇帖子的多个维度分数归一化后加权求和,得到最终得分。简单、可解释、好调优。
五个排序因子详解
兴趣匹配度 — 权重 35%(最高)
衡量帖子内容与用户兴趣画像的匹配程度。来自 ES Boost 查询的 _score 字段。
// ES 返回的 _score 是 Boost 叠加分 // 例:帖子命中 美食(3.5) + 旅行(2.5) → _score = 6.0 // 归一化到 0~1: 兴趣匹配度 = min(es_score / max_possible_score, 1.0) // max_possible_score = 所有标签 boost 之和 // 如果用户有 4 个标签 {3.5, 2.5, 2.0, 1.2}, // 则 max = 9.2,得 6.0 / 9.2 ≈ 0.65
质量分 — 权重 25%
直接取 Section 05 计算好的 quality_score,已经是 0~1 区间,无需再归一化。质量分保证了"匹配用户兴趣但内容很烂"的帖子不会排前面。
时效分 — 权重 20%
新帖优先。用户更希望看到新鲜内容,而不是三天前的旧帖。用时间衰减函数把发布时间映射为 0~1 的分数。
衰减效果
热度分 — 权重 15%
来自 Section 04 热门召回中计算好的 hot_score,归一化后直接使用。体现"大家都在看"的从众效应,适度的热门加成可以提升用户的"发现感"。
// 从 Redis 取预计算好的热度分,归一化 raw_hot := redis.ZScore(ctx, "hot:global", postID) 热度分 = min(raw_hot / hot_score_p99, 1.0) // 用 P99 分位数做归一化上界,避免极端值
来源加成 — 权重 5%
帖子来自哪个召回通道,给予不同加成。关注流的帖子有社交关系加持,用户本身有主动关注意愿,点击率天然偏高。
关键步骤:归一化
五个因子的原始值量纲完全不同——ES _score 可能是 0~10,质量分是 0~1,热度分可能是 0~10000。如果不归一化,绝对值大的因子会淹没其他因子。
Min-Max 归一化
用当前这批候选的最大最小值做归一化。简单直观,适合当前规模。
分位数归一化(进阶)
用全局 P99 分位数做归一化。更稳定,不受单次召回极端值影响。需要离线统计 P99。
Go 实现代码
type RankWeights struct { Interest float64 // 0.35 Quality float64 // 0.25 Freshness float64 // 0.20 Hot float64 // 0.15 Source float64 // 0.05 } func RankPosts(posts []Post, user *Profile, w RankWeights) []Post { // 1. 批量获取各项数据 hotScores := redis.ZMScore(ctx, "hot:global", postIDs...) // 2. 计算各因子的 min/max(用于归一化) var minES, maxES float64 = math.MaxFloat64, 0 for _, p := range posts { minES = min(minES, p.ESScore) maxES = max(maxES, p.ESScore) } // 3. 对每篇帖子打分 for i := range posts { p := &posts[i] interest := normalize(p.ESScore, minES, maxES) quality := p.QualityScore // 已经是 0~1 freshness := 1.0 / (1 + p.HoursAgo() * 0.05) hot := min(hotScores[i] / hotP99, 1.0) source := sourceBonus(p.RecallChannel) p.RankScore = interest * w.Interest + quality * w.Quality + freshness * w.Freshness + hot * w.Hot + source * w.Source } // 4. 按分数降序排列 sort.Slice(posts, func(i, j int) bool { return posts[i].RankScore > posts[j].RankScore }) return posts }
权重调优方法
初始权重靠经验设定,上线后根据线上指标反馈调整。核心指标是点击率(CTR)和人均阅读时长。
| 线上现象 | 诊断 | 调优方向 |
|---|---|---|
| 点击率低 | 推的内容不匹配用户兴趣 | 提高兴趣匹配度权重 |
| 点进去就退出 | 封面党多,内容质量不行 | 提高质量分权重 |
| 内容都是旧帖 | 时效分太低或衰减太慢 | 提高时效分权重 / 加大衰减系数 |
| 内容太相似 | 兴趣权重太高导致同质化 | 降低兴趣权重,提高热门/来源权重 |
| 热门帖霸屏 | 热度权重太高 | 降低热度分权重 |
演进路线:从规则到模型
V1 · 规则公式排序(当前)
手动设置权重,加权求和。简单、可解释、可快速迭代。适合 DAU < 10 万的产品。
Go 内存计算V2 · LR/GBDT 粗排
用逻辑回归或 GBDT 模型学习权重。输入特征不变,但权重由模型从数据中学习,比人调更精准。需要搭建训练流水线。
Python 训练 Go 推理V3 · 深度模型精排
引入 DNN/DeepFM/DIN 等深度模型。可以捕捉特征之间的交叉关系,效果显著提升。需要 GPU 推理服务和特征工程平台。
TensorFlow/PyTorch GPU 推理结果打散
排序后同类内容可能扎堆出现。打散保证推荐列表的多样性,避免用户审美疲劳。打散是排序之后、入队之前的最后一步调整。
打散效果对比
打散前(同类扎堆)
用户刷了 5 条美食后已经腻了,可能直接关掉 App
打散后(交错分布)
每隔 1~2 条换个类目,保持新鲜感,用户刷得更久
打散维度
打散不只看类目,实际要考虑多个维度。多维度之间有冲突时,按优先级依次处理。
类目打散 — 最高优先级
同一个一级类目(美食/旅行/摄影)的帖子不能连续超过 N=2 条。这是最核心的打散规则。
作者打散 — 次高优先级
同一个作者的帖子不能连续出现。即使某个大 V 发了 10 篇优质帖,也要穿插其他作者的内容。连续推同一个人会给用户"这是广告"的感觉。
来源打散 — 可选
不同召回通道的帖子要穿插。如果前 10 条全是"兴趣匹配"的结果,关注流和探索的帖子就被挤没了。保证每 5 条内至少有 1 条来自非兴趣通道。
视觉打散 — 进阶
两篇帖子可能属于不同类目,但封面图很像(比如都是蓝天白云)。视觉打散需要计算图片特征相似度,成本较高,后期再做。
算法实现:滑动窗口打散
核心思路:遍历排序后的列表,用一个滑动窗口检查最近 N 条的类目/作者是否重复。如果重复就跳过放入"待插队列",最后把跳过的帖子插回空位。
const ( maxSameCategory = 2 // 同类目最多连续 2 条 maxSameAuthor = 1 // 同作者不连续(最多 1 条) ) func Scatter(ranked []Post) []Post { result := make([]Post, 0, len(ranked)) skipped := make([]Post, 0) for _, post := range ranked { if shouldSkip(result, post) { skipped = append(skipped, post) } else { result = append(result, post) } } // 把跳过的帖子插回结果中的"安全位置" result = insertSkipped(result, skipped) return result } func shouldSkip(result []Post, post Post) bool { n := len(result) // 规则 1: 类目不连续超过 2 条 if n >= maxSameCategory { allSame := true for i := n - maxSameCategory; i < n; i++ { if result[i].Category != post.Category { allSame = false break } } if allSame && result[n-1].Category == post.Category { return true } } // 规则 2: 同作者不连续 if n >= maxSameAuthor && result[n-1].AuthorID == post.AuthorID { return true } return false } func insertSkipped(result, skipped []Post) []Post { // 对跳过的帖子,找到第一个不违反规则的位置插入 for _, post := range skipped { inserted := false for i := 0; i < len(result); i++ { if canInsertAt(result, i, post) { result = insertAt(result, i, post) inserted = true break } } if !inserted { result = append(result, post) // 放末尾兜底 } } return result }
业界进阶算法
上面的滑动窗口是最简单的规则打散。业界还有更数学化的多样性算法:
MMR(Maximal Marginal Relevance)
每次选下一条帖子时,同时考虑相关性(和用户兴趣的匹配度)和多样性(和已选帖子的差异度),取两者加权最高的。
λ=0.7 偏重相关性 · λ=0.3 偏重多样性 · 复杂度 O(n²)
DPP(Determinantal Point Process)
数学上最优雅的多样性方法。构造一个核矩阵,用行列式的值衡量一个子集的"质量×多样性"联合概率。抖音/快手早期多样性用的就是 DPP。
优点:全局最优 · 数学保证 · 缺点:计算成本高,需要近似算法 · 适合精排后的 Top 50
规则打散(当前方案)
滑动窗口 + 分类/作者约束。简单直接,性能极佳(O(n)),适合中小规模。缺点是规则是"硬"约束,不能像 MMR/DPP 那样连续地平衡相关性和多样性。
推荐:先用规则打散上线,效果不够再升级 MMR
打散的代价与度量
打散必然牺牲排序分数——把排序靠前的帖子往后挪了。关键是找到"相关性"和"多样性"的平衡点。
打散过度
用户刷到的帖子和兴趣不匹配,点击率下降。为了多样性牺牲了太多相关性。
表现:CTR 下降 · 人均阅读时长减少
打散不足
用户觉得推荐"千篇一律",审美疲劳后离开。短期 CTR 可能还行,但长期留存下降。
表现:次日留存下降 · 刷新次数减少
特殊内容的处理
运营置顶
运营可以手动将某篇帖子"置顶"到推荐列表前几条。置顶帖不参与打散,直接插入指定位置。但一次最多 1~2 条置顶,不能滥用。
广告插入
如果有广告系统,广告帖在打散之后按固定规则插入(如每 8 条推荐插 1 条广告)。广告不影响推荐内容的打散逻辑,是独立叠加的。
探索帖位置
随机探索和新帖探索的帖子,不宜全放在列表末尾(用户可能刷不到)。应在打散时保证每 10 条中至少穿插 1 条探索帖,给它们足够的曝光。
已读去重
用户刷过的帖子不能再推。布隆过滤器用极小的内存实现海量已读记录的快速判断。这是推荐体验的底线——重复推送会让用户觉得"这推荐系统有 bug"。
Bloom Filter 原理
Bloom Filter 是一个概率数据结构,用 一个 bit 数组 + K 个哈希函数 来判断"某个元素是否在集合中"。它的核心特性:判定不存在时一定不存在,判定存在时可能误判。
写入过程:标记已读
用户看了帖子 post_123 → 用 K 个哈希函数分别计算 → 得到 K 个位置 → 把这些位置的 bit 设为 1。
bit 数组:将位置 3、11、23 设为 1
查询过程:判断是否已读
要推帖子 post_456 → 用同样的 K 个哈希计算位置 → 检查这些位置的 bit 是否全部为 1。
全部为 1 → "可能已读"
这些位可能是被其他元素标记的(哈希碰撞),所以存在误判的可能。误判的结果是少推一条帖子给用户——可接受。
有任一个 0 → "一定没读过"
只要有一个位是 0,就说明这个元素一定没被写入过。这条帖子可以放心推给用户——绝对不会漏判。
Bloom Filter bit 数组可视化
蓝色 = 被标记为 1(对应某个已读帖子的哈希位)。灰色 = 0(未被标记)。
参数设计:要多大的 bit 数组?
Bloom Filter 的误判率由两个参数决定:bit 数组长度 m 和 哈希函数个数 K。对于 n 个元素,有精确的数学公式。
实际场景参数估算
| 场景 | 已读数 n | 目标误判率 | bit 数组 m | 内存 | 哈希数 K |
|---|---|---|---|---|---|
| 轻度用户(30天) | 1,000 | 1% | 9,585 bit | ~1.2 KB | 7 |
| 中度用户(30天) | 10,000 | 1% | 95,851 bit | ~12 KB | 7 |
| 重度用户(30天) | 50,000 | 1% | 479,253 bit | ~59 KB | 7 |
1% 误判率意味着每推 100 条未读帖子中,大约有 1 条被误判为"已读"而被跳过——完全可接受。
Redis 实现
Redis 4.0+ 支持 Bloom Filter 模块(RedisBloom),提供原生命令。如果 Redis 版本不支持,可以用 Redis Bitmap 手动实现。
# 创建 Bloom Filter(每用户一个) # 预期 10000 条已读,误判率 1% BF.RESERVE seen:user:10086 0.01 10000 # 用户看了一篇帖子 → 标记已读 BF.ADD seen:user:10086 post_456 # 推荐时判断帖子是否已读 BF.EXISTS seen:user:10086 post_789 # → 0(未读) / 1(可能已读) # 批量判断(一次检查多篇帖子) BF.MEXISTS seen:user:10086 post_1 post_2 post_3 post_4 post_5
func MarkSeen(ctx context.Context, userID, postID string) { key := "seen:" + userID for i := 0; i < numHashFuncs; i++ { pos := hashN(postID, i) % bitArraySize redis.SetBit(ctx, key, pos, 1) } } func IsSeen(ctx context.Context, userID, postID string) bool { key := "seen:" + userID for i := 0; i < numHashFuncs; i++ { pos := hashN(postID, i) % bitArraySize bit, _ := redis.GetBit(ctx, key, pos).Result() if bit == 0 { return false // 有一个 0 就一定没读过 } } return true // 全部为 1,可能已读(有误判风险) } // 批量过滤:在线推荐时使用 Pipeline 减少 RTT func FilterSeen(ctx context.Context, userID string, posts []Post) []Post { var unseen []Post for _, p := range posts { if !IsSeen(ctx, userID, p.ID) { unseen = append(unseen, p) } } return unseen }
滚动时间窗口:定期重建
Bloom Filter 不支持删除元素(把某个位设回 0 会影响其他元素)。随着已读帖子越来越多,bit 数组逐渐被填满,误判率会持续上升。解决方案:滚动时间窗口。
// 双 Bloom Filter 轮换 // 每 15 天轮换一次 → 实际覆盖 15~30 天的已读记录 func currentBFKey(userID string) string { period := time.Now().Unix() / (15 * 86400) // 15 天一个周期 return fmt.Sprintf("seen:%s:%d", userID, period) } func previousBFKey(userID string) string { period := time.Now().Unix() / (15 * 86400) - 1 return fmt.Sprintf("seen:%s:%d", userID, period) } func MarkSeen(ctx context.Context, userID, postID string) { // 只写入当前周期的 BF redis.Do(ctx, "BF.ADD", currentBFKey(userID), postID) } func IsSeen(ctx context.Context, userID, postID string) bool { // 同时查当前和上一周期(覆盖 15~30 天) cur, _ := redis.Do(ctx, "BF.EXISTS", currentBFKey(userID), postID) if cur == 1 { return true } prev, _ := redis.Do(ctx, "BF.EXISTS", previousBFKey(userID), postID) return prev == 1 } // 清理:设置 key 的 TTL,过期自动删除 // BF.RESERVE 后执行 EXPIRE seen:user:10086:123 2592000 (30天)
去重方案对比
Bloom Filter 不是唯一选择。根据规模和需求,有几种方案可以选。
| 方案 | 内存 / 用户 | 查询速度 | 支持删除 | 误判 | 适用场景 |
|---|---|---|---|---|---|
| Redis Set | ~500 KB | O(1) | 支持 | 无 | DAU < 1K · 精确去重 |
| Redis Bitmap | ~60 KB | O(K) | 不支持 | ~1% | 无 RedisBloom 模块时 |
| Bloom Filter ✅ | ~12 KB | O(K) | 不支持 | ~1% | 万级 DAU · 推荐场景首选 |
| Cuckoo Filter | ~15 KB | O(1) | 支持 | ~3% | 需要删除能力时 |
多级去重策略(推荐架构总览)
在完整的推荐链路中,去重不止一层:
召回时粗筛 — ES must_not
传最近 1 小时内看过的 ID(几十条)给 ES,用 must_not.terms 排除。成本低,减少下游浪费。
合并后精筛 — Bloom Filter(本节)
五路召回合并后,逐条过 Bloom Filter。覆盖全量已读(30 天),微秒级判断。这是去重的主力。
多路间去重 — 内存 Set
同一篇帖子可能被多路召回同时捞到(兴趣匹配 + 热门都命中了)。合并时用 Go 内存 map 按 post_id 去重,取分数最高的那个。
待推队列去重 — 入队前校验
每次新批推荐结果写入待推队列前,和队列中已有的帖子做去重。避免用户快速刷新时,两批结果中有重复。
待推队列与无限刷
待推队列是推荐系统和客户端之间的"缓冲池"——一次召回 200 条缓存在 Redis,用户每次取 20 条。核心命题:如何保证用户永远刷不光?
每个用户一条 Redis List,key = rec:queue:{uid},LPOP 消费、RPUSH 补充,天然 FIFO 顺序。
队列中不只存 post_id,还需要携带预计算的排序分和召回来源,方便后续取出时做轻量重排和日志归因。
// 队列元素结构(JSON 序列化存入 Redis) type QueueItem struct { PostID int64 `json:"pid"` Score float64 `json:"s"` // 排序综合分(已算好) RecallFrom string `json:"rf"` // 召回来源: interest/hot/follow/explore/cold EnqueueTime int64 `json:"et"` // 入队时间戳(用于过期淘汰) Quality float64 `json:"q"` // 质量分快照 }
"什么时候补"比"补多少"更关键。业界普遍采用双水位线策略:
const ( HighWatermark = 200 // 每次补充目标量 LowWatermark = 40 // 触发补充的阈值 PageSize = 20 // 每次客户端取的条数 MinServe = 5 // 最少保证能返回的条数(否则进兜底) ) // 拉取逻辑 func Pull(ctx context.Context, uid int64) ([]QueueItem, error) { // 1. 从队列头部取 PageSize 条 items, err := redis.LPopN(ctx, queueKey(uid), PageSize) if err != nil { return nil, err } // 2. 检查剩余长度,决定是否补充 remaining := redis.LLen(ctx, queueKey(uid)) if remaining < LowWatermark { // 异步触发补充,不阻塞当前请求 go asyncRefill(ctx, uid, HighWatermark) } // 3. 如果取出的数量不够,走兜底逻辑 if len(items) < MinServe { fallbackItems := fallbackPool(ctx, uid, PageSize-len(items)) items = append(items, fallbackItems...) } return items, nil }
用户刷了 2000 条之后,个性化召回确实会逐渐枯竭。业界的答案是多级降级(Fallback Ladder)——逐步放宽策略,保证永远有东西可看。
should 子句的权重,让更多非精确匹配的内容进入召回池。
// 异步补充逻辑:分级降级 func asyncRefill(ctx context.Context, uid int64, target int) { var items []QueueItem var filled int // L1: 标准个性化召回 items = personalizedRecall(ctx, uid, target) filled += len(items) enqueue(ctx, uid, items) if filled >= target { return } // L2: 放宽兴趣范围(上位类目 + 降低匹配阈值) items = relaxedRecall(ctx, uid, target-filled) filled += len(items) enqueue(ctx, uid, items) if filled >= target { return } // L3: 全局热门兜底(按时间桶) items = globalHotRecall(ctx, uid, target-filled) filled += len(items) enqueue(ctx, uid, items) if filled >= target { return } // L4: 运营精选池 items = editorialPool(ctx, target-filled) filled += len(items) enqueue(ctx, uid, items) // L5 & L6 类似,省略... // 最终兜底: 如果所有层级都不够(几乎不可能),记录告警 if filled < MinServe { metrics.EmptyQueueAlert(uid) } }
| 层级 | 候选池大小 | 更新频率 | 耗尽条件 | 是否可能耗尽 |
|---|---|---|---|---|
| L1 个性化 | 取决于兴趣宽度,通常 1k~50k | 实时(新内容持续入库) | 兴趣极窄 + 日活高 | 可能(深度用户) |
| L2 放宽兴趣 | L1 的 5~10 倍 | 实时 | 类目本身内容少 | 低概率 |
| L3 全局热门 | 全站 Top 10k(按时间桶滚动) | 小时级 | 刷完全站所有热门 | 几乎不可能 |
| L4 运营精选 | 500~2000(人工维护) | 日/周级 | 长时间不更新 | 不会(有 L3 前置) |
| L5 历史重推 | 用户自身已读池(>30d) | 随时间自然增长 | 新用户无历史 | 不会 |
| L6 跨域探索 | 全站内容 | 实时 | — | 不可能 |
内容入队后不是一劳永逸的。以下情况需要处理已入队但"变质"的内容:
enqueueTime:入队 >6h 的热点内容丢弃(热度可能已过)。// 懒过滤:取出时过滤无效内容 func pullWithFilter(ctx context.Context, uid int64, need int) []QueueItem { result := make([]QueueItem, 0, need) maxAttempts := 3 // 最多额外取 3 轮,防止死循环 for attempt := 0; len(result) < need && attempt < maxAttempts; attempt++ { // 多取一些,预留过滤空间 fetchSize := (need - len(result)) * 5 / 4 + 2 raw, _ := redis.LPopN(ctx, queueKey(uid), fetchSize) for _, item := range raw { // 检查内容是否仍有效 if isContentDeleted(item.PostID) { continue } // 检查是否过期(热点内容 6h,普通内容不限) if item.RecallFrom == "hot" && time.Since(item.EnqueueTime) > 6*time.Hour { continue } result = append(result, item) if len(result) >= need { break } } // 如果队列空了,退出 if len(raw) < fetchSize { break } } return result }
同一用户多设备同时刷推荐(手机 + 平板),会导致队列竞争:
rec:queue:{uid}:{device_id},各自独立。
缺点:内存翻倍,已读去重需要在 Bloom Filter 层统一,可能推重复内容。
优点:各设备体验独立。
新用户没有兴趣画像,L1 个性化召回几乎无法工作。冷启动队列的组成:
// 冷启动队列配比 func coldStartRefill(ctx context.Context, uid int64, target int) []QueueItem { items := make([]QueueItem, 0, target) // 40% — 全局热门(安全牌,大众口味) hot := globalHotRecall(ctx, uid, target*40/100) items = append(items, tag(hot, "hot")...) // 25% — 注册时选择的兴趣(如果有) if interests := getRegisterInterests(uid); len(interests) > 0 { interest := interestRecall(ctx, uid, interests, target*25/100) items = append(items, tag(interest, "cold_interest")...) } else { // 没有选择兴趣 → 用运营精选补位 editorial := editorialPool(ctx, target*25/100) items = append(items, tag(editorial, "editorial")...) } // 20% — 同城/地域内容(基于 IP 或注册城市) local := localRecall(ctx, uid, target*20/100) items = append(items, tag(local, "local")...) // 15% — 探索内容(多样化品类,帮助快速建立画像) explore := diverseExplore(ctx, target*15/100) items = append(items, tag(explore, "explore")...) return items }
| 参数 | 值 | 说明 |
|---|---|---|
| 单条 QueueItem JSON | ~120 Bytes | pid(8) + score(8) + rf(10) + et(8) + q(8) + JSON 开销 |
| 每用户队列上限 | 200 条 | 120B × 200 = 24 KB |
| 同时在线用户数(DAU 高峰) | 100 万 | — |
| 总内存占用 | ~24 GB | 加上 Redis 内部数据结构开销 ×1.5 ≈ 36 GB |
| TTL 策略 | 24 小时 | 非活跃用户队列自动过期,实际占用远低于峰值 |
优化:可以用 MessagePack 替代 JSON 序列化(体积减少 30~40%),或用 Redis Stream 替代 List(支持 Consumer Group 模式)。
存储架构总览
推荐系统是"多存储协同"的典型场景——没有任何单一数据库能满足所有需求。每种存储解决一类问题,组合起来构成完整的数据底座。
| 存储 | 数据内容 | 读写特点 | 选型理由 | 可替代方案 |
|---|---|---|---|---|
| MySQL | 帖子信息、标签体系、用户信息 | 低频写、结构化查询 | ACID 事务保证,强一致性 | PostgreSQL |
| ClickHouse | 用户行为日志(日千万~亿条) | 高频追加写、聚合分析 | 列式存储,聚合查询秒级 | MongoDB(前期)/ StarRocks |
| Elasticsearch | 帖子召回索引 | 高频读、多条件组合检索 | 倒排索引 + BM25 + Boost | Redis Set(前期)/ Milvus(向量) |
| Redis | 画像、队列、布隆、热门池、缓存 | 极高频读写、微秒级 | 数据结构丰富,延迟极低 | —(核心组件,不可替代) |
| Kafka | 异步消息(发帖事件、行为上报) | 高吞吐、顺序消费 | 削峰 + 解耦 + 可重放 | Redis Stream / Pulsar / NATS |
MySQL 是整个系统的 Source of Truth——所有其他存储的数据都是从 MySQL 同步/衍生过去的。
-- 核心表结构 -- 帖子主表 CREATE TABLE posts ( id BIGINT PRIMARY KEY AUTO_INCREMENT, author_id BIGINT NOT NULL, title VARCHAR(200), content TEXT, image_urls JSON, -- 图片列表 status TINYINT DEFAULT 1, -- 1=正常 2=隐藏 3=删除 quality_score FLOAT DEFAULT 0, -- 质量分(定期更新) created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, INDEX idx_author (author_id), INDEX idx_created (created_at), INDEX idx_status_created (status, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 帖子标签关联表(多对多) CREATE TABLE post_tags ( post_id BIGINT NOT NULL, tag_id INT NOT NULL, weight FLOAT DEFAULT 1.0, -- 标签权重(NLP 提取的置信度) source ENUM('nlp', 'manual', 'user'), PRIMARY KEY (post_id, tag_id), INDEX idx_tag (tag_id) ) ENGINE=InnoDB; -- 帖子统计表(独立出来,高频更新不影响主表) CREATE TABLE post_stats ( post_id BIGINT PRIMARY KEY, view_count INT DEFAULT 0, like_count INT DEFAULT 0, comment_count INT DEFAULT 0, share_count INT DEFAULT 0, collect_count INT DEFAULT 0, updated_at DATETIME NOT NULL ) ENGINE=InnoDB;
ES 不存完整内容,只存召回需要的字段——越精简越快。完整内容通过 post_id 回查 MySQL + 缓存获取。
// ES Index Mapping PUT /posts_recall { "settings": { "number_of_shards": 3, // 按数据量估算 "number_of_replicas": 1, "refresh_interval": "5s" // 近实时,5s 延迟可接受 }, "mappings": { "properties": { "post_id": { "type": "long" }, "author_id": { "type": "long" }, "tags": { "type": "keyword" }, // 标签数组,精确匹配 "category": { "type": "keyword" }, // 一级类目 "quality_score": { "type": "float" }, // 质量分 "hot_score": { "type": "float" }, // 热度分 "created_at": { "type": "date" }, "status": { "type": "byte" }, "image_count": { "type": "integer" }, "text_length": { "type": "integer" }, "author_level": { "type": "byte" } // 作者等级 } } }
terminate_after: 500 限制每个分片最多扫描 500 条,防止慢查。
Redis 在推荐系统中承担了 5 种完全不同的角色,充分利用其丰富的数据结构:
| 角色 | 数据结构 | Key 设计 | 内存估算(100 万 DAU) | TTL |
|---|---|---|---|---|
| 用户画像 | Hash | profile:{uid} |
~2 GB(每人 ~2KB) | 7 天(离线 Job 刷新) |
| 待推队列 | List | rec:queue:{uid} |
~36 GB(每人 200 条) | 24 小时 |
| 已读去重 | Bitmap / BloomFilter | bf:read:{uid} |
~6 GB(每人 ~6KB) | 30 天(滚动窗口) |
| 热门池 | ZSet | hot:pool:{bucket} |
~50 MB(共享,非 per-user) | 随桶轮转 |
| 帖子缓存 | String / Hash | post:cache:{pid} |
~1 GB(Top 10 万热帖) | 1 小时(LRU 淘汰) |
// 用户画像 Hash 结构示例 HSET profile:10086 "interest_tags" '["摄影","美食","旅行"]' "interest_weights" '{"摄影":0.85,"美食":0.6,"旅行":0.3}' "category_dist" '{"生活":0.4,"技术":0.35,"娱乐":0.25}' "active_hours" '[10,12,14,20,21,22]' "avg_read_time" "45" "last_active" "1738700000" "follow_authors" '[2001,2002,2003]'
用户每一次曝光、点击、点赞、收藏、跳过……都是宝贵的信号。ClickHouse 负责存储 + 离线分析。
-- 行为日志表 CREATE TABLE user_actions ( event_date Date, event_time DateTime, uid UInt64, post_id UInt64, action Enum8( 'impression' = 1, 'click' = 2, 'like' = 3, 'collect' = 4, 'comment' = 5, 'share' = 6, 'dislike' = 7, 'report' = 8, 'read_finish' = 9 ), duration_ms UInt32, -- 停留时长 recall_from LowCardinality(String), -- 召回来源 position UInt16, -- 在列表中的位置 device LowCardinality(String), app_version LowCardinality(String) ) ENGINE = MergeTree() PARTITION BY toYYYYMM(event_date) -- 按月分区 ORDER BY (uid, event_date, post_id) TTL event_date + INTERVAL 90 DAY -- 自动清理 90 天前数据 SETTINGS index_granularity = 8192;
-- 画像更新 Job(每天凌晨跑):近 7 天兴趣标签分布 SELECT uid, arrayJoin(post_tags) AS tag, countIf(action = 'click') * 1.0 + countIf(action = 'like') * 3.0 + countIf(action = 'collect') * 5.0 + countIf(action = 'share') * 4.0 - countIf(action = 'dislike') * 2.0 AS weighted_score FROM user_actions LEFT JOIN post_tag_map USING (post_id) -- 维表 JOIN WHERE event_date >= today() - 7 GROUP BY uid, tag HAVING weighted_score > 0 ORDER BY uid, weighted_score DESC;
Kafka 是推荐系统的"神经系统"——所有事件通过它传递,各消费者按需订阅。
推荐系统对一致性的要求是最终一致——允许 5~10 秒的延迟,但不允许永久不一致。
| 故障场景 | 影响范围 | 降级策略 | 恢复时间 |
|---|---|---|---|
| MySQL 不可用 | 新帖无法发布,详情页兜底缓存 | 推荐链路不受影响(不直接依赖 MySQL) | 主从切换 < 30s |
| ES 不可用 | 召回失败,无法补充新内容 | 消费现有队列存量 + 全局热门池兜底 | 集群恢复 ~分钟级 |
| Redis 不可用 | 全链路受影响(画像/队列/布隆全丢) | P0 故障。降级为无个性化热门流 + 内存布隆过滤器 | Sentinel/Cluster 自动切换 < 10s |
| Kafka 不可用 | 新帖延迟入库、行为日志丢失 | 本地磁盘暂存 + 恢复后重放 | Controller 选举 ~秒级 |
| ClickHouse 不可用 | 画像无法更新(用旧画像) | 无直接影响,画像 Redis 有 7 天 TTL | 可容忍 ~小时级 |
衡量指标体系
推荐系统的好坏不是拍脑袋评价的——需要一套完整的指标体系,从北极星指标到诊断指标,层层拆解,用数据驱动每一次迭代。
完读率 / 时长
负反馈率
创作者活跃度
北极星指标是整个推荐团队唯一的终极目标,所有优化最终都要看它有没有涨。
这些是推荐系统直接优化的对象——A/B 测试中主要看这些指标的变化。
曝光定义:什么算"曝光"?画面露出 50% 且停留 >500ms 才算有效曝光。 否则用户快速划过的内容也算曝光,会稀释 CTR。
WeightedEngagement = (Like×1 + Comment×3 + Collect×5 + Share×8) / Impressions分享是最强的正信号——用户愿意用自己的社交货币为内容背书。 评论次之(需要思考和输入成本)。点赞最轻(无脑操作)。
体验指标是"护栏"——不一定要最大化,但不能突破底线。如果为了提升 CTR 而牺牲多样性,长期会导致用户疲劳、流失。
| 指标 | 公式 | 基线 | 红线 | 说明 |
|---|---|---|---|---|
| 多样性 ILS | 1 - avg(sim(i,j)) |
0.75 | < 0.5 | 推荐列表中内容的两两相似度均值的补。越接近 1 越多样。 |
| 新鲜度 | 推荐列表中 24h 内发布的占比 | >30% | < 15% | 过低说明在推老内容,用户感觉 App "没有新东西"。 |
| 负反馈率 | (不喜欢+举报)/ 曝光数 | < 2% | > 5% | 用户主动表达"不想看"。飙升说明推荐严重跑偏。 |
| 重复率 | 用户看到重复内容的比例 | < 0.5% | > 2% | Bloom Filter 误判或跨 Session 去重失效。 |
推荐不只是"给用户推内容",还要维护平台的内容生态——创作者发帖的积极性、内容的公平分发。
推荐系统的每一次改动(换召回策略、调排序权重、改打散窗口……)都必须通过 A/B 测试验证。"我觉得好"没有意义,数据说好才是好。
A/B 测试成本高(占用线上流量 + 等待时间长)。在上线前,用离线数据集做评估可以快速淘汰差策略。
| 指标 | 含义 | 公式直觉 | 适用场景 |
|---|---|---|---|
| Precision@K | 推荐的 Top K 中用户真正交互的比例 | 推了 20 条,用户点了 3 条 → 15% | 召回 + 排序 |
| Recall@K | 用户感兴趣的内容中被推荐 Top K 覆盖的比例 | 用户喜欢 10 条,推的 20 条里命中 3 条 → 30% | 召回 |
| NDCG@K | 考虑排序位置的相关度——排在前面的越相关越好 | 用户点的帖子排在第 1 位比第 20 位得分高很多 | 排序 |
| MAP | 所有相关结果位置的平均精度 | 综合考虑所有正样本的排序位置 | 整体评估 |
| AUC | 模型区分正负样本的能力 | 随机选一正一负样本,正样本分数更高的概率 | CTR 预估模型 |
当业务指标下降时,需要通过技术指标定位问题出在哪个环节。
当北极星指标下降时,按以下路径逐层排查:
迭代演进路线
推荐系统不是一步到位的工程——从"能用"到"好用"到"智能",需要分阶段演进。每个阶段有明确的目标、技术栈、团队要求和判断"该不该进入下一阶段"的信号。
② 简单规则画像:用户近 N 天点赞最多的标签 = 兴趣标签
③ ES bool query + should boost 做召回
④ 手写公式排序(兴趣×0.4 + 质量×0.3 + 新鲜度×0.2 + 热度×0.1)
⑤ 简单滑动窗口打散
⑥ Redis List 做待推队列 + Redis Bitmap 做已读去重
❌ 不搞实时特征
❌ 不搞 A/B 测试平台
❌ 不搞向量召回
这些都是后面阶段的事,过早引入只会增加复杂度、拖慢进度。
训练数据:ClickHouse 中的行为日志(点击=正样本,曝光未点击=负样本)。
特征:用户画像特征 + 帖子特征 + 上下文特征(时间、设备),通常 50~200 维。
效果:CTR 模型 AUC 从 0.5(随机)→ 0.65~0.72,线上 CTR 提升 20~40%。
优势:能捕捉语义层面的相似性(标签不同但"感觉"相似的内容),突破标签匹配的天花板。
输入:用户特征(画像、行为序列)+ 帖子特征(标签、质量分、统计)+ 上下文(时间、位置、设备)。
输出:pCTR(预估点击率),用于排序。
pCTR × 0.4 + pLike × 0.3 + pCollect × 0.2 + pFinish × 0.1 = 最终排序分。
确保推荐的内容不仅"被点击",还"被认可"。
模型推理服务:TensorFlow Serving / Triton Inference Server,P99 延迟 <10ms。
向量数据库:Milvus / Faiss,存储帖子 Embedding,支持 ANN 检索。
训练平台:GPU 集群 + 数据管道 + 实验管理(MLflow / W&B)。
用户最近 10 分钟点了 3 篇美食帖 → 实时将"美食"兴趣权重拉到最高 → 下次补充队列时优先召回美食。
效果:捕捉用户的实时意图(Session 内兴趣),CTR 再提升 5~10%。
用户价值:CTR、互动率、留存率
创作者价值:覆盖率、公平分发、创作者留存
平台价值:广告收入、商业化转化
三者之间有冲突(给用户推广告降体验,不推广告没收入),需要 Pareto 优化找平衡点。
| Phase 1 | Phase 2 | Phase 3 | Phase 4 | |
|---|---|---|---|---|
| 召回 | ES Boost | + 协同过滤 | + 双塔向量召回 | + Graph / RL 探索 |
| 排序 | 手写公式 | LightGBM | DeepFM / DIN | 多任务 + 多目标 |
| 标签 | jieba 关键词 | FastText 分类 | BERT Embedding | LLM 语义理解 |
| 画像 | 规则聚合 | 离线 ClickHouse Job | + 近实时特征 | 实时流式特征 |
| 实验 | 无(直接上线) | 手动 A/B | 简单 A/B 平台 | 多层自动化 A/B |
| 团队 | 2 人 | 3~4 人 | 5~6 人 | 8~10+ 人 |
| 预期 CTR | ~10% | ~15% | ~20% | ~22%+ |
| 关键基础设施 | ES + Redis + Kafka | + ClickHouse + 离线训练 | + GPU + 特征仓库 + 向量库 | + 流计算 + A/B 平台 + MLOps |