Skip to content

PostStore

Thunder 的 `PostStore` 结构体:CompactPost 正文表加三套 TinyPost 队列,以及写入、查询、删除与分片裁剪

Updated

是什么

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 按以下步骤执行:

  1. 只保留 created_at 早于当前时间且在保留期内的帖子。
  2. 按 created_at 升序排序。
  3. 调用 insert_posts_internal。

insert_posts_internal

post_store.rs:156-227 对每条帖子执行以下步骤:

  1. 帖子 id 在 deleted_posts 中时,跳过该帖(post_store.rs:163-165)。
  2. 写入 posts。帖子已存在时跳过,不重复入队(post_store.rs:167-172)。
  3. 原创帖进入 original_posts_by_user,其他帖子进入 secondary_posts_by_user。入队前,如果队列已满 5000 条,弹出最旧的一条并记为待删(post_store.rs:178-196)。
  4. 判定视频资格:帖子自身 has_video;或者帖子是转发,且被转发的原帖是带视频的非回复帖。回复一律不算视频帖(post_store.rs:198-210)。合格的帖子进入 video_posts_by_user,同样受 5000 条上限约束(post_store.rs:212-220)。
  5. 把第 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 是共用的查询核心。它对每个作者执行以下步骤:

  1. 检查超时:request_timeout 非零且已超时时,记录 POST_STORE_REQUEST_TIMEOUTS 并停止(post_store.rs:304-318)。
  2. 逆序遍历队列(最新优先),去掉排除列表中的帖子,最多扫描 500 条(post_store.rs:324-328)。
  3. 回查 posts 得到 LightPost;回查失败时跳过该帖(post_store.rs:330-335)。
  4. 去掉“转发了请求者本人帖子”的转发(post_store.rs:337-339)。
  5. following_users 非空时(即查询回复与转发队列时),应用回复可见性规则(post_store.rs:341-365)。
  6. 每个作者取前 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 —— 常量

相关页面

Type to search…

↑↓ navigate↵ selectEsc close