Database
Next.js Techniques

独立开发省钱指南:Supabase 一个数据库给多个产品使用

独立开发省钱指南:Supabase 一个数据库给多个产品使用
还在为每个小项目单独开数据库实例付费吗?本文手把手教你利用 PostgreSQL Schema 机制实现多项目数据隔离,配合 Drizzle ORM 完美适配,并附赠从 Neon 到 Supabase 的跨 Schema 数据迁移脚本。

对独立开发者来说,选择数据库是一个难题,虽然可选方案不少,但是要么太贵,要么太麻烦,要么又贵又麻烦。

比如:

  • 使用 Supabase,免费账号只能创建2个 Project,开通付费后每新增一个要多加10美元,如果你有很多个产品,这个成本很快就会累积成一笔不小的数目。
  • 使用 Neon,虽然号称免费账号可以创建100个 Project,但是人家是靠流量赚钱,限制产品数量是没有意义的,实际用起来就知道流量费超贵。
  • 自己买服务器,搭建 PostgreSQL 数据库,自己维护,总会担心性能和安全问题。

今天教大家的方法是:使用 Supabase 的 Schema 机制,实现一个数据库给多个产品使用。

这套方案适合的场景:你有多个产品流量不高,即使合并到一个数据库,流量也不会超过 Supabase 的免费额度(Supabase 免费账号有 5G 出站流量,对于小产品来说根本用不完)。

什么是 Schema?

Schema 机制本质上是 PostgreSQL 的原生功能,不是 Supabase 独有的。

你可以把 Postgres 的 Schema 想象成文件夹。同一个数据库实例(Database)下可以创建多个 Schema,每个 Schema 里的表(Table)互不干扰。

在 Supabase 里,创建数据库后,默认使用 Public Schema,所有的表都创建在 Public Schema 下。

为什么使用 Supabase Schema

很多人只考虑到 Supabase 的免费额度是2个数据库,其实是2个数据库可以用5G出站流量(Egree)、500M存储,这个额度大部分产品是用不完的。如果可以多个产品共用数据库不就可以把成本打下来了吗?

使用Supabase schema就能实现共用数据库资源,既可以最大化利用免费额度,也能互相隔离不同产品的数据,兼顾了成本与安全,是独立开发者最佳选择。

迁移步骤

以下文件路径基于 NEXTY.DEV 模板,如果你使用其他模板,请根据实际情况调整文件路径。

以 Next.js + Drizzle ORM 为例,从 Neon 迁移到 Supabase 的步骤如下:

1. 改造 Drizzle ORM 配置

修改 drizzle.config.ts,添加 schemaFilter,这样 Drizzle Kit 在生成迁移文件时,只会关注这个专属 Schema,不会误删 public Schema 的表。

typescript
// drizzle.config.ts
export default defineConfig({
  // ... 其他配置
  schemaFilter: ['new-project-schema'], // <--- 关键:只管理这个 schema,名称自定义
});

2. 修改 Schema 定义

修改 lib/db/schema.ts,将所有的 pgTable 替换为基于自定义 schema 的表定义。

技巧:定义一个 schema 对象,然后用它来创建表。

typescript
// lib/db/schema.ts
import { pgSchema } from 'drizzle-orm/pg-core';

// 1. 定义 Schema
export const newProjectSchema = pgSchema('new-project-schema'); // <--- 名称自定义,和 drizzle.config.ts 文件里的一致

// 2. 将原来的 pgTable 替换为 newProjectSchema.table
// 旧代码: export const user = pgTable('user', { ... })
// 新代码:
export const user = newProjectSchema.table('user', {
  id: uuid('id').primaryKey().defaultRandom(),
  // ...
});

// 3. 枚举也需要迁移
// 旧代码: export const roleEnum = pgEnum('role', ['admin', 'user'])
// 新代码:
export const roleEnum = newProjectSchema.enum('role', ['admin', 'user']);

3. 重置迁移文件

对于我们这种全量迁移的场景,不要试图基于旧迁移文件生成新迁移文件,那样非常容易迁移失败。最简单方法是重置迁移历史,让 Drizzle 帮我们自动创建新 Schema 和所有表。

  1. 删除旧迁移文件:直接删除项目中的 lib/db/migrations/ 文件夹下的所有内容。
  2. 生成新迁移:运行生成命令。
    bash
    pnpm db:generate
    或 
    pnpm drizzle-kit generate
    
  3. 应用迁移:将新的结构推送到 Supabase。
    bash
    pnpm db:migrate
    或
    pnpm drizzle-kit migrate
    

这样,Drizzle 就会自动在 Supabase 中创建好 new-project-schema Schema 和所有的表结构,完全不需要手动去 SQL Editor 里敲命令。

4.用脚本迁移数据

把新旧数据库的连接串配置到 .env 文件中,然后让 AI 根据 lib/db/schema.ts 内容生成一个数据迁移脚本。

typescript
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '../lib/db/schema';
import * as dotenv from 'dotenv';
import { getTableConfig } from 'drizzle-orm/pg-core';

dotenv.config();

// 在 .env 配置好旧库和新库的连接串
const { OLD_DB_URL, NEW_DB_URL } = process.env;

// 定义迁移顺序(注意外键依赖:先迁移主表,再迁移子表)
const tables = [
  { name: 'user', table: schema.user }, // 被依赖的基础表
  { name: 'pricing_plans', table: schema.pricingPlans },
  { name: 'orders', table: schema.orders }, // 依赖 user 和 pricing_plans
  // ... 其他表
];

async function main() {
  const sourceClient = postgres(OLD_DB_URL!, { max: 1 });
  const destClient = postgres(NEW_DB_URL!, { max: 1 });
  const destDb = drizzle(destClient, { schema });

  for (const t of tables) {
    console.log(`正在迁移表: ${t.name}...`);

    // 1. 构建字段映射 (db_column -> schema_property)
    const columnMapping: Record<string, string> = {};
    const tableColumns = t.table; 
    for (const key in tableColumns) {
      // @ts-ignore
      const col = tableColumns[key];
      // 这里的逻辑是:Drizzle Schema 对象里的 key 通常就是驼峰属性名 (cardTitle)
      // 而 col.name 是数据库实际列名 (card_title)
      if (col && typeof col === 'object' && 'name' in col) {
          columnMapping[col.name] = key;
      }
    }

    // 2. 从旧库查数据 (假设旧库在 public schema)
    const rows = await sourceClient`SELECT * FROM public.${sourceClient(t.name)}`;
    
    if (rows.length === 0) continue;

    // 3. 转换数据格式 (snake_case -> camelCase)
    const transformRow = (row: any) => {
      const newRow: any = {};
      for (const dbColName in row) {
          const schemaKey = columnMapping[dbColName];
          // 如果 Schema 里定义了这个字段,就用 Schema 的 key
          // 否则保持原样(可能是未定义在 schema 里的字段)
          newRow[schemaKey || dbColName] = row[dbColName];
      }
      return newRow;
    };

    // 4. 批量写入新库
    const batchSize = 100;
    for (let i = 0; i < rows.length; i += batchSize) {
      const batch = rows.slice(i, i + batchSize).map(transformRow);
      await destDb.insert(t.table).values(batch).onConflictDoNothing();
    }
  }

  await sourceClient.end();
  await destClient.end();
  console.log('迁移完成!');
}

main();

5. 更新生产环境配置

更新生产环境的 .env 文件,将 DATABASE_URL 更新为新数据库的连接串。

env
DATABASE_URL=new-project-database-url

然后重启项目,迁移完成。

总结

用这套方案,你就可以在同一个 Supabase 数据库里,安安稳稳地跑 10 个甚至 20 个低流量的副业项目,每个项目都有自己干净的数据空间,既省钱又安全。

NEXTY.DEV 模板的用户可以使用内置的 SKILL 辅助完成迁移,非模板用户也可以把这篇文档发给 AI,大部分工作就不需要手动操作了。