这一页回答什么
本页说明推荐系统如何获取当前用户已关注作者发布的站内候选帖子。系统通过 Rust 实现的高性能内存库 Thunder 提供该能力。Thunder 实时消费 Kafka 帖子事件流,在内存中维护最近两天内按作者归类的帖子索引,并通过 gRPC 接口向 home-mixer 的 ThunderSource 组件提供关注作者最新帖子检索服务。
本页侧重说明 Thunder 的服务端架构:内存组织结构、查询执行链路、时效检索逻辑、限流保护与常量配置。Kafka 数据消费、反序列化与内存裁剪流程详见 Thunder Kafka 摄入;PostStore 的具体字段与方法定义详见 PostStore。
核心结论
- 采用三套时间线队列与单份正文存储结构:系统将每个作者的帖子分流至三个双端队列:原创帖子队列、回复与转发队列、视频帖子队列。帖子正文仅在
posts: DashMap<i64, Arc<CompactPost>>中存储一份,各队列仅保存 16 字节的TinyPost(包含帖子标识符与时间戳)(thunder/posts/post_store.rs:87-96)。带有视频的原创帖子同时写入原创队列与视频队列。 - 基于关注列表执行检索过滤:RPC 接口
get_in_network_posts接收请求者标识符、关注列表与排除列表。服务对列表中每个被关注作者按时间倒序扫描队列,回查正文并执行业务过滤,最终合并输出候选列表(thunder/thunder_service.rs:152-336)。 - 召回阶段仅按发布时间倒序排列:函数
score_recent按照created_at降序截取前max_results条候选帖子,不执行特征提取与内容相关性打分(thunder_service.rs:339-343)。细粒度排序由下游的 打分与排序 阶段执行。 - 服务采用 QPS 令牌桶限流机制:服务入口集成
governor限流器,默认配置每秒最多允许 8000 次请求;超过阈值时立即返回RESOURCE_EXHAUSTED错误(thunder_service.rs:49-61、156-161;thunder/args.rs:90-91)。旧版并发信号量机制已移除。 - 系统硬编码剔除三个机器人账号:即使当前用户关注了 @grok、@gork 或特定的产品实验账号,Thunder 服务依然在检索前将其从关注列表中排除(
thunder_service.rs:32-40、276-280)。 - 核心限制常量已开源公开:单次请求最多返回 1200 条候选(视频请求返回 600 条);单个作者最多保留 50 条原创帖子、30 条回复或转发帖子、100 条视频帖子;单次扫描单个作者最多遍历 500 条记录;关注列表与排除列表最大截断为 10000 项(
thunder/config.rs:1-8)。 - 数据保留采用滑动窗口机制:内存仅保留最近 172800 秒(2 天)内发布的帖子;后台定时任务每 2 分钟执行一次淘汰裁剪,每次轮询裁剪五分之一的作者分片(
thunder/args.rs:48-49;thunder/main.rs:109;post_store.rs:547-549)。
Thunder 在系统中的位置
flowchart LR
KAFKA[("Kafka<br/>innetwork_post topic")] -->|"v2 listener"| TH
subgraph TH["Thunder 服务实例"]
PS["PostStore<br/>内存库"]
SVC["ThunderServiceImpl<br/>gRPC"]
PS --- SVC
end
HM["home-mixer<br/>ThunderSource"] -->|"GetInNetworkPosts"| SVC
SVC -->|"关注者最近的帖"| HM
HM --> PIPE["过滤 · 打分 · 选择"]
Thunder 专注于站内候选召回环节。其输出结果在 home-mixer 中与 Phoenix 召回 及 SimClusters 产出的站外候选合并,随后统一交付后续流水线。
进程启动
启动流程在 thunder/main.rs:17-128 中按以下顺序执行:
- 初始化
PostStore实例,配置数据保留时长与请求超时时间(main.rs:26-29)。 - 构建
ThunderServiceImpl服务实例,注入qps_limit限流参数(main.rs:40-48)。 - 检查环境变量
GRPC_MTLS_*;若配置则开启服务间 mTLS 双向认证,未配置则使用 h2c 明文传输(main.rs:53-64)。 - 启动 Kafka 数据消费线程(
main.rs:90-92)。 - 在
is_serving模式下,主线程阻塞等待全部 Kafka 分区消费追平延迟(lag)。追平后调用finalize_init(),上报INIT_DURATION_SECONDS指标,并启动后台统计任务与自动裁剪协程(main.rs:94-114)。 - 调用
set_readiness(true)将服务就绪状态置为就绪(main.rs:116)。
步骤 5 确保服务对外暴露流量时内存中已建立完整的近期帖子索引,避免冷库提供推荐服务。消费追平判定逻辑详见 Thunder Kafka 摄入。
三套内存时间线
// thunder/posts/post_store.rs:87-96
pub struct PostStore {
posts: Arc<DashMap<i64, Arc<CompactPost>>>,
original_posts_by_user: Arc<DashMap<i64, VecDeque<TinyPost>>>,
secondary_posts_by_user: Arc<DashMap<i64, VecDeque<TinyPost>>>,
video_posts_by_user: Arc<DashMap<i64, VecDeque<TinyPost>>>,
deleted_posts: Arc<DashMap<i64, bool>>,
retention_seconds: u64,
request_timeout: Duration,
}| 结构 | 键 | 值 | 作用 |
|---|---|---|---|
posts |
帖子 id | Arc<CompactPost> |
唯一一份正文,三套时间线共享 |
original_posts_by_user |
作者 id | VecDeque<TinyPost> |
原创帖(非回复、非转发) |
secondary_posts_by_user |
作者 id | VecDeque<TinyPost> |
回复与转发 |
video_posts_by_user |
作者 id | VecDeque<TinyPost> |
视频帖 |
deleted_posts |
帖子 id | bool |
删除墓碑 |
帖子正文从旧版本的 LightPost 重构为 CompactPost:可选字段使用 0 表示空缺,采用定长且支持 Copy 的内存布局;gRPC 查询时动态转回 Protocol Buffers 的 LightPost 格式(post_store.rs:35-85)。
多队列拆分设计依据:普通推荐请求需要聚合原创帖子与回复转发内容,视频推荐请求仅需要包含视频的帖子。视频帖子独立维护队列,避免视频请求全量扫描作者所有帖子。队列重复引用的对象仅为 16 字节的 TinyPost 轻量结构。
入队规则
函数 insert_posts_internal(post_store.rs:156-227)按以下规则写入每条新帖子:
- 检查帖子状态:帖子标识符存在于墓碑表中则跳过;已存在于
posts映射表中同样跳过(post_store.rs:163-172)。 - 分流主队列:原创帖子写入
original_posts_by_user,非原创帖子写入secondary_posts_by_user(post_store.rs:178-196)。 - 判定视频属性:帖子自身设置
has_video,或者属于带视频且非回复的转发帖子;回复帖子一律不归入视频队列(post_store.rs:198-210)。符合条件时追加写入video_posts_by_user。 - 队列容量硬限制:常量
MAX_POSTING_LIST_SIZE = 5000。若队列写入前达到上限,从队首弹出最旧条目,并从posts中同步删除对应正文(post_store.rs:181-185、222-224;config.rs:12)。
第 4 条为新增防护机制。单个作者在 2 天窗口内发布超过 5000 条帖子时,旧帖子将提前淘汰。根据源码逻辑推断,当视频队列容量溢出并移除正文时,同一帖子在原创队列中的引用将因正文缺失而在查询时被跳过;开源源码未对跨队列引用失效提供专门处理。
gRPC 服务
ThunderServiceImpl(thunder_service.rs:42-67)实现 InNetworkPostsService 接口,仅暴露 get_in_network_posts 单个 RPC 方法,并启用 zstd 数据压缩。
请求消息 GetInNetworkPostsRequest 包含以下字段(依据调用端 home-mixer/sources/thunder_source.rs:32-41 整理):user_id、following_user_ids、max_results、exclude_tweet_ids、algorithm、debug、is_video_request、per_author_limits。
一次 get_in_network_posts 的路径
flowchart TD
A["gRPC 请求到达"] --> B{"QPS 令牌桶<br/>有余量?"}
B -- 否 --> R["RESOURCE_EXHAUSTED"]
B -- 是 --> C["IN_FLIGHT_REQUESTS 加一"]
C --> D{"关注列表为空<br/>且 debug?"}
D -- 是 --> E["StratoClient 拉关注列表"]
D -- 否 --> F["用请求里的列表"]
E --> G["确定 max_results<br/>与每作者上限"]
F --> G
G --> H["两个列表各截到 10000"]
H --> I["spawn_blocking"]
I --> J["剔除机器人账号"]
J --> K{"is_video_request?"}
K -- 是 --> L["get_videos_by_users"]
K -- 否 --> M["get_all_posts_by_users"]
L --> N["score_recent<br/>按时间降序取前 N"]
M --> N
N --> O["返回"]
第 1 步:QPS 限流
// thunder/thunder_service.rs:156-161
if self.rate_limiter.check().is_err() {
REJECTED_REQUESTS.inc();
return Err(Status::resource_exhausted(
"QPS rate limit exceeded, please retry",
));
}限流器初始化时通过 Quota::per_second(qps_limit) 配置吞吐配额(thunder_service.rs:55-59),默认上限为 qps_limit = 8000(args.rs:90-91)。架构由旧版的并发请求信号量重构为每秒请求率控制。服务通过 Drop 守卫对在途请求计数器 IN_FLIGHT_REQUESTS 执行增减维护(thunder_service.rs:163-171)。
第 2 步:关注列表回退获取
通常情况下 home-mixer 会直接在请求中传入关注列表。仅当列表为空且 debug 标识为真时,Thunder 才会主动调用 StratoClient 拉取关注列表;拉取失败时返回 INTERNAL 状态码(thunder_service.rs:187-219)。该分支仅供测试使用;生产中 ThunderSource 默认传入 debug: false。
第 3 步:确定结果上限与单作者限制
max_results:若请求指定正整数则采用请求值;未指定时视频请求默认为 600,普通请求默认为 1200(thunder_service.rs:226-232;config.rs:1-2)。per_author_limits:请求参数可缩小单作者配额,但不可超出系统常量(thunder_service.rs:235-245)。原创帖子上限MAX_ORIGINAL_POSTS_PER_AUTHOR = 50,回复与转发上限MAX_REPLY_POSTS_PER_AUTHOR = 30(config.rs:5-6)。当回复与转发上限配置为 0 时,服务完全跳过 secondary 队列查询(post_store.rs:270-281)。ThunderSource默认传入None使用常量配置。
第 4 步:输入列表截断
服务将关注列表与排除列表截取前 MAX_INPUT_LIST_SIZE = 10000 条记录,超出限制时输出警告日志(thunder_service.rs:247-270;config.rs:3)。
第 5 步:在阻塞线程中检索帖子
检索计算调度至 spawn_blocking 线程池执行(thunder_service.rs:275-321)。执行前首先从关注列表中剔除内置黑名单账号 BLACKLISTED_USER_IDS:
// thunder/thunder_service.rs:32-40
static ref BLACKLISTED_USER_IDS: HashSet<i64> = {
let mut set = HashSet::new();
set.insert(1720665183188922368); // @grok bot account
set.insert(1909390269533011968); // @gork bot account
set.insert(1999031187604717568); // @products experimentation account
set
};随后根据 is_video_request 分流调用检索函数,并执行 score_recent。参数 algorithm 仅识别 ""、"recent"、"default" 与 "latest";传入其余数值时记录错误日志,并降级按时间倒序排序(thunder_service.rs:307-313)。home-mixer 传入的值来源于 ThunderAlgorithm,默认配置为 "default"(home-mixer/params/param.rs:29-34)。
取数:两条路径与共用核心
get_all_posts_by_users(普通请求)
针对关注者列表,方法先后查询原创队列与回复转发队列,随后执行列表合并(post_store.rs:250-285)。两次调用的差异在于:原创队列检索时 following_users 传入空集合;回复转发检索时传入实际关注者集合,用于触发回复可见性规则判定。
get_videos_by_users(视频请求)
服务仅检索视频队列,限制单作者上限 MAX_VIDEO_POSTS_PER_AUTHOR = 100,不执行回复可见性校验(post_store.rs:229-248;config.rs:7)。
get_posts_from_map(共用核心检索逻辑)
函数实现于 post_store.rs:287-377,针对每个被关注者按序执行:
- 超时校验:当配置的
request_timeout大于 0 且耗时超限时,累加POST_STORE_REQUEST_TIMEOUTS计数并中断遍历,已检索到的结果直接返回(post_store.rs:304-318)。默认配置request_timeout_ms = 0表示不开启超时限制(args.rs:87-88)。 - 倒序遍历队列:逆序遍历双端队列,过滤处于排除列表中的帖子,单次最多检查
MAX_TINY_POSTS_PER_USER_SCAN = 500条条目(post_store.rs:324-328;config.rs:8)。 - 回查正文缓存:从
TinyPost索引查取CompactPost并转换为LightPost。正文因删除、裁剪或溢出淘汰而不存在时自动跳过(post_store.rs:330-335)。 - 过滤自我转发:剔除原帖作者为请求者本人的转发记录(
is_retweet且作者为当前用户)(post_store.rs:337-339)。 - 执行回复可见性规则:该规则仅在遍历回复转发队列时生效(
post_store.rs:341-365)。 - 截断作者限额:单作者收集满
max_per_user条记录后停止遍历当前作者(post_store.rs:367)。
旧版本在正文回查后执行的墓碑表查询已被简化。当前架构下删除事件直接将数据移出 posts 表(post_store.rs:111-125),回查正文失败即自动达成过滤效果。
回复可见性规则
// thunder/posts/post_store.rs:341-365(节选)
post.in_reply_to_post_id.is_none_or(|reply_to_post_id| {
if let Some(replied_to_post) = self.posts.get(&reply_to_post_id) {
if !replied_to_post.is_retweet && !replied_to_post.is_reply {
return true;
}
return post.conversation_id.is_some_and(|convo_id| {
let reply_to_reply_to_original =
replied_to_post.in_reply_to_post_id == convo_id;
let reply_to_followed_user = post
.in_reply_to_user_id
.map(|uid| following_users.contains(&uid))
.unwrap_or(false);
reply_to_reply_to_original && reply_to_followed_user
});
}
false
})| 候选帖子类型 | 过滤规则 |
|---|---|
| 非回复帖子(纯转发) | 放行 |
| 回复对象为原创帖子 | 放行 |
| 回复对象为另一条回复 | 仅当被回复帖子直接挂接在会话根节点,且本条回复的目标用户属于当前用户的关注列表时放行 |
| 被回复的父帖子不存在于内存库中 | 丢弃 |
规则效果:关注作者对主帖的一级回复全量保留;更深层级的嵌套回复仅在涉及已关注作者时保留。
按时效排序:score_recent
// thunder/thunder_service.rs:339-343
fn score_recent(mut light_posts: Vec<LightPost>, max_results: usize) -> Vec<LightPost> {
light_posts.sort_unstable_by_key(|post| Reverse(post.created_at));
light_posts.into_iter().take(max_results).collect()
}Thunder 仅保障内容的新鲜度。对于关注 2000 个账号的用户,Thunder 聚合这些账号最近发布的至多 1200 条候选帖子,随后交由 打分与排序 执行个性化精排。
调用方:ThunderSource
组件定义于 home-mixer/sources/thunder_source.rs:21-120:
- 调用条件:非缓存命中请求触发调用(
thunder_source.rs:23-25)。 - 请求组装:关注列表取自上游水合产出的
followed_user_ids;排除列表取自用户已曝光帖子集合seen_ids;返回数量设为max_results = quality_factor::apply(ThunderMaxResults),参数ThunderMaxResults默认值为 1200(thunder_source.rs:27-41;home-mixer/params/param.rs:23-28)。函数quality_factor的实现细节源码未包含。 - 通道路由:配置开关
enable_thunder_capi_home_mixer开启且配置了 CAPI 客户端时走 CAPI 通道,未开启时随机选取一个直连通道建立连接(thunder_source.rs:43-67)。 - 数据加工:将检索到的站内回复写入
query.in_network_replies供后续链路使用;为回复补充父帖与会话根节点上下文;在 For You 推荐流中标记served_type为ForYouInNetwork,在“关注”推荐流中标记为RankedFollowing(thunder_source.rs:69-118)。
官方文档说明,全系统中仅 ThunderSource 在服务内部执行已读列表排除,其余候选源的历史去重统一由流水线过滤器完成(README.md:407)。
可观测性
函数 analyze_and_report_post_statistics 在检索完成阶段(stage = "retrieved")与排序完成阶段(stage = "scored")分别执行指标上报(thunder_service.rs:69-147):
| 指标 | 含义 |
|---|---|
GET_IN_NETWORK_POSTS_FOUND_FRESHNESS_SECONDS |
最新一条帖距今秒数 |
GET_IN_NETWORK_POSTS_FOUND_TIME_RANGE_SECONDS |
最旧到最新的时间跨度 |
GET_IN_NETWORK_POSTS_FOUND_REPLY_RATIO |
回复占比 |
GET_IN_NETWORK_POSTS_FOUND_UNIQUE_AUTHORS |
去重作者数 |
GET_IN_NETWORK_POSTS_FOUND_POSTS_PER_AUTHOR |
平均每作者帖数 |
服务级监控指标还包含 GET_IN_NETWORK_POSTS_DURATION 与 ..._WITHOUT_STRATO(不包含 Strato 通信耗时)、GET_IN_NETWORK_POSTS_FOLLOWING_SIZE、..._EXCLUDED_SIZE、..._MAX_RESULTS、IN_FLIGHT_REQUESTS 以及 REJECTED_REQUESTS(thunder/metrics.rs)。
并发模型
| 机制 | 用在哪 | 目的 |
|---|---|---|
DashMap |
全部内存表 | 分片锁哈希表,摄入写与查询读并行 |
Arc<CompactPost> |
正文 | 回查时只克隆引用,读锁持有时间短 |
governor 令牌桶 |
gRPC 入口 | 每秒请求数上限,超出立即拒绝 |
spawn_blocking |
取数、裁剪、排序 | CPU 密集的同步循环不占 async 工作线程 |
| 请求超时(默认关) | 逐作者遍历 | 宁可少返回,不让单个请求无限拖长 |
设计决策
| 决策 | 理由 |
|---|---|
纯内存(DashMap + VecDeque) |
站内候选对延迟敏感;只存 2 天,内存可控 |
| 三套队列 | 普通请求和视频请求直接命中各自子集 |
队列存 TinyPost,正文单存 |
排序和裁剪只动 16 字节;正文不重复 |
| QPS 令牌桶取代并发信号量 | 直接约束吞吐;超限立即拒绝,调用方按源失败处理 |
| 每个队列 5000 条硬上限 | 防止个别高频账号撑爆内存 |
| 只按时间排序 | Thunder 只负责“新”,相关性交给下游统一排序 |
| 硬编码排除机器人账号 | 这些账号发帖量极大,会挤占关注者的站内名额(推测) |
| 等 Kafka 追平才就绪 | 避免半空的内存库返回不完整的站内候选 |
FAQ
Q:Thunder 是否会返回发布超过两天的帖子?
A:数据写入阶段就会直接丢弃超过保留期的条目(post_store.rs:127-140)。后台裁剪任务按作者标识符划分为 5 个分片轮询,每 2 分钟执行一个分片,因此单个作者已过期的帖子在内存中最多滞留约 10 分钟(推测依据代码逻辑)。下游流水线另设有 48 小时阈值的 AgeFilter 执行保底过滤。
Q:当前用户关注了 @grok,为何站内推荐流中未出现其发布的帖子?
A:Thunder 服务在执行检索前将 @grok 等三个账号硬编码从关注列表中剔除(thunder_service.rs:32-40、276-280)。这些账号的内容仅能经由站外召回链路进入候选集。
Q:限流配额耗尽时系统如何应对?
A:服务立即返回 RESOURCE_EXHAUSTED 错误。ThunderSource 捕获该错误后,按照 candidate-pipeline 框架 的容错规范,将本路站内候选置为空集合,其余候选源正常输出。
源码锚点
thunder/main.rs:17-128—— 启动、mTLS、Kafka 追平门控thunder/config.rs:1-14—— 返回上限、每作者上限、扫描上限、队列上限、裁剪分片thunder/args.rs:48-49、87-91—— 保留期、请求超时、QPS 上限thunder/thunder_service.rs:32-67—— 机器人黑名单、服务结构、限流器thunder/thunder_service.rs:152-336——get_in_network_posts全流程thunder/thunder_service.rs:339-343——score_recentthunder/posts/post_store.rs:35-96——CompactPost与PostStorethunder/posts/post_store.rs:156-227—— 入队与 5000 条上限thunder/posts/post_store.rs:287-377—— 取数核心与回复可见性规则home-mixer/sources/thunder_source.rs:21-120—— 调用方ThunderSource
相关页面
- Thunder Kafka 摄入 —— 摄入、反序列化、裁剪
- PostStore ——
PostStore结构体与方法清单 - SimClusters 站外候选源 —— 并列的站外候选源
- Phoenix 召回:双塔检索架构 —— 另一路站外召回
- home-mixer 编排层 ——
ThunderSource所在的编排层 - candidate-pipeline 框架与七个组件 trait —— 源的并行调度与失败处理
- 打分与排序 —— Thunder 只按时效取数,相关性排序在这里
- 系统架构总览 —— Thunder 在全局中的位置