是什么
PostStore 是 Thunder 的核心内存数据结构。它按作者分组存储近期帖子,线程安全,为站内候选查询提供数据。定义位于 thunder/posts/post_store.rs:87-96。服务如何使用它,见 Thunder 站内帖子内存库;由谁写入它,见 Thunder Kafka 摄入。
结构体定义
// thunder/posts/post_store.rs:87-96
#[derive(Clone)]
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 → 原创帖队列;另外用特殊键 DELETE_EVENT_KEY = 0 存放删除墓碑队列 |
secondary_posts_by_user |
作者 id → 回复与转发队列 |
video_posts_by_user |
作者 id → 视频帖队列 |
deleted_posts |
删除墓碑集合 |
retention_seconds |
保留期(秒) |
request_timeout |
查询时逐个作者遍历的超时;0 表示不启用 |
所有表都使用 Arc<DashMap>。DashMap 是分片加锁的并发哈希表。Arc 让 PostStore 能以低成本 Clone,使摄入线程和查询线程共享同一份数据。
数据布局
flowchart TB
subgraph IDX["时间线索引"]
OP["original_posts_by_user<br/>作者 → TinyPost 队列"]
SP["secondary_posts_by_user<br/>回复 + 转发"]
VP["video_posts_by_user<br/>视频帖"]
DK["original_posts_by_user[0]<br/>删除墓碑队列"]
end
subgraph MAIN["正文"]
P["posts<br/>帖子 id → CompactPost"]
D["deleted_posts<br/>墓碑集合"]
end
OP -. "post_id 回查" .-> P
SP -.-> P
VP -.-> P
DK -. "裁剪时同步清理" .-> D
TinyPost
// thunder/posts/post_store.rs:20-24
pub struct TinyPost {
pub post_id: i64,
pub created_at: i64,
}队列中只存放这 16 字节。队列按 created_at 升序排列,队尾是最新的帖子。
CompactPost
CompactPost(post_store.rs:35-48)是正文在内存中的形态,字段与 proto LightPost 一一对应:post_id、author_id、created_at、in_reply_to_post_id、in_reply_to_user_id、source_post_id、source_user_id、conversation_id、is_retweet、is_reply、has_video。区别是:CompactPost 用 0 表示可选字段“无值”,它是定长的 Copy 结构。两者的互相转换见 post_store.rs:50-85。
旧版 posts 直接存储 LightPost。改为 Arc<CompactPost> 后,回查只克隆一个引用,读锁的持有时间更短。
常量
| 常量 | 值 | 用途 | 位置 |
|---|---|---|---|
MAX_POSTING_LIST_SIZE |
5000 | 单个作者单个队列的上限 | thunder/config.rs:12 |
MAX_TINY_POSTS_PER_USER_SCAN |
500 | 查询时每个作者最多扫描的条数 | config.rs:8 |
MAX_ORIGINAL_POSTS_PER_AUTHOR |
50 | 每个作者最多返回的原创帖数 | config.rs:6 |
MAX_REPLY_POSTS_PER_AUTHOR |
30 | 每个作者最多返回的回复与转发数 | config.rs:5 |
MAX_VIDEO_POSTS_PER_AUTHOR |
100 | 每个作者最多返回的视频帖数 | config.rs:7 |
DELETE_EVENT_KEY |
0 | 墓碑队列使用的哨兵作者 id | config.rs:10 |
TRIM_FRACTION |
5 | 裁剪分片数 | config.rs:14 |
构造
| 方法 | 说明 |
|---|---|
new(retention_seconds, request_timeout_ms) |
指定保留期与查询超时(post_store.rs:99-109) |
Default |
保留期 2 天(2 * 24 * 60 * 60),不启用超时(post_store.rs:646-650) |
服务启动时,用 --post-retention-seconds(默认 172800)和 --request-timeout-ms(默认 0)构造 PostStore(thunder/main.rs:26-29;thunder/args.rs:48-49、87-88)。
写入方法
insert_posts(&self, posts: Vec<LightPost>)
post_store.rs:127-140 按以下步骤执行:
- 只保留
created_at早于当前时间且在保留期内的帖子。 - 按
created_at升序排序。 - 调用
insert_posts_internal。
insert_posts_internal
post_store.rs:156-227 对每条帖子执行以下步骤:
- 帖子 id 在
deleted_posts中时,跳过该帖(post_store.rs:163-165)。 - 写入
posts。帖子已存在时跳过,不重复入队(post_store.rs:167-172)。 - 原创帖进入
original_posts_by_user,其他帖子进入secondary_posts_by_user。入队前,如果队列已满 5000 条,弹出最旧的一条并记为待删(post_store.rs:178-196)。 - 判定视频资格:帖子自身
has_video;或者帖子是转发,且被转发的原帖是带视频的非回复帖。回复一律不算视频帖(post_store.rs:198-210)。合格的帖子进入video_posts_by_user,同样受 5000 条上限约束(post_store.rs:212-220)。 - 把第 3、4 步挤出的帖子从
posts删除(post_store.rs:222-224)。
mark_as_deleted(&self, posts: Vec<TweetDeleteEvent>)
post_store.rs:111-125 对每个删除事件执行三个动作:从 posts 删除正文;写入 deleted_posts;在 original_posts_by_user[DELETE_EVENT_KEY] 中追加 TinyPost(帖子 id, deleted_at),使墓碑也能按时间过期。
查询方法
get_all_posts_by_users(user_ids, exclude, start_time, request_user_id, max_original_per_author, max_secondary_per_author)
post_store.rs:250-285 先读取原创队列;max_secondary_per_author > 0 时,再读取回复与转发队列,合并后返回。调用方传入每个作者的上限(旧版使用写死的常量)。
get_videos_by_users(user_ids, exclude, start_time, request_user_id)
post_store.rs:229-248 只读取视频队列,每个作者的上限为 MAX_VIDEO_POSTS_PER_AUTHOR。
get_posts_from_map(...)
post_store.rs:287-377 是共用的查询核心。它对每个作者执行以下步骤:
- 检查超时:
request_timeout非零且已超时时,记录POST_STORE_REQUEST_TIMEOUTS并停止(post_store.rs:304-318)。 - 逆序遍历队列(最新优先),去掉排除列表中的帖子,最多扫描 500 条(
post_store.rs:324-328)。 - 回查
posts得到LightPost;回查失败时跳过该帖(post_store.rs:330-335)。 - 去掉“转发了请求者本人帖子”的转发(
post_store.rs:337-339)。 following_users非空时(即查询回复与转发队列时),应用回复可见性规则(post_store.rs:341-365)。- 每个作者取前
max_per_user条(post_store.rs:367)。
查询结束时,它记录“返回数 / 队列总长”比例 POST_STORE_POSTS_RETURNED_RATIO(post_store.rs:371-374)。
旧版在第 3 步之后还有一道“查询 deleted_posts”的过滤,现已删除。删除事件会直接移除正文,因此第 3 步回查失败就足以排除已删帖子。
维护方法
| 方法 | 说明 |
|---|---|
finalize_init() |
启动收尾:排序全部队列,执行满 5 轮裁剪,再按墓碑表清除一遍 posts(post_store.rs:142-154) |
trim_old_posts(trim_iter) |
在 spawn_blocking 中裁剪三个队列表。墓碑队列每轮都处理;其余作者只处理 user_id % 5 == trim_iter % 5 的一片。弹出超期或超过 5000 条的帖子并删除正文;容量过剩时收缩;移除空队列的作者(post_store.rs:501-606) |
start_auto_trim(interval_minutes) |
后台任务:每 interval_minutes 分钟调用一次 trim_old_posts,trim_iter 递增,错过的 tick 直接跳过(post_store.rs:479-499) |
start_stats_logger() |
每 5 秒把用户数、帖子数、各队列条数与容量写入指标和日志(post_store.rs:379-477) |
sort_all_user_posts() |
按 created_at 排序每个队列(post_store.rs:608-635) |
clear() |
清空正文表和三个队列表(post_store.rs:637-643) |
设计决策
| 决策 | 理由 |
|---|---|
两层存储:TinyPost 队列 + 正文表 |
遍历时间线时只读取 16 字节;正文不在多个队列间重复存储 |
正文存为 Arc<CompactPost> |
定长、可 Copy;回查只克隆引用,读锁释放更快 |
| 按三类队列存储 | 普通请求与视频请求直接命中对应队列 |
| 每个队列上限 5000 条 | 限制单个高频账号的内存占用 |
| 删除时移除正文并记录墓碑 | 查询无需再查墓碑;墓碑拦截迟到的创建事件 |
| 墓碑挂在作者 0 的队列中 | 复用按时间裁剪的逻辑 |
| 裁剪按作者 id 分 5 片 | 每轮只扫描一部分作者,缩短单轮持锁时间 |
| 每个作者扫描上限 500 条 | 不为高频账号回溯过深 |
| 调用方传入每个作者的上限 | 调用方可按请求压低某类帖子的配额;服务端常量仍是上限 |
源码锚点
thunder/posts/post_store.rs:20-109——TinyPost、CompactPost、结构体与构造thunder/posts/post_store.rs:111-227—— 删除、写入、入队与上限thunder/posts/post_store.rs:229-377—— 查询方法thunder/posts/post_store.rs:479-650—— 自动裁剪、排序、清空、默认值thunder/config.rs:1-14—— 常量
相关页面
- Thunder 站内帖子内存库 —— 建在
PostStore之上的站内候选服务 - Thunder Kafka 摄入 —— 向
PostStore写入数据的组件 - home-mixer 编排层 ——
ThunderSource经 gRPC 调用 Thunder - 系统架构总览 —— Thunder 在系统中的位置