用 Redis 和 pg-boss 将 Simmetric Chat 扩展到多实例
Simmetric Chat 可在任何负载均衡器后水平扩展,无需 sticky session。状态如何迁移到 Redis、Postgres 和 pg-boss 任务队列。
一个实例很简单。两个实例是一个设计决定。
运行单个 Simmetric Chat 实例刻意保持平凡:一个服务器进程、一个 Postgres,而本来需要共享的每块状态都舒适地待在内存里。当你把第二个实例放到负载均衡器后面时,这些进程内状态立刻变成正确性问题。速率限制计数器各自漂移,于是实际限制乘以实例数。在实例 A 上撤销的 token 在实例 B 上仍被接受。连着实例 B 的流式用户永远看不到实例 A 发出的事件。这些故障都不会自我介绍--它们之后才浮出水面,表现为「限制不管用」或「登出没有在所有地方断开我」。
Simmetric Chat 把水平扩展当作一等部署模式。多实例的工作在 Redis 层 milestone 中完成并包含在 v0.22 中,它正是把那些状态外置,让任何实例都能服务任何请求。本文走遍拓扑、五个 Redis 消费者、pg-boss 任务队列、必须在整个机群上一致的那些 key,以及让单实例部署保持完全受支持的优雅降级行为。
拓扑:完全相同、无状态的实例
部署形态刻意不起眼。N 个完全相同的服务器进程站在任何 HTTP 负载均衡器后面;一个共享 Postgres 承载应用数据、pgvector 和 pgboss 任务 schema;一个共享 Redis 保存跨实例协调状态:
负载均衡器(任何 HTTP LB - 不需要 sticky session)
│
┌───────┼───────┐
Server 1 Server 2 … Server N 完全相同的进程,:3000
└───────┼───────┘
│
共享 Postgres 共享 Redis
应用数据 + pgvector 速率限制、撤销列表、
+ pgboss schema SSE relay、锁、缓存每个实例运行相同的代码,从负载均衡器的角度看是无状态的。不存在把客户端锚定到某个实例的服务端 session 映射:认证是无状态 JWT,用共享 JWT_SECRET 做 HS256 签名并在每个请求上验证。本来会留在进程内存里的东西全部外置到声明好的位置:
- 速率限制计数器 → Redis(整个机群共享窗口);没有 Redis 时每个实例各自计数
- JWT 撤销(`jti` 列表) → Redis;没有 Redis 时撤销不生效,token 直到过期前保持有效
- SSE fan-out → 通过 Redis pub/sub 中继;没有 Redis 时事件只能到达发出它的实例上的客户端
- 分布式锁(reaping 调度器)→ Redis 上的 redlock;没有 Redis 时锁什么都不做
- 认证与配置缓存 → Redis;没有 Redis 时每次读取都回落到数据库查询
- 后台任务 → pg-boss,依托同一个共享 Postgres
不需要 sticky session
负载均衡器不需要为 REST 流量做 session 亲和。唯有的长连接是 SSE 流,而它们同样不需要锚定:客户端保持一条到接受它的实例的流式连接,但在任何其他实例上发出的事件依然到达,因为每个实例把 SSE 事件发布到 Redis 频道,每个实例又把这个频道的消息重发给自己的已连接客户端。该 relay 已在双实例模拟中端到端验证,包括阻止实例重复收到自己事件的 origin-skip 行为。
如果你的均衡器提供 least-connections 路由,SSE 路径请优先使用它--长连接应当按打开的连接数分配,而不是轮询。这是效率选择,不是正确性要求。任何 HTTP 负载均衡器都可以。
五个 Redis 消费者
REDIS_URL 是刻意可选的,单实例回退是完全受支持的行为,不是事故。但多实例要求每个实例指向同一个 Redis(或 cluster、sentinel),因为五个子系统否则会静默分化:
- 速率限制存储。 使用共享 Redis 存储时,每分钟 200 次请求的全局限制意味着整个部署 200 次。没有它时每个实例维护自己的计数器,实际限制变成配置值的 N 倍--一个等待流量峰值的隐患。
- JWT 撤销。 撤销检查查询的黑名单必须是全局的。没有 Redis 时,已登出或管理员撤销的 token 在其他实例上继续工作直到过期;「在所有地方登出」静默变成「在这里登出」。
- SSE fan-out。 跨实例 relay 活在 Redis pub/sub 上。没有它时,流落在实例 B 上的用户永远看不到实例 A 发出的事件--故障表现为实时更新神秘地卡住。
- 分布式锁。 reaper 式维护调度器通过 redlock 获取跨实例互斥锁,因此两个实例永远不会并发运行同一个清理。没有 Redis 时锁包装器什么都不做--这也是计划任务迁移到 pg-boss 的原因之一,后者的去重不依赖锁。
- 认证与配置缓存。 系统设置和角色缓存通过 Redis 共享。没有它就没有缓存:每次读取都是一次全新的 Postgres 查询,恰好在流量增长时表现为读放大。
# 根目录 .env - 每个实例完全相同
REDIS_URL=redis://redis-host:6379
# TLS 变体:
# REDIS_URL=rediss://redis-host:6379客户端通过 ioredis 使用标准 Redis URL。生产启动时若 REDIS_URL 未设置会记录一条响亮的警告;把那条警告当作部署在告诉你它正以单实例模式运行,无论实例们怎么说。
pg-boss:不增加基础设施的任务队列
Simmetric Chat 的后台工作--archive 一致性检查、synthesis reaping、向量清理、MCP 健康检查、MCP reaping、聊天消息保留和上传草稿清理--运行在 pg-boss 上,一个依托 Postgres 的任务队列。七个基于 setInterval 的调度器已迁移到 pg-boss 计划任务。pg-boss 会在你已用于应用数据的数据库里自动创建自己的 pgboss schema,因此水平扩展不增加任何基础设施组件,并保持整个 stack 与气隙兼容。
对多实例部署最重要的特性是分布式任务去重:当多个实例同时到达同一个计划触发点,恰好一个实例领取任务,其余因争用而跳过。队列,而不是锁包装器,才是去重机制--少一件可能配错的东西。
两个对延迟敏感的 poller 被刻意保留。OCR 管线的 reaper 和 synthesis 管线的 reaper 保持在 10 秒的进程内间隔,因为 pg-boss 的最小 cron 粒度是一分钟,会让管线的领取慢六倍。它们的多实例行为已有记录且安全:两者都是幂等的先认领后派发循环,在数据库层用原子认领去重,因此并发实例无法重复认领同一个待处理任务,collector 把每个冗余派发吸收为 no-op。
优雅关闭是契约的一部分。在途任务在数据库连接关闭前于 pg-boss 的 grace timeout 内被排空,整个关闭序列在外部时限下运行。如果启动时 Postgres 不可用或运行中宕机,pg-boss 任务会被跳过并记录错误--服务器不进入 crash-loop,队列在 Postgres 恢复后复原。队列中断绝不能让聊天 API 下线。
必须在整个机群上一致的 key
从单实例走向多实例时,有两个密钥值得明确关注:
- `JWT_SECRET` - 无状态会话 token 的对称签名密钥。每个实例上相同的值,正是「任何实例都能服务任何请求」这一特性成立的原因。
- `ENCRYPTION_KEY` - 静态加密 AES-256-GCM 密钥(32 字节),生产环境必需,缺失时服务器在启动时大声失败。每个实例必须携带相同的值,因为一个实例加密的 blob 必须能在所有实例上解密。
把两者分开本身就是一项多实例安全属性。轮换 JWT_SECRET 很便宜--只是重新签发 token--而轮换 ENCRYPTION_KEY 需要完整的重加密 runbook。旧的从 JWT secret 派生加密密钥的行为把两种轮换耦合在一起;在机群中,这种耦合是一块等着例行密钥轮换的砖头。
# 生成一次,分发到每个实例
openssl rand -hex 32 # JWT_SECRET
openssl rand -base64 32 # ENCRYPTION_KEYPostgres 连接池是实际的扩展限制器。每个实例打开一个有界应用池外加一个独立的 pg-boss 池,因此按实例数调整数据库池和 Postgres 的 max_connections 是操作者的责任。实例数没有硬性上限;从两个开始,按需扩展。
依赖缺失时会坏什么
优雅降级是设计好的行为,值得在容量规划前而不是事故后了解:
- 没有 Redis,一个实例。 一切正常。SSE 只是没有地方中继,速率限制是进程级的,缓存是进程级的。这是受支持的单实例默认值。
- 没有 Redis,N 个实例。 上面五种分化静默出现--速率限制倍增、实例级撤销、跨实例流式损坏、锁失效以及数据库读取过热。生产启动警告就是你的信号。
- Postgres 不可用。 服务器启动并服务不需要队列的请求;七个 pg-boss 调度器跳过本轮稍后重试;需要队列的请求优雅失败并记录错误。启动时的短暂中断不会让进程进入 crash-loop。
设计立场是一致的:可选依赖降级为有记录的行为,一个依赖的中断降级的是一项能力,而不是整个平台。
从一个实例到两个
让部署增长是配置工作,不是代码工作:
- 让每个实例指向相同的
DATABASE_URL、JWT_SECRET、COLLECTOR_SECRET和ENCRYPTION_KEY。 - 在每个实例上设置
REDIS_URL,指向共享 Redis(单实例、cluster 或 sentinel)。 - 重启并确认启动日志不再显示单实例警告。
- 把实例放到负载均衡器后面,把健康检查路由到
GET /api/health,它报告数据库、collector 和磁盘状态。
最后一个值得了解的多实例细节:API key 认证是 O(1) 的带 key HMAC 查找,不是 bcrypt 循环。这项改动正是为了让 API key 验证足够便宜,能在每个实例的每个请求上运行,不需要共享缓存也没有实例级 CPU 尖峰。这正是让水平扩展变得无聊的那类静默决定--而这恰恰是它应有的样子。


