同一套 PostgreSQL,可以从两个距离很远的方向观察:在存储层,团队希望把冷数据迁移到 Apache Iceberg 和对象存储,同时不改应用查询;在应用层,Nuxt 开发者则希望用简洁、安全的方式访问数据库。这两类问题表面上一个偏基础设施、一个偏前端框架,实际都在处理同一件事:如何把复杂性封装在稳定边界之后。
冷数据迁走了,关系却不能消失
ColdFront 展示的思路,是把冷行从 PostgreSQL 转移到对象存储,以 Apache Iceberg 表管理元数据,并使用列式 Parquet 文件保存数据。PostgreSQL 仍然负责原有表和 SQL 接口,pg_duckdb 在进程内读写 Iceberg,Lakekeeper 提供目录服务,对象存储则可以沿用团队已有的设施。
最重要的设计目标不是“把数据写成 Parquet”,而是让应用继续查询同一个关系。理想情况下,上层业务不需要知道某行位于 PostgreSQL 堆表还是对象存储:
Application SQL
│
▼
One logical relation
├── hot rows → PostgreSQL
└── cold rows → Iceberg catalog → Parquet in object storage
这种透明性会直接影响迁移策略。实施时至少要明确以下规则:
- 哪些行算冷数据:按创建时间、最后访问时间,还是业务状态判断;
- 迁移期间如何避免热表与冷层同时出现同一行;
- 查询是否需要跨冷热层排序、聚合和过滤;
- 冷层不可用时,是让查询失败、返回部分结果,还是降级到异步任务;
- 数据保留、删除和合规请求如何同步到 Iceberg 快照及底层文件。
因此,分层不是一次简单的 INSERT ... SELECT。它改变了数据的故障域、可见性和生命周期。
多节点写入时,票据协议比文件格式更关键
单节点向 Iceberg 写入相对直接;当多个 PostgreSQL 节点共享一个 Iceberg catalog 和同一个 bucket 时,真正棘手的是并发提交。两个节点如果同时基于旧快照生成文件并更新元数据,就可能发生提交冲突或产生需要清理的孤儿文件。
ColdFront 的处理方式是在每次冷层写入前取得 ticket,让两个写入者从一开始就不会相撞。这个协议还使用 TLA+ 建模,而不是只依赖人工推理。对分布式写协议而言,这一点很实际,因为最危险的问题通常藏在崩溃、超时和重试的组合里。
如果要设计类似机制,可以把下面这些性质写成模型或测试不变量:
- 任意时刻,最多只有一个写入者持有有效 ticket;
- 写入者崩溃后,ticket 最终可以被回收;
- 超时重试不会把同一批逻辑数据发布两次;
- 只有完整写入的数据文件才能进入可见快照;
- catalog 提交失败后,临时文件可以被识别和回收;
- 旧 ticket 不能覆盖新持有者已经提交的状态。
票据机制也有代价:它把并发写转换成了受控串行化,可能成为吞吐瓶颈。上线前应分别测量文件写入时间、catalog 提交时间、ticket 等待时间和失败重试次数,而不是只观察一条端到端延迟。
冷层还可以承载向量检索。这里的特点是探测可以表现为普通 WHERE 条件,不必先为整批冷数据构建传统索引。不过,没有索引并不意味着没有成本:扫描多少 Parquet row group、能否下推过滤条件、对象存储请求次数以及并发查询量,都会决定实际延迟。
从另一端看 PostgreSQL:Nuxt API 应该保持薄而安全
另一场分享从 Nuxt 出发,沿 Vue、Nitro 和 UnJS 走到数据库访问层。db0 的定位是用统一 API 包装多个 SQL 驱动,并负责参数化查询。这种渐进式抽象很适合框架生态:小项目可以快速采用,需求增加后再更换驱动或补充能力,而不必一开始就引入庞大的数据访问层。
下面给出一个可以直接改造的 Nuxt 服务器端示例。为了让代码独立可运行,这里使用 postgres 包连接 PostgreSQL,而不是复现演讲中的 db0 接口;如果项目需要统一多个数据库驱动,可以再把这个模块替换为 db0 适配层。
先安装依赖:
npm install postgres
准备数据库并设置连接字符串:
export DATABASE_URL='postgres://app:secret@localhost:5432/appdb'
psql "$DATABASE_URL" <<'SQL'
CREATE TABLE IF NOT EXISTS app_user (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO app_user (name)
VALUES ('Ada'), ('Linus'), ('Grace');
SQL
创建 server/utils/db.ts:
import postgres from 'postgres'
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required')
}
export const sql = postgres(process.env.DATABASE_URL, {
max: 10,
idle_timeout: 20,
})
再创建 server/api/users.get.ts:
import { getQuery } from 'h3'
import { sql } from '../utils/db'
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const requested = Number(query.limit ?? 20)
const limit = Number.isInteger(requested)
? Math.min(Math.max(requested, 1), 100)
: 20
const users = await sql`
SELECT id, name, created_at
FROM app_user
ORDER BY id DESC
LIMIT ${limit}
`
return { users }
})
启动 Nuxt 后即可测试:
npm run dev
curl 'http://localhost:3000/api/users?limit=2'
${limit} 会作为查询参数传给驱动,而不是直接拼进 SQL 文本。实际项目仍要注意:动态列名和排序方向通常不能像普通值一样参数化,必须通过白名单映射。
例如,不要直接把 sort 查询参数拼到 ORDER BY 后面,而应明确列出允许值:
const sortColumns = {
newest: sql`created_at DESC`,
name: sql`name ASC`,
} as const
const sort = query.sort === 'name' ? sortColumns.name : sortColumns.newest
const users = await sql`
SELECT id, name, created_at
FROM app_user
ORDER BY ${sort}
LIMIT ${limit}
`
部署到无服务器或弹性扩缩环境时,还要重新计算连接总量。每个实例的连接池上限 × 最大实例数 不应超过 PostgreSQL 能承受的连接数;必要时应加入连接代理,而不是单纯调高 max_connections。
两种“渐进式”设计其实遵循同一原则
冷热分层让存储实现发生变化,却尽量保留原来的 SQL 关系;Nuxt 数据库层让底层驱动可以变化,却尽量保留应用调用方式。两者都在依靠稳定接口隔离内部复杂度。
落地时可以使用这份检查清单:
- 先定义边界:应用依赖的是逻辑关系、SQL 语义,还是某个具体驱动;
- 把并发当成协议问题:多写入者场景需要明确所有权、过期、重试和恢复;
- 默认参数化查询:仅对无法参数化的标识符采用白名单;
- 分别观察冷热路径:记录 PostgreSQL 查询、对象存储扫描、catalog 提交和 ticket 等待;
- 控制连接与并发:数据库连接池和 Iceberg 写入票据都可能成为全局瓶颈;
- 准备失败演练:模拟节点崩溃、catalog 超时、对象存储不可用和重复请求;
- 渐进采用:先迁移可重建、低频访问的数据,或先用一个只读 Nuxt 端点验证访问层。
真正可靠的抽象不是让复杂性消失,而是明确复杂性由哪一层承担、发生故障时如何暴露,以及团队能否在不推翻上层代码的情况下替换内部实现。