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

> Documentation Index
> Fetch the complete documentation index at: https://x-algorithm.vergego.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# PostStore

## 是什么

`PostStore` 是 Thunder 的核心内存数据结构。它按作者分组存储近期帖子，线程安全，为站内候选查询提供数据。定义位于 `thunder/posts/post_store.rs:87-96`。服务如何使用它，见 [Thunder 站内帖子内存库](https://x-algorithm.vergego.ai/concepts/thunder-in-network-store/)；由谁写入它，见 [Thunder Kafka 摄入](https://x-algorithm.vergego.ai/concepts/thunder-kafka-ingestion/)。

## 结构体定义

```rust
// 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`，使摄入线程和查询线程共享同一份数据。

## 数据布局

```mermaid
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

```rust
// 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` —— 常量

## 相关页面

- [Thunder 站内帖子内存库](https://x-algorithm.vergego.ai/concepts/thunder-in-network-store/) —— 建在 `PostStore` 之上的站内候选服务
- [Thunder Kafka 摄入](https://x-algorithm.vergego.ai/concepts/thunder-kafka-ingestion/) —— 向 `PostStore` 写入数据的组件
- [home-mixer 编排层](https://x-algorithm.vergego.ai/concepts/home-mixer-orchestration/) —— `ThunderSource` 经 gRPC 调用 Thunder
- [系统架构总览](https://x-algorithm.vergego.ai/concepts/system-architecture/) —— Thunder 在系统中的位置

Source: https://x-algorithm.vergego.ai/entities/post-store/index.mdx
