saas
SaaS 的最新实践——为生产级软件即服务产品提供多租户、基于使用量的计费、PLG、AI 功能集成、现代可观测性、合规和安全性。
说明
多租户
根据您的用例选择合适的隔离模型:
| 模型 | 描述 | 适用场景 |
|---|---|---|
| 共享模式 | 所有租户共享同一张表;使用 tenant_id 标记行 | 早期阶段、成本敏感、合规要求简单 |
| 按租户模式(Schema-per-tenant) | 每个租户拥有独立的数据库 schema | 中等隔离需求、易于按租户迁移 |
| 按数据库模式(DB-per-tenant) | 每个租户拥有独立的数据库 | 企业级、严格的数据驻留要求、高合规(SOC 2、HIPAA) |
| 混合模式 | 核心数据共享,敏感数据隔离 | 拥有混合层级产品的受监管行业 |
共享模式租户规则:
- 每个查询必须通过
tenant_id进行过滤——通过基础仓库或 ORM 中间件强制执行,而非依赖开发人员自律 - 使用 PostgreSQL 行级安全(RLS) 作为第二道防线:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON orders USING (tenant_id = current_setting('app.current_tenant')::uuid); - 在每个租户作用域的表上为
tenant_id创建索引 - 编写集成测试来验证跨租户数据隔离
- 限定所有对象存储路径:
s3://bucket/{tenantId}/uploads/{fileId} - 永远不要暴露可预测的自增 ID——使用 UUID(v7 支持排序)
订阅与计费
固定费率订阅
- 将计费委托给提供商(Stripe、Paddle、LemonSqueezy)——永远不要在内部实现支付处理
- 仅在数据库中存储
customerId和subscriptionId;永远不要存储卡号 - 通过 webhook 同步状态——将您的数据库视为计费提供商事实的最终一致只读副本:
customer.subscription.created → 激活计划 customer.subscription.updated → 升级 / 降级 / 座位变更 customer.subscription.deleted → 取消 + 开始宽限期 invoice.payment_failed → 催缴:通知 → 限制 → 取消 - 使用 webhook 事件
id作为幂等键——在处理前存储它;如果已存在则跳过 - 存储原始 webhook 负载以用于审计和重放
基于使用量/计量计费(现代默认)
- 实时跟踪使用事件(API 调用、座位数、消耗的 token、存储 GB):
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, { quantity: tokensUsed, timestamp: Math.floor(Date.now() / 1000), action: 'increment', }); - 向客户展示实时用量仪表盘——账单日出现意外会导致流失
- 在每个计费周期内实施软限制(80% 时警告)和硬限制(100% 时阻止)
- 考虑采用免费层级 + 基于使用量的向上销售作为产品驱动增长(PLG)的入门
产品驱动增长(PLG)
- 免费层 / 免费增值 — 让用户在付费前体验价值;设置限制使付费计划明显物有所值
- 价值实现时间(TTV) — 衡量并最小化从注册到第一个有意义操作(“啊哈时刻”)的时间
- 应用内升级提示 — 在用户达到计划限制的上下文时刻显示升级操作,而不是在通用设置页面
- 产品合格线索(PQL) — 根据产品使用信号(不仅仅是演示请求)对用户评分,并转给销售跟进
- 病毒式/协作功能 — 邀请团队成员、分享报告、公开页面——每个动作都是一个分发渠道
AI 功能集成
现代 SaaS 产品需要提供 AI 原生功能。应用以下模式:
AI 助手和副驾驶:
- 使用流式响应(SSE /
ReadableStream),让用户实时看到生成的 token——绝不能让他们等待完整响应 - 始终使 AI 输出可编辑——用户需要能够更正、优化和拥有输出
- 显示置信度信号并引用来源(RAG)以建立信任
检索增强生成(RAG):
- 在隔离的命名空间(Pinecone、pgvector、Qdrant)中存储每个租户的向量嵌入——绝不能混合租户数据
- 写入时嵌入内容;读取时通过余弦相似度查询
- 智能地切分文档(512–1024 token,重叠);当源内容更改时重新嵌入
使用和成本控制:
- 跟踪每个租户的 LLM token 消耗——相应地计费或者限流
- 缓存确定性提示(相同输入 → 相同输出)以降低成本
- 对于分类/分流任务使用更便宜的模型;保留前沿模型用于生成任务
数据隐私:
- 提供数据处理附录(DPA),明确声明租户数据是否用于模型训练
- 提供退出选项;企业客户会要求这一点
功能标志与计划门控
- 在服务层对功能进行门控——客户端隐藏只是 UI 优化,不是安全措施
- 将授权逻辑集中到一个单一的
can(tenant, feature)辅助函数中:function can(tenant: Tenant, feature: Feature): boolean { return tenant.plan.features.includes(feature); } - 使用标志系统(LaunchDarkly、Unleash、GrowthBook 或简单的基于数据库的表)将功能部署与功能发布解耦
- 使用标志的场景:渐进式发布(% 的租户)、测试计划、A/B 测试、终止开关
入职引导
- 最小化 TTV:在 < 5 分钟内引导用户完成第一个有价值的操作
- 使用清单驱动的入职引导流程,带有清晰的进度指示器
- 预填充示例/演示数据,使产品在首次使用时不会感觉空荡荡
- 仅收集最低限度的数据;其他所有内容推迟收集
- 基于产品事件(不仅仅是时间)触发入职引导邮件:注册时欢迎,24 小时后如果未完成第二步则提醒,第一个里程碑时庆祝
- 提供交互式产品导览(而非视频),让用户通过实践学习
可观测性与运维
结构化日志 — 每条日志行必须包含:
{ "level": "info", "msg": "...", "tenantId": "...", "userId": "...", "requestId": "...", "timestamp": "..." }
配备 OpenTelemetry 的分布式追踪:
- 在框架级别进行插桩(Next.js
instrumentation.ts、Express 中间件) - 跨服务边界传播追踪上下文(
traceparent标头) - 导出到您选择的后端(Jaeger、Honeycomb、Datadog、Grafana Tempo)
需要跟踪的每个租户指标:
- 活跃用户(DAU/MAU)、API 调用量、错误率、p50/p95/p99 延迟、功能采用率、存储消耗
告警:
- 对租户级别的异常进行告警——突然的错误率飙升、使用量下降、计费失败
- 按层级定义 SLO:免费(尽力而为)、专业(99.5%)、企业(99.9%)
管理工具(内部):
- 为支持目的模拟租户(审核每次模拟操作)
- 查看审计日志、管理订阅、覆盖功能标志、触发重新入职引导
- 构建健康仪表盘:按租户状态、最近错误、使用趋势
数据、分析与合规
事件跟踪:
- 将结构化产品事件(
user.signed_up、feature.used、subscription.upgraded)发送到数据仓库(Snowflake、BigQuery)或 CDP(Segment、PostHog、RudderStack) - 使用这些事件进行流失预测、同期群分析和 PLG 评分
GDPR/隐私:
- 提供数据导出端点和账户删除端点——GDPR 要求,企业买家也期望
- 记录您的数据保留政策;实施自动清理
- 对营销 cookie 和分析使用同意管理
SOC 2/合规卫生(企业就绪):
- 维护所有改变状态用户操作(谁、做了什么、何时、从哪个 IP)的审计日志
- 实施基于角色的访问控制(RBAC),默认最小权限
- 对所有管理员账户强制要求 MFA;为企业租户提供 SSO(SAML/OIDC)
- 定期进行依赖项漏洞扫描和渗透测试
- 维护漏洞披露政策
数据隔离与安全
- 针对每个 API 调用,根据已验证会话验证
tenant_id——永远不要信任请求体 - 按租户进行速率限制,以防止“吵闹邻居”效应
- 对数据库中的 API 密钥进行哈希处理(bcrypt 或 Argon2);绝不存储明文
- 要求 HTTPS;设置较长时间
max-age的Strict-Transport-Security头 - 会话 cookie 使用
HttpOnly; Secure; SameSite=Lax属性
示例
// 具有 RLS 上下文的租户作用域仓库
class TenantRepository<T> {
constructor(
private readonly tenantId: string,
private readonly db: PrismaClient,
) {}
protected async withTenantContext<R>(fn: () => Promise<R>): Promise<R> {
return this.db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${this.tenantId}, true)`;
return fn();
});
}
async findById(id: string): Promise<T | null> {
return this.withTenantContext(() =>
(this.db as any).findFirst({ where: { id, tenantId: this.tenantId } })
);
}
}
// 具有按租户用量计费的 AI 流式端点
export async function POST(req: Request) {
const session = await getSession(req);
const { prompt } = await req.json();
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
stream: true,
});
let tokensUsed = 0;
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? '';
controller.enqueue(new TextEncoder().encode(text));
tokensUsed += chunk.usage?.completion_tokens ?? 0;
}
controller.close();
// 流完成后记录用量
await meteringService.record(session.tenantId, 'ai_tokens', tokensUsed);
},
});
return new Response(readable, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
// 具有幂等性的 Stripe webhook
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature']!, process.env.STRIPE_WEBHOOK_SECRET!
);
const seen = await db.webhookEvent.findUnique({ where: { stripeEventId: event.id } });
if (seen) return res.sendStatus(200);
await db.webhookEvent.create({ data: { stripeEventId: event.id, payload: event } });
switch (event.type) {
case 'customer.subscription.updated':
await subscriptionService.sync(event.data.object);
break;
case 'customer.subscription.deleted':
await subscriptionService.cancel(event.data.object.metadata.tenantId);
break;
}
res.sendStatus(200);
});