Vercel 推出存储能力后,前端部署不只是静态页面了

Vercel 刚上线 Postgres、Blob、KV 这几个存储产品,博客里满是"几行代码就有了数据库"这类乐观措辞。我做惯了电商中后台,见过太多"接入很爽、迁移要命"的东西,没急着照单全收,先拿手上一个要重做的内部小工具练了练手。

页面部分照例很快写完,卡住的全在后面:用户提交的数据放哪、上传的附件放哪、首页那个列表要不要缓存、测试环境和线上数据怎么分开存放。这几个问题恰好把 Postgres、Blob、KV 各自的定位摆到了台面上,我干脆拿这个工具当试验田,一边接一边判断它值不值得用在更正式的项目里。

一个能用的应用,缺的从来不是页面

以前我对 Vercel 的印象就一句话:部署前端很方便。把 Next.js 或静态站点推到 GitHub,连上去,几分钟上线。

但静态页面只负责展示,动态应用要读写数据。用户信息、订单记录、上传的图片、要缓存的热点列表,这些都落在页面之外。过去要做这类小应用,得自己去找数据库、对象存储、缓存服务,一个个申请、配连接、塞环境变量。工具链没问题,就是碎,小项目也被拖成中等复杂度,前端开发者在部署这一步要在好几个平台之间来回切。

Vercel 这批存储能力,解决的不是"数据库技术本身"——PostgreSQL 早就成熟了——而是把全栈小应用的集成成本压下来。它把部署平台往"轻量应用平台"的方向又推了一步,不再只是托管静态资源。

我对这类平台能力的态度是保留的:它很适合把个人项目、内部工具、早期 SaaS 快速跑起来,但接入方便不等于可以不设计数据模型。**页面部署可以推倒重来,数据模型和权限一旦做错,后面的成本要翻好几倍。**这条判断我一开始就写在评估笔记的第一行。

Vercel Postgres:结构化数据的落点

Vercel Postgres 可以直接理解成 Vercel 托管的 PostgreSQL。关系型数据库适合有明确关系的结构化数据,用户、文章、商品、订单这类。

在 Next.js 里,服务端可以直接查:

1import { sql } from "@vercel/postgres";
2
3export async function getPosts() {
4  const { rows } = await sql`
5    SELECT title, slug
6    FROM posts
7    ORDER BY created_at DESC
8  `;
9
10  return rows;
11}

这样前端项目就不只是"展示页面",能更自然地长成一个完整应用。

但有两点在接入时就要盯住。一是查询必须发生在服务端,别把连接串暴露到浏览器。App Router 从这个月(13.4)起才刚转稳定,我暂时还是把数据访问放在自己更有把握的地方——Route Handler,或者 Server Component 里,配合 React.cache 收一下同一次请求内的重复查询,实验性质的 Server Action 我只在这个内部工具里试,还没敢往正式项目搬。

二是连接数。Serverless 场景下函数频繁冷启,每次新建连接,数据库连接数很容易被打满。@vercel/postgres 底层走的是 Neon 那套带连接池的 driver,接入时要用平台推荐的 pooled 连接字符串,别把长驻服务那套"应用启动时建一个连接池用到底"的习惯直接搬过来。这个坑在本地压测时不明显,一上线跑几个并发就现原形。

一张最朴素的文章表:

1CREATE TABLE posts (
2  id SERIAL PRIMARY KEY,
3  title TEXT NOT NULL,
4  slug TEXT NOT NULL UNIQUE,
5  content TEXT NOT NULL,
6  created_at TIMESTAMP NOT NULL DEFAULT NOW()
7);

需要查询、排序、过滤、关联的数据,放这里合适。但别把什么都往里塞——图片、视频、PDF 这类大文件直接进数据库通常是坏主意,库里存文件地址和元信息才清爽。

关系型数据库这一层,别因为"只是个小工具"就把它当 KV 用。索引、外键、唯一约束这些不是负担,是省未来事的护栏。文章表上 slug 加唯一约束,重复 slug 在写入那一刻就被数据库拦下,比在应用层查一遍再插靠谱得多——应用层那套在并发下会漏。真要保证"查完再写"的原子性,就用事务或 INSERT ... ON CONFLICT

1INSERT INTO posts (title, slug, content)
2VALUES ($1, $2, $3)
3ON CONFLICT (slug) DO NOTHING;

Serverless 场景下每个函数实例都是独立的,没有共享内存能帮你做去重,一致性得靠数据库这层兜住。这也是我为什么在小工具里也坚持用真正的关系型数据库、而不是拿 KV 硬凑的原因。

分页也别踩偷懒的坑。数据一多,列表接口如果每次都 SELECT * 再在应用层切一段,既费查询又费带宽。至少要用 LIMIT/OFFSET 让数据库只返回这一页;数据量再大、翻页很深时,OFFSET 会越翻越慢(它得先扫过前面所有行再丢掉),这时候换成基于游标的分页(按 created_atidWHERE ... < cursor)更稳。

1-- 深翻页时 OFFSET 会退化,改用游标
2SELECT title, slug, created_at
3FROM posts
4WHERE created_at < $1
5ORDER BY created_at DESC
6LIMIT 20;

小工具一开始可能几十条数据,OFFSET 毫无压力,但接口的分页方式一旦定下来、被前端依赖了,后面想换成本就高了。这种"以后会不会变慢"的判断,在设计接口那一刻就该带进去。

要不要上 ORM,也是这次评估里想了一会儿的点。直接写 sql 模板字符串够快,但表一多、查询一复杂,手写 SQL 拼字符串既容易出错也没类型提示。我这次选了 Drizzle,一是它的查询构造器带完整 TypeScript 类型,改了表结构、查询里字段拼错编译期就报;二是它足够薄,生成的 SQL 可预期,不像某些重 ORM 会在背后偷偷发一堆查询。类型安全这条对小工具也值——数据层的字段名写错,是那种线上才炸、还很难查的 bug。当然这是取舍,纯 sql 模板对极简项目也够用,别为了用 ORM 而用。

Vercel Blob:大文件的去处

数据库擅长结构化数据,不擅长直接存大文件。图片、PDF、音视频这类,更适合对象存储。Vercel Blob 就是干这个的,头像、封面、上传附件、富文本图片、导出报表都归它。

标准做法是:文件放 Blob,数据库只留地址、类型、大小、所属用户、创建时间。

文件上传绕不开权限。头像、附件、导出报表不一定都是公开资源。公开 Blob 地址很方便,可业务要私有访问时,就得考虑签名 URL、过期时间、服务端鉴权。Blob 现阶段的公开访问很直接,私有访问这块能力我还没摸透能做到什么程度,所以涉及敏感附件的场景我先没上,继续观望它后续把访问控制补到什么程度。图片格式我默认转 WebP,浏览器早就全面支持了,AVIF 也在起来,但编码成本更高,这个内部工具我还没折腾。

封面这样建模就够:

1{
2  "postId": "p_123",
3  "coverUrl": "https://example.public.blob.vercel-storage.com/cover.webp",
4  "mimeType": "image/webp",
5  "size": 120340
6}

页面读文章时,从数据库拿到封面地址,剩下的交给浏览器加载。

上传前的校验也别只信前端。前端限了类型和大小,绕过前端直接打接口的请求照样能塞进来,服务端得再验一遍 MIME 类型和体积,别让一个伪装成图片的可执行文件、或者一个几百 MB 的大文件直接落进 Blob。文件名也别直接用用户传的原名,容易带路径穿越或重名覆盖,我一般用一个随机 id 加原扩展名重命名后再存。

上传路径也有个选型点。文件先经过我的 Serverless 函数再转存 Blob,还是浏览器直接传到 Blob。Serverless 函数有请求体大小和执行时长限制,大文件走函数容易踩上限;@vercel/blob 提供了客户端直传的方式,服务端只负责签发一个上传凭证,文件不占函数带宽:

1import { put } from "@vercel/blob";
2
3// 服务端:小文件直接转存
4export async function POST(request) {
5  const file = await request.blob();
6  const blob = await put("uploads/cover.webp", file, {
7    access: "public",
8  });
9  return Response.json(blob);
10}

内部工具里附件都不大,我先用服务端转存图省事;一旦要传视频或大报表,就得换成客户端直传,这个临界点在评估时就要想到,别等函数超时了才改。

入口先校验:别让脏数据直达数据库

存储方便了,写入的口子也多了,一个 Route Handler 就能直接往库里塞数据。这时候校验这层反而更要紧——用户提交的东西、上传的元信息,进数据库前都得先验一遍,不能拿着请求体直接拼 SQL 或直接 insert。

我在服务端入口统一用 Zod 定义 schema,先把外部数据解析成可信的结构,验不过就直接返回 400,绝不让它往下走:

1import { z } from "zod";
2
3const CreatePost = z.object({
4  title: z.string().min(1).max(200),
5  slug: z.string().regex(/^[a-z0-9-]+$/),
6  content: z.string().min(1),
7});
8
9export async function POST(request) {
10  const parsed = CreatePost.safeParse(await request.json());
11  if (!parsed.success) {
12    return Response.json({ error: parsed.error.flatten() }, { status: 400 });
13  }
14  // parsed.data 到这里已经是干净且带类型的
15}

这么做有两层好处:一是数据库不用去扛"字段缺失、类型不对、超长"这类本该在门口拦下的问题;二是 parsed.data 带上了 TypeScript 类型,从入口到数据层类型是连贯的。存储平台把接入成本降下来了,可"什么数据能进库"这条防线还得自己守,越是能几行代码就写库的场景,越容易忘了在入口验一道。

安全上还有条底线得守住:写库一律用参数化查询,别拿字符串拼 SQL。@vercel/postgressql 模板标签本身就是参数化的,sql\... WHERE id = ${id}`里的${id}` 会被当参数传,不会被当 SQL 片段执行,这层默认就防住了注入。真正危险的是有人图省事绕开模板、手动把用户输入拼进查询串,那口子一开注入就来了。平台把数据库塞到手边,用错方式的机会也一起塞过来了,越顺手越要守规矩。

跨存储的一致性:孤儿文件是个隐坑

三种存储各管一段,但业务操作常常横跨两段,一致性问题就藏在接缝里。最典型的是删文章:既要删 Postgres 里的记录,又要删 Blob 里的封面。这两个操作不在一个事务里——数据库有事务,对象存储没有,你没法把"删记录"和"删文件"绑成一个原子操作。

于是就会出现两种破绽:数据库删了、Blob 删失败,留下一堆没人引用的孤儿文件慢慢占存储;或者反过来,文件删了、数据库没删,页面拿着一个已经失效的地址加载图片。

我在这个内部工具里的处理是"先删主、后清附":先在事务里删数据库记录(这是用户能立刻感知的),文件删除放到后面异步做,删失败也不影响主流程,再靠一个定时任务扫出"数据库里已无引用的 Blob"来兜底清理。这不是完美方案,但对小工具够用,关键是承认"跨存储做不到强一致",然后把不一致的窗口控制在能接受的范围里,而不是假装它不存在。评估任何这类多存储组合时,这条接缝都得提前想到。

KV:轻量缓存和临时状态

有些数据压根不需要进关系型数据库:验证码、临时状态、页面访问计数、热门列表缓存、会话信息、限流计数。它们结构简单、读写频繁、活得短,适合键值存储。

Vercel KV 底层是 Upstash 的 Redis,用起来就是 Redis 那套。缓存首页热门文章:

1import { kv } from "@vercel/kv";
2
3const cacheKey = "home:popular-posts";
4
5export async function getPopularPosts() {
6  const cached = await kv.get(cacheKey);
7
8  if (cached) {
9    return cached;
10  }
11
12  const posts = await queryPopularPostsFromDb();
13  await kv.set(cacheKey, posts, { ex: 60 * 5 });
14
15  return posts;
16}

缓存五分钟,减轻数据库压力,也让访问更快。

这段代码还藏着个高并发才现形的坑:缓存击穿。缓存刚过期的那一刻,如果同时来一堆请求,它们会一起发现"没缓存"、一起冲去查数据库、一起写回缓存,瞬间把数据库压垮。热点 key 尤其明显。缓解办法是加一把短时锁,让第一个请求去回源、其余的短暂等待或先返回旧值,别让它们一窝蜂全打到库上。小工具流量小时可以先不管,但这条得留在心里,量一上来就要补。

缓存最难的从来是失效。热门文章能接受五分钟延迟,订单状态、权限菜单就未必。KV 快,但别把它当强一致数据库使。每个缓存 key 都得想清楚过期时间、刷新时机、失败兜底。既然底层是 Redis,限流这类场景还可以直接用它的原子自增,配合 expire 做滑动窗口,比自己在应用层拼一套要稳:

1async function hitRateLimit(ip) {
2  const key = `rate:${ip}`;
3  const count = await kv.incr(key);
4  if (count === 1) {
5    await kv.expire(key, 60);
6  }
7  return count > 100;
8}

选型看数据特征,不看谁更"高级"

Postgres、Blob、KV 不是互相替代,是各管一段。判断规则很朴素:

需要关系查询、排序、事务的进 Postgres;需要存图片、附件、大文件的进 Blob;需要临时状态、缓存、计数的进 KV。一个博客系统,文章标题、正文、作者进 Postgres,封面进 Blob,首页热门列表缓存进 KV,三者拼起来才是一个完整的应用结构。

评估里我也把它和自己另建一套(比如 Supabase 或直接托管 Postgres 加 S3 加 Redis)比了比。Vercel 这套的好处是和部署环境天然打通、环境变量自动注入、每个预览环境自带一份独立数据;代价是你的存储和部署平台绑在了一起。对内部工具,我认这个绑定;对可能长成核心业务的东西,我会更谨慎。

缓存不只有 KV 这一层

真接进 Next.js 才发现,"缓存"在这套体系里不是一个东西,是好几层叠着的,评估时容易只盯着 KV 忘了别的。

App Router 里 fetch 默认会被框架缓存,Route Segment 也有自己的缓存策略,还能用 revalidate 做基于时间的再验证;这些是渲染层的缓存,和我手动往 KV 里塞的数据缓存是两码事。刚上手时我一度困惑:明明数据库改了,页面还是旧的——不是 KV 的问题,是 fetch 那层默认缓存住了。

1// 渲染层:让这段数据 60 秒重新取一次
2const res = await fetch(url, { next: { revalidate: 60 } });

所以选型时得把这两层分开想:能靠框架的渲染缓存解决的,就别自己再往 KV 里搭一套,重复缓存反而让"什么时候失效"更难说清。KV 留给那些跨请求、跨用户共享的短生命周期状态(计数、限流、会话),渲染结果的缓存交给框架。这条边界没划清,缓存失效会变成排查噩梦。

边界和成本:值不值得绑定

平台内置存储降低集成成本,但不代表每个项目都该把身家押在一个平台上。选型前至少过一遍:备份和迁移方不方便、本地开发怎么模拟、费用随访问量怎么涨、有没有区域和延迟要求、会不会被平台能力反向限制、团队熟不熟对应数据库。

个人项目、活动页、小型 SaaS、内部工具,用平台内置存储非常划算。强合规、大数据量、多云部署、复杂数据库运维的场景,就得更慎重地评估。我给这个内部工具的结论是:可以用,而且省了我不少事;但同一套东西要不要上到面向客户的正式项目,我打算再观望一两个版本,看它的私有访问、监控、导出这些能力补得怎么样。

成本模型也得在接入前算清楚,别等账单来了才发现结构不对。这类托管存储通常按存储量、请求次数、出口流量分别计费。KV 是读写次数计费,如果哪段代码在循环里逐条 kv.get,量一上来请求数会很吓人,这种就该用批量接口一次取回,或者干脆把这块数据挪回一次查询能拿全的 Postgres。Blob 是存储量加流量计费,公开图片如果不套 CDN、每次都回源,流量费会比预期高。这些不是接入时会立刻暴露的问题,是量涨上来之后才咬人的,所以评估阶段就得把"哪类操作会随访问量线性增长计费"标出来。

本地开发也得提前设计。团队不能每个人都连生产库调试,至少要有本地或测试环境的连接配置、种子数据、迁移脚本。平台降低了部署门槛,不会顺手帮你把各环境的数据分开这件事也解决了。vercel env pull 能把环境变量拉到本地,但库本身还得自己想清楚怎么隔离。

我这次的隔离方案是:本地用 Docker 起一个本地 Postgres,开发和跑测试都连它;平台上再分 preview 和 production 两套连接。这样有三个好处——本地开发不消耗云端配额、跑测试可以随便建表删表不怕污染、每个 PR 的预览环境也能连到独立数据,评审时不用担心手一抖改到线上。种子数据写成一个脚本纳入版本库,新人 clone 下来一条命令就能把库填成可用状态,不用互相问"你那边数据是怎么造的"。这些都是平台不会替你想、但小团队协作绕不开的事。

迁移脚本要从第一天就留

只要用了数据库,就会遇到 schema 变化。哪怕小项目,也别在控制台手改表结构。至少把建表 SQL、迁移脚本或 ORM schema 放进版本库。我这次配了 Drizzle 来管 schema 和迁移,一开始就把 drizzle-kit 生成的迁移文件纳入 git,好处很实在:新环境能复现,出问题时知道结构是怎么一步步变过来的。

迁移本身也有讲究,不是把 SQL 一跑就完事。改表结构最容易踩的坑是"改坏了历史数据"或"改的过程中把服务停了"。加一个非空字段,如果直接 NOT NULL 又不给默认值,历史行没法满足约束,迁移直接失败;正确的做法是分步来——先加可空字段,回填数据,确认没问题了再加约束。这类"先加、后回填、再收紧"的三步走,在有存量数据的表上几乎是标配。

1-- 第一步:加可空字段,不影响现有行
2ALTER TABLE posts ADD COLUMN status TEXT;
3-- 第二步:回填历史数据
4UPDATE posts SET status = 'published' WHERE status IS NULL;
5-- 第三步:确认无空值后再收紧约束
6ALTER TABLE posts ALTER COLUMN status SET NOT NULL;

小工具数据量小,这套看着有点重,但习惯一旦养成,等数据涨起来、迁移不能停服的时候,就不用临时抱佛脚。平台让你几秒钟就能对着生产库跑一条 ALTER,越是这种时候越要克制,把迁移当成有版本、有回退、分步骤的正经变更来做。

平台越方便,越容易让人跳过这些基本功。可数据层最怕的就是"只有线上才知道真实结构"。我现在把这类平台能力当成快速起步的好工具,但不当成不用设计数据层的理由。

还有一件评估时容易漏的事:可观测性。小工具跑起来之后,慢查询、连接被打满、KV 命中率低这些问题,靠"感觉"是发现不了的。至少要能看到数据库的慢查询日志、函数的执行时长分布、缓存的命中和穿透。平台自带的面板能覆盖一部分,但关键查询我会自己在服务端埋一层耗时日志,超过阈值就记下来。平台把部署和运维门槛降下来了,可到底哪条查询在拖后腿、哪个 key 在被反复回源,这些还得自己有手段看见,否则出了问题只能干瞪眼。

结构化数据进数据库,文件进对象存储,临时热点进 KV,这三类分工一开始就要分清楚,别图省事都往一个地方塞。平台能替你省掉部署和集成,替不了你设计表结构、权限、备份、迁移和错误处理。个人项目、内部工具、早期 SaaS 用它会很舒服;可只要数据开始变重要,就得提前留好迁移脚本、环境隔离和备份方案。页面能推倒重来,数据很少有这么轻松。