Multi-Tenant SaaS 架构 2026:7 大 Boilerplate 横向对比

2026 年多租户 SaaS 架构三种隔离模式横评:行级 vs Schema vs 数据库,7 大 Boilerplate 完整决策矩阵。

Huifer
Huifer
August 31, 202610 min read
其他语言:Deutsch · English

本文由 Huifer 撰写,Huifer 是一名独立开发者,也是 TanStack Ship 的维护者。TanStack Ship 在第一天就内置了多租户支持——子域名路由、D1 租户隔离和每张表的行级安全。2025 年,我为两家实施多租户的 B2B SaaS 公司提供过咨询服务,帮助他们避免了第二个客户签约后才暴露的数据泄露陷阱。

经验证的信息来源:Cloudflare D1 文档 · TanStack Router 文档 · TanStack Start 文档 · SQLite 查询规划器 · Stripe Connect 多方支付 · 最后更新:2026-08-31 · 更新日志


TL;DR: 2026 年的多租户 SaaS 架构本质上就是三种隔离模式——行级、每租户 Schema 和每租户数据库——七家 Boilerplate 至少支持其中一种。95% 的 B2B SaaS 的正确选择是行级隔离:一个数据库、一个 Schema、每张表加 tenant_id 列、在任何 Server Function 运行前于边缘注入租户上下文。下方:三种模式对比、七大 Boilerplate 表格、代码级实现模式、从单租户到多租户的迁移路径,以及我与 B2B 客户决策时使用的决策矩阵。


Multi-Tenant SaaS Boilerplate:7 大选项横向对比

为什么多租户架构是你做出的第一个决策

每个 B2B SaaS 在第一天就会做出多租户决策,只是往往没有意识到。这个决策决定了你的数据库 Schema、路由层、认证模型、账单集成以及未来五年的迁移路径。选错代价是重写;选对代价只是一行配置变更。

我的第一个 B2B 产品(2023 年)就是在这个问题上踩过坑的。我交付了一个单租户应用,有两个客户,后来第三个客户要求独立的仪表板,我又花了三周做多租户改造。改造涉及 Schema 迁移、中间件重写和 Stripe Connect 配置——如果在第一天就做,这些都是微不足道的。但那次改造一点也不微不足道。

这份指南是我现在随项目交付的执行手册,也是当客户问"我们应该用哪个 Starter 做 B2B?"时,我评估的七款 Boilerplate。

三种多租户模式详解

行级隔离——正确的默认值

行级隔离只有一个数据库、一个 Schema,每个域表都以 tenant_id 开头,每条查询都按租户过滤。这是我为约 95% 的 B2B SaaS 产品推荐的模式,也是 TanStack Ship 默认交付的模型。

根据 SQLite 查询规划器,复合索引 (tenant_id, created_at) 能被高效用于 WHERE tenant_id = ? ORDER BY created_at DESC 这类查询,而在缺少 tenant_id 时则被忽略——这正是你期望的"每条查询都必须按租户过滤"的数据库行为。带有 WHERE archived_at IS NULL 的部分索引让索引体量随表增长保持在可控范围。

优势: 一个数据库意味着一条连接、一次迁移、一套备份。跨租户分析就是一条 SQL 查询。迁移简单。大多数工程师已经理解这个模式。

代价: 每条查询都必须记得加 tenant_id 过滤,这意味着每个 Server Function 都必须注入租户上下文。一次忘记加过滤就是一次数据泄露。

每租户 Schema——很少能挣回自己那笔账的中间地带

每租户 Schema 在单个数据库内给每个租户独立的命名空间。根据 PostgreSQL Schema 文档,PostgreSQL 用户期望 search_path 切换能干净利落地工作。但在 SQLite(因此也在 D1)中,这个期望不成立——查询规划器不会像跨表那样优化跨 Schema 的查询,跨 Schema JOIN 速度很慢。

它能挣回自己那笔账的场景: 使用 PostgreSQL 后端且 Schema 切换被充分理解的应用。我为一个客户交付过每租户 Schema——一个地区性 SaaS,合规要求每租户 Schema 隔离。大多数应用并不需要。

每租户数据库——物理隔离

每租户数据库给每个租户独立的 D1 binding(或 PostgreSQL 数据库)。应用根据租户上下文路由请求。

优势: 物理隔离、每租户独立备份与恢复、为大型租户提供独立读副本拓扑的能力。

代价: 每增加一个数据库就多一个 binding、多一次迁移、多一条账单明细。运维开销随租户数量线性增长。我为一个医疗平台交付过这个方案——其中一个客户要求符合 HIPAA 的每租户数据驻留。如果没有客户提出要求,就不要主动采用。

SaaS Boilerplate 多租户支持横评(2026)

#Boilerplate技术栈隔离模式开箱即用多租户迁移路径价格
1TanStack ShipTanStack Start + Workers + D1行级 + 子域名是(内置)简单(第一天就是行级)$149
2ShipFastNext.js + Vercel + Postgres无(单租户)否(需手动)手动改造$199
3MakerkitNext.js + SupabaseRLS(Supabase)部分(仅 RLS)手动 Schema 变更$279
4Wasp Open-SaaSWasp DSL + Prisma行级(手动)否(需自行接入)手动中间件免费
5Next-ForgeNext.js monorepo + Postgres每租户 Schema部分(示例代码)手动拆分$399
6SupastarterNext.js + SupabaseRLS(Supabase)部分(仅 RLS)手动 Schema 变更$229
7TurbostarterNext.js + Turso行级(手动)否(需自行接入)手动中间件$249

七款中只有 TanStack Ship 开箱即用多租户支持。ShipFast 交付单租户,把接线工作留给你。Makerkit 和 Supastarter 依赖 Supabase RLS——这是一个强大的原语,但你仍需自行接入租户上下文。免费方案和基于 Turso 的方案没有立场,需要手动工作。

数据隔离实现模式

边缘的租户中间件

第一个模式是运行在每个 Server Function 之前的租户中间件。租户上下文从 Host 头(子域名)或第一个路径段(/$tenantSlug/)解析,并作为环境状态在请求中流动:

typescript
// src/middleware/tenant.ts — 在每个 Server Function 之前运行
export const tenantMiddleware = defineMiddleware({
  before: async ({ request }) => {
    const host = request.headers.get('host') ?? ''
    const slug = host.split('.')[0]
    if (['www', 'api', 'app'].includes(slug)) return
    const tenant = await db
      .prepare('SELECT id, plan, status FROM tenants WHERE slug = ?')
      .bind(slug).first<Tenant>()
    if (!tenant || tenant.status !== 'active') {
      return new Response('Tenant not found', { status: 404 })
    }
    return { tenantId: tenant.id, tenantPlan: tenant.plan }
  },
})

中间件在每个 Server Function 之前运行;返回的对象成为环境上下文。每个 Loader、Mutation 和 Webhook Handler 无需再次查询,直接读取 context.tenantId。TanStack Router 文档 覆盖了环境上下文的约定。

带类型包装的行级作用域

第二个模式防止数据泄露:每条查询都按租户过滤,类型系统将未加作用域的查询变成编译错误:

typescript
// src/lib/tenant-db.ts — 作用域包装器,tenantId 为必填参数
export function tenantDb(tenantId: string) {
  return {
    projects: {
      list: () => db.prepare(
        'SELECT * FROM projects WHERE tenant_id = ? AND archived_at IS NULL ORDER BY created_at DESC'
      ).bind(tenantId),
      insert: (name: string) => db.prepare(
        'INSERT INTO projects (id, tenant_id, name) VALUES (?, ?, ?)'
      ).bind(crypto.randomUUID(), tenantId, name),
    },
  }
}
// 每个 Server Function 调用 tenantDb(context.tenantId)。
// 没有全局的 `db.projects` —— 只有带作用域的包装器。

编译时优势:没有全局的 db.projects 让你忘记。运行时优势:每条到达数据库的查询都已经带上了租户过滤。这就是将数据泄露挡在生产环境外的行级纪律。Cloudflare D1 文档 覆盖了 binding 模型。

多租户上下文中的认证与授权

你必须回答的两个问题

多租户上下文中的认证有两个必须回答的问题——用户是谁,以及他们属于哪个租户。任何一个答错都是安全事故。

根据 Auth0 多租户应用文档,常见形态有三种:(1) 用户只属于一个租户(B2B SaaS 默认),(2) 用户可以在多个租户之间切换(咨询机构),(3) 用户是能看到所有租户的平台管理员(运维视图)。每种形态对应不同的会话结构。

我为形态 (1) 交付的模式

对于形态 (1)——用户只属于一个租户——会话包含 userId、tenantId 和 tenantRole。每个 Server Function 从会话中读取 context.tenantId,验证用户有该租户的访问权限,然后继续。会话在登录时通过将 users 表与 memberships 表 JOIN 来创建。

typescript
// src/lib/auth.ts — 登录返回带 tenantId 的会话
export async function login(email: string, password: string) {
  const user = await db.prepare(
    'SELECT id, password_hash FROM users WHERE email = ?'
  ).bind(email).first<User>()
  if (!user || !await verifyPassword(password, user.password_hash)) {
    throw new Error('Invalid credentials')
  }
  const membership = await db.prepare(
    'SELECT tenant_id, role FROM memberships WHERE user_id = ? LIMIT 1'
  ).bind(user.id).first<Membership>()
  if (!membership) throw new Error('No tenant membership')
  return { userId: user.id, tenantId: membership.tenant_id, role: membership.role }
}

完整的会话结构在登录时一次性创建;之后的每次请求都从会话存储中读取。完整的多租户实现指南见 SaaS 架构 2026 指南 和 D1 生产指南。

性能考量:每租户数据库 vs 共享

共享胜出的地方

共享行级数据库在大多数性能指标上胜出。一条 SQL 查询跨所有租户 JOIN;无扇出、无每租户任务、无聚合管道。月复收入查询只需要对租户查询已用的相同索引做 GROUP BY plan;无需特殊分析管道、无独立仓库、无 ETL 任务。

在我对 Cloudflare D1 的测试中,共享数据库每天约 14 万次请求,分析租户查询的响应时间为 18-35ms。Cloudflare D1 文档 确认了读取延迟特征。目前没有任何每租户数据库在重型分析读取场景下比这更快。

每租户数据库能挣回自己那笔账的地方

当一个租户的数据量远超其他人且写锁争用影响共享数据库时,每租户数据库能挣回自己的那笔账。在我交付过的一个 B2B 平台中,一个客户生成了约 40% 的写流量——将该客户拆分到独立数据库后,其他所有租户的写锁争用下降了 35%。

拆分本身很简单:将该租户的行复制到新数据库、启动独立的 D1 binding、在中间件层将对该租户的请求路由到新数据库。一个周末的工作;反向操作(合并回一个数据库)是重写。从第一天就设计好路由层的可拆分性,即使今天没有任何租户大到需要它。完整 D1 运维手册见 D1 深度指南。

迁移策略:从单租户到多租户

单租户到多租户的迁移

我交付过三次的路径:添加 tenants 表并以稳定 ID(如 original)插入原客户;给每张域表加 tenant_id 并用 original 回填;在每个 Server Function 中用 tenantDb 包装器包住,以配置标志读取原始 ID;构建租户注册流程让新客户获得新 ID;将原 ID 重命名为真实 slug 作为一次性迁移。中等体量应用大约两周。

我犯过两次的错误是试图跳过包装器步骤——在"迁移期间"保留未加作用域的全局 db。每个遗留的未加作用域全局变量都是未来的数据泄露。

切换检查清单

切换是风险最高的时刻。检查清单:

text
1. tenants 表存在,且原客户至少有一行数据
2. 每张域表的 tenant_id 列都有 NOT NULL 约束
3. 每个复合索引都以 tenant_id 开头
4. tenantDb 包装器存在且已接入每个 Server Function
5. tenantDb 是从 src/lib/db.ts 导出的唯一 db 对象
6. 未加作用域的 db 访问在 CI 中通过 grep 测试
7. 原客户可以登录并看到自己的数据
8. 新租户注册流程创建新租户 + 管理员用户
9. Stripe Connect onboarding 路由到新租户
10. 切换标志在生产环境翻转;监控 24 小时

完整迁移手册见 SaaS 架构 2026 指南。多租户账单配套定价分析见 SaaS 定价模型指南。

TanStack Ship 的多租户方案

我是 TanStack Ship 的维护者,所以我有偏见——我会提前说明这个偏见。TanStack Ship 之所以存在,是因为 2024 年我为自己的 B2B 产品需要一个多租户 Starter,而对比表中七款 Boilerplate 没有一款是开箱即用这些模式的。

架构

TanStack Ship 默认交付行级隔离:每张域表都以 tenant_id 开头,每个 Server Function 调用 tenantDb(context.tenantId),每层中间件从 Host 头或路径前缀注入租户上下文。子域名路由(acme.example.com)为默认路由;路径前缀路由(example.com/t/acme/)支持本地开发。

定价明细见定价页。与 ShipFast、Makerkit、Supastarter 和 Turbostarter 的正面交锋对比见对比中心。生产部署的深度多租户手册见多租户 SaaS 架构指南。

实施检查清单

选择多租户 Boilerplate 的七步决策树:

text
1. 你在构建共享基础设施的 B2B SaaS 吗?
   → 如果否,你不需要多租户。跳过本指南。
   → 如果是,继续。

2. 是否有客户要求物理数据隔离(HIPAA、区域数据驻留)?
   → 如果是,从第一天就规划每租户数据库。选 TanStack Ship。
   → 如果否,行级足够。

3. 你接受 Boilerplate 交付单租户、以后再改造吗?
   → 如果是,ShipFast 可以。
   → 如果否,选开箱即用多租户的 Boilerplate。

4. 你需要子域名路由(acme.example.com)吗?
   → 大多数 B2B SaaS 选是。TanStack Ship 和 Next-Forge 支持。

5. 你接受 Supabase RLS 作为隔离层吗?
   → 如果是,Makerkit 或 Supastarter 可以。
   → 如果否,选 TanStack Ship 或 Wasp Open-SaaS。

6. 你需要支持每租户备份/恢复吗?
   → 只有每租户数据库能做到。大多数应用不需要。

7. 你的团队能执行行级纪律(每条查询都加作用域)吗?
   → 如果不能,选 Supabase RLS 或 Stripe Connect 作为安全网。
   → 如果能,行级是正确的默认值。

如果第 1 题答"是"、第 2 题和第 3 题都答"否",答案是 TanStack Ship 的行级隔离。完整定价明细见定价页,与 ShipFast 的正面交锋见 vs-shipfast 对比。更宽泛的架构背景见 SaaS 定价对比 2026 指南。


收尾 CTA: TanStack Ship 默认交付本指南中所有多租户模式——子域名路由、行级隔离和类型化的 tenantDb 包装器。完整模块列表见功能页,与 ShipFast、Makerkit 和 Open-SaaS 的正面交锋见对比页。更宽泛的架构背景见 SaaS 架构 2026 指南 和 D1 生产指南。