
深入 Midday API 服务双实例 Redis 架构、多区域数据库副本与写后读一致性设计【免费下载链接】middayInvoicing, Time tracking, File reconciliation, Storage, Financial Overview your own Assistant made for Freelancers项目地址: https://gitcode.com/GitHub_Trending/mi/midday本文基于 Midday 的 API 服务文档 展开讲清楚三件对部署和二次开发都至关重要的事API 服务需要哪些环境变量两套 Redis 实例 四个数据库连接串、bun dev/bun start如何启动与优雅停机以及 API 的分布式缓存体系5 个缓存实例、TTL 策略、Redis 不可用时的降级行为如何在源码中落地。读完后你将能够独立完成该 API 服务的本地环境搭建并理解其「Upstash 缓存 本地 BullMQ 队列 多区域读副本」这一生产架构的设计动机与源码实现。一、API 服务概览Midday 是一款面向自由职业者的平台提供 Invoicing发票、Time tracking工时跟踪、File reconciliation文件对账、Storage存储与 Financial Overview财务概览能力。apps/api是其中的后端 API 服务基于Hono tRPC构建运行在Bun运行时上。从 入口文件 可以看到整个服务的装配方式使用OpenAPIHono创建应用实例挂载 CORS、安全头、httpLogger等全局中间件/trpc/*路由通过hono/trpc-server挂载 tRPC 路由树router/openapi输出 OpenAPI 3.1 规范/提供 Scalar 交互式文档提供/health、/health/ready、/health/dependencies三个健康检查端点依赖检查逻辑来自midday/health。开发与生产启动方式如下来自 package.json 的 scripts# 开发模式TZUTC PORT3003 bun run --hot src/index.tsbun dev 的等价调用 bun dev # 生产模式 bun start入口最终导出 Bun 的 server 配置监听端口取PORT环境变量默认 3000dev 脚本中固定为 3003host固定为0.0.0.0idleTimeout为 60 秒见 index.ts 导出段。二、环境变量双实例 Redis 架构这是该 API 部署方案中最具特色的部分——同一服务同时依赖两套独立的 Redis 实例承担完全不同的职责# 本地开发Docker REDIS_URLredis://localhost:6379 REDIS_QUEUE_URLredis://localhost:6379 # 生产环境 # REDIS_URLrediss://:password...upstash.io:6379 (Upstash 多区域 Redis) # REDIS_QUEUE_URLredis://...railway.internal:6379 (Railway Redis - BullMQ 队列)变量生产实现职责为什么这样选REDIS_URLUpstash 多区域 Redis分布式缓存Upstash 自动将读请求路由到最近的副本、写请求路由到主节点一个 URL 即可在所有 Railway 区域通用REDIS_QUEUE_URLRailway 内网 RedisBullMQ 任务队列BullMQ 依赖持久 TCP 连接与阻塞操作BRPOPLPUSH等与 Upstash 的 HTTP 协议不兼容因此必须保留在 Railway 内网的传统 Redis 上源码可以印证这两条路径的分离缓存侧的客户端由 shared-redis.ts 中的resolveRedisUrl()解析REDIS_URL未配置时直接抛出No Redis URL configured. Set REDIS_URL.队列侧由 job-client/queues.ts 读取REDIS_QUEUE_URL缺失时抛出REDIS_QUEUE_URL environment variable is required。也就是说两套 Redis 缺一不可本地开发时可以让两者指向同一个 Docker Redis但生产环境绝不能合并。本地开发环境搭建用 Docker 启动 Redisdocker run -d --name redis -p 6379:6379 redis:alpine设置环境变量export REDIS_URLredis://localhost:6379 export REDIS_QUEUE_URLredis://localhost:6379缓存客户端对rediss://TLS前缀有专门处理createClient 会据此开启 TLS所以本地明文redis://与生产加密rediss://无需改代码即可切换。三、数据库配置多区域读副本API 服务的数据库环境变量DATABASE_PRIMARY_URLpostgresql://... DATABASE_FRA_URLpostgresql://... # EU 副本 DATABASE_IAD_URLpostgresql://... # US East 副本 DATABASE_SJC_URLpostgresql://... # US West 副本这四个连接串对应「一个 EU 主库 三个区域读副本」的拓扑。仓库中更完整的说明见 数据库连接池文档API 运行在 3 个 Railway 区域每个实例通过RAILWAY_REPLICA_REGION决定就近读取哪个副本DATABASE_FRA_URL为 EU 主库本身、DATABASE_IAD_URL对应 us-east-1、DATABASE_SJC_URL对应 us-west-1所有写操作一律走DATABASE_PRIMARY_URL连接通过 Supabase 的 Supavisor 事务模式池端口 6543。副本路由的具体实现位于packages/db/src/replicas.ts中的withReplicas包装器。四、缓存实现5 个缓存实例与 TTL 策略API 使用 Upstash 多区域 Redis 做跨区域分布式缓存文档列出的 5 个缓存实例在 packages/cache 中一一对应且每个实例用独立的 key 前缀隔离命名空间、独立的默认 TTL缓存实例源码文件key 前缀默认 TTL缓存内容apiKeyCacheapi-key-cache.tsapi-key30 分钟API key 查找回结果userCacheuser-cache.tsuser30 分钟用户数据teamCacheteam-cache.tsteam30 分钟团队访问权限teamPermissionsCacheteam-permissions-cache.tsteam-permissions30 分钟团队权限查询replicationCachereplication-cache.tsreplication10 秒近期写操作标记写后读一致性除上述 5 个之外源码中还存在两个同构扩展缓存banking30 分钟与connectors24 小时说明该缓存基础设施是按「一个业务域一个RedisCache实例」的模式持续扩展的。这些缓存服务于 REST 认证链路。以 auth.ts 中间件 为例请求携带Bearertoken 时先尝试 Supabase JWT 校验再处理 OAuth access tokenmid_access_token_前缀最后处理 API key其中 OAuth 用户会先走userCache.get(user.id)查缓存未命中才落库并userCache.set(...)回填。RedisCache 基类超时、去重与静默降级所有缓存实例都继承自 RedisCache 类它实现了文档中「Redis 不可用时优雅降级」的关键机制命令级超时每条 Redis 命令GET/SETEX/DEL/PING都被withTimeout包裹1500ms 未返回即视为超时不会拖垮请求超过 50ms 的慢命令会记录Slow GET/Slow SET告警inflight 去重inflightMap 保证同一 key 的并发 GET 只发一次实际命令避免缓存击穿失败即 no-opget在连接断开或命令失败时返回undefined对上层等价于缓存未命中set/delete失败仅记录日志而不抛错——这正是文档所述「cache misses returnundefinedand operations no-op instead of throwing」的源码实现序列化值默认 JSON 序列化key 统一加上前缀:命名空间如api-key:xxx写入使用SETEX携带 TTL。连接层同样为可用性做了加固见 shared-redis.ts共享RedisClient单例开启autoReconnect每 5 秒发一次带 2 秒超时的 keepalive PING若断连超过 15 秒且自动重连耗尽客户端会被整体销毁并重建API 无需人工重启即可自愈。五、replicationCache写后读一致性的核心5 个缓存中语义最特殊的是replicationCache。由于写永远落在 EU 主库、读分散到各区域副本刚完成的写入可能存在复制延迟。replication-cache.ts 的解法非常轻量set(teamId)写入Date.now() 10000REPLICATION_LAG_WINDOW为 10 秒作为「该团队何时可以安全读副本」的时间戳TTL 恰为 10 秒窗口结束后 key 自动消失get(teamId)返回该时间戳供中间件判断是否仍在窗口内。这个标记被两套请求路径共同消费REST 中间件rest/middleware/primary-read-after-write.ts按 HTTP 方法区分读写——POST/PUT/PATCH/DELETE判定为 mutation执行replicationCache.set(teamId)并强制走主库查询类请求则replicationCache.get(teamId)若时间戳仍在未来则把本次查询临时改道主库tRPC 中间件trpc/middleware/primary-read-after-write.ts同样的逻辑按 tRPC procedure 类型mutation/query分支并支持通过forcePrimary标记对应 CORS 白名单中的x-force-primary请求头让客户端显式要求主库读。由于缓存本身走 Upstash 多区域复制「哪个区域实例收到的写操作」都会同步给所有区域的实例从而保证一致性判断是跨实例共享的——这是它必须用 Redis 而非进程内 Map 的原因。六、优雅停机与 Redis 生命周期Redis 连接的关闭被纳入了服务级的优雅停机流程。index.ts 的 shutdown 处理 在收到SIGTERM/SIGINT时按序执行清除连接池统计定时器 →closeDb()关闭数据库连接 → closeSharedRedisClient() 停止 keepalive 并关闭共享 Redis 客户端 →Sentry.close(2000)刷出错误事件。整个流程设有 12 秒硬超时SHUTDOWN_TIMEOUT以适配 Railway 的 15 秒排水窗口超时后强制退出。这解释了为什么 Redis 客户端被设计为可重建的单例——停机关闭后再次请求会重新走getSharedRedisClient()建立新连接。七、开发、测试与质量保障除文档中的bun dev/bun start外package.json 还定义了完整的质量工具链bun run typecheck # tsc --noEmit bun run lint # biome check . bun run test # bun test先跑 trpc/ 全量 指定 routers 测试再分组运行其余套件 bun run test:e2e # 端到端测试src/__tests__/e2e/ bun run eval # evalite watch跑 evals/ 下的 LLM 工具选择评测测试基础设施位于 src/tests/通过setup.tspreload 初始化环境src/__tests__/下的测试也引用了replicationCache相关路径说明写后读路由逻辑是被测试覆盖的行为。小结这套架构的可借鉴之处按协议选型而非按数量选型需要多区域低延迟读写的缓存选 Serverless/多区域 Redis需要持久 TCP 与阻塞语义的队列选传统 Redis两者不互相妥协缓存永远不是正确性依赖RedisCache的任何失败路径都退化为「未命中 落库」Redis 整体宕机时服务功能完整、只是失去加速用 10 秒 TTL 的时间戳 key 解决复制延迟把「写后读一致性窗口」显式建模成一个可自动过期、跨实例共享的缓存条目而不是在应用层做复杂的事务隔离连接自愈 优雅停机成对出现断连 15 秒自动重建客户端停机时显式close两个方向都不会留下悬挂连接。如果你要在此基础上做本地二次开发最小闭环是Docker 起一个 Redis同时充当两个 Redis 角色→ 配置 4 个DATABASE_*_URL本地可用同一主库填充→bun dev启动 3003 端口 → 访问http://localhost:3003/使用 Scalar 文档验证 OAuth/API key 认证链路。【免费下载链接】middayInvoicing, Time tracking, File reconciliation, Storage, Financial Overview your own Assistant made for Freelancers项目地址: https://gitcode.com/GitHub_Trending/mi/midday创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考