第 1 章:总体架构与进程拓扑
一、为什么先看进程,而不是先看模型
Mastodon 的代码量很大,但运行时角色并不多。最容易产生的误解是:看到 Rails 项目,就以为所有工作都发生在一个 Puma 请求里。实际上,Web 请求只负责把“事实”落库并安排后续工作;真正耗时、可重试、需要批量化的操作会被移到 Sidekiq;实时连接则由独立 Node 进程负责。
这个边界带来一个关键判断:
Rails 决定“发生了什么”,Sidekiq 负责“把影响传播出去”,Redis 负责“让传播变快”,Streaming 负责“把变化交给在线客户端”。
二、四类运行时进程
flowchart TB
Client[浏览器 / 移动端 / Bot]
Web[Puma + Rails Web]
Worker[Sidekiq Worker]
Stream[Node Streaming]
DB[(PostgreSQL)]
Redis[(Redis)]
Remote[远端 ActivityPub Inbox]
Client -->|HTTP REST / HTML| Web
Client -->|WebSocket / SSE| Stream
Web --> DB
Web --> Redis
Web -->|enqueue| Redis
Worker --> Redis
Worker --> DB
Worker --> Remote
Stream --> Redis
Stream --> DB
1. Rails Web:同步边界
入口是 config.ru、Puma 配置和 Rails application。Web 层承担:
- 普通网页和 React shell;
- OAuth2 / Doorkeeper 授权与 REST API;
- ActivityPub 的 actor、inbox、outbox、签名验证入口;
- 把用户动作转换为 domain service 调用;
- 将需要异步完成的工作入队。
config/application.rb 明确将 Active Job 的 queue adapter 设为 Sidekiq,并初始化 Redis、缓存、国际化和中间件。读源码时可以把 Controller 当成“协议适配器”,而不是业务核心。
2. Sidekiq:传播边界
app/workers 的 worker 通常只接收 ID、URL、少量选项,而不是把整个 ActiveRecord 对象塞进队列。这样做有三个好处:
- 队列 payload 小且可序列化;
- worker 执行时可以重新读取最新状态;
- 重试时不会持有请求线程中的对象状态。
代价是 worker 必须面对记录被删除、权限改变、远端不可达等“时间过去之后”的情况。因此 worker 里经常能看到幂等判断、return unless、失败追踪和重试策略。
3. Node Streaming:连接边界
streaming/index.js 不是 Rails Action Cable。它使用 Express、ws、Redis 和 PostgreSQL 连接池,负责:
- 鉴权并解析 OAuth token;
- 升级 HTTP 到 WebSocket;
- 订阅用户、通知、公共时间线、列表和标签频道;
- 将 Redis 消息过滤后推送给当前连接;
- 提供
/api/v1/streaming/health和 Prometheus metrics。
把实时连接拆出去,可以避免大量长连接占满 Rails/Puma 线程,也能独立水平扩展 streaming 实例。
4. PostgreSQL 与 Redis:不同的真相
PostgreSQL 保存可以重新计算的事实:账户、状态、关注、权限、媒体元数据和联邦对象。Redis 保存加速结构或短暂协作状态:
- home/list/tag feed 的有序集合; -缓存的序列化 payload;
- Sidekiq 队列;
- rate limit、互斥锁、Stoplight 状态;
- streaming Pub/Sub 频道。
一个实用判断是:如果 Redis 丢了,系统应能通过维护任务或重新读取数据库恢复大部分状态;如果 PostgreSQL 丢了,则是事实数据丢失。
三、仓库目录如何映射到架构
app/
controllers/ HTTP、REST、ActivityPub、管理后台入口
models/ ActiveRecord 事实与关系
services/ 用例级业务编排
workers/ Sidekiq 异步边界
serializers/ REST / ActivityPub 输出形状
lib/ Feed、ActivityPub 基础设施、过滤器、缓存
javascript/ React、Redux、Vite 前端
config/
routes/ 分层路由表
initializers/ Redis、Sidekiq、Doorkeeper、OpenTelemetry 等
db/
migrate/ schema 演进
streaming/
index.js 独立实时服务
database.js PostgreSQL 查询与连接池
redis.js Redis 配置与客户端
目录不是严格的六边形架构,但有明显的“薄 Controller + Service + Model/Library + Worker”倾向。Service 负责跨模型的业务过程,Model 负责关系、约束和局部不变量,Worker 负责把可延迟的副作用切出同步请求。
四、从 docker-compose 还原生产拓扑
快照中的 docker-compose.yml 直接暴露了四个必要服务:
| 服务 | 镜像/命令 | 网络位置 | 健康检查 |
|---|---|---|---|
db | postgres:14-alpine | internal | pg_isready |
redis | redis:7-alpine | internal | redis-cli ping |
web | Mastodon image + Puma | external + internal | /health |
streaming | streaming image + Node | external + internal | /api/v1/streaming/health |
sidekiq | Mastodon image + Sidekiq | internal | 进程检查 |
external_network 让反向代理连接 Web/Streaming,internal_network 隔离数据库与 Redis。公共入口不应该直接暴露 PostgreSQL 或 Redis。
五、运行一次请求时如何读日志
把同一个 X-Request-Id 当作线索:
- Rails 请求日志确认路由、用户和 HTTP 状态;
- service 日志确认同步写入是否成功;
- Sidekiq 日志根据 job id 和 queue 名确认副作用是否入队、重试或进入 dead set;
- streaming 日志确认连接、订阅频道和 Redis 消息是否抵达;
- 远端投递根据 inbox host 的 failure tracker 看是网络失败、HTTP 4xx 还是签名/授权问题。
六、设计取舍
取舍 1:数据库事实与缓存副本分离
Feed 是读多写多、排序稳定但可重建的结构,放在 Redis 可以减少每次打开首页都扫描关注关系的成本。代价是写入路径复杂,需要处理“落库成功但 fan-out 尚未完成”的短暂窗口。
取舍 2:Streaming 独立于 Rails
WebSocket 的生命周期长、数量多,和短请求混在一起会放大线程与 GC 压力。独立进程让两类负载隔离,但需要额外的 token 鉴权、数据库连接和 Redis 协议。
取舍 3:ActivityPub 副作用异步化
不能让一次发帖等待所有远端服务器响应。把投递放进 ActivityPub::DeliveryWorker,系统才能通过重试、熔断和按 host 连接池吸收联邦网络的不稳定。
七、源码入口清单
config/application.rb:Rails 应用级队列与基础设施配置。config/initializers/sidekiq.rb:Sidekiq Redis、队列和 middleware。docker-compose.yml:生产容器拓扑。streaming/index.js:Streaming server 的完整入口。app/workers:异步传播的第二条主线。
八、小结
读 Mastodon 的第一原则是先记住“多个进程共享一个事件系统”。下一章把这些进程共同操作的业务事实——Account、User、Status、Follow、Media 和 ActivityPub 对象——拆开。