这一页回答什么
本页说明 Thunder 如何从 Kafka 实时摄入帖子的创建事件和删除事件,内容包括:两种角色如何分工、事件如何反序列化、启动时如何追平、旧帖和删除墓碑如何裁剪。
核心结论
- 同一个二进制有两种角色。
is_serving = false时,进程是 feeder:它消费原始 Thrift 帖子事件,转成精简的 protoInNetworkEvent,写入innetwork_posttopic。is_serving = true时,进程是 serving 实例:它消费innetwork_post,写入内存中的PostStore(thunder/kafka_utils.rs:71-161)。 - 每个 serving 实例都读取全量数据。每次启动时,consumer group id 都拼接一个随机 UUID(
kafka_utils.rs:72-73),因此各实例不分摊分区。实例没有已提交的 offset,按auto_offset_reset(默认earliest)从头读取(thunder/args.rs:9-10)。每个实例都持有一份完整的两天窗口。 - feeder 过滤三类事件:直接丢弃 nullcast 帖(不进入时间线的定向推广帖);忽略超过保留期的帖子的删除事件;引用帖被删除时,删除引用它的那条帖子(
thunder/kafka/tweet_events_listener.rs:220-263)。 - 视频资格门槛是 5 秒:帖子只有一个媒体,且该媒体是时长不少于 5000 ms 的视频时,feeder 才标记
has_video(tweet_events_listener.rs:55-74)。旧版引用的常量MIN_VIDEO_DURATION_MS已删除,代码改用字面量。 - 追平后才对外服务:每个线程在“剩余 lag 小于分区数 × 批大小”时发送一次信号。
main收齐全部线程的信号后,才调用finalize_init()并进入就绪状态(thunder/kafka/tweet_events_listener_v2.rs:182-229;thunder/main.rs:94-116)。 - 追平后写入串行执行:稳态时,所有摄入线程共用一个许可数为 1 的信号量,同一时刻只有一个批次在写库(
tweet_events_listener_v2.rs:52、206-210)。旧版的许可数是 3。 - 裁剪分 5 片轮转:后台每 2 分钟裁剪一次。每次只处理
user_id % 5等于本轮编号的作者,外加删除墓碑队列(thunder/posts/post_store.rs:479-606;thunder/config.rs:14)。
两种角色
flowchart LR
RAW[("tweet_events<br/>Thrift TweetEvent")] -->|"feeder · is_serving=false"| FEED["tweet_events_listener<br/>转码 + 过滤"]
FEED -->|"proto InNetworkEvent"| INE[("innetwork_post")]
INE -->|"serving · is_serving=true"| SRV["tweet_events_listener_v2<br/>反序列化 + 写库"]
SRV --> STORE[("PostStore")]
| 角色 | is_serving |
消费 topic | 入口 | 输出 | offset |
|---|---|---|---|---|---|
| feeder | false |
tweet_events(Thrift) |
start_tweet_event_processing |
写入 innetwork_post |
关闭自动提交;每批发送完成后手动提交(tweet_events_listener.rs:340-370) |
| serving | true |
innetwork_post(proto) |
start_tweet_event_processing_v2 |
写入 PostStore |
自动提交;每次启动时 group id 唯一 |
topic 名与地址常量位于 kafka_utils.rs:18-23。仓库中的地址是占位符 example.invalid。
认证方式
innetwork_post 的消费端和生产端各自可选两种认证方式:提供 --in-network-events-consumer-mtls-zone / --in-network-events-producer-mtls-zone 时,使用该 zone 的 mTLS;否则使用 SCRAM 用户名和密码(kafka_utils.rs:25-52、75-97、135-158;args.rs:72-76)。两端的认证方式相互独立。SASL 密码从环境变量 SASL_PASSWORD / PRODUCER_SASL_PASSWORD 或命令行参数读取(kafka_utils.rs:60-69)。
多线程与分区
两个 listener 的分配方式相同:按 kafka_num_threads(默认 32)启动线程,把分区按块平均分给各线程,partitions_per_thread = 分区数.div_ceil(线程数)(tweet_events_listener.rs:97-101;tweet_events_listener_v2.rs:28-32)。分区数默认值为:feeder 120(tweet_events_num_partitions),serving 32(kafka_tweet_events_v2_num_partitions)(args.rs:30-31、54-55)。
每个线程有一个 consumer,并启动一个分区 lag 监控任务,周期为 lag_monitor_interval_secs(默认 30 秒)(tweet_events_listener_v2.rs:79-85;args.rs:39-40)。线程意外退出或 consumer 创建失败时,进程直接 panic(tweet_events_listener_v2.rs:96-106)。
反序列化
thunder/deserializer.rs 提供三个函数,分别解析 Thrift TweetEvent、Thrift Event 和 proto InNetworkEvent。kafka/utils.rs 的 deserialize_kafka_messages() 是通用批量解析器:单条消息解析失败时,它只记录 KAFKA_MESSAGES_FAILED_PARSE 并跳过该条(thunder/kafka/utils.rs:20-39)。
Thrift 结构体的 Rust 定义位于新增的 thunder/schema/ 下(tweet.rs、tweet_events.rs、media_entity.rs 等)。这些文件由 Thrift IDL 生成,共约 2.9 万行。
feeder:转码与过滤
process_message_batch()(tweet_events_listener.rs:189-338)逐条处理事件:
- TweetCreateEvent:
nullcast为真时跳过(tweet_events_listener.rs:220-224)。其余事件转成LightPost,字段包括帖子 id、作者 id、created_at_secs、回复对象(in_reply_to_status_id/in_reply_to_user_id)、转发来源(share.source_status_id/source_user_id)、conversation_id、has_video。is_retweet取决于share是否存在,is_reply取决于reply是否存在(tweet_events_listener.rs:226-244)。 - TweetDeleteEvent:帖子发布时间早于保留期时忽略(
tweet_events_listener.rs:246-260);否则记入待删列表。 - QuotedTweetDeleteEvent:把引用了被删帖的那条帖子
quoting_tweet_id记入待删列表(tweet_events_listener.rs:261-263)。 - 忽略其他事件类型。
随后,feeder 把每条创建事件封装成 InNetworkEvent::TweetCreateEvent,把每条删除事件封装成 InNetworkEvent::TweetDeleteEvent,deleted_at 填当前时间。它逐条异步发送,并等待全部发送完成(tweet_events_listener.rs:269-323)。feeder 不持有 PostStore。
视频资格
// thunder/kafka/tweet_events_listener.rs:55-74(节选)
let [first_media] = media.as_slice() else { return false; };
let Some(MediaInfo::VideoInfo(video_info)) = first_media.media_info.as_ref() else {
return false;
};
video_info.duration_millis.map(|d| d >= 5_000).unwrap_or(false)let [first_media] = ... 要求帖子恰好有一个媒体。因此,多图帖和图加视频的帖子都不算视频帖。
serving:写入内存库
拆分批次
deserialize_batch()(tweet_events_listener_v2.rs:113-161)把 InNetworkEvent 分成创建列表和删除列表。is_reply 有兜底判断:事件自带 is_reply、有 in_reply_to_post_id 或有 in_reply_to_user_id,任一条件成立即视为回复(tweet_events_listener_v2.rs:145-147)。
写库
process_tweet_events_v2()(tweet_events_listener_v2.rs:163-239)循环执行以下步骤:
- 每次调用
poll(batch_size),kafka_batch_size默认为 1000(args.rs:12-13)。 - 尚未追平时,读取各分区 lag。总 lag 小于
分区数 × batch_size时,标记为已追平并准备发送信号(tweet_events_listener_v2.rs:182-197)。 - 缓冲区攒够一批后,在
spawn_blocking中依次调用insert_posts()和mark_as_deleted()(tweet_events_listener_v2.rs:212-222)。 - 追平之后,每批写入前先获取全局信号量的许可(许可数 1);追平之前不获取许可(
tweet_events_listener_v2.rs:206-210)。 - 本批写完后,如果第 2 步已标记追平,向
main发送一次信号(tweet_events_listener_v2.rs:224-229)。
追平期间不限流,让内存库尽快填满。追平后,所有线程串行写库,把 CPU 让给查询。许可数从 3 改为 1,说明上游进一步减少了稳态摄入对查询的干扰(推测)。
启动门控
在 is_serving 模式下,main 循环 kafka_num_threads 次 rx.recv()。收齐信号后,它调用 finalize_init(),记录 INIT_DURATION_SECONDS,再启动统计日志和自动裁剪(main.rs:94-114)。is_serving = false(feeder)时,进程不等待,直接就绪。
删除事件的处理
mark_as_deleted()(post_store.rs:111-125)对每个删除事件执行三个动作:
- 从
posts删除正文。之后,任何队列中指向该帖的TinyPost回查都会失败,查询自然跳过它。 - 在
deleted_posts中记录墓碑。之后迟到的创建事件会在insert_posts_internal中被拒绝(post_store.rs:163-165)。 - 在特殊作者
DELETE_EVENT_KEY = 0的原创队列中追加一条TinyPost(帖子 id, deleted_at)(config.rs:10)。这条记录用于按时间裁剪墓碑。
finalize_init() 最后再把墓碑表中的帖子从 posts 清除一遍,以处理追平期间“删除先到、创建后到”的乱序(post_store.rs:142-154)。
保留与裁剪
- 写入时过滤:
insert_posts()只保留created_at在过去retention_seconds内且不在未来的帖子,并按时间升序写入(post_store.rs:127-140)。保留期默认为 172800 秒,即 2 天(args.rs:48-49)。 - 每个队列的上限:单个作者的单个队列超过 5000 条时,新帖入队会挤出最旧的一条(
post_store.rs:181-185;config.rs:12)。 - 启动裁剪:
finalize_init()先给所有队列排序,再依次执行 5 轮裁剪,覆盖全部分片(post_store.rs:142-154)。 - 周期裁剪:
start_auto_trim(2)每 2 分钟执行一轮,错过的 tick 直接跳过(main.rs:109;post_store.rs:479-499)。
trim_old_posts(trim_iter)(post_store.rs:501-606)在 spawn_blocking 中对三个队列表各执行一次以下步骤:
- 先处理
DELETE_EVENT_KEY队列:弹出超过保留期的墓碑,并同时从posts和deleted_posts删除(post_store.rs:526-538)。 - 其余作者只处理
user_id % 5 == trim_iter % 5的那一片(post_store.rs:547-549)。 - 对选中的作者,从队首弹出“超过保留期”或“队列超过 5000 条”的帖子,并从
posts删除(post_store.rs:552-563)。 - 队列容量超过长度的两倍时,收缩到长度的 1.5 倍;移除队列为空的作者(
post_store.rs:565-577)。
分片的代价是:同一作者每 5 轮才轮到一次,即每 10 分钟一次。因此,过期帖在内存中最多多停留约 10 分钟(按代码推断)。下游另有 48 小时的 AgeFilter,所以这不影响信息流。
未接入的部分
thunder/o2/mod.rs 定义了一个 S3 兼容对象存储客户端 O2Client。args.rs 也有 --o2-bucket(默认 innetwork-posts)和 --o2-prefix(args.rs:78-82)。但当前 thunder 代码中没有任何地方调用它。源码未说明它的用途,推测是为快照加载预留。
设计决策
| 决策 | 理由 |
|---|---|
| 分离 feeder 与 serving | Thrift 解析和过滤只执行一次;serving 实例只解码精简的 proto |
| serving 使用唯一 group id 并从头读取 | 每个实例都需要完整的两天窗口,不能分摊分区 |
| 追平期不限流,稳态串行写入 | 启动后尽快可用;上线后把 CPU 让给查询 |
| 追平后才就绪 | 避免半空的内存库返回不完整的站内候选 |
| 删除时立即删除正文并记录墓碑 | 查询无需再查墓碑表;迟到的创建事件也能被拦截 |
| 墓碑挂在特殊作者 0 下 | 复用按时间裁剪队列的逻辑,墓碑自然过期 |
| 裁剪分 5 片 | 单轮裁剪只扫描五分之一的作者,缩短每轮持锁时间 |
| 单条消息解析失败时跳过 | 个别坏消息不中断整批 |
| 处理线程退出时 panic | 摄入停止等于数据过期;宁可让编排系统重启进程 |
FAQ
Q:删除事件早于创建事件到达时会怎样?
A:mark_as_deleted 先记录墓碑。之后的创建事件在 insert_posts_internal 中因墓碑而被跳过(post_store.rs:163-165)。finalize_init 再兜底清除一遍。
Q:重启一个 serving 实例需要多久?
A:实例需要从 innetwork_post 头部重放到追平。耗时取决于 topic 保留的数据量,并记录在 INIT_DURATION_SECONDS 中。源码中没有具体数值。
Q:feeder 和 serving 是同一个二进制吗?
A:是。thunder 是一个二进制,由 --is-serving 决定角色(args.rs:57-58;kafka_utils.rs:71、116)。
源码锚点
thunder/kafka_utils.rs:18-161—— topic、认证、按角色分流thunder/args.rs:6-91—— Kafka、保留期、线程与分区默认值thunder/kafka/tweet_events_listener.rs:55-74—— 视频资格thunder/kafka/tweet_events_listener.rs:189-338—— feeder 转码、过滤、发送thunder/kafka/tweet_events_listener_v2.rs:42-239—— serving 线程、追平信号、写库thunder/posts/post_store.rs:111-154—— 删除墓碑、写入过滤、启动收尾thunder/posts/post_store.rs:479-606—— 自动裁剪与分片thunder/main.rs:90-116—— 追平门控与就绪
相关页面
- Thunder 站内帖子内存库 —— 查询路径与服务端常量
- PostStore ——
PostStore结构体细节 - 端到端数据流:一条帖子从发布到被推荐 —— Kafka 摄入是推荐链路的起点
- home-mixer 编排层 ——
ThunderSource如何调用 Thunder - 系统架构总览 —— Thunder 作为站内候选源的角色