跳到主要内容

3.6 数据存储方案

为什么需要数据库?

你做了一个 Todo App,用户添加了几条待办事项,刷新页面——全没了。

为什么?因为浏览器里的数据存在内存里,页面一刷新,内存清空,一切归零。

数据库就是解决这个问题的。它把数据持久化地存下来,不管用户关掉浏览器、换台电脑、过了一年,数据还在那里。

用户注册的账号、发的帖子、下的订单、上传的图片——这些都要存进数据库。可以说,没有数据库,你的产品就是个玩具。


Supabase:新手的最佳拍档

数据库的选择有很多,但对于 AI 编程的新手,我强烈推荐 Supabase

第一,它免费。 免费套餐给你 500MB 数据库空间、1GB 文件存储、50000 月活用户。对于个人项目和 MVP 完全够用。

第二,它简单。 传统数据库你需要自己搭服务器、配环境、写 SQL。Supabase 是云服务,注册就能用,还有一个可视化的管理后台,像操作 Excel 一样管理数据。

第三,它功能全。 除了数据库,Supabase 还内置了用户认证、文件存储、实时订阅。一个平台解决你大部分后端需求。

第四,它基于 PostgreSQL。 这是世界上使用最广泛的开源数据库之一。你在 Supabase 里学到的知识,以后迁移到其他地方也能用。


完整的 Supabase 设置流程

第一步:注册账号

supabase.com,点击 "Start your project",用 GitHub 账号一键登录。登录后进入 Dashboard 主页,列出你所有的项目。

第二步:创建项目

点 "New Project",填写:

  • Organization:选你的组织(第一次用会自动创建)
  • Project name:项目名字,比如 my-blog-app
  • Database Password:数据库密码,一定要记下来!
  • Region:选离用户最近的区域,国内用户选 Tokyo 或 Singapore

点 "Create new project",等一两分钟就创建好了。进入项目 Dashboard,左侧有 Table Editor、Authentication、Storage、SQL Editor 等入口。

第三步:找到 API 密钥

点左侧 "Project Settings"(齿轮图标)→ "API",找到:

  • Project URL:形如 https://xxxx.supabase.co
  • anon / public key:很长的字符串

在 Next.js 项目根目录创建 .env.local

NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUz...

NEXT_PUBLIC_ 前缀是 Next.js 约定,表示这个变量暴露给前端。anon key 设计上就是公开的,配合 RLS 使用是安全的。

第四步:创建表

在左侧菜单点 "Table Editor" → "New Table"。假设做博客系统,建一个 posts 表:

列名类型说明
idint8 (自增)主键,自动生成
created_attimestamp创建时间,自动生成
titletext文章标题
contenttext文章内容
authortext作者
publishedboolean是否发布,默认 false

或者在 SQL Editor 里用 SQL 建表:

CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
title TEXT NOT NULL,
content TEXT,
author TEXT NOT NULL,
published BOOLEAN DEFAULT FALSE
);

CREATE INDEX idx_posts_author ON posts(author);

第五步:安装客户端并连接

npm install @supabase/supabase-js

创建 lib/supabase.ts

import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

这个文件只需要创建一次,其他地方 import 就能用。


增删改查:四步走天下的操作

数据库最核心的操作就四个:创建(Create)、读取(Read)、更新(Update)、删除(Delete),简称 CRUD。

创建:往数据库里存数据

const { data, error } = await supabase
.from('posts')
.insert({
title: '我的第一篇博客',
content: '今天开始写博客了...',
author: '小明'
})
.select() // 加上 select() 返回插入的数据(包括自动生成的 id)

if (error) {
console.error('创建失败:', error.message)
}

读取:从数据库里取数据

// 获取所有文章
const { data: posts } = await supabase.from('posts').select('*')

// 按条件筛选
const { data: myPosts } = await supabase
.from('posts').select('*').eq('author', '小明')

// 只取某些字段
const { data: titles } = await supabase
.from('posts').select('id, title, author')

// 分页 + 排序
const { data: page1 } = await supabase
.from('posts').select('*')
.order('created_at', { ascending: false })
.range(0, 9)

// 模糊搜索
const { data: results } = await supabase
.from('posts').select('*').ilike('title', '%博客%')

常用筛选方法速查:

方法作用示例
.eq()等于.eq('author', '小明')
.gt() / .lt()大于 / 小于.gt('id', 10)
.in()在列表中.in('author', ['小明', '小红'])
.ilike()模糊匹配.ilike('title', '%关键词%')
.is()判断 null.is('deleted_at', null)
.or()或条件.or('author.eq.小明,published.eq.true')

更新:修改已有数据

const { data, error } = await supabase
.from('posts')
.update({ title: '修改后的标题', published: true })
.eq('id', 1)
.select()

重要提醒: update 必须配合 .eq() 使用!漏掉筛选条件会更新整张表所有数据。这是新手最常犯的错误之一。

删除:删掉数据

// 硬删除
const { error } = await supabase.from('posts').delete().eq('id', 1)

// 推荐:软删除(添加 deleted_at 字段,而不是真的删掉)
const { error } = await supabase
.from('posts')
.update({ deleted_at: new Date().toISOString() })
.eq('id', 1)

// 查询时过滤掉已删除的
const { data: posts } = await supabase
.from('posts').select('*').is('deleted_at', null)

软删除的好处是数据可恢复,出了问题还能找回来。

就这四个操作,你能应付 90% 的数据管理需求。


Row Level Security(RLS):数据安全的护城河

默认情况下,任何人只要知道你的 anon key,就能读写你所有的表数据。 这非常危险。RLS 让每个用户只能操作自己的数据。

开启 RLS

ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

开启后默认所有用户都无法访问(除非你设置策略)。

创建策略

-- 所有人可以读已发布的文章
CREATE POLICY "公开文章可读" ON posts FOR SELECT
USING (published = true);

-- 用户可以读自己的所有文章
CREATE POLICY "用户可读自己的文章" ON posts FOR SELECT
USING (auth.uid() = user_id);

-- 用户只能创建自己的文章
CREATE POLICY "用户只能创建自己的文章" ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);

-- 用户只能更新自己的文章
CREATE POLICY "用户只能更新自己的文章" ON posts FOR UPDATE
USING (auth.uid() = user_id);

-- 用户只能删除自己的文章
CREATE POLICY "用户只能删除自己的文章" ON posts FOR DELETE
USING (auth.uid() = user_id);

auth.uid() 是 Supabase 内置函数,返回当前登录用户的 ID。

RLS 是必须的。 只要你的应用有用户登录功能,就必须开 RLS。建表的时候就要一起做,不是"以后再加"的功能。


实时订阅:数据变了自动通知你

Supabase 支持实时监听数据库变化,适合做聊天室、协作编辑、实时看板等场景。

import { useEffect, useState } from 'react'

function PostList() {
const [posts, setPosts] = useState([])

useEffect(() => {
// 初始加载
supabase.from('posts').select('*').then(({ data }) => setPosts(data || []))

// 订阅实时变化
const channel = supabase
.channel('realtime-posts')
.on('postgres_changes', { event: '*', schema: 'public', table: 'posts' }, (payload) => {
if (payload.eventType === 'INSERT') {
setPosts(prev => [payload.new as any, ...prev])
} else if (payload.eventType === 'UPDATE') {
setPosts(prev => prev.map(p => p.id === payload.new.id ? payload.new : p))
} else if (payload.eventType === 'DELETE') {
setPosts(prev => prev.filter(p => p.id !== payload.old.id))
}
})
.subscribe()

return () => { supabase.removeChannel(channel) }
}, [])

return <div>{posts.map(post => <div key={post.id}>{post.title}</div>)}</div>
}

注意: 实时功能需要在 Supabase Dashboard 的 Database → Replication 里,对目标表开启 Realtime。默认没有开启。


Storage:文件上传

用户头像、文章配图、PDF 文档等文件用 Supabase Storage 存储。

// 上传图片
const file = event.target.files[0]
const fileName = `${Date.now()}-${file.name}`

const { data, error } = await supabase.storage
.from('images') // 存储桶名称
.upload(fileName, file)

// 获取公开 URL
const { data: urlData } = supabase.storage
.from('images')
.getPublicUrl(fileName)

console.log('图片地址:', urlData.publicUrl)

// 删除文件
await supabase.storage.from('images').remove(['old-photo.png'])

在 Dashboard 左侧点 "Storage" → "New Bucket" 创建存储桶。设置为 Public 适合公开图片,Private 适合私密文件,配合 RLS 可以实现"每个用户只能访问自己的文件"。


数据库设计基础

建表三原则

原则 1:每张表只存一类东西。 用户表存用户,订单表存订单,别混在一起。

原则 2:用 id 关联,不要重复数据。 订单里存 user_id,不是再存一遍用户名。

-- ❌ 重复存了用户信息
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_name TEXT,
user_email TEXT,
product_name TEXT,
amount DECIMAL
);

-- ✅ 用 id 关联
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id),
product_id BIGINT REFERENCES products(id),
amount DECIMAL,
created_at TIMESTAMPTZ DEFAULT NOW()
);

原则 3:字段类型要选对。 价格用 DECIMAL 不用 FLOAT,时间用 TIMESTAMPTZ(带时区),布尔值用 BOOLEAN

通用表模板

CREATE TABLE your_table (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
-- 业务字段...
);

-- 自动更新 updated_at
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER set_updated_at BEFORE UPDATE ON your_table
FOR EACH ROW EXECUTE FUNCTION update_updated_at();

选择数据库方案

特性SupabaseFirebasePlanetScaleNeon
数据库类型PostgreSQLFirestore(文档型)MySQLPostgreSQL
免费额度500MB + 1GB 存储1GB 存储免费已取消512MB
实时功能✅ 内置✅ 最强
认证/存储✅ 内置✅ 内置❌ 需自建❌ 需自建

Firebase:谷歌产品,实时功能最强,适合聊天/协作类应用。但它是文档型数据库(NoSQL),复杂查询不如关系型直观。

PlanetScale:MySQL 云数据库,性能好,有数据库分支功能。但免费套餐已取消,适合有预算的团队。

Neon:PostgreSQL 云服务,有类似 Git 的分支功能,适合开发测试。

我的建议: 对于大多数个人项目和 MVP,用 Supabase。不要在"选什么数据库"上纠结太久,先把东西做出来。


常见坑和注意事项

坑 1:忘记开 RLS。 建表后第一件事就是开 RLS,否则任何人知道 API key 就能读写所有数据。

坑 2:update/delete 忘加条件。 .update({ ... }) 不加 .eq() 会更新整张表。.delete() 不加 .eq() 会删除整张表。

坑 3:暴露 service_role key。 Supabase 有两个 key:anon key(公开的,配合 RLS 安全)和 service_role key(绕过 RLS)。service_role key 只能用在后端,绝对不能带 NEXT_PUBLIC_ 前缀。

坑 4:N+1 查询。 查 20 篇文章循环查 20 次作者。用关系查询一次搞定:

const { data } = await supabase
.from('posts')
.select('*, author:users(name, avatar_url)')

坑 5:不处理 error。 每次调用都检查 error:

const { data, error } = await supabase.from('posts').select('*')
if (error) {
console.error('查询失败:', error.message)
return []
}

坑 6:线上没配环境变量。 本地正常部署报错,99% 是 Vercel 的 Environment Variables 里没加 Supabase 的 URL 和 Key。


最后的话

数据库听起来很技术,但现代工具已经把门槛降到了地板上。核心就是四个操作:增、删、改、查。掌握了 CRUD,你就掌握了数据管理的 90%。

先把数据存起来,让你的产品"有记忆",这才是最重要的。