跳到主要内容

4.6 数据存储方案

为什么需要数据库?

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

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

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

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


Supabase:新手的最佳拍档

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

第一,起步成本低。 Supabase 通常提供适合试验的方案,但数据库、存储、带宽和月活额度会变化,创建项目前查看当前 Pricing 页面。

第二,它简单。 传统数据库你需要自己搭服务器、配环境、写 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=***...

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)

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

这四个操作覆盖了大多数入门项目的基础数据管理需求。


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
典型优势Auth、RLS、Storage 一体移动端生态与实时能力托管 MySQL 工作流Serverless PostgreSQL 与分支
选型前必查休眠、容量、流量读写计费、查询限制当前套餐和连接方式计算时长、存储、休眠

套餐额度变化频繁,选型时应打开四家官方价格页重新比较,不要依据截图或教程里的历史数字。

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:线上没配环境变量。 本地正常、部署后报错时,优先检查 Vercel Environment Variables 是否缺少 Supabase URL 或 Key。


最后的话

数据库听起来很技术,但现代工具已经降低了起步门槛。先掌握增、删、改、查,再逐步学习约束、索引、事务、备份和权限。

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