第 9 章:[深度] 多租户架构设计
欢迎来到第九章。到目前为止,我们已经构建了一个单用户系统:用户登录,创建_自己的_项目。但几乎所有成功的 SAAS (Software as a Service) 应用(如 Slack, Notion, Figma)都不是单用户系统,它们是多租户系统。
第 9 章:[深度] 多租户架构设计
欢迎来到第九章。到目前为止,我们已经构建了一个单用户系统:用户登录,创建_自己的_项目。但几乎所有成功的 SAAS (Software as a Service) 应用(如 Slack, Notion, Figma)都不是单用户系统,它们是多租户系统。
一个“租户”(Tenant)通常是一个组织 (Organization)、工作空间 (Workspace) 或团队 (Team)。用户(User)是租户的成员 (Member)。
一个用户可以创建或加入多个租户(比如你同时在公司 A 和个人项目 B 两个 Slack 工作空间中)。SAAS 应用必须在架构层面保证:A 租户的数据(项目、文档、消息)绝对不能被 B 租户的成员看到。
实现这种数据隔离,就是多租户架构设计的核心。
9.1. 选型:独立 Schema vs. 共享数据库 (tenant_id)
你有两种主流方案来实现租户隔离,这很像现实世界中“租房”的两种模式:
方案 A:独立数据库 / Schema (Isolated Tenancy)
- 这是什么? 为每一个新注册的租户(Organization),都在数据库中创建一个全新的、独立的数据库或 Schema (在 Postgres 中,Schema 更轻量)。
- 类比:你给每个租户一栋独立的别墅。
- 优势:
- 极高的数据隔离:数据在物理上是分开的。A 租户的 Bug 绝对不可能意外查询到 B 租户的数据。
- 无“吵闹的邻居”:A 租户的高强度查询不会影响 B 租户的性能。
- 易于备份/迁移:你可以轻易地为某个特定租户备份或迁移数据。
- 劣势:
- 成本高昂:1000 个租户 = 1000 个 Schema。
- 维护噩梦:当你需要修改一个表结构(数据库迁移)时,你必须在所有 1000 个 Schema 上都成功运行一遍。
- 连接复杂:应用需要动态地切换数据库连接或 Schema。
方案 B:共享数据库,tenant_id 过滤 (Shared Tenancy)
- 这是什么? 所有的租户共享同一个数据库,同一套表。在每一个需要隔离的表上(如
projects,documents),都添加一个organization_id(或workspace_id) 字段。 - 类比:你给所有租户在同一栋公寓楼里各自分配一套公寓房。
- 优势:
- 成本低廉、易于维护:只有一个数据库。数据库迁移只需运行一次。
- 易于扩展:添加新租户只是在表中添加新行,几乎零成本。
- 易于聚合数据:你可以轻松地跨所有租户进行分析(例如“我们总共有多少个项目?”)。
- 劣势:
- 隔离依赖应用层:安全完全依赖于你的代码。如果你在查询时忘记添加
WHERE organization_id = ?,你就会立刻造成灾难性的数据泄露。 - “吵闹的邻居”:A 租户的一个超大查询可能会拖慢整个数据库,影响 B 租户。
- 隔离依赖应用层:安全完全依赖于你的代码。如果你在查询时忘记添加
结论:
对于绝大多数 SAAS 应用,尤其是我们的模板项目,方案 B (共享数据库 + tenant_id****) 是现代的标准选择。它提供了成本、性能和可维护性上的最佳平衡点。
我们接下来的挑战,就是如何 100% 严格地执行 tenant_id 过滤,防止数据泄露。
9.2. 实现:在 Middleware 中解析租户上下文
在我们开始查询之前,应用需要知道两件事:
- “你是谁?” (认证, Authentication) ->
userId - “你现在代表哪个租户?” (租户上下文, Tenancy) ->
organizationId
我们在第 8 章通过 better-auth 解决了问题 1。auth() 函数可以告诉我们 session.user.id。
对于问题 2,一个用户可能属于多个组织,我们如何知道他当前正在操作哪一个?
- 方案 1:子域名 (如
tenant-a.saas.com)。Middleware 从hostheader 中解析。 - 方案 2:URL 路径 (如
saas.com/org/tenant-a/dashboard)。Middleware 从pathname中解析。 - 方案 3:客户端 Cookie / Session。用户登录后,选择一个组织,我们将
current_org_id写入一个 Cookie(就像 8.3 节的偏好设置)或session。
在我们的实战项目中,方案 3 (Cookie) 结合方案 2 (URL) 是最灵活的。但无论哪种,Next.js Middleware (src/middleware.ts) 都是解析这个上下文的最佳场所。
// src/middleware.ts (概念)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { auth } from '@/lib/auth'; // 导入我们的 auth 配置
export async function middleware(request: NextRequest) {
const session = await auth(); // 1. 获取会话
const pathname = request.nextUrl.pathname;
// 2. 如果未登录,且访问受保护页面,重定向到登录
if (!session && pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (session && pathname.startsWith('/dashboard')) {
// 3. [租户上下文]
// 在这里,我们应该从 cookie 或 session 中获取用户当前的 'activeOrgId'
// 或者从 pathname (如 /dashboard/[orgId]/...) 中解析
const activeOrgId = request.cookies.get('active_org_id')?.value;
if (!activeOrgId && pathname !== '/dashboard/select-org') {
// 4. 如果用户已登录但没有选定组织,强制他去选择
return NextResponse.redirect(new URL('/dashboard/select-org', request.url));
}
// 5. (高级) 将 orgId 注入请求头,以便 Server Components 访问
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-org-id', activeOrgId);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
}
return NextResponse.next();
}
// ... config matcher ...Middleware 确保了任何进入 /dashboard 的请求都必须有关联的 userId (来自 session) 和 orgId (来自 cookie 或 URL)。
9.3. [代码解析]:分析 tenant_id 模式与服务层隔离
现在我们有了 userId 和 orgId,如何在每一个数据库查询中安全地使用它们?
第 1 步:更新 Schema (****tenant_id 模式)
我们必须在所有需要隔离的资源上添加 organizationId。
// src/db/schema/organizations.ts
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
import { usersTable } from "./users";
// 1. 租户 (Organization) 表
export const organizationsTable = pgTable("organizations", {
id: varchar("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
});
// 2. 多对多 (Many-to-Many) 联结表
// 一个用户可以属于多个组织,一个组织可以有多个用户
export const usersToOrganizationsTable = pgTable("users_to_organizations", {
userId: varchar("user_id").notNull().references(() => usersTable.id, { onDelete: "cascade" }),
organizationId: varchar("organization_id").notNull().references(() => organizationsTable.id, { onDelete: "cascade" }),
// (可以加个 role 字段: 'admin', 'member')
});
// src/db/schema/projects.ts (修改)
import { organizationsTable } from "./organizations";
export const projectsTable = pgTable("projects", {
id: varchar("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
// [关键] 不再是 ownerId,而是 organizationId
// ownerId 只代表创建者,但归属权在组织
organizationId: varchar("organization_id").notNull()
.references(() => organizationsTable.id, { onDelete: "cascade" }),
// (可选) 仍然保留 'created_by_user_id'
// ownerId: varchar("owner_id")...
});第 2 步:实现服务层 (Service Layer) 隔离
这是防止数据泄露的核心策略。
我们永远不应该在 Server Actions 或 API 路由中直接编写 db.select()...。为什么?因为开发者会忘记添加 where(eq(projectsTable.organizationId, orgId))。
取而代之,我们创建一个**“服务层”** (或称 "Repository"),它是唯一允许直接与 db 对话的地方。
// src/data/projects.service.ts
// (注意:这是 .ts 文件,不是 React 组件)
import { db } from "@/db";
import { projectsTable } from "@/db/schema";
import { and, eq } from "drizzle-orm";
// 这是一个安全上下文,从 Server Action 传入
// 保证了调用者必须是已认证且有租户上下文的
type TenantContext = {
userId: string;
orgId: string;
};
/**
* [安全] 根据 ID 获取单个项目,已强制执行租户隔离
*/
export async function getProjectById(ctx: TenantContext, projectId: string) {
// 服务层的核心职责:注入租户 ID
const project = await db.query.projectsTable.findFirst({
where: and(
eq(projectsTable.id, projectId),
eq(projectsTable.organizationId, ctx.orgId) // <--- [安全防线]
)
});
if (!project) {
// 找不到项目,或项目不属于该租户,统一返回 null 或抛出错误
// 绝不泄露 "项目存在但你无权访问" 的信息
return null;
}
return project;
}
/**
* [安全] 获取当前租户的所有项目
*/
export async function getProjectsForCurrentOrg(ctx: TenantContext) {
const projects = await db.select()
.from(projectsTable)
.where(
eq(projectsTable.organizationId, ctx.orgId) // <--- [安全防线]
);
return projects;
}第 3 步:在 Server Action 中使用服务层
现在,我们的 Server Action (见第 6 章) 变得非常干净和安全。它只负责认证和上下文,不碰触原始数据库查询。
// src/actions/project.actions.ts
"use server";
import { auth } from "@/lib/auth";
import { getActiveOrgIdForUser } from "@/data/organizations.service"; // 假设的函数
import { getProjectById } from "@/data/projects.service"; // 导入我们的安全服务
import { createSafeActionClient } from "next-safe-action";
import { z } from "zod";
// ...
const action = createSafeActionClient({
// 1. 中间件:自动获取并验证 Auth 和租户上下文
async middleware() {
const session = await auth();
if (!session?.user?.id) throw new Error("未认证");
// 从 cookie 或数据库获取当前激活的 orgId
const orgId = await getActiveOrgIdForUser(session.user.id);
if (!orgId) throw new Error("未找到激活的组织");
// 2. 将安全上下文 (ctx) 传递给 Action
return { userId: session.user.id, orgId };
},
});
const getProjectSchema = z.object({
projectId: z.string(),
});
/**
* [安全] 一个用于客户端的安全 Action
*/
export const getProject = action(
getProjectSchema,
async ({ projectId }, ctx) => { // <-- ctx 来自中间件
// 3. 调用服务层,传入安全上下文
// 我们 100% 确定这个调用是租户隔离的
const project = await getProjectById(ctx, projectId);
if (!project) {
return { error: "项目未找到" };
}
return { success: true, data: project };
}
);本章总结:
我们通过共享数据库 (tenant_id) 模式奠定了 SAAS 的基础,然后通过服务层 (Service Layer) 模式,构建了一个可维护、可测试且高度安全的数据访问层。
这套 Middleware (上下文) -> Server Action (认证) -> Service Layer (安全查询) 的架构,是你在实际 SAAS 项目中防止数据泄露的最佳实践。