多租户 SaaS API 架构设计
多租户 SaaS API 架构设计
学习目标
- 理解多版本 API 并行运行的架构模式
- 掌握中间件链的设计顺序与职责划分
- 了解基于 Redis 的多租户限流方案
前置知识
- Express 中间件机制
- Redis 基础
项目实践
三版本并行架构
Firecrawl 同时维护 v0、v1、v2 三个 API 版本,各自独立 Router:
app.use(v0Router); // /v0/* - 旧版 APIapp.use("/v1", v1Router); // /v1/* - 稳定版 APIapp.use("/v2", v2Router); // /v2/* - 最新版 API每个版本的差异:
| 版本 | 状态 | 特点 |
|---|---|---|
| v0 | 已废弃 | 最初设计,请求/响应格式不统一 |
| v1 | 稳定 | 统一的 Zod Schema 验证,标准化响应格式 |
| v2 | 推荐 | 新增 Agent、Interact 等端点,改进的认证链 |
为什么保留旧版本? 生产环境有大量客户端依赖旧版 API,直接废弃会导致下游集成断裂。v1 作为稳定版提供长期支持,v2 作为最新版引入新功能。
中间件链设计
每个 v2 端点的中间件链按以下顺序组装:
requestTimingMiddleware → 记录请求计时authMiddleware → 验证 API Key,注入 team_idcountryCheck → 地理位置检查(合规性)idempotencyMiddleware → 幂等性检查(仅 Crawl 等有副作用的端点)checkCreditsMiddleware → 信用额度预检blocklistMiddleware → 黑名单检查deprecationMiddleware → 废弃警告(特定端点)wrap(controller) → 控制器执行顺序很重要:认证先于业务逻辑,确保后续所有中间件都能拿到 req.auth.team_id。信用检查在限流之前,避免无效请求消耗限流配额。
Zod Schema 验证
每个端点使用 Zod Schema 定义请求和响应的类型:
req.body = scrapeRequestSchema.parse(req.body);验证失败时由 Express 全局错误处理中间件捕获 ZodError,返回统一的 400 响应格式。对于无法识别的字段,返回自定义提示:
"Unrecognized key in body -- please review the v2 API documentation for request body changes"基于 Redis 的限流
限流使用 rate-limiter-flexible 库,按端点模式(RateLimiterMode)创建不同的限流器:
const fallbackRateLimits = { crawl: 15, // 爬取:最低(资源密集) scrape: 100, // 抓取:中等 search: 100, // 搜索:中等 map: 100, // 映射:中等 account: 1000, // 账户查询:最高 browser: 2, // 浏览器会话:极低(资源最密集) ...};限流键是 team_id,确保每个团队的配额独立。对于有数据库认证的实例,限流值从数据库动态获取;自托管实例使用 fallback 值。
OpenTelemetry 集成
每个控制器和关键操作使用 withSpan 创建追踪 Span:
return withSpan("api.scrape.request", async span => { setSpanAttributes(span, { "scrape.job_id": jobId, "scrape.url": req.body.url, ... });});关键操作嵌套子 Span(验证、权限检查、引擎执行等),形成完整的追踪树。
问题与规避
1. Zod 严格模式导致的误报
Zod Schema 默认拒绝未知字段。客户端发送了旧版 API 的字段名(如 crawlerOptions 在 v2 中已改名)会被直接拒绝。
规避:错误响应中明确提示”Unrecognized key”,引导用户查阅 v2 文档。同时通过 deprecationMiddleware 对旧端点返回警告头。
2. 限流器与信用额度的冲突
限流按分钟计算(duration: 60),而信用额度按月计算。一个用户可能在限流配额内但信用已耗尽,或者相反。
规避:两个检查独立运行,任何一个不通过即拒绝请求。限流防止短期滥用,信用防止长期资源透支。
设计取舍
为什么用单进程 Express 而非 API Gateway?
Firecrawl 的 API 网关是单个 Express 应用,而非 API Gateway + Lambda 的微服务架构。原因:
- 部署简单:单个进程,不需要额外的 Gateway 层
- 低延迟:中间件链在进程内执行,无网络跳转
- 共享状态:所有路由共享 Redis/PostgreSQL 连接池
代价是单进程成为瓶颈,需要通过水平扩展(多个 API 实例 + 负载均衡)来扩容。
为什么保留 v0 而不强制迁移到 v2?
强制迁移意味着一次性破坏所有旧客户端。Firecrawl 选择逐步废弃:v0 仍然工作但返回废弃警告,v2 作为推荐版本。