从热表到对象存储,再回到 Nuxt:PostgreSQL 的两端实践

2026-10-01 28 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

同一套 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+ 建模,而不是只依赖人工推理。对分布式写协议而言,这一点很实际,因为最危险的问题通常藏在崩溃、超时和重试的组合里。

如果要设计类似机制,可以把下面这些性质写成模型或测试不变量:

  1. 任意时刻,最多只有一个写入者持有有效 ticket;
  2. 写入者崩溃后,ticket 最终可以被回收;
  3. 超时重试不会把同一批逻辑数据发布两次;
  4. 只有完整写入的数据文件才能进入可见快照;
  5. catalog 提交失败后,临时文件可以被识别和回收;
  6. 旧 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 端点验证访问层。

真正可靠的抽象不是让复杂性消失,而是明确复杂性由哪一层承担、发生故障时如何暴露,以及团队能否在不推翻上层代码的情况下替换内部实现。


相关推荐