为什么我从 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 可能不是最优选择。
性能对比
在做迁移决策前,我研究了第三方的基准测试数据:
冷启动延迟
| 区域 | Neon | Turso |
|---|---|---|
| 美东 | 449ms | 105ms |
| 欧洲 | 792ms | 482ms |
| 东京 | 642ms | 856ms |
热查询延迟
| 区域 | Neon | Turso |
|---|---|---|
| 美东 | 22ms | 14ms |
| 欧洲 | 275ms | 182ms |
| 东京 | 357ms | 180ms |
从数据看,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 是更合适的选择。