返回博客

为什么我从 Turso 迁移到 Neon

Francis
Francis
MetaSight 创始人
为什么我从 Turso 迁移到 Neon

当业务需求遇上数据库限制,一个独立开发者的 Serverless 数据库迁移实录

起因:一个关于时间的需求

MetaSight 的核心功能之一是命理计算,而准确的命理计算需要「真太阳时」——不是手机上显示的北京时间,而是根据用户所在经纬度计算出的当地实际太阳时间。

这意味着我需要存储用户的地理坐标,根据经纬度查询对应的时区,并精确处理各种时间格式和时区转换。当我开始评估这个需求时,发现现有的数据库选型正在成为瓶颈。

回顾:为什么最初选择 Turso

项目启动时,我选择了 Turso——一个基于 SQLite 的 Serverless 数据库。SQLite 的轻量特性意味着更快的冷启动,不需要单独管理数据库服务器,免费额度也足够早期开发。对于一个刚起步的 side project,这些都是合理的考量。

遇到的限制

随着产品迭代,问题开始累积。

时间类型的妥协

SQLite 没有原生的时间戳类型。在 Drizzle ORM 中,我不得不用 integer 模拟:

// SQLite: 用整数存储时间戳
export const users = sqliteTable("users", {
  id: text("id").primaryKey(),
  createdAt: integer("created_at", { mode: "timestamp" }),
  birthTime: integer("birth_time", { mode: "timestamp" }),
});

每次读取都需要手动转换,时区处理更是一团乱麻。

地理数据的缺失

SQLite 没有原生的地理数据类型。存储经纬度只能拆成两个浮点数字段:

// SQLite: 拆分存储经纬度
export const locations = sqliteTable("locations", {
  latitude: real("latitude"),
  longitude: real("longitude"),
});

想要查询「某个点属于哪个时区」?需要在应用层实现复杂的几何计算。

第三方库的适配成本

Better Auth 等认证库原生支持 PostgreSQL,但在 SQLite 上需要额外的适配层:

// SQLite 适配器需要手动处理类型转换
const adapter = new LibsqlAdapter(client, {
  transformRow: (row) => ({
    ...row,
    createdAt: new Date(row.createdAt),
    updatedAt: row.updatedAt ? new Date(row.updatedAt) : null,
  }),
});

每次升级库版本都要小心翼翼,担心适配层出问题。

Serverless 并非最佳场景

Turso 近期做了重大调整:多区域边缘副本(Edge Replication)功能已停用,新用户只能使用单一区域部署。这意味着如果你的用户分布在全球各地,数据库延迟会成为瓶颈。

Turso 目前最强的性能特性是 Embedded Replicas——把数据库副本嵌入到应用进程内,读取延迟几乎为零。但这个特性需要文件系统支持,而 Vercel Functions、Cloudflare Workers 这类 serverless 平台没有持久文件系统。

换句话说,Turso 的最佳使用场景是 VPS 或容器部署(可以使用 Embedded Replicas),而不是纯 serverless。如果你在 Vercel 上部署,又需要全球低延迟访问,Turso 可能不是最优选择。

性能对比

在做迁移决策前,我研究了第三方的基准测试数据

冷启动延迟

区域NeonTurso
美东449ms105ms
欧洲792ms482ms
东京642ms856ms

热查询延迟

区域NeonTurso
美东22ms14ms
欧洲275ms182ms
东京357ms180ms

从数据看,Turso 在冷启动上确实有优势,但在东京区域两者差距不大。遗憾的是,目前主流基准测试都没有覆盖新加坡或香港区域。

不过,Neon 支持连接池(PgBouncer),可以有效缓解冷启动问题;热查询延迟两者相近;而且部署在 Vercel 上可以利用 Fluid Compute 优化。综合来看,性能不是阻碍迁移的理由。

为什么选择 PostgreSQL

真正让我下定决心的是 PostgreSQL 的数据类型支持。

原生时间戳

// PostgreSQL: 原生时间戳支持
export const users = pgTable("users", {
  id: text("id").primaryKey(),
  createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
  birthTime: timestamp("birth_time", { withTimezone: true }),
});

不需要转换,时区信息直接存储,查询时自动处理。

PostGIS 地理扩展

PostgreSQL 的 PostGIS 扩展是处理地理数据的行业标准。虽然我还没有在生产环境启用,但迁移后这个能力随时可用:

-- 根据经纬度查询时区(规划中的功能)
SELECT tzid FROM timezones
WHERE ST_Intersects(
  geom,
  ST_SetSRID(ST_MakePoint(116.4074, 39.9042), 4326)
);

一行 SQL 解决之前需要在应用层写一堆代码的问题。

为什么是 Neon 而不是 Supabase

市面上的 Serverless PostgreSQL 服务不止 Neon 一家。Supabase 同样流行,但我选择了 Neon,原因在于它更符合我的使用场景。

Neon 的核心特性是 Scale to Zero——数据库在 5 分钟不活跃后自动暂停 compute,只有存储产生费用。对于一个还在验证产品的 side project,这意味着开发环境几乎零成本。Supabase 不支持这个特性,即使数据库闲置也会持续计费。

另一个决定性因素是 Neon 的 Serverless Driver@neondatabase/serverless 支持 HTTP 和 WebSocket 协议,可以在 Vercel Edge Functions 和 Cloudflare Workers 等边缘环境中直接使用,无需传统的 TCP 连接。这与 Vercel 的 Fluid Compute 配合良好。

Supabase 更像是一个「Firebase 替代品」,提供 Auth、Realtime、Storage 等全套服务。如果你需要这些,它是个好选择。但我只需要一个纯粹的数据库,额外的中间件反而增加了复杂度。Neon 更接近标准 Postgres,迁移成本更低。

迁移方案选型

数据库迁移有多种策略,每种都有其适用场景和风险。

方案一:停机迁移

最简单的方案:停止服务 → 导出数据 → 导入新库 → 恢复服务。

风险在于停机时间不可控——大数据量可能需要数小时,迁移失败时回滚成本高,用户体验也会受影响。这个方案只适合用户量极小、可接受长时间停机的早期项目。

方案二:双写 + 渐进切换

同时写入新旧两个数据库,逐步将读请求切换到新库。

// 双写示例
async function createUser(data: UserData) {
  // 写入旧库
  await tursoDb.insert(users).values(data);

  // 同步写入新库
  await neonDb.insert(users).values(transformForPg(data));
}

优势很明显:零停机、可随时回滚、渐进验证数据一致性。但风险同样不小——需要维护两套数据库连接,双写期间的事务一致性难以保证,代码复杂度会显著增加。这个方案适合高可用要求的生产系统,前提是团队有足够资源维护双写逻辑。

方案三:CDC(变更数据捕获)

使用 Debezium 等工具捕获源库的变更日志,实时同步到目标库。这个方案对应用代码无侵入,支持增量同步,适合大数据量迁移。但架构复杂——需要引入 Kafka 等消息队列,而且 SQLite 的 CDC 生态不成熟,运维成本高。更适合有专门数据团队支持的大型企业级系统。

我的选择:分阶段迁移 + 幂等脚本

考虑到 MetaSight 的实际情况——用户量可控、数据量不大、但需要保证数据完整性——我采用了一个折中方案:选择低峰时段设置短暂的只读模式,使用幂等脚本确保迁移可重复执行、失败后可继续,迁移后逐表对比记录数和关键字段进行数据校验。

迁移实践

幂等脚本设计

迁移脚本的核心要求是可重复执行。即使中途失败,重新运行也不会产生重复数据:

async function migrateTable(tableName: string) {
  // 检查迁移状态
  const status = await getMigrationStatus(tableName);
  if (status === 'completed') {
    console.log(`[跳过] ${tableName} 已完成迁移`);
    return;
  }

  // 获取上次迁移的 offset
  const lastOffset = status?.lastOffset ?? 0;
  let offset = lastOffset;
  const batchSize = 1000;

  console.log(`[开始] ${tableName} 从 offset ${offset} 继续`);

  while (true) {
    const rows = await sourceDb
      .select()
      .from(table)
      .limit(batchSize)
      .offset(offset);

    if (rows.length === 0) break;

    // 使用 upsert 确保幂等性
    await targetDb
      .insert(table)
      .values(rows.map(transformRow))
      .onConflictDoUpdate({
        target: table.id,
        set: rows.map(transformRow)[0], // 更新为最新值
      });

    offset += rows.length;

    // 记录进度,支持断点续传
    await updateMigrationStatus(tableName, { lastOffset: offset });

    console.log(`[进度] ${tableName}: ${offset} 条`);
  }

  await updateMigrationStatus(tableName, { status: 'completed' });
  console.log(`[完成] ${tableName}`);
}

数据转换层

从 SQLite 到 PostgreSQL,需要处理类型差异:

function transformRow(row: SqliteRow): PostgresRow {
  return {
    ...row,
    // integer → timestamp
    createdAt: new Date(row.createdAt * 1000),
    updatedAt: row.updatedAt ? new Date(row.updatedAt * 1000) : null,
    // text → jsonb(如果有 JSON 字段)
    metadata: row.metadata ? JSON.parse(row.metadata) : null,
  };
}

数据校验

迁移完成后,逐表校验数据完整性:

async function validateMigration() {
  for (const tableName of tables) {
    const sourceCount = await sourceDb
      .select({ count: count() })
      .from(table);

    const targetCount = await targetDb
      .select({ count: count() })
      .from(table);

    if (sourceCount !== targetCount) {
      throw new Error(
        `${tableName} 记录数不一致: 源 ${sourceCount}, 目标 ${targetCount}`
      );
    }

    console.log(`[校验通过] ${tableName}: ${sourceCount} 条`);
  }
}

连接池配置

Serverless 环境下,连接管理至关重要。Neon 内置的 PgBouncer 让这变得简单:

import { Pool } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-serverless";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
});

export const db = drizzle({ client: pool });

使用 -pooler 后缀的连接字符串,Neon 自动处理连接池,支持最多 10,000 并发连接。

迁移后的变化

代码变得更简洁。之前需要适配层处理的类型转换,现在数据库原生支持。

开发体验明显改善。Drizzle ORM 对 PostgreSQL 的支持更完善,IDE 提示更准确,文档示例可以直接用。

为未来的功能预留了空间。真太阳时计算、地理围栏、位置搜索——这些需求现在只差业务层的实现。

写给正在做选择的你

如果你也在纠结数据库选型,建议从需求出发——不是「哪个更快」,而是「哪个能满足我的数据类型需求」。主流方案意味着更好的工具链和社区支持,而且技术债务会随着数据量增长变得更难还,所以迁移宜早不宜迟。

Turso 是一个优秀的产品,更适合那些不需要复杂数据类型、追求极致冷启动性能的场景。但对于 MetaSight 这样需要处理时间和地理数据的应用,PostgreSQL 是更合适的选择。


延伸阅读