SaaS 架构设计与技术选型
SaaS(Software as a Service)不只是"把软件放到云上"——它是一种商业模式,要求技术架构支撑多租户隔离、订阅计费、弹性扩展、自助服务。用 Next.js 构建 SaaS 有天然优势:SSR 保证 SEO(营销页)、App Router 组织复杂路由(Dashboard)、Server Actions 简化表单处理、Edge 支持全球分发。本章系统讲解从零设计一个 SaaS 产品的架构决策。
1. SaaS 产品的技术需求
1.1 SaaS 与传统软件的区别
| 维度 | 传统软件 | SaaS |
|---|---|---|
| 部署 | 客户本地安装 | 供应商托管 |
| 更新 | 客户手动升级 | 供应商统一更新 |
| 计费 | 一次性购买 | 订阅制(月/年) |
| 用户 | 单租户 | 多租户共享基础设施 |
| 定制 | 按需定制 | 配置化定制 |
| 数据 | 客户自己管理 | 供应商托管 + 备份 |
1.2 SaaS 产品的核心功能模块
| 模块 | 说明 | 技术关注点 |
|---|---|---|
| 营销站 | 首页、定价页、博客 | SEO、性能、转化率 |
| 认证系统 | 注册、登录、SSO | 安全、第三方登录 |
| 租户管理 | 组织/团队/成员 | 多租户隔离 |
| 计费系统 | 订阅、付费、发票 | Stripe 集成、Webhook |
| 核心功能 | 产品的核心价值 | 业务逻辑 |
| 管理后台 | 用户管理、数据分析 | 权限控制 |
| API | 开放 API | 限流、文档、版本化 |
| 通知系统 | 邮件、应用内通知 | 异步处理 |
1.3 SaaS 的非功能需求
| 需求 | 指标 | 说明 |
|---|---|---|
| 可用性 | 99.9%+ | 每月停机 < 43 分钟 |
| 延迟 | P99 < 1s | 全球用户 |
| 安全 | SOC 2 / GDPR | 企业客户要求 |
| 扩展性 | 10x 增长无需重构 | 支撑业务增长 |
| 隔离 | 租户数据不互通 | 安全 + 合规 |
| 审计 | 操作日志 | 企业客户要求 |
2. 技术栈选型
2.1 Next.js SaaS 技术栈(2025)
| 层级 | 推荐方案 | 备选 |
|---|---|---|
| 框架 | Next.js App Router | - |
| 语言 | TypeScript | - |
| 样式 | Tailwind v4 + shadcn/ui | - |
| 认证 | Clerk / Better Auth / NextAuth | Supabase Auth |
| 数据库 | PostgreSQL(Neon / Supabase) | PlanetScale |
| ORM | Drizzle ORM | Prisma |
| 计费 | Stripe | Lemon Squeezy |
| 邮件 | Resend | SendGrid |
| 文件存储 | Uploadthing / S3 | Cloudflare R2 |
| 队列 | Inngest / Trigger.dev | BullMQ |
| 监控 | Sentry + Vercel Analytics | Datadog |
| 部署 | Vercel | Docker + AWS |
2.2 选型决策因素
| 因素 | 权重 | 说明 |
|---|---|---|
| 开发速度 | 🔴 高 | SaaS 需要快速迭代 |
| 成本 | 🔴 高 | 早期收入有限 |
| 可扩展性 | 🟡 中 | 先做出产品再优化 |
| 团队熟悉度 | 🔴 高 | 不要为了新技术拖慢进度 |
| 社区支持 | 🟡 中 | 遇到问题能找到答案 |
| 锁定风险 | 🟡 中 | 避免过度依赖单一供应商 |
2.3 认证方案对比
| 方案 | 类型 | 多租户 | 价格 | 推荐场景 |
|---|---|---|---|---|
| Clerk | 托管 | ✅ 内置组织 | 免费层 + 按用户 | 快速开发、需要组织功能 |
| Better Auth | 自托管 | ✅ 插件 | 免费 | 完全控制、自定义需求 |
| NextAuth (Auth.js) | 自托管 | ⚠️ 需自建 | 免费 | 简单认证场景 |
| Supabase Auth | 托管 | ⚠️ 需自建 | 随 Supabase | 使用 Supabase 全家桶 |
2.4 ORM 选型
| ORM | 类型 | Edge 支持 | 迁移 | 推荐场景 |
|---|---|---|---|---|
| Drizzle | SQL-first | ✅ | SQL 迁移文件 | 性能敏感、喜欢 SQL |
| Prisma | Schema-first | ⚠️ 需要 Accelerate | Prisma Migrate | 快速原型、类型安全 |
| Kysely | Query Builder | ✅ | 手动 | 极致性能控制 |
3. 多租户架构
3.1 多租户的三种模型
| 模型 | 数据隔离 | 成本 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 共享数据库 + 租户列 | 行级隔离 | 最低 | 低 | 大部分 SaaS |
| Schema 隔离 | Schema 级隔离 | 中 | 中 | 中型 SaaS |
| 独立数据库 | 完全隔离 | 最高 | 高 | 企业级 / 合规要求 |
3.2 共享数据库 + 租户列(推荐起步方案)
users 表:
id | email | tenant_id | role
1 | a@t1 | tenant_1 | admin
2 | b@t1 | tenant_1 | member
3 | c@t2 | tenant_2 | admin
projects 表:
id | name | tenant_id
1 | Proj A | tenant_1
2 | Proj B | tenant_2
每个查询都必须带 tenant_id 过滤——这是安全的基础。
风险:如果某个查询忘了加 WHERE tenant_id = ?,会导致数据泄漏。
解决方案:
| 方案 | 说明 |
|---|---|
| ORM 中间件 | Drizzle/Prisma 的全局过滤器自动注入 tenant_id |
| 数据库 RLS | PostgreSQL 行级安全策略(Row Level Security) |
| 代码审查 | PR Review 检查所有查询是否带 tenant_id |
| 集成测试 | 测试跨租户数据是否隔离 |
实践:封装租户安全查询——所有数据访问都经过 withTenant() 函数,从根源杜绝遗漏:
// lib/db/tenant.ts
import { eq } from 'drizzle-orm'
import { db } from './index'
import { projects } from './schema'
import { getCurrentTenantId } from '@/lib/auth'
// 核心原则:绝不暴露不带 tenant_id 的查询
export async function withTenant<T>(
queryFn: (tenantId: string) => Promise<T>
): Promise<T> {
const tenantId = await getCurrentTenantId()
if (!tenantId) throw new Error('Tenant context required')
return queryFn(tenantId)
}
// 使用示例:所有业务查询都必须走 withTenant
export async function getProjects() {
return withTenant((tenantId) =>
db.select().from(projects).where(eq(projects.tenantId, tenantId))
)
}
export async function createProject(name: string) {
return withTenant((tenantId) =>
db.insert(projects).values({ name, tenantId }).returning()
)
}3.3 PostgreSQL RLS
PostgreSQL 的 RLS(Row Level Security)在数据库层面强制租户隔离——即使应用代码忘了加过滤条件,数据库也会自动过滤。
实践:配置 RLS 策略:
-- 1. 启用 RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- 2. 创建策略:只能访问当前租户的数据
-- current_setting('app.tenant_id') 由应用层在每个请求开始时设置
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- 3. 对 INSERT 同样限制,防止插入到其他租户
CREATE POLICY tenant_insert ON projects
FOR INSERT WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);应用层配合——每个请求开始时设置租户上下文:
// lib/db/rls.ts
import { sql } from 'drizzle-orm'
import { db } from './index'
export async function withRLS<T>(tenantId: string, fn: () => Promise<T>) {
// set_config 第三个参数 true = 仅当前事务有效,请求结束自动清除
await db.execute(sql`SELECT set_config('app.tenant_id', ${tenantId}, true)`)
return fn()
}
// 在 Server Action 中使用
export async function getProjects() {
const tenantId = await getCurrentTenantId()
return withRLS(tenantId, () =>
db.select().from(projects) // 不需要 WHERE——RLS 自动过滤!
)
}优势:安全性最高——隔离逻辑在数据库层面,应用层无法绕过。 劣势:调试复杂——查询结果可能因 RLS 过滤而"丢失"数据。
3.4 租户标识策略
| 方式 | 示例 | 优势 | 劣势 |
|---|---|---|---|
| 子域名 | tenant.app.com | 完全隔离感 | SSL、DNS 配置复杂 |
| 路径 | app.com/tenant/... | 简单 | 不够专业 |
| 自定义域名 | custom.com | 白标完美 | 最复杂(SSL、DNS) |
| 请求头 / Cookie | 隐式 | 简单 | 用户无感知 |
推荐:子域名(workspace.app.com)——Notion、Slack、Linear 都用这种模式。
实践:用 Middleware 解析子域名租户:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || ''
const baseDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN! // 'example.com'
// 提取子域名:workspace.example.com → 'workspace'
const subdomain = hostname.replace(`.${baseDomain}`, '').split('.')[0]
// 排除主域名和特殊子域名
const isSubdomain =
subdomain && subdomain !== 'www' && hostname !== baseDomain
if (isSubdomain) {
// 将 tenant slug 写入请求头,后续 Server Component 可通过 headers() 读取
const headers = new Headers(request.headers)
headers.set('x-tenant-slug', subdomain)
return NextResponse.next({ request: { headers } })
}
return NextResponse.next()
}
export const config = { matcher: ['/((?!api|_next/static|favicon.ico).*)'] }Server Component 中读取租户:
// lib/auth/tenant.ts
import { headers } from 'next/headers'
import { db } from '@/lib/db'
import { tenants } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
import { cache } from 'react'
// cache() 保证同一个请求内只查一次数据库
export const getCurrentTenant = cache(async () => {
const headerList = await headers()
const slug = headerList.get('x-tenant-slug')
if (!slug) return null
const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, slug))
return tenant ?? null
})4. 目录结构设计
4.1 推荐的 Next.js SaaS 目录结构
app/
├── (marketing)/ ← 营销站(公开页面)
│ ├── page.tsx ← 首页
│ ├── pricing/page.tsx ← 定价页
│ ├── blog/ ← 博客
│ └── layout.tsx ← 营销站 Layout
├── (auth)/ ← 认证相关
│ ├── login/page.tsx
│ ├── signup/page.tsx
│ └── layout.tsx
├── (dashboard)/ ← 已登录用户的主界面
│ ├── layout.tsx ← Dashboard Layout(侧边栏)
│ ├── page.tsx ← Dashboard 首页
│ ├── projects/ ← 项目管理
│ ├── settings/ ← 设置
│ └── billing/ ← 计费
├── api/ ← API Routes
│ ├── webhooks/stripe/ ← Stripe Webhook
│ └── v1/ ← 公开 API
└── layout.tsx ← 根 Layout
4.2 Route Groups 的作用
| Route Group | 用途 | Layout |
|---|---|---|
(marketing) | 公开营销页面 | 导航栏 + Footer |
(auth) | 认证页面 | 居中卡片 |
(dashboard) | 已登录用户 | 侧边栏 + 顶栏 |
Route Groups 不影响 URL——(marketing)/pricing/page.tsx 的 URL 仍然是 /pricing。
4.3 业务代码组织
lib/
├── db/
│ ├── schema.ts ← Drizzle Schema 定义
│ ├── index.ts ← 数据库连接
│ └── migrations/ ← 迁移文件
├── auth/
│ └── index.ts ← 认证配置
├── stripe/
│ └── index.ts ← Stripe 配置
├── email/
│ └── index.ts ← 邮件发送
└── utils/
└── index.ts ← 通用工具函数
components/
├── ui/ ← shadcn/ui 组件
├── marketing/ ← 营销站组件
├── dashboard/ ← Dashboard 组件
└── shared/ ← 共享组件
5. 数据模型设计
5.1 核心表
| 表 | 说明 | 关键字段 |
|---|---|---|
users | 用户 | id, email, name |
tenants | 租户/组织 | id, name, slug, plan |
memberships | 用户-租户关系 | user_id, tenant_id, role |
subscriptions | 订阅 | tenant_id, stripe_subscription_id, status |
invoices | 发票 | tenant_id, amount, status |
实践:Drizzle Schema 定义:
// lib/db/schema.ts
import { pgTable, uuid, text, timestamp, pgEnum } from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'
export const roleEnum = pgEnum('role', ['owner', 'admin', 'member', 'viewer'])
export const planEnum = pgEnum('plan', ['free', 'pro', 'enterprise'])
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: text('email').notNull().unique(),
name: text('name'),
avatarUrl: text('avatar_url'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const tenants = pgTable('tenants', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(), // 子域名标识
plan: planEnum('plan').default('free').notNull(),
stripeCustomerId: text('stripe_customer_id'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const memberships = pgTable('memberships', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
tenantId: uuid('tenant_id').references(() => tenants.id, { onDelete: 'cascade' }).notNull(),
role: roleEnum('role').default('member').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
// 关系定义——让 Drizzle 的 query API 支持 JOIN
export const usersRelations = relations(users, ({ many }) => ({
memberships: many(memberships),
}))
export const tenantsRelations = relations(tenants, ({ many }) => ({
memberships: many(memberships),
}))
export const membershipsRelations = relations(memberships, ({ one }) => ({
user: one(users, { fields: [memberships.userId], references: [users.id] }),
tenant: one(tenants, { fields: [memberships.tenantId], references: [tenants.id] }),
}))5.2 关系模型
User ←──┐
│ Membership(role: admin/member/viewer)
Tenant ←┘
│
├── Subscription(Stripe 订阅)
├── Projects(业务数据)
└── Settings(租户配置)
关键设计决策:用户和租户是多对多关系——一个用户可以属于多个组织(Slack 模式),通过 memberships 表关联。
6. 部署与基础设施
6.1 早期架构(MVP)
Vercel(Next.js 应用)
↓
Neon(PostgreSQL,Serverless)
↓
Stripe(计费)+ Resend(邮件)+ Clerk(认证)
成本:0-50 美元/月(足够支撑前 1000 个用户)
6.2 增长期架构
Vercel Pro
├── Next.js 应用
├── Edge Middleware(认证 + 路由)
└── Cron Functions(定时任务)
↓
Neon Pro / Supabase
├── PostgreSQL + 连接池
├── Row Level Security
└── 自动备份
↓
Inngest / Trigger.dev
├── 后台任务
├── Webhook 处理
└── 定时任务
6.3 成本估算
| 阶段 | 用户数 | 月成本 | 主要支出 |
|---|---|---|---|
| MVP | < 100 | $0-20 | 免费层覆盖 |
| 早期 | 100-1K | $50-200 | Vercel Pro + 数据库 |
| 增长 | 1K-10K | $200-1000 | 数据库 + 认证 + 基础设施 |
| 规模化 | 10K+ | $1000+ | 需要评估自建 vs 托管 |
7. 快速启动方案
7.1 SaaS Starter Kit 对比
| Starter | 技术栈 | 价格 | 特点 |
|---|---|---|---|
| next-saas-starter | Next.js + Drizzle + Stripe | 免费 | Vercel 官方,轻量 |
| Taxonomy | Next.js + Prisma + Stripe | 免费 | 功能完整 |
| Shipfast | Next.js + Supabase + Stripe | $199 | 文档好,模板多 |
| Makerkit | Next.js + Supabase/Firebase | $299 | 多租户内置 |
| Supastarter | Next.js/Nuxt + Supabase | $299 | 支持多框架 |
7.2 Build vs Buy 决策
| 组件 | 自建 | 购买/使用服务 | 建议 |
|---|---|---|---|
| 认证 | 复杂、安全风险 | Clerk($0-25/月) | 买 |
| 计费 | Stripe 已经做好了 | Stripe | 用 |
| 邮件 | 送达率难保证 | Resend($0-20/月) | 买 |
| 核心功能 | 你的竞争优势 | - | 自建 |
| UI 组件 | 耗时 | shadcn/ui(免费) | 用 |
| 部署 | 运维负担 | Vercel | 买(早期) |
本章小结
- SaaS 本质:多租户 + 订阅计费 + 自助服务,技术架构要支撑这三个核心
- 技术栈:Next.js + TypeScript + Tailwind + shadcn/ui + Drizzle + Neon + Stripe + Clerk
- 多租户:共享数据库 + 租户列是最佳起步方案,PostgreSQL RLS 保障隔离安全
- 目录结构:Route Groups 分离营销站 / 认证 / Dashboard 三套 Layout
- 数据模型:User-Tenant 多对多关系,Membership 表管理角色
- 部署成本:MVP 阶段 $0-50/月,利用免费层快速验证
- Build vs Buy:核心功能自建,认证/计费/邮件用服务,不要重复造轮子