← 全栈开发:从入门到入土
全栈开发全栈开发:从入门到入土

数据层:Serverless Postgres、Drizzle,以及不会半夜把你叫醒的迁移

Zero to Depth 系列第 6 篇:2026 年 Postgres 该放在哪里、Drizzle 查询层、边缘连接的物理约束,以及 expand-contract 迁移模式。

PostgresDrizzle数据库MigrationsNeonServerless

到目前为止,这套技术栈里的所有东西都是无状态的——边缘 isolate、Server Component、Server Action:启动、应答、消亡。无状态正是它们能够水平扩展的原因。这也意味着它们把唯一自己做不了的事,都外包到了同一个地方:数据库。数据层是整个架构中唯一有状态的组件,它悄无声息地决定了系统的天花板——正确性的天花板、延迟的天花板,以及你未来一百次部署有多痛苦的天花板。

2026 年,定义这一层的是三个决策:数据库放在哪里、谁来跟它对话、以及它的 schema 如何在不吵醒任何人的前提下变更。

Postgres 赢了,问题是它住在哪里

先说那个无聊的头条:在这套技术栈里,关系型负载的默认答案就是 Postgres,句号。真正有意思的问题是你租用的 Postgres 是什么形态

  • Neon —— serverless Postgres(现已并入 Databricks):存储与计算分离,闲置时计算可以缩到零,个人项目的成本约等于免费。它的招牌特性是 branching——基于 copy-on-write 的数据库分叉,几秒钟就能完成,于是每个 pull request 都能拿到一个带真实数据的隔离数据库。它的 neon-http 驱动正好回答了下面要讲的边缘问题。
  • Supabase —— Postgres 加一整套后端服务(Auth、Storage、Realtime),最出彩的是 Row Level Security:授权规则在数据库内部强制执行,policy 里直接引用 auth.uid()
  • 普通的托管 Postgres(RDS、Cloud SQL、自建)——对大多数 B2B SaaS 来说仍然是正确答案:单区域、可预期、单位算力最便宜。

“边缘数据库”这个词实际上指三种完全不同的东西,厂商们是故意把它们搅在一起的:全球只读副本(Neon read replicas:写入仍然只去一个主库)、local-first 同步(Turso——每个租户一个 SQLite,嵌入式副本与主库同步;它 2026 年的方向是明确的 push/pull 同步),以及 Workers 原生绑定(Cloudflare D1——以 env.DB 形式存在的 SQLite,在 Cloudflare 网络内读取延迟个位数毫秒,但为读优化且单库有上限)。诚实的判断标准是:用户分布在五大洲、读多写少,这些方案才值得它们的复杂度;对于一个区域性 B2B 产品,单区域的普通 Postgres 在简单性和成本上完胜三者。

查询层:默认 Drizzle

第 1 篇给出的 2026 结论依然成立,而且只会越来越硬:新项目用 Drizzle ORM。理由是结构性的,不是赶时髦:

  • Schema 就是普通 TypeScript——类型实时推导,没有 generate 步骤可以让你忘记、在 Docker 构建里断掉、或者留下过期产物。
  • 查询读起来就像它生成的 SQL,凌晨两点没有魔法需要调试;原生 SQL 是一等公民,不是逃生舱。
  • gzip 后约 7.4 KB,零运行时依赖,原生跑在边缘运行时上——包括 Cloudflare Workers D1 和 Bun 的 SQLite——那些二进制引擎进不去的地方。
  • Benchmark 显示它和裸驱动的差距在噪声范围内(索引读取 p95 约 2.3 ms 对 2.1 ms),因为它的全部工作就是:构造 SQL,加上类型。

Prisma 7(2025 年 11 月)交出了有诚意的答卷:Rust 引擎没了,客户端体积缩了约 90%,它的 relation API 在深层嵌套读取上依然表达能力更强——而且拥有这个品类里最厚的文档和社区。对已经熟悉它的团队、或者关系模型一团乱麻的项目,它仍然是合理选择。唯一真正的反模式是:一个仓库里放两个 ORM。schema 的单一事实来源只能有一个。

复利式的收益来自 drizzle-zod:一个 schema.ts 同时生成 SQL 迁移(通过 drizzle-kit)、编译期类型,以及第 5 篇里那个边界的运行时校验器。数据库形态、类型安全和输入校验永远不可能互相漂移,因为它们就是同一个文件。

连接才是真正的物理问题

Postgres 每个连接 fork 一个进程,它从容应对的是几百个连接,而不是几千个。现在算一笔账:一个应用跑在三百个边缘节点上,每个节点按请求创建连接,再加上自动扩缩容的 serverless 函数——早饭还没吃完,数据库的连接就被耗光了。这就是本节所有内容存在的原因,也是解决它的两条路径:

  1. 边缘用 HTTP 驱动。 Neon 的 neon-http(以及类似的无状态驱动)通过 HTTP 和数据库对话——根本没有持久连接,这恰好就是无状态 isolate 需要的东西。让所有人栽跟头的规则是:用 HTTP 方言(drizzle-orm/neon-http,而不是 node-postgres),并且让事务保持短小、或者干脆用单语句查询,因为没有 session 替你保管事务状态。
  2. 区域服务器用连接池。 PgBouncer、pgcat 或 Supavisor 把几千个客户端连接复用到几十个真实连接上。Serverless Postgres 厂商都内置了一个——而生产环境最常见的错误就是连错了连接串:应用流量走池化连接串(host 里带 -pooler);迁移走直连的、不池化的连接串,因为 prepared statement 和 session 级特性在 transaction 模式的池化下活不下来。

对于普通的 Node 服务器,postgres.js 配一个大小适中的连接池是当下的默认驱动。在新代码里看到老的 pg 包,说明这段代码有年头了。

不会半夜把你叫醒的迁移

每个有经验的团队都有同一道疤:对一个在线大表执行 ALTER TABLE ... DROP COLUMN 会拿一把 ACCESS EXCLUSIVE 锁,于是一次列改名变成凌晨两点 47 分钟的事故。能消灭这一整类故障的模式叫 expand-and-contract(Martin Fowler 的 ParallelChange):把任何破坏性变更拆成三个可以独立部署、各自可逆的阶段。

  1. Expand(扩) —— 只做新增:新列、新表、新索引(大表上用 CONCURRENTLY 创建,不锁写入)。不删任何东西,不改任何名字。部署。
  2. Migrate(迁) —— 按每批 1,000–10,000 行回填历史数据,批次之间刻意留出停顿(回填打挂网站的机制就是 I/O 打满),同时应用双写新旧两列,让新列永远不落后。然后把读取切到新列。部署。
  3. Contract(收) —— 当旧路径被证明彻底死掉之后,删掉旧列。在现代 Postgres 上这是纯元数据操作:毫秒级,任何时间都可以做。部署。

工具对自己的边界很诚实:drizzle-kit generate 把你的 TypeScript schema diff 成带时间戳的 SQL 文件(要 review——生成的 SQL 偶尔需要手工调整),drizzle-kit push 跳过文件直接改库、适合原型期,但它和 Prisma Migrate 都不替你处理回滚——可逆性是你用上面的模式设计进去的,不是工具还给你的。对于几亿行的表,请按顺序动用重型机械(pg_repackgh-ost)和一位数据库专家。

动手练习,然后进入第 7 篇

  1. 创建一个免费的 serverless Postgres,把同一条 Drizzle 查询跑两遍:一遍在长驻的本地服务器上走池化连接串,一遍在边缘函数里走 HTTP 驱动。冷启动的差异,自己动手量出来。
  2. schema.ts 里写一张表,用 drizzle-kit 生成迁移,用 drizzle-zod 派生插入校验器,接到一个第 5 篇风格的端点上。注意这里只有一份定义,而不是三份。
  3. 找一个你拖延已久的改名需求,写成三个 expand-and-contract 的 PR。今天就发布第一阶段——它对用户毫无可见变化,而这正是重点。

第 7 篇离开机房、走向发射台:部署目标以及它们的真实成本、能接住你笔记本会放过的问题的 CI/CD、先于用户发现问题的可观测性,以及为“反正总会出事”那一天准备的回滚纪律。

guest@swangnice:~$