这是Blacksky社区对AT协议参考实现(由Bluesky Social PBC开发)的一个分支。它驱动着位于 api.blacksky.community 的 AppView。
我们发布此代码是为了透明,并让其他社区也能从中受益。本仓库不接受贡献、issue 或 PR。 如果你需要规范的 atproto 实现,请使用 bluesky-social/atproto。
所有变更都在 packages/bsky(AppView 逻辑)、services/bsky(运行时配置)以及一个自定义迁移文件中。其余部分均与上游保持一致。
上游数据平面包含一个 TypeScript 编写的 firehose consumer(subscription.ts),用于直接索引事件。我们将其替换为 rsky-wintermute,一个 Rust 编写的索引器,原因如下:
本仓库中的数据平面和 AppView 仍然按原样运行。它们读取 wintermute 写入的 PostgreSQL 数据库。我们只是没有启动内置的 firehose 订阅。
这些修复对任何需要大规模自托管 AppView 的人都非常有用。
LATERAL JOIN 查询优化(packages/bsky/src/data-plane/server/routes/feeds.ts)
getTimeline 和 getListFeed,强制每个用户使用索引,避免全表扫描。对于关注数千个账号的用户来说,性能提升显著。Redis 缓存层(packages/bsky/src/data-plane/server/cache/)
Timestamp 对象在经过 Redis 的 JSON 往返后丢失了 .toDate() 方法,导致缓存命中时资料补全不完整。我们目前运行时不启用 Redis 缓存。修复方法是在缓存写入时将时间戳序列化为 ISO 字符串,并在读取时重建。服务器端强制通知偏好(packages/bsky/src/api/app/bsky/notification/listNotifications.ts)
reasons 时,服务器应用用户保存的通知偏好。没有这个设置,偏好仅在客户端生效,实际上不起作用。认证验证器陈旧签名密钥修复(packages/bsky/src/auth-verifier.ts)
forceRefresh)时,绕过数据平面内存中的身份缓存,直接从 PLC 目录解析 DID 文档。修复了账号迁移后签名密钥轮换但缓存仍保留旧密钥导致的认证失败问题。JSON 清理(packages/bsky/src/data-plane/server/routes/records.ts)
\u0000)和控制字符。这些字符在 RFC 8259 中是合法的,但会被 Node.js 的 JSON.parse() 拒绝,导致数据平面中 rowToRecord 解析静默失败,最终表现为帖子缺失。用于私有社区帖子的基础设施,这些帖子存储在 AppView 而非各个 PDS 上。这是 Blacksky 的工作方式,但也可以作为其他社区的参考。
community.blacksky.feed.*,包含提交、获取、删除、时间线和帖子视图的端点community_post 表(迁移文件:20260202T120000000Z-add-community-post.ts)getPostThreadV2 集成,支持标准/社区帖子的混合线程BLACKSKY_MEMBERSHIP_DB_URL)Bluesky 中继 (bsky.network)
|
v
rsky-wintermute -----> PostgreSQL 17 <----- Palomar
(Rust 索引器) | (Go 搜索)
- firehose consumer | |
- backfiller | v
- label indexer | OpenSearch
- direct indexer |
v
bsky-dataplane (gRPC :2585) <--- Redis (可选)
|
v
bsky-appview (HTTP :2584)
|
v
反向代理 (Caddy/nginx)
Wintermute 是一个单体 Rust 服务,包含四个并行处理路径:
bsky.network firehose,将事件写入 Fjall(嵌入式键值存储)队列ON CONFLICT 写入 PostgreSQL(幂等性)rsky 仓库中还包含以下 CLI 工具:
queue_backfill —— 通过 CSV、PDS 发现或直接 DID 列表将要回填的 DID 加入队列direct_index —— 绕过队列直接获取并索引特定仓库(适用于修复单个账号)label_sync —— 从游标 0 开始重放标签流,以捕获遗漏的撤销事件plc_import —— 从 PLC 目录批量导入 handle/DID 映射palomar-sync —— 将粉丝数和 PageRank 同步到 OpenSearch为那些 PDS 不支持 Bluesky 的 video.bsky.app 的用户提供的视频上传服务。使用自己的 DID(did:web:video.blacksky.community)通过服务认证 JWT 向用户 PDS 进行身份验证。流程如下:
审核标签来自 labeler 服务(例如 Bluesky 的 Ozone),通过 WebSocket 订阅获取。Wintermute 的 ingester 在专用的 label_live 队列中处理标签(低流量,与主 firehose 分开)。label_sync 工具可以重放 labeler 的完整流,以捕获遗漏的撤销事件(标签移除),而无需重新插入标签。
bsky 模式bsky 模式由数据平面的迁移脚本创建。首次运行时,数据平面会自动应用所有迁移。唯一一个 Blacksky 特有的迁移是 20260202T120000000Z-add-community-post.ts(社区帖子表)。如果不需要社区帖子,可以将其移除。
rsky-wintermute 也写入同一个模式。其所有 INSERT 语句都使用 ON CONFLICT,因此按任何顺序运行 wintermute 和数据平面迁移都是安全的。
pnpm install
pnpm build
node services/bsky/dataplane.js
node services/bsky/api.js
即使使用 wintermute 的并行处理,全网络回填(全部约 4200 万用户、约 185 亿条记录)也需要数周时间。预期如下:
在回填期间,AppView 可以正常工作,但尚未回填的用户数据将显示不完整。实时事件无论回填进度如何都会立即索引。
以下是我们启动全网络 AppView 时遇到的问题。如果你正在做同样的事情,很可能也会遇到其中一些:
COPY 文本格式 JSON 损坏:PostgreSQL 的 COPY 文本协议将反斜杠视为转义字符。如果批量加载器没有转义 JSON 字符串中的反斜杠,\" 会变成 ",导致记录被静默损坏。record.json 列的类型是 text(不是 jsonb),因此 PostgreSQL 不会检测到这个问题。我们发现约 66,000 条记录被损坏,不得不通过从公共 API 重新获取来修复。
JSON 中的空字节:部分 AT 协议记录包含 \u0000(空字节),这在 RFC 8259 中是合法的 JSON,但 Node.js 的 JSON.parse() 会拒绝。数据平面对于这些记录会静默返回 null。在写入数据库之前应剥离空字节。
时间戳格式敏感性:数据平面期望时间戳具有毫秒精度和 Z 后缀(2026-01-12T19:45:23.307Z)。纳秒精度或时区偏移格式(+00:00)会导致微妙的排序和比较问题。
通知表膨胀:如果没有对 (did, recordUri, reason) 设置唯一约束,通知表会无限增长,出现重复记录。我们的通知表增长到了 13 亿行(663 GB)才被发现。在 INSERT 中添加 ON CONFLICT DO NOTHING 仅在唯一索引已存在时有效,而创建索引需要先对现有数据进行去重。
帖子嵌入表:如果索引器不处理,post_embed_image 和 post_embed_video 表默认不会被填充。没有这些表,getAuthorFeed 上的媒体过滤器会返回空结果。需要单独回填这些表。
标签撤销顺序:标签撤销(移除)事件通过来源、URI 和值来引用原始标签。如果撤销事件在原始标签之前到达(在回填时很常见),它们会被静默丢弃。label_sync 工具可以重放完整流来捕获这些事件。
Fjall 队列中毒:Fjall 嵌入式数据库(用于 wintermute 的队列)在崩溃后可能进入“中毒”状态,阻塞所有队列操作。修复方法是删除队列数据库目录并重启——wintermute 会从中继的游标处开始追赶(中继保留约 72 小时的历史数据)。
TLS 提供者初始化:Rust 的 rustls 需要在任何 TLS 连接之前显式安装加密提供者。如果没有在启动时调用 rustls::crypto::aws_lc_rs::default_provider().install_default(),第一次 WebSocket 连接到 firehose 就会 panic。
账号迁移后的签名密钥轮换:当用户在不同 PDS 之间迁移时,他们的签名密钥会改变。数据平面会缓存身份数据,staleTTL 为 1 小时。在此期间,迁移用户的 JWT 验证会失败。修复方法是在验证重试时绕过缓存,直接从 PLC 目录解析。
基于运行全网络 AppView(全部约 4200 万用户、约 185 亿条记录)的经验。
存储细分(近似值,全网络):
| 表组 | 大小 |
|---|
对于运行部分 AppView(仅索引社区成员)的小型社区,资源需求大致与索引账号数量成线性关系。
git remote add upstream https://github.com/bluesky-social/atproto.git
git fetch upstream
git merge upstream/main
冲突通常发生在 packages/bsky/src/data-plane/server/routes/ 和 packages/bsky/src/api/ 中。解决方式是在保留上游更改的同时保留我们的添加项。
与上游相同:采用 MIT 和 Apache 2.0 双许可证。详见 LICENSE-MIT.txt 和 LICENSE-APACHE.txt。
| 组件 | 来源 | 用途 |
|---|
| rsky-wintermute | blacksky-algorithms/rsky | Rust firehose 索引器:消费事件、回填仓库、将记录索引到 PostgreSQL |
| rsky-relay | blacksky-algorithms/rsky | AT 协议中继,用于接收来自 labeler 服务的审核标签 |
| rsky-video | blacksky-algorithms/rsky | 视频上传服务:通过 Bunny Stream CDN 转码,将 blob 引用上传到用户 PDS |
| bsky-dataplane | 本仓库 (services/bsky) | 基于 PostgreSQL 的 gRPC 数据层 |
| bsky-appview | 本仓库 (services/bsky) | 为 app.bsky.* XRPC 端点提供的 HTTP API 服务器 |
| Palomar | blacksky-algorithms/indigo | 全文搜索:将用户资料和帖子索引到 OpenSearch,并支持粉丝数加权 |
| palomar-sync | blacksky-algorithms/rsky | 将粉丝数和 PageRank 分数从 PostgreSQL 同步到 OpenSearch |
| 变量 | 必需 | 描述 |
|---|
DB_PRIMARY_URL | 是 | PostgreSQL 连接字符串,需附加 ?options=-csearch_path%3Dbsky |
DB_REPLICA_URL | 否 | 只读副本连接字符串 |
BSKY_DATAPLANE_PORT | 否 | gRPC 端口(默认 2585) |
BSKY_REDIS_HOST | 否 | Redis 主机:端口,用于缓存(当前建议保持禁用) |
BLACKSKY_MEMBERSHIP_DB_URL | 否 | 社区成员资格独立数据库(Blacksky 特有) |
| 变量 | 必需 | 描述 |
|---|
BSKY_APPVIEW_PORT | 否 | HTTP 端口(默认 2584) |
BSKY_DATAPLANE_URLS | 是 | 数据平面 gRPC URL,逗号分隔 |
BSKY_DID | 是 | AppView 的 DID(例如 did:web:api.example.com) |
BSKY_MOD_SERVICE_DID | 是 | Ozone 审核服务 DID |
BSKY_ADMIN_PASSWORDS | 是 | 管理员密码,逗号分隔(用于基本认证) |
| 资源 | 最低配置 | 推荐配置 |
|---|
| CPU | 16 核 | 48+ 核 |
| RAM | 64 GB | 256 GB |
| 存储 | 10 TB NVMe | 28+ TB NVMe(RAID) |
| PostgreSQL | 专用服务器,同一机器或低延迟 | 建议同一机器 |
| 网络 | 持续 100 Mbps | 1 Gbps+ |
| 帖子 + 记录 | ~3.5 TB |
| 点赞 | ~2 TB |
| 关注 | ~500 GB |
| 通知 | ~600 GB |
| 索引 | ~4 TB |
| OpenSearch(Palomar) | ~500 GB |