首页
/ LibreChat × FerretDB 多租户落地方案:Database-Per-Org 隔离、水平分片 PoC 与死锁重试策略

LibreChat × FerretDB 多租户落地方案:Database-Per-Org 隔离、水平分片 PoC 与死锁重试策略

2026-09-05 22:26:01作者:贡沫苏Truman

本文基于 LibreChat 仓库中的 FerretDB 多租户调研文档,完整还原其以 FerretDB(PostgreSQL 后端、DocumentDB 模式)实现「每组织一个数据库」隔离架构的调研过程:从 PostgreSQL 底层表结构映射、98 个自定义索引的兼容性验证,到 100 租户的扩展曲线、分片路由 PoC、死锁重试工具与生产级备份/迁移方案。读完后,你将掌握一套可复制到任意 Mongoose 多租户项目的 FerretDB 选型依据、基准测试方法、容量规划阈值和运维操作手册。

1. 调研目标与约束

该调研(文档状态:Active Investigation)的核心目标是:使用 FerretDB(PostgreSQL 后端)实现 database-per-org 的数据隔离,并通过多个 FerretDB + Postgres 实例对进行水平分片扩展。文档明确排除了两个备选:原生 MongoDB 和 AWS DocumentDB 均不在选项之列。

这一定位意味着:

  • 隔离粒度是「逻辑数据库」(Mongoose 的 useDb()),而非单个集合加 orgId 字段;
  • 水平扩展方式是「多套 FerretDB + Postgres 实例对 + 租户路由」,而非 MongoDB 原生的 chunk 分片;
  • 所有性能数据都在真实 FerretDB v2.7.0 实例上实测得出,不是纸面推断。

2. FerretDB 底层架构:DocumentDB 后端在 PostgreSQL 中到底长什么样

文档的第一项发现揭示了 FerretDB postgres-documentdb 后端的真实存储结构,这直接决定了隔离模型和备份方式:

  • 不会为每个 MongoDB 数据库创建独立的 PostgreSQL schema。所有数据都集中在单一的 documentdb_data PG schema 中;
  • 每个 MongoDB collection 映射为 documents_<id> + retry_<id>表对(retry 表用于 DocumentDB 语义下的事务重试日志);
  • 目录(catalog)由 documentdb_api_catalog.collectionsdocumentdb_api_catalog.collection_indexes 两张表维护;
  • Mongoose 的 mongoose.connection.useDb('org_X') 会在 DocumentDB 的 catalog 中创建一条逻辑数据库记录,即所谓「逻辑隔离」。

关键含义:不存在 PG 级别的 schema 隔离,隔离由 FerretDB 的 wire protocol 层强制执行;因此备份/恢复必须走 FerretDB(即 MongoDB 协议/驱动),不能直接对底层 Postgres 做 pg_dump——这一点在仓库基准测试 multiTenancy.ferretdb.spec.ts 中通过 catalogMetrics() 函数直接对 catalog 表做 count(*) 快照来印证,该函数正是用 psql 查询 documentdb_api_cataloginformation_schema 来度量目录增长的。

3. 兼容性验证:29 个模型、98 个自定义索引

LibreChat 的 Mongoose 模型规模对任何替代存储都是压力测试。调研在 FerretDB v2.7.0 上验证了全部 29 个 org-local 模型与 98 个自定义索引,结果全部通过:

索引类型 数量 状态
Sparse + unique 9(User 的 OAuth ID) Working
TTL(expireAfterSeconds) 8 个模型 Working
partialFilterExpression 2(File、Group) Working
Compound unique 5+ Working
并发创建(全部 29 模型) 单 org 场景无死锁 Working

对应验证逻辑见 multiTenancy.ferretdb.spec.ts 的 Phase 2:它从 User 集合的 indexes() 回读结果中断言 sparse 与 TTL 索引各至少 1 个,并专门检查 FileGroup 模型回传了 partialFilterExpression——即 FerretDB 不只是「建了索引」,而是按类型如实回传索引元数据,这保证了 Mongoose 的 syncIndexes 类操作不会误判。

模型清单并非手写副本,而是从活体模型注册表动态派生(见 schemas.tsgetModelSchemas),避免基准测试与真实 schema 漂移。

4. 扩展曲线:10 → 100 个组织,初始化与查询延迟保持平坦

基准测试(spec 的 Phase 3)逐档创建组织(默认档位 10,50,100,可用环境变量 SCALE_TIERS 覆盖),每档记录 catalog 增长、单组织初始化耗时和点查询延迟(50 次迭代取 avg/p95)。实测结果:

组织数 集合数 Catalog 索引 数据表 pg_class 初始化/组织 查询均值 查询 p95
10 450 1,920 900 5,975 501ms 1.03ms 1.44ms
50 1,650 7,040 3,300 20,695 485ms 1.00ms 1.46ms
100 3,150 13,440 6,300 39,095 483ms 0.83ms 1.13ms

核心结论:初始化时间与查询延迟在 100 个组织规模内保持平坦,无退化。目录表行数(pg_class 约 4 万行)与 catalog 索引 1.3 万条并未成为瓶颈。

该测试还顺带对比了「共享集合 + orgId 判别字段」的替代方案(Phase 5):向单集合插入 100 组织 × 50 用户共 5,000 条文档,测量复合唯一索引 {orgId:1, email:1} 下的点查、列表与计数性能,为 database-per-org 方案提供了横向参照基线。

5. 写放大:11+ 索引 vs 零索引,仅 1.11x

Phase 4 的写放大实验对 User 模型(11+ 个索引)与一个零索引的裸集合各执行 200 次 updateOneWRITE_AMP_DOCS 可调),并同时用 pg_stat_wal.wal_bytes 差值度量 WAL 字节数。实测时间比仅 1.11x——即高索引模型的写开销只比零索引多 11%,说明 DocumentDB 后端的 JSONB 索引维护效率足够高,「索引多的核心模型」不构成写路径隐患。

6. 分片 PoC:TenantRouter 的 fill-then-spill 路由

调研实现了完整的租户路由器 PoC(sharding.ferretdb.spec.ts),验证了多「池」(每池 = 一对 FerretDB + Postgres)下的租户分配与数据流。PoC 中两个池指向同一 FerretDB 实例,生产环境下每个池 URI 对应独立实例对。

6.1 核心设计

  • 分配表:独立 control 连接中的 OrgAssignment 集合(orgId 唯一索引 + poolId 索引),持久化组织到池的映射;
  • 容量限制 + fill-then-spillselectPoolWithCapacity() 顺序扫描池,选择 countDocuments({ poolId }) < maxOrgs 的池,全满则抛出 All pools at capacity. Add a new pool.(见 sharding.ferretdb.spec.ts);
  • 幂等分配:重复调用返回既有分配;并发下依赖唯一索引的 code === 11000(duplicate key)错误回读已有记录,避免覆盖(L164-L176);
  • 懒加载模型注册getOrgModels() 首次访问时才在目标 org 连接上注册全部 29 个模型,并缓存 connection 与 models Map;
  • Express 中间件模式:为 req 挂载 getModel(name)(L442-L461 的模拟中间件测试),使业务代码对「多池 + 多库」完全无感——这是该 PoC 对上层框架最重要的接口承诺。

6.2 实测数据

指标 结果
跨池数据隔离 org_1(池 A)与 org_6(池 B)的 User/Message 互不可见,并发读写正常
热缓存路由开销 0.001ms(亚微秒级,纯 Map 命中)
冷路由(DB 查分配表 + 建连接 + 注册模型) 6ms
容量溢出 全部池满时正确抛错;重复分配幂等
批量供给 10 个 org 逐池统计均值,全流程通过

冷路由 6ms 意味着首次请求一次组织可接受;热路径 0.001ms 意味着路由层本身几乎不占用 QPS 预算。

7. 规模阈值:何时需要第二套 Postgres

组织数 Postgres 实例数 说明
1–300 1 默认配置即可
300–700 1 调优 autovacuum、PgBouncer、shared_buffers
700–1,000 1–2 监控信号出现压力时再拆分
1,000+ N / 每实例约 500 每 ~500 个组织配一对 FerretDB + Postgres

即 300 组织以内单实例无压力,超过 1,000 后按「一对实例约 500 组织」线性扩展,这正是第 6 节 TenantRouter 存在的理由。

8. 死锁行为与生产重试策略

8.1 实测到的死锁模式

  • 单组织并发建索引:无死锁(DocumentDB 后端自行处理);
  • 批量供给(10 个 org 顺序供给):在 Pool B 上发生真实死锁,通过重试恢复。

即死锁不是发生在单组织内部,而是批量供给/迁移场景下多个组织同时打 catalog 表时出现的 PostgreSQL 级死锁。

8.2 重试工具实现

生产工具是 retry.ts 中的 retryWithBackoff(调研文档以 retryWithBackoff.ts 之名引用,实际实现导出于 src/utils/retry.ts)。从源码看其默认参数(L12-L18)为:

  • maxAttempts: 5baseDelayMs: 100maxDelayMs: 10_000jitter: true
  • 可重试错误按消息子串匹配:deadlocklock timeoutwrite conflictECONNRESET
  • 退避公式:min(baseDelay × 2^(attempt-1) + random jitter, maxDelay)(L62-L64),并暴露 onRetry 钩子用于监控埋点。

同文件还封装了两个上层函数:

  • createIndexesWithRetry(model)(L84-L93):替代裸 model.createIndexes() 的安全入口;
  • initializeOrgCollections(models)(L100-L122):逐模型顺序执行 createCollection() + 带重试的 createIndexes()故意串行以最小化 DocumentDB catalog 上的竞争,并返回 { totalMs, perModel } 供部署脚本记录耗时。

8.3 真实死锁恢复数据

场景 尝试次数 总耗时 备注
无竞争的组织初始化 1 165–199ms 大多数组织一次成功
User 索引死锁 2 994ms 单次重试即恢复
重试叠加的最坏情况 2–3 1,839ms 5 组织顺序批中最差值

5 组织批量供给的完整日志:

retry_1: 193ms (29 models) — clean
retry_2: 199ms (29 models) — clean
retry_3: 165ms (29 models) — clean
retry_4: 1839ms (29 models) — deadlock on User indexes, recovered
retry_5: 994ms (29 models) — deadlock on User indexes, recovered
Total: 3,390ms for 5 orgs (678ms avg, but 165ms median)

User 模型(11+ 索引,含 9 个 sparse unique)是最容易触发死锁的集合。retry_4retry_5 证明了工具确实在真实 FerretDB 负载下捕获并恢复了死锁,而非仅覆盖单元测试路径。

9. 按组织备份/恢复:驱动级方案取代 mongodump

由于 mongodump/mongorestore CLI 在 FerretDB 下不可用,调研验证了纯驱动层方案(orgOperations.ferretdb.spec.ts):

  • 备份listCollections() 枚举 → 每集合 find({}).toArray() → 汇聚为内存 OrgBackup 结构;
  • 恢复:向新组织数据库逐集合 collection.insertMany(docs)
  • BSON 类型保真验证通过:ObjectId、Date、String 全部正确往返;
  • 数据一致性验证通过_id、字段值、文档计数与源完全一致;
  • 性能:24ms 备份 / 15ms 恢复(29 个集合中 25 个为空、共 8 条文档的真实组织);
  • 耗时随文档数线性增长,瓶颈是到 FerretDB 的网络 I/O 而非序列化。
操作 耗时 明细
备份(整组织) 24ms 8 条文档 / 29 集合(25 空)
恢复(到新组织) 15ms 每集合含 insertMany()
索引重建 ~500ms 独立的 initializeOrgCollections 调用

10. 跨组织 Schema 迁移

操作 总耗时 折算每组织
幂等重初始化(无变更) 86ms 86ms
新增集合(AuditLog)+ 4 索引 → 5 组织 109ms 22ms/组织
users 新增复合索引 {username:1, createdAt:-1} → 5 组织 22ms 4.4ms/组织
全量迁移(29 模型 × 5 组织) 439ms 88ms/组织

关键特性:

  • createIndexes() 幂等,可安全重跑;
  • 已有数据在迁移后完整保留;
  • createIndexescreateCollection 不锁定既有数据,迁移可在线上服务流量期间执行
  • 外推:1,000 个组织 × 88ms ≈ 88–90 秒完成一次全量迁移扫描。

11. 环境准备与复现步骤

仓库提供了最小可用的 FerretDB 开发/测试栈 docker-compose.ferretdb.yml

  • PostgreSQL 后端镜像:ghcr.io/ferretdb/postgres-documentdb:17-0.0.07.0-ferretdb-2.7.0 对应版本标签(Postgres 17 + FerretDB 2.7.0 的 DocumentDB 兼容镜像);
  • FerretDB 镜像:ghcr.io/ferretdb/ferretdb:2.7.0,宿主端口 27020 映射容器 27017
  • 连接串 FERRETDB_POSTGRESQL_URL=postgres://ferretdb:ferretdb@ferretdb-postgres:5432/postgres

测试通过独立 Jest 配置运行(jest.ferretdb.config.mjs 明确注明这些测试依赖运行中的 FerretDB 实例,不在 CI 中执行):

# 启动 FerretDB + Postgres
docker compose -f packages/data-schemas/misc/ferretdb/docker-compose.ferretdb.yml up -d

# 多租户基准(Phase 1-5,耗时较长)
FERRETDB_URI="mongodb://ferretdb:ferretdb@127.0.0.1:27020/mt_bench" \
  npx jest multiTenancy.ferretdb --testTimeout=600000

# 分片 PoC
FERRETDB_URI="mongodb://ferretdb:ferretdb@127.0.0.1:27020/shard_poc" \
  npx jest sharding.ferretdb --testTimeout=120000

可用的环境变量:FERRETDB_URI(必填,未设置时整个测试文件自动 describe.skip)、PG_CONTAINER(psql 直查 catalog 用的容器名,默认 librechat-ferretdb-postgres-1)、SCALE_TIERSWRITE_AMP_DOCS

测试文件全景:

文件 用途
multiTenancy.ferretdb.spec.ts 5 阶段基准(useDb 映射、索引、扩展曲线、写放大、共享集合对照)
sharding.ferretdb.spec.ts 分片 PoC(路由、分配、隔离、中间件模式)
orgOperations.ferretdb.spec.ts 生产运维(备份/恢复、迁移、死锁重试)
retry.ts 生产重试工具(retryWithBackoff / createIndexesWithRetry / initializeOrgCollections)

12. 生产运维建议

调研文档给出的四条落地建议,均与仓库源码相互印证:

  1. 组织供给:所有新组织一律走 initializeOrgCollections()retry.ts);批量供给按 10 个一批用 Promise.all() 跨池并行、池内串行,兼顾吞吐与 catalog 竞争控制。
  2. 备份策略(驱动级,替代 mongodump):
    • listCollections() 枚举集合;
    • 大集合用 find({}).batchSize(1000) 流式拉取;
    • 按集合写 NDJSON 到对象存储(S3/GCS);
    • 恢复用 1,000 条一批的 insertMany()
  3. Schema 迁移:把 migrateAllOrgs() 作为部署步骤——从分配表枚举全部组织 → 逐组织注册模型、createCollection()createIndexesWithRetry();幂等可重跑,千级组织约 90 秒完成。
  4. 监控:跟踪每组织供给/迁移耗时,中位数供给时间超过 500ms/组织时排查 PostgreSQL catalog 压力,具体看三个指标:
    • pg_stat_user_tables.n_dead_tup(autovacuum 健康度);
    • pg_stat_bgwriter.buffers_backend(缓冲压力);
    • documentdb_api_catalog.collections 行数(总表数规模)。

13. 结论与适用边界

这份调研给出的可执行结论可以概括为三点:

  1. FerretDB(postgres-documentdb)完整兼容 LibreChat 的 29 模型 / 98 索引体系,含 sparse、TTL、partial、复合唯一等全部索引类型,且 100 组织规模内无性能退化;
  2. 隔离与扩展模型成立useDb() 逻辑数据库 + catalog 层隔离 + 每 ~500 组织一对 FerretDB+Postgres 的水平分片,路由热路径开销可忽略(0.001ms);
  3. 运维闭环已验证:死锁重试(指数退避 + 抖动,100ms 起、10s 封顶)、驱动级备份/恢复(BSON 保真、线性扩展)、幂等跨组织迁移(~88ms/组织)三者均有真实负载下的恢复记录。

适用边界需要说明:所有数字均产生于单机 Docker 内的 FerretDB 2.7.0 + Postgres 17 环境、以 29 个 org-local 模型为基准,属于「调研期实测」而非 SLA 承诺;生产环境在 300+ 组织后需要按第 7 节阈值逐步调优 autovacuum、PgBouncer 与 shared_buffers,并在中位供给耗时越过 500ms 告警线时介入排查。

登录后查看全文
热门项目推荐
相关项目推荐