不想为一个本地功能再起 PostgreSQL:PGlite 如何把真实 SQL 语义嵌进 Node 与浏览器
做 AI 应用原型、离线优先网页、命令行工具或集成测试时,经常会遇到一个尴尬的中间地带:业务确实需要 SQL、事务、索引,甚至希望开发和生产都保持 PostgreSQL 语义;但为了一个本地检索索引、会话缓存或测试夹具去启动 Docker、维护端口和清理数据目录,又显得过重。换成内存对象或 SQLite 虽然省事,却可能让 SQL 方言、扩展能力和迁移脚本在真正接入 PostgreSQL 时才暴露差异。
PGlite 提供了另一条路径:它把 PostgreSQL 编译为 WebAssembly,再以 TypeScript 客户端的形式嵌入浏览器、Node.js、Bun 或 Deno。应用调用的是本进程中的数据库对象,不需要先安装数据库服务,也不需要通过 TCP 连接远端实例。npm 当前包版本为 0.5.5;项目采用 Apache-2.0 许可证,并同时说明 PostgreSQL 源码改动遵循 PostgreSQL License。
这里的关键词是“嵌入式”。PGlite 并不是托管 PostgreSQL 的替代品,也不应被理解为可以直接接住多用户生产流量的小型服务端。它更适合把一份真实的 PostgreSQL 能力带进原本不方便依赖外部数据库的运行环境。
先看适用边界:要的是数据库语义,还是共享数据库服务?
在开始安装前,先区分两类需求。第一类是一个应用或一次测试运行内部需要数据库:例如 AI Agent 在本地整理任务、浏览器应用缓存结构化数据、CLI 在用户机器上保存索引,或者测试需要跑迁移和验证查询。这正是 PGlite 擅长的场景。
第二类是多个客户端要并发访问同一份数据,要求连接池、高可用、备份恢复、网络权限控制和运维监测。这仍然属于常规 PostgreSQL 服务端的职责。PGlite README 明确列出限制:它是 single user/connection。因此不要把它放到 Web API 后面,让多个请求把它当作共享数据库;即便原型阶段能够运行,这个架构也会把并发和进程生命周期问题留到以后爆发。
对 AI 开发而言,这个边界尤其重要。一个桌面端或本地 Agent 的“单用户记忆库”可以很适合嵌入;团队所有 Agent 共用的长期知识库,则应选择独立数据库,并把鉴权、备份和资源隔离作为系统设计的一部分。
最小可运行示例:先用内存库验证 SQL 路径
Node 项目中先安装官方包:
npm install @electric-sql/pglite
下面的示例创建内存数据库,建表、写入两条任务,并用参数化查询读取未完成项。它既不需要数据库 URL,也不会在本地留下数据目录,适合单元测试、临时转换或先验证迁移逻辑。
import { PGlite } from "@electric-sql/pglite";
const db = new PGlite();
await db.exec(`
create table agent_tasks (
id serial primary key,
title text not null,
done boolean not null default false
);
`);
await db.query(
"insert into agent_tasks (title, done) values ($1, $2)",
["检查导入文件", false],
);
await db.query(
"insert into agent_tasks (title, done) values ($1, $2)",
["生成摘要", true],
);
const result = await db.query(
"select id, title from agent_tasks where done = $1 order by id",
[false],
);
console.log(result.rows);
这段代码的重点不只是“能跑 SQL”。如果项目未来也会使用 PostgreSQL,尽早在开发期执行接近目标数据库的建表语句、参数格式和查询,有助于避免把只在另一种嵌入式数据库里成立的假设带入生产。反过来,应用仍应把数据库访问封装在一层接口之后:这样本地模式使用 PGlite,部署模式改用服务端 PostgreSQL 时,替换点才清晰。
持久化不是自动发生的:显式选择目录或 IndexedDB
new PGlite() 创建的是临时内存库,进程退出后数据消失。若 CLI 或本地桌面应用需要下次启动继续读取状态,必须显式传入持久化位置。在 Node、Bun 和 Deno 环境中,官方 README 给出的方式是一个文件系统目录:
import { PGlite } from "@electric-sql/pglite";
const db = new PGlite("./.local/pglite-data");
await db.exec(`
create table if not exists runs (
run_id text primary key,
created_at timestamptz not null default now()
);
`);
把数据目录放到项目根目录并不总是正确。它可能被误提交、随打包产物复制,或者让多个测试并发抢同一份状态。更稳妥的做法是:开发时放入已被 .gitignore 排除的目录;测试为每个用例创建独立临时目录;用户数据则放到应用约定的数据目录,并在升级和删除功能中明确处理它。
浏览器侧没有普通文件系统路径。PGlite 使用带 idb:// 前缀的地址把数据持久化到 IndexedDB:
import { PGlite } from "@electric-sql/pglite";
const db = new PGlite("idb://assistant-workspace");
await db.exec(`
create table if not exists notes (
id serial primary key,
body text not null
);
`);
这使离线应用可以保留结构化状态,但不意味着浏览器存储天然适合长期关键数据。用户清理站点数据、无痕模式、浏览器配额和不同设备之间的不共享,都会影响可用性。对需要同步、协作或灾备的数据,仍要明确远端存储和导出策略;不要把 IndexedDB 当成不需要备份的服务器磁盘。
为什么它不是“浏览器里的虚拟机”
PGlite 的实现选择也解释了它的能力边界。README 特别指出,它不像一些早期“浏览器中运行 PostgreSQL”的方案那样依赖 Linux 虚拟机,而是直接运行 WASM 版本的 PostgreSQL。项目利用 PostgreSQL 原有的 single-user mode,并为 JavaScript 环境建立输入输出通路。
这种设计的结果是,开发者可以在 JavaScript 进程中使用 PostgreSQL,而无需把一个完整多进程服务端带入页面或 CLI。它也解释了为何不该从“能在本地跑”推导出“能模拟完整生产集群”:网络监听、多个独立连接、服务端运维和跨主机共享并不是该嵌入式模型的目标。
项目还说明支持多种 PostgreSQL 扩展,包括 pgvector 和 PostGIS。这里同样要避免过度承诺:扩展可用不等于任何工作负载都适合放进浏览器或单进程。模型向量、数据量、初始化时间和内存占用仍应通过实际数据压测;在产品中更应把索引构建、迁移版本和数据清理纳入生命周期管理。
把它放进工程,而不是放进演示
让测试暴露迁移问题,而不是制造测试替身
一个实用做法是让每个测试文件创建自己的内存 PGlite,再在 beforeEach 中执行与应用相同的建表或迁移函数。这样测试不依赖开发者机器上已运行的 PostgreSQL,也不会因共享测试库留下的旧数据而偶发失败。测试结束后进程退出,内存库自然消失;若某个失败用例需要保留现场,则改用唯一的临时目录,并把目录路径打印到日志中供排查。
但“SQL 能执行”不等于迁移已经可靠。应至少覆盖三类断言:新安装能否从空库建出目标 schema;旧版本数据能否按预期升级;重复执行初始化是否安全。尤其是 create table if not exists 只能避免建表报错,不能自动处理列类型、索引或约束的变化。把这些变更写成明确、可排序的迁移步骤,才不会让本地开发与上线过程各自积累一套隐性状态。
一条更可靠的落地路线可以分四步。第一,先把 schema 和迁移脚本从业务逻辑中拆出来,在内存 PGlite 中跑测试;第二,为需要保留的本地功能选择受控数据目录或 IndexedDB 名称;第三,在应用启动时检查 schema 版本,必要时执行迁移;第四,为导出、清空和诊断提供显式命令,而不是要求用户手动删除未知目录。
还应把进程边界写进设计文档。若一个 Electron 主进程和多个渲染页面都需要访问数据,不要默认它们可以各自打开同一份 PGlite 数据并安全协调;应指定单一拥有者,并通过进程通信暴露受控操作。若后续功能演变为多个用户共同访问,再把同一套迁移和查询逻辑迁移到标准 PostgreSQL 服务,而不是继续给嵌入式实例叠加网络代理。
PGlite 最有价值的地方,不是让所有项目“去掉 PostgreSQL”,而是让本地、离线、测试和原型阶段不必在“真实数据库语义”与“零外部服务依赖”之间二选一。只要清楚地把单用户单连接当作架构边界,它就能成为一个很干净的工具:本地用真实 SQL 快速验证,服务端规模化时再回到真正该由 PostgreSQL 服务端承担的部分。