第 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 对象塞进队列。这样做有三个好处:

  1. 队列 payload 小且可序列化;
  2. worker 执行时可以重新读取最新状态;
  3. 重试时不会持有请求线程中的对象状态。

代价是 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 直接暴露了四个必要服务:

服务镜像/命令网络位置健康检查
dbpostgres:14-alpineinternalpg_isready
redisredis:7-alpineinternalredis-cli ping
webMastodon image + Pumaexternal + internal/health
streamingstreaming image + Nodeexternal + internal/api/v1/streaming/health
sidekiqMastodon image + Sidekiqinternal进程检查

external_network 让反向代理连接 Web/Streaming,internal_network 隔离数据库与 Redis。公共入口不应该直接暴露 PostgreSQL 或 Redis。

五、运行一次请求时如何读日志

把同一个 X-Request-Id 当作线索:

  1. Rails 请求日志确认路由、用户和 HTTP 状态;
  2. service 日志确认同步写入是否成功;
  3. Sidekiq 日志根据 job id 和 queue 名确认副作用是否入队、重试或进入 dead set;
  4. streaming 日志确认连接、订阅频道和 Redis 消息是否抵达;
  5. 远端投递根据 inbox host 的 failure tracker 看是网络失败、HTTP 4xx 还是签名/授权问题。

六、设计取舍

取舍 1:数据库事实与缓存副本分离

Feed 是读多写多、排序稳定但可重建的结构,放在 Redis 可以减少每次打开首页都扫描关注关系的成本。代价是写入路径复杂,需要处理“落库成功但 fan-out 尚未完成”的短暂窗口。

取舍 2:Streaming 独立于 Rails

WebSocket 的生命周期长、数量多,和短请求混在一起会放大线程与 GC 压力。独立进程让两类负载隔离,但需要额外的 token 鉴权、数据库连接和 Redis 协议。

取舍 3:ActivityPub 副作用异步化

不能让一次发帖等待所有远端服务器响应。把投递放进 ActivityPub::DeliveryWorker,系统才能通过重试、熔断和按 host 连接池吸收联邦网络的不稳定。

七、源码入口清单

八、小结

读 Mastodon 的第一原则是先记住“多个进程共享一个事件系统”。下一章把这些进程共同操作的业务事实——Account、User、Status、Follow、Media 和 ActivityPub 对象——拆开。