返回探索
指南VibeFix 编辑部更新于 2026年10月11日

从单人工具到团队生意:vibe 项目的多租户隔离架构实战

第二个客户是 vibe 项目的成人礼:单租户代码里藏着全局单用户、硬编码配置、无租户边界三个隐性假设,一碰就塌。本文给出三种隔离模型的六维决策表(共享表/Schema-per-tenant/DB-per-tenant),手把手落地 Postgres RLS(三件套:Next.js 中间件解析租户、CREATE POLICY 行级策略、Prisma 扩展自动过滤),对比子域名/路径前缀/自定义域名三种路由,附 10 项跨租户泄露负向测试清单、5 步不停机迁移 SOP(含每步回滚点),以及按租户计量用量的最小实现。

多租户隔离架构示意图:多个租户通过中间件解析后,经行级安全策略访问共享数据库中各自的数据分区

我第一个付费客户签下来的时候,整个人是飘的:一个人、几个周末搭出来的小工具,居然有人愿意按月付 29 美元。第二个客户是朋友介绍的,我花了 10 分钟给他开了个账号——同一套数据库,只是多建了一个 user_id。第三个客户签约那天,我做了一个这辈子最后悔的决定:为了"快",直接把新客户的数据也塞进了同一张表,反正"先跑起来再说"。

爆雷发生在一个周二的凌晨。客户 A 在后台点了"导出全部数据",下载下来的 CSV 里躺着客户 B 的 40 多条订单记录。原因蠢到离谱:导出接口是我最早写的,where 条件里压根没加 user_id。更惨的是,这不是一个 bug,是架构——我的代码里到处都是"当前只有一个客户"的隐性假设,第二个客户一进来,这些假设一个接一个地塌。修导出接口花了 20 分钟,但接下来两周,我 grep 出了 47 处同样裸奔的查询、3 套硬编码的第三方密钥、1 个全局单例的"当前用户"。

多租户隔离不是大公司的专利,它是 vibe 项目从"单人玩具"变成"团队生意"的成人礼。这篇把我重构时踩过的坑一次性讲透:三种隔离模型怎么选、Postgres RLS 怎么落地、单租户老代码怎么迁、计费怎么按租户计量。先划清一句边界:如果你看过本站那篇 RBAC 权限指南——那篇讲的是同一个租户里"谁能做什么",这篇讲的是租户之间"数据如何互相看不见",两者是上下游,不重叠。

三种多租户隔离模型对比架构示意图:共享表、独立 schema、独立库

一、为什么 vibe 项目死在"第二个客户":单租户代码的 3 个隐性假设

第一个客户永远不会暴露架构问题,因为 n=1 时任何隔离 bug 都是不可见的。第二个客户带来的不是 2 倍复杂度,是 N² 的交叉污染面。下面三个假设,vibe 阶段几乎人人中招:

假设 1:全局单用户——"当前用户"是一个单例

典型写法:一个全局的 getCurrentUser(),所有查询默认"查我的"。单人用时天衣无缝;第二个客户进来后,"我的项目"页面直接变成"所有人的项目"。更隐蔽的变体是把 user_id 当 tenant_id 用:客户公司的员工离职了、账号转交了,数据归属瞬间错乱——人会流动,租户才是稳定的计费与归属主体。

判断句:user_id 回答"谁在操作",tenant_id 回答"账算在谁头上",这是两列,不是一列。重构时第一件事,就是把所有"按用户隔离"的查询改成"按租户隔离",用户只解决登录态,不解决数据归属。

假设 2:硬编码配置——全站共用一份密钥

Stripe Key、OpenAI Key、邮件发件域名、Webhook Secret,全塞在 .env 里一份。单客户时没问题;第二个客户说"我要用自己的 Stripe 收款",你会发现配置层根本没有"按租户覆盖"的概念。更惨的是通知模板:A 客户想叫"工作台",B 客户想叫"控制台",硬编码的文案让你改一行惊动全站。

我的建议是:凡是"将来可能每个客户都不一样"的东西,第一天就放进 tenants 表或 tenant_settings 表。密钥、域名、品牌色、功能开关,全部按租户存。环境变量只放"整个应用都一样"的东西(比如数据库连接串)。这个习惯的成本是建表时多想 5 分钟,收益是第二个客户进来时零重构。

假设 3:无租户边界——查询默认查全表,靠前端过滤

这是爆雷重灾区。列表页、搜索、导出 CSV、后台定时任务、管理员看板——所有"顺手写"的查询默认都是全表。前端只渲染当前用户的数据,看起来一切正常,直到有人调了 API、导出了文件、或者搜索引擎爬到了未鉴权的分享链接。记住一句话:前端过滤是 UI 便利,不是安全边界;真正的边界必须在数据库层。

自查方法:全局搜 findMany( / SELECT * FROM,逐个看 where 里有没有租户条件。我当时的 47 处里,有 31 处是"以为加了其实没加"——比如加了 userId 但没加 tenantId,跨团队协作的场景直接穿透。

二、三种隔离模型决策表:6 维度打分,一人团队闭眼选第一个

多租户隔离就三种主流做法,没有银弹。我按 6 个维度打了分,★ 在成本类维度代表"代价越高",在能力类维度代表"能力越强":

隔离模型              新增租户成本  实现复杂度  运维负担  合规/数据驻留  故障隔离能力  后期迁移难度
────────────────────  ────────────  ──────────  ────────  ────────────  ────────────  ────────────
共享表(tenant_id 列)      ★           ★★        ★         ★★           ★           ★★★★
Schema-per-tenant        ★★          ★★★       ★★★        ★★★          ★★★          ★★
DB-per-tenant           ★★★★        ★★★★      ★★★★★      ★★★★★        ★★★★★        ★

说明:前三列 ★ 越多=越贵越难;合规/故障隔离 ★ 越多=能力越强;迁移难度 ★ 越多=越难迁走。
  • 共享表 + tenant_id(行级隔离):所有租户共用一套表,每行带 tenant_id。新增租户成本几乎为零(一行 tenants 记录),代价是每次查询都要带租户条件,写漏一处就是泄露。适合 95% 的 vibe 项目起步。
  • Schema-per-tenant:每个租户一个 Postgres schema,表结构相同。天然隔离,备份恢复可以按租户做;代价是 migration 要对 N 个 schema 跑一遍,连接池和 ORM 配置复杂度陡增。适合租户数在几十到几百、且有合规要求的阶段。
  • DB-per-tenant:每个租户独立数据库。故障 blast radius 最小(一个租户的慢查询拖不死别人),数据驻留合规最好做;代价是运维地狱——备份、监控、升级全部乘以租户数。适合客单价上万、有专属合规合同的大客户。

一人团队默认选型:共享表 + Postgres RLS,一个都别多想。什么时候升级?出现以下任一信号再动:① 客户合同明确要求物理隔离或数据必须落在特定区域;② 单租户数据量超过约 100GB,或大租户的慢查询已经影响到别人;③ 有大客户愿意为独立数据库付 3-5 倍的价格。三个信号都没出现就别折腾——过早做 DB-per-tenant,是独立开发者最常见的"用运维复杂度给自己加戏"。

反模式预警:我见过有人为了"架构优雅",在只有 3 个客户时上了 DB-per-tenant,结果每周花半天给 3 个库跑 migration,vibe 全耗在运维上。记住:隔离模型的强度应该和客单价成正比,和你的架构审美无关。

三、Postgres RLS 落地:中间件 + 策略 + Prisma 扩展,三件套缺一不可

选了共享表模型,隔离就靠三层:中间件解析租户 → 数据库 RLS 强制隔离 → ORM 自动附加过滤。RLS(Row Level Security)是最后一道防线——即使应用层代码写漏了,数据库直接拒绝返回别人的行。下面代码可直接复制,按注释改表名和字段即可。

1. Next.js 中间件:从子域名/session 解析租户并注入上下文

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';

export async function middleware(req: NextRequest) {
  const res = NextResponse.next();
  // 1) 优先从子域名解析:acme.example.com -> acme
  const host = req.headers.get('host') ?? '';
  const subdomain = host.split('.')[0];
  const reserved = new Set(['www', 'app', 'api', 'localhost']);
  // 2) 兜底从 session/JWT 取(路径前缀路由、本地开发、自定义域名场景)
  const token = await getToken({ req });
  const tenantSlug = !reserved.has(subdomain) && subdomain
    ? subdomain
    : (token?.tenantSlug as string | undefined);
  if (!tenantSlug) return new NextResponse('Unknown tenant', { status: 404 });

  // 3) 查 tenants 表拿租户 id(生产环境务必加缓存,见注释)
  // 注意:Edge Runtime 直连数据库受限,建议把 tenantId 写进 JWT,
  // 这里只做 slug -> id 的缓存查询,避免每次请求打数据库
  const tenant = await lookupTenantBySlug(tenantSlug);
  // 4) 注入请求头:后端只认这个头,不信任前端传参里的 tenantId
  res.headers.set('x-tenant-id', tenant.id);
  return res;
}

export const config = { matcher: ['/api/:path*', '/dashboard/:path*'] };

坑在两处:① 永远不要信任前端传的 tenantId 参数,租户身份只能从子域名、session 或签名过的 JWT 里来,否则就是越权的直通车;② Edge Runtime 下别直连 Postgres,用 JWT 携带 tenantId 或带缓存的 lookup,否则冷启动能把你 P99 干到 2 秒。

请求链路租户上下文注入流程示意图

2. RLS 策略:数据库层的最后一道墙

-- 1) 业务表加租户列(已有表用可空列 + 回填,见第六节迁移 SOP)
ALTER TABLE projects ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenants(id);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);

-- 2) 开启行级安全:开了之后,没有匹配策略的查询一律返回 0 行
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

-- 3) 策略:读写都只能碰当前租户的行
CREATE POLICY tenant_isolation ON projects
  FOR ALL
  USING (tenant_id = current_setting('app.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

-- 4) 每个请求开始时注入上下文(事务级:SET LOCAL,事务结束自动清除)
-- 在你的 db 中间件 / 请求入口执行:
-- SET LOCAL app.tenant_id = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx';

三个实测要点:① FOR ALL 一把梭最省心,SELECT/INSERT/UPDATE/DELETE 全覆盖,别分开写四个 policy 给自己挖坑;② 索引必须建在 tenant_id 上,否则 RLS 的过滤条件走全表扫描,数据量一大 P99 直接爆炸;③ 用 pgBouncer transaction 模式的注意:SET LOCAL 是事务级的,事务结束自动清除,正好适配连接池复用——但千万别用 SET(会话级),连接被复用给下个租户时上下文还残留着,那就是官方认证的泄露通道。

3. Prisma Client 扩展:让"忘记加 tenantId"在编译期就 impossible

光靠 RLS 不够——RLS 报错是运行时 0 行返回,排查时你根本不知道是"真没数据"还是"策略拦了"。应用层再加一道自动过滤,开发体验好得多:

// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
import { AsyncLocalStorage } from 'node:async_hooks';

// 请求级租户上下文:中间件里从 x-tenant-id 取出塞进来
export const tenantStore = new AsyncLocalStorage<{ tenantId: string }>();

const base = new PrismaClient();

export const prisma = base.$extends({
  name: 'tenant-isolation',
  query: {
    $allModels: {
      async $allOperations({ model, operation, args, query }) {
        const ctx = tenantStore.getStore();
        // 白名单:tenants 表本身、全局配置表等不需要租户过滤
        const skip = new Set(['Tenant', 'GlobalSetting']);
        if (ctx && !skip.has(model!) && args && typeof args === 'object' && 'where' in args) {
          (args as any).where = { ...((args as any).where ?? {}), tenantId: ctx.tenantId };
        }
        return query(args);
      },
    },
  },
});
// 用法:await tenantStore.run({ tenantId }, () => handler(req));

记住这个分工:Prisma 扩展管"别写错"(开发期省心),RLS 管"写错了也漏不出去"(生产期保命)。两道都配齐,才算隔离真正落地。另外聚合查询(count/groupBy)和原生 SQL($queryRaw)不会经过这个扩展——凡是手写 SQL 的地方,自己拼 tenant_id 条件,这是 code review 时的必查项。

四、租户路由取舍:子域名 vs 路径前缀 vs 自定义域名

租户路由决定用户怎么访问、也决定中间件怎么写。三选一,没有"最好",只有"最配你当前阶段":

  • 子域名(acme.example.com):隔离感最强,用户觉得"这是我的独立后台";中间件解析最自然(host.split 取第一段)。代价是通配符证书和 DNS 配置。适合正式对外、有品牌感要求的 SaaS,我的默认推荐。
  • 路径前缀(example.com/acme):零 DNS 成本,本地开发最省事;代价是所有链接、重定向、OAuth 回调都要带前缀,漏一处就 404,且用户感知上"寄人篱下"。适合 MVP 验证期、或租户数极少(<5 个)的内测阶段。
  • 自定义域名(app.acme.com):大客户最爱,合同里能写"专属域名";代价是每个域名都要做域名验证 + 证书签发(CNAME 接入 + ACME DNS-01 挑战),还要处理证书续期失败的告警。这是付费增值项,不是默认项。

通配符证书配置清单(子域名方案一次配好,直接照抄):

□ 1. DNS:添加 A/ALIAS 记录 *.example.com 指向你的服务器或 LB
□ 2. 证书:用 DNS-01 挑战签发 *.example.com(HTTP-01 搞不定通配符),
     Let's Encrypt / ZeroSSL 都行,有效期 90 天
□ 3. 自动续期:cron 跑 certbot renew,每周一次;续期失败发告警到你手机,
     别等用户报"证书过期"才发现
□ 4. 本地开发:/etc/hosts 加 127.0.0.1 acme.localhost(现代浏览器支持
     *.localhost 免配 hosts,Chrome/Firefox 均可)
□ 5. 中间件保留字:www/app/api/status/docs 等子域名走公共逻辑,
     别被当成租户 slug 查库查出 404
□ 6. Cookie 域:登录态 Cookie 的 Domain 设为 .example.com,
     否则用户在 acme.example.com 登录后跳到 app.example.com 又要重登

坑在 Cookie 域上——我当初漏了第 6 项,用户每次从子域名跳回主站都要重新登录,被当成 bug 报了三次才定位到。以及自定义域名一定要做域名所有权验证(让客户加一条指定 TXT 记录),否则任何人把 app.evil.com CNAME 到你服务器,就能冒充租户。

五、跨租户数据泄露:10 项负向测试清单(上线前逐条跑)

隔离做没做对,不能靠"我觉得",要靠负向测试——专门证明"不该看到的就是看不到"。下面 10 条直接复制进你的测试文件,条条都是我或同行真实踩过的坑:

□ T1 越权读:用租户 A 的 token,GET /api/projects/<租户B的资源id>,
     期望 404(注意是 404 不是 403——403 会暴露"这个 id 存在")
□ T2 越权写:用租户 A 的 token,PATCH /api/projects/<租户B的资源id>,
     期望 404,且数据库里该行 updated_at 未变化
□ T3 列表穿透:用租户 A 的 token,GET /api/projects(不带任何过滤),
     断言返回条数 == 租户 A 的真实条数,且每条 tenant_id 都等于 A
□ T4 搜索穿透:在搜索框输入租户 B 的独有关键词(如对方公司名),
     期望 0 结果(搜索是最容易漏加租户条件的重灾区)
□ T5 导出穿透:用租户 A 的 token 调"导出全部数据",解压 CSV,
     断言无任何 tenant_id != A 的行(开篇的事故就是这条没测)
□ T6 缓存串味:租户 A 访问 /api/dashboard 后,租户 B 用相同 URL 访问,
     断言拿到的是 B 自己的数据(缓存 key 必须带租户前缀,
     如 dashboard:{tenantId}:summary)
□ T7 后台任务串租户:跑一遍定时任务(如每日汇总邮件),
     断言任务日志里每个租户的上下文切换正确,无"用 A 的配置给 B 发邮件"
□ T8 管理员越界:用租户 A 的管理员 token 访问 /api/admin/tenants,
     期望 403——租户管理员不是平台管理员,跨租户管理走独立的
     平台后台 + 操作审计日志
□ T9 分享链接:把租户 A 的"公开分享链接"发给未登录浏览器打开,
     断言只能看到该分享的单条资源,看不到 A 的其他数据;
     再把租户 B 的资源 id 拼进分享 URL,期望 404
□ T10 删除租户:删除测试租户后,用其旧 token 调任何接口,
     期望 401;且该租户数据在 30 天冷冻期后物理删除(或按合同匿名化),
     期间任何查询不可见

执行建议:T1-T5 进 CI,每次部署自动跑;T6-T10 每月手动跑一遍(缓存和定时任务的行为容易随重构漂移)。记住一句话:负向测试的价值不在"通过",在"第一次失败时拦住了一次泄露事故"。我现在的 CI 里这 10 条跑了大半年,拦下过两次真实的回归。

六、单租户→多租户:5 步迁移 SOP(含每步回滚点)

已经有线上数据和付费客户了,怎么不停机迁?五步走,每步都有明确的回滚点——没有回滚点的迁移计划就是赌博。

第 1 步:加 tenant_id 列,回填默认租户

所有业务表加 tenant_id uuid 可空列,建一个"默认租户"(就是你现有的那个客户),把全量老数据回填上。此时代码完全不动,只是多了个列。回滚点:DROP COLUMN,一分钟恢复。数据量大的表分批回填(每批 1 万行),别一个 UPDATE 锁全表把线上打挂。

第 2 步:代码双写 + 读路径加过滤(Feature Flag 控制)

新写入强制带 tenant_id;读路径通过第三节的 Prisma 扩展自动附加过滤,但用 flag 包起来(if (flags.multitenancy) ...)。先在 staging 全开,生产保持关闭,观察一周。回滚点:关 flag,代码回到老路径,零数据变更。这一步最磨人:逐个过那 47 处裸查询,漏一处就是未来的 T3 失败。

第 3 步:打开 RLS,先审计模式再强制模式

先建 policy 但配成"宽松 + 日志":用一个只记录不拦截的触发器/审计表,对比"RLS 会拦掉的行"和"应用层返回的行"是否一致,跑 3-7 天。差异为 0 后,再把 policy 切到强制模式。回滚点:DROP POLICY,数据库行为瞬间回到从前。坑:记得给 postgres 超级用户和 migration 角色 BYPASSRLS,否则你自己的 migration 会被策略拦住,半夜部署直接卡死。

第 4 步:灰度切流量——先内部租户,再 5% 真实客户

建一个内部测试租户,走完整的新链路(子域名 + RLS + 计费打点)跑两周;然后按 tenants 表的 id % 20 == 0 灰度 5% 客户,盯三个指标:P99 延迟、错误率、T1-T5 负向测试。回滚点:把灰度租户的 flag 关掉,切回老链路;数据层 tenant_id 列还在,不影响。灰度期间每天看一次 RLS 审计日志,确认拦截的全是"真的越权"而不是"误伤"。

第 5 步:tenant_id 设 NOT NULL,清理老代码

全量切完稳定两周后,把列改成 NOT NULL,删掉 feature flag 和老查询路径。但别急着 DROP 旧列/旧表:保留 30 天"墓碑期",每天备份一次;30 天后无异常再物理清理。回滚点:30 天内的任何一天,把备份表恢复、flag 重新打开即可。

整个 SOP 的核心就一句话:每一步都让"新旧两条路同时能走",直到新路被证明没问题,老路才拆。迁移期间多花的存储和代码分支,是你能买到的最便宜的保险。

七、计费映射:按租户计量用量的最小实现

多租户的终点是收钱:每个租户用了多少 API、多少 AI token、多少存储,直接决定账单。最小实现三件套——中间件打点 + Redis 计数 + 定时聚合落库,半天能搭完:

// middleware.ts(节选):每次 API 请求打点,几乎零开销
const day = new Date().toISOString().slice(0, 10); // 2026-10-11
const pipe = redis.pipeline();
pipe.incr(`usage:${tenantId}:${day}:api_calls`);
// AI token 消耗在业务代码里打点:pipe.incrby(`usage:${tenantId}:${day}:ai_tokens`, tokens);
pipe.expire(`usage:${tenantId}:${day}:api_calls`, 86400 * 3); // 保留3天,防聚合任务挂掉丢数
await pipe.exec();
-- 聚合表:每小时的 cron 把 Redis 计数落库
CREATE TABLE usage_daily (
  tenant_id uuid NOT NULL REFERENCES tenants(id),
  day date NOT NULL,
  api_calls bigint NOT NULL DEFAULT 0,
  ai_tokens bigint NOT NULL DEFAULT 0,
  storage_bytes bigint NOT NULL DEFAULT 0,
  PRIMARY KEY (tenant_id, day)
);
-- 套餐限额检查就一句 SQL:
-- SELECT api_calls > plan.monthly_api_limit FROM usage_monthly WHERE tenant_id = $1;

四个实测要点:① 打点用 pipeline + incr,单次 Redis RTT,中间件里感知不到延迟;别在请求链路里同步写 Postgres,那是给自己加 P99;② Redis key 保留 3 天,聚合 cron 每小时跑一次,挂了也有时间补——计量数据丢了等于少收钱,宁可重复聚合(幂等 upsert)不可丢失;③ 存储用量别实时算,每天凌晨扫一遍对象存储按租户前缀汇总,写进 storage_bytes;④ 超限策略提前定好:是硬拦截(403 + 升级提示)还是软超限(先记账单月底结算),写进合同,别等客户超了 10 倍用量才吵架。

总结成一句话:多租户隔离的本质,是把代码里所有"默认全站"的假设,一个个改成"默认本租户"。共享表 + RLS 是性价比最高的起点,10 项负向测试是保命的 CI,5 步迁移 SOP 让老项目不停机上车,按租户计量让每一行隔离的代码都对应上账单。从第二个客户开始,你的 vibe 项目才算真正开门做生意。

浏览项目广场发布你的项目

相关文章

API 生命周期管理示意图:v1 与 v2 版本标签由迁移箭头连接,日落时钟标记废弃时间线
指南
API 别翻车:vibe 项目的版本管理与废弃 SOP

vibe coding 的迭代速度是 API 契约的十倍快,这正是一个人做项目最容易翻车的地方。本文补上 API 治理缺失的那块拼图:vibe 项目 API 的 3 种真实死法、版本策略 5 维度决策表(URL 路径版 vs 请求头版本 vs 无版本)、12 条破坏性变更判定清单(可直接贴墙上)、废弃 SOP 四步(Deprecation/Sunset 响应头、双语废弃公告模板、双跑观察看板、下线执行检查表)、可直接复制的一页纸迁移指南模板,以及一人版本治理的最小配置:CHANGELOG 驱动 + CI 用 openapi-diff 自动卡住 breaking change。

后端工程独立开发开发工作流
独立开发者退款政策与 Chargeback 防守实战指南:Stripe 退款 API、webhook 自动化、争议证据包与防欺诈规则全解析
指南
退款不肉疼:vibe 项目的退款政策与 Chargeback 防守实战

敢收费不敢退费的心态是错的:清晰的退款政策是最便宜的信任广告,处理得当的退款用户有 30% 会回来,而一次处理失当的 Chargeback 可能吃掉一整月利润。本指南覆盖三档退款政策设计与话术模板、Stripe 手动与 API 退款实战、charge.refunded 的 webhook 自动化闭环、挽留还是秒退决策树、争议证据包 6 类材料、Stripe Radar 防欺诈规则,以及 Paddle/Lemon Squeezy 代扣代缴的跨境税务细节。

支付与变现独立开发安全与隐私
vibe 项目社区冷启动与开源获客实战指南:从 0 到 100 个铁粉的运营 SOP、README 定位与冲榜打法
指南
从 0 到 100 个铁粉:vibe 项目的社区冷启动与开源获客实战

vibe coding 让做出产品变容易、让人知道更难,社区和开源是少数以时间换流量的公平赛道。这篇获客战术执行手册覆盖:社区为什么是获客杠杆、Discord/微信群/GitHub Discussions 阵地选择、冷启动 0→20 的 5 个种子用户来源、0→100 的每日 15 分钟运营 SOP、低成本参与感设计、README 即落地页的写作公式、GitHub trending 机制与发布时间窗口、issue 驱动营销、build in public 节奏模板、社区危机处理、5 个健康度指标,以及从社区到收入的转化路径设计。

增长与营销独立开发开源项目观察