
Midday 数据库连接池设计Supavisor 事务模式池化 多区域读写分离全解析【免费下载链接】middayInvoicing, Time tracking, File reconciliation, Storage, Financial Overview your own Assistant made for Freelancers项目地址: https://gitcode.com/GitHub_Trending/mi/midday本文基于 Midday 仓库的 数据库连接池文档完整还原其数据库访问层的连接架构如何通过 Supabase 共享池化器Supavisor以事务模式transaction mode接入 Postgres、为什么放弃专用 PgBouncer、如何按 Railway 区域将读请求路由到最近的只读副本以及各服务API / Worker连接池参数的源码级实现。读完后你将掌握一套可直接借鉴的“云托管池化器 多区域副本 Drizzle/pg 池配置”的生产级数据库连接方案。整体架构概述Midday 的应用层通过SupavisorSupabase 的共享连接池化器以事务模式连接 Supabase Postgres。部署上API 实例分布在多个 Railway 区域每个区域的实例从地理上最近的 Supabase 只读副本读取数据而所有写操作统一发往位于eu-central-1的主库。这个架构由三层组成客户端池各服务进程内基于pgnode-postgres创建的连接池负责把高频并发请求收敛为有限的 TCP 连接池化器层Supavisor 事务模式把大量客户端连接复用为少量 Postgres 后端连接突破max_connections限制多区域副本层通过区域化的副本 URL 与withReplicas封装实现读写分离读流量就近、写流量集中。核心实现集中在 packages/db/src/client.ts、packages/db/src/replicas.ts、packages/db/src/worker-client.ts 与 packages/db/src/job-client.ts。连接模式选择Session 与 Transaction 的本质区别Supabase 通过pooler.supabase.com提供两种池化模式模式端口行为Session 模式5432客户端到后端 1:1 映射没有真正的池化——每个应用连接在整个生命周期内独占一条 Postgres 后端连接Transaction 模式6543真正的连接池化后端连接在多个客户端之间共享仅在事务执行期间被占用事务结束后即归还池中Midday 明确选择事务模式端口 6543。这一点至关重要如果误用 5432 端口的 session 模式即使流量经过了pooler.supabase.com也得不到任何池化收益客户端连接数会原样穿透到 Postgres。连接串格式如下postgresql://postgres.project-ref:passwordaws-0-region.pooler.supabase.com:6543/postgres其中region是连接池所在的 AWS 区域决定了池化器与数据库主库之间的物理距离。为什么不选专用 PgBouncerDedicated PoolerSupabase 还提供与数据库同机部署的专用 PgBouncer地址形如db.ref.supabase.co:6543。它的优势是少一次到独立服务器的网络跳转、延迟更低但代价是要求客户端具备IPv6 连通性或购买 Supabase 的 IPv4 附加组件按库计费文档中标注为 $4/月/库而Railway 不支持 IPv6 直连 Supabase 的直接端点。因此在 Midday 的基础设施下共享的 Supavisor 池化器是正确选择——这是“低 1 毫秒延迟”让位于“架构可达性”的典型权衡。多区域副本映射RAILWAY_REPLICA_REGION驱动就近读API 部署在 3 个 Railway 区域每个区域实例通过RAILWAY_REPLICA_REGION环境变量读取最近的 Supabase 只读副本Railway 区域环境变量Supabase 区域角色europe-west4-drams3aDATABASE_FRA_URLeu-central-1主库读 写us-east4-eqdc4aDATABASE_IAD_URLus-east-1只读副本us-west2DATABASE_SJC_URLus-west-1只读副本读请求经executeOnReplica与withReplicas封装packages/db/src/replicas.ts路由到区域副本写请求永远走DATABASE_PRIMARY_URLeu-central-1主库。源码级实现区域 → 副本 URL 的解析client.ts 中维护了一张区域映射表// Map Railway region → replica URL const replicaUrlForRegion: Recordstring, string | undefined { europe-west4-drams3a: process.env.DATABASE_FRA_URL, us-east4-eqdc4a: process.env.DATABASE_IAD_URL, us-west2: process.env.DATABASE_SJC_URL, }; const currentRegion process.env.RAILWAY_REPLICA_REGION; const rawReplicaUrl currentRegion ? replicaUrlForRegion[currentRegion] : undefined;注意 client.ts 中的去重逻辑若某区域的副本 URL 与主库 URL 相同EU 区域即如此副本就是主库本身则不再为副本创建独立池replicaUrl置为undefined。这意味着 EU 实例只有主库一个池而美东/美西实例各有主库 副本两个池——这正是文档中“每个 API 实例最多创建 2 个池”的由来。此外client.ts 在非开发环境下对配置缺失做了防御性告警RAILWAY_REPLICA_REGION未设置时所有读走主库区域与 URL 不匹配时回退主库——配置错误不会导致服务崩溃只会退化为“全量走主库”并留下日志线索。withReplicas读写路由的 Proxy 封装replicas.ts 中的withReplicas把主库 Drizzle 实例包装为带读写分离能力的代理对象其路由规则是读方法select、selectDistinct、selectDistinctOn、$count、with、$with、query通过 getter 动态解析到副本写方法与事务update、insert、delete、execute、transaction、refreshMaterializedView硬绑定到主库另暴露executeOnReplica在副本上执行原生 SQL 查询与transactionOnReplica在副本上开只读事务。replicas.ts 中还有一段值得注意的实现注释所有方法必须bind到其所属的数据库实例否则展开...primary会把session复制为自身属性JS 方法调用语义会把this指向 Proxy 对象本身导致所有查询被错误地路由到主库。这类细节是读写分离封装中最容易踩的坑。client.ts中最终导出的db即该封装的产物client.tsexport const db withReplicas( primaryDb, [replicaDb], (replicas) replicas[0]!, );由于当前架构每实例只有一个副本getReplica策略直接取replicas[0]若未来扩展为多副本这里可以改成随机或轮询策略withReplicas的默认实现就是随机选择。池配置packages/db/src/client.ts的完整参数文档给出的 API 服务池配置如下定义于 client.ts设置DevelopmentProductionmax840idleTimeoutMillis5,000ms60,000msconnectionTimeoutMillis5,000ms5,000msmaxUses1000无限ssl禁用启用rejectUnauthorized: false对照当前源码client.ts实际的connectionConfig是三段式取值——开发 / 预发staging/ 生产const isDevelopment process.env.NODE_ENV development; const isProduction process.env.RAILWAY_ENVIRONMENT_NAME production; const connectionConfig { max: isDevelopment ? 8 : isProduction ? 40 : 6, min: isDevelopment ? 0 : isProduction ? 8 : 1, idleTimeoutMillis: isDevelopment ? 5000 : isProduction ? 30000 : 10000, connectionTimeoutMillis: 5000, maxUses: isDevelopment ? 100 : 7500, allowExitOnIdle: !isProduction, keepAlive: true, keepAliveInitialDelayMillis: 10_000, ssl: isDevelopment ? false : { rejectUnauthorized: false }, };逐项解读这些参数的工程意图max: 40生产单池客户端连接上限。每 API 实例最多 2 个池主库 区域副本故单实例客户端连接上限为40 × 2 80在 Supavisor 事务模式下这些连接会被复用为远少于 80 条真实的 Postgres 后端连接。池大小整体是针对PgBouncer 600 客户端连接上限覆盖全部 API Worker 实例调参的。min: 8生产保持 8 条常驻连接让热池在流量突增时无需冷启动建连。idleTimeoutMillis生产 30s源码值空闲连接 30 秒后回收。文档标注 60s以当前源码 30000ms 为准——两者差异说明该参数经历过调优。maxUses: 7500非开发环境源码值单条连接最多复用 7500 次后强制重建。文档写的是 0无限从源码结构看这是后来引入的连接老化保护用于规避长连接被池化器/网络层静默断开后的僵尸连接问题。allowExitOnIdle: !isProduction非生产环境允许进程在无连接时直接退出生产环境进程存活时保持池不退出。keepAlive 10s 初始延迟TCP 层保活及时探测被中间设备断开的空闲连接。ssl: { rejectUnauthorized: false }生产启用 TLS 但不校验证书——这是经过池化器的常见做法池化器会终结/重加密 TLS客户端通常无法验证到真实 Postgres 证书链。Worker 与 Job 的差异化池策略除 API 主池外仓库中还有两套更激进的池策略体现了“按工作负载分池”的思路Worker 常驻池worker-client.tsconst connectionString process.env.DATABASE_PRIMARY_POOLER_URL ?? process.env.DATABASE_PRIMARY_URL; const workerPool new Pool({ connectionString, max: isDevelopment ? 10 : 50, // BullMQ 并发任务需要更高并发 idleTimeoutMillis: isDevelopment ? 5000 : 60000, connectionTimeoutMillis: 15000, // 批处理任务容忍更长的建连等待 maxUses: isDevelopment ? 200 : 3000, keepAlive: true, keepAliveInitialDelayMillis: 10_000, ssl: isDevelopment ? false : { rejectUnauthorized: false }, allowExitOnIdle: true, });连接串优先取DATABASE_PRIMARY_POOLER_URL文档中 Worker 服务唯一要求的数据库变量缺失时回退DATABASE_PRIMARY_URL两者皆无则直接抛错worker-client.ts。Worker 不使用副本因此 worker-client.ts 手工为 Drizzle 实例附加了executeOnReplica垫片将其转发到主库的普通execute并保持相同的行结果形状——这让同一批共享查询代码在 API 与 Worker 中都能运行。Job 单连接池job-client.ts/** * Creates a new job-optimized database instance. * * - Single connection per job (max: 1) to avoid flooding Supabase pooler * - Separate disconnect function for lifecycle management */ export const createJobDb () { const jobPool new Pool({ connectionString: process.env.DATABASE_PRIMARY_POOLER_URL!, max: 1, ... }); ... return { db, disconnect: () jobPool.end() }; };max: 1的注释道出了与池化器协同的核心动机避免短生命周期任务洪水式冲击 Supabase 池化器。配合disconnect显式归还任务结束即释放这是事务模式池化下任务型负载的最佳实践。连接池可观测性client.ts 的attachPoolMonitoring为每个池挂了error监听器空闲客户端断连即记录 PG 错误详情与池统计并可通过DB_POOL_EVENT_LOGGINGtrue打开connect/acquire/remove事件日志。client.ts 的getPoolStats()暴露两个池的total / idle / waiting计数。再叠加 instrument.ts 的DEBUG_PERF插桩包装pool.connect与pool.query获取连接耗时超过 100ms 告警、单条 SQL 超过 100ms 记 warn、超过 500ms 记 error并从 SQL 中解析出操作类型与表名parseQuery。这套组合让“池是否被打满”“慢在哪张表”都有日志可查。预处理语句Prepared Statements兼容性Midday 全部数据库客户端基于pgnode-postgres。这里存在一个硬性约束事务模式端口 6543不支持预处理语句——pg 客户端默认会发送Parse消息准备语句在事务池化器下会报错。原文档为此链接到 Supabase 官方讨论“Disabling prepared statements”对应的工程处理方式是让 pg 以非预处理方式发送查询参数以字面量内联的 plain SQL 形式下发或者改用session 池化器端口 5432/ 直连来保留 pg 的预处理能力。这是一个“连接池模式选择会反向约束客户端库行为”的典型案例选 6543 拿到真正的池化收益就必须放弃或禁用prepared statements反之若必须使用预处理语句则只能退回到无池化收益的 session 模式。Midday 选择了前者。环境变量总览按服务划分的数据库环境变量与文档一致API 服务DATABASE_PRIMARY_URL— 主库EU读 写DATABASE_FRA_URL— EU 只读副本当前配置中即主库DATABASE_IAD_URL— 美东只读副本DATABASE_SJC_URL— 美西只读副本Worker 服务DATABASE_PRIMARY_POOLER_URL— 主库EU源码中可回退到DATABASE_PRIMARY_URLDashboard 服务无任何数据库变量——Dashboard 通过 tRPC 调用 API不直连 Postgres。测试环境同样遵循这套契约apps/api/src/tests/setup.ts 为四个 URL 变量设置了postgres://test:testlocalhost:5432/test的默认值且 mock 数据库对象显式实现了usePrimaryOnly/$primary以模拟withReplicas的接口形状setup.ts。apps/api/README.md 的“Database Configuration”一节也列出了相同的四个变量可直接照抄用于本地.env。Railway 部署拓扑与池容量核算连接池文档给出的部署规模API生产 3 区域 × 各 1 副本staging 3 区域 × 各 1 副本Dashboard生产 3 区域 × 各 2 副本staging 3 区域 × 各 1 副本启用 serverless/sleepWorker1 个区域EU× 3 副本生产。对照仓库当前实际的 railway.json 配置文档与配置存在演进差异扩容方向一致apps/api/railway.json生产 3 区域 ×各 3 副本staging 各 1 副本健康检查路径/health、drainingSeconds: 15apps/dashboard/railway.json生产 3 区域 × 各 3 副本健康检查/api/healthapps/worker/railway.json仅europe-west4-drams3aEU 单区域× 2 副本staging 1 副本——Worker 固定部署在 EU与主库同区域写密集批处理零跨洋延迟。用当前配置做一次池容量核算生产 API 共 9 个实例每实例最多 2 池 ×max 40 单实例最多 80 条客户端连接9 实例理论峰值 720 条加上 Worker 3 个副本 ×max 50 150 条。这些客户端连接经 Supavisor 事务模式复用后落到 Postgres 的真实后端连接数远低于此——这正是引入共享池化器后可以在“600 客户端上限”约束内安全部署多区域、多副本拓扑的前提。优雅关闭进程退出时池必须被显式关闭client.ts 提供closeDbPromise.all([primaryPool.end(), replicaPool?.end()])worker-client.ts 提供closeWorkerDb。结合 railway.json 的drainingSecondsAPI 15s、Dashboard 30sRailway 先停止流入新请求、等待在途请求完成再由进程生命周期钩子结束连接池——避免连接被强制 RST 导致的事务半提交风险。小结Midday 的数据库连接池方案可以归纳为四条可复用的决策模式选择优先于延迟微优化放弃低延迟的专用 PgBouncer选共享 Supavisor 事务模式6543因为基础设施Railway 无 IPv6不允许读写分离下沉到连接层区域 → 副本 URL 映射 withReplicasProxy写永远走主库、读就近路由副本 URL 与主库相同时自动合并池按负载分池API 常驻池40/ Worker 并发池50/ Job 单连接池1各自匹配并发画像并针对 PgBouncer 600 客户端上限整体调参可观测兜底池级错误/事件日志 慢查询插桩 getPoolStats让池化器的黑盒部分连接复用、等待队列仍然可见。所有关键实现可追溯至client.ts、replicas.ts、worker-client.ts、job-client.ts、instrument.ts 及各服务的railway.json。【免费下载链接】middayInvoicing, Time tracking, File reconciliation, Storage, Financial Overview your own Assistant made for Freelancers项目地址: https://gitcode.com/GitHub_Trending/mi/midday创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考