· mongodb / postgresql / database
Migrate MongoDB sang PostgreSQL mà không cần downtime
Migrate MongoDB sang PostgreSQL không downtime bằng dual-write: ETL TypeScript, mapping schema, kiểm tra dữ liệu, và 8 vấn đề thực tế từ migration production.
Bởi Ethan
2.116 từ · 11 phút đọc
Migrate MongoDB sang PostgreSQL không phải là phần khó. Phần khó là làm điều đó mà không cần maintenance window trong khi traffic production vẫn chạy bình thường. Bài này trình bày chiến lược dual-write — cho phép bạn rollback ngay lập tức ở bất kỳ bước nào. Bạn có thể chạy ETL trong cuối tuần và bật flag cutover vào sáng thứ Ba mà không cần phải gọi cho ai.
Đã kiểm thử với MongoDB 8.0 và PostgreSQL 18.3.
Dành cho ai
Backend developer đang chạy MongoDB trên production — dù là Atlas hay tự host — và cần chuyển sang PostgreSQL. Bài này phù hợp với codebase dưới ~50 GB. Nếu vượt ngưỡng đó hoặc cần CDC sync liên tục, bạn có thể bỏ qua xuống phần AWS DMS ở cuối bài.
Chọn tool
Đừng dùng pgloader. pgloader 3.6.9 hỗ trợ MySQL, SQLite, và MS SQL Server — không phải MongoDB. Bất kỳ hướng dẫn nào đề xuất dùng nó cho MongoDB đều sai.
Tool phù hợp phụ thuộc vào quy mô:
| Tool | Phù hợp với | Chi phí |
|---|---|---|
mongodump + custom ETL script | Indie dev, SaaS nhỏ (<50 GB) | Miễn phí |
| AWS DMS | Team vừa, >50 GB, kiểu dữ liệu phức tạp | ~$200/tháng EC2 |
| Airbyte / Hevo | Managed CDC, sync liên tục | Paid SaaS |
Bài này đi sâu vào con đường miễn phí. Phần DMS và Airbyte được giới thiệu ở cuối bài.
Bảng mapping schema
Trước khi viết một dòng code nào, hãy map từng khái niệm MongoDB sang PostgreSQL tương ứng.
| Khái niệm MongoDB | Tương đương trong PostgreSQL |
|---|---|
| Collection | Table |
_id (ObjectId) | mongo_id TEXT + id UUID DEFAULT gen_random_uuid() |
| Nested document | Cột JSONB (hoặc normalize thành bảng con) |
| Array field | Cột JSONB (hoặc bảng con với FK) |
ISODate | TIMESTAMP WITH TIME ZONE |
| Không có schema cứng | Thêm constraint NOT NULL sau khi backfill xong |
Cột mongo_id là lưới an toàn trong quá trình migrate. Giữ lại nó cho đến khi bạn đã xác nhận mọi foreign key reference đều được cập nhật. Xóa đi sau khi cutover đã ổn định.
Nếu bạn đang xây SaaS multi-tenant, đọc các pattern Postgres multi-tenant 2026 trước khi chốt thiết kế schema — quyết định giữa row-level security và schema-per-tenant rất khó thay đổi về sau.
Bước 1: Tạo schema PostgreSQL
Map từng collection thành một table. Ví dụ với collection users điển hình:
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
mongo_id TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
name TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
-- Recreate Mongo indexes as concurrent builds so production reads don't stall
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
Điều cần tránh: Thêm constraint NOT NULL trước khi backfill xong sẽ làm từ chối các row có dữ liệu thiếu trong MongoDB. Bỏ qua các constraint cho đến khi Bước 5 xác nhận backfill đã hoàn chỉnh.
Bước 2: Dump MongoDB mà không cần downtime
Với replica set (tự host hoặc Atlas M10+), dùng --oplog để ghi lại các write xảy ra trong quá trình dump:
mongodump \
--uri="mongodb+srv://user:[email protected]/mydb" \
--oplog \
--readPreference=secondary \
--gzip \
--out=./dump
--readPreference=secondary giữ dump không ảnh hưởng đến primary. --oplog ghi lại các write trong khoảng thời gian dump để bạn có thể replay lại.
Điều cần tránh: --oplog bị chặn trên Atlas M0, M2, và M5 (cluster miễn phí/shared). Trên các tier đó, dùng mongoexport theo từng collection:
mongoexport \
--uri="mongodb+srv://user:[email protected]/mydb" \
--collection=users \
--out=./dump/users.json
Bạn sẽ mất khả năng capture oplog, vì vậy hãy chạy ETL vào giờ ít traffic và chấp nhận một khoảng lag nhỏ.
Bước 3: Viết ETL script
Script TypeScript này đọc trực tiếp từ MongoDB và ghi vào PostgreSQL theo từng batch. Nó idempotent — chạy nhiều lần cũng không bị trùng row.
Cài đặt dependencies:
npm install mongodb pg
npm install --save-dev @types/pg tsx
// migrate.ts
import { MongoClient } from 'mongodb';
import { Pool } from 'pg';
const MONGO_URI = process.env.MONGO_URI!;
const PG_URI = process.env.PG_URI!;
const BATCH_SIZE = 500;
async function migrate() {
const mongo = new MongoClient(MONGO_URI);
const pg = new Pool({ connectionString: PG_URI });
await mongo.connect();
const db = mongo.db('mydb');
const collection = db.collection('users');
let skip = 0;
let processed = 0;
while (true) {
const docs = await collection
.find()
.sort({ _id: 1 })
.skip(skip)
.limit(BATCH_SIZE)
.toArray();
if (docs.length === 0) break;
const values = docs.map((doc) => ({
mongo_id: doc._id.toString(),
email: doc.email as string,
name: (doc.name as string) ?? null,
// JSON null vs SQL NULL — normalize explicitly
metadata: doc.metadata ? JSON.stringify(doc.metadata) : null,
// ISODate → new Date(milliseconds) for TIMESTAMP conversion
created_at: doc.createdAt instanceof Date
? doc.createdAt
: new Date(doc.createdAt),
updated_at: doc.updatedAt instanceof Date
? doc.updatedAt
: new Date(doc.updatedAt),
}));
await pg.query(
`INSERT INTO users (mongo_id, email, name, metadata, created_at, updated_at)
SELECT
v.mongo_id, v.email, v.name,
v.metadata::jsonb,
v.created_at::timestamptz, v.updated_at::timestamptz
FROM jsonb_to_recordset($1::jsonb) AS v(
mongo_id text, email text, name text,
metadata text, created_at text, updated_at text
)
ON CONFLICT (mongo_id) DO NOTHING`,
[JSON.stringify(values)]
);
processed += docs.length;
skip += BATCH_SIZE;
console.log(`Migrated ${processed} rows`);
}
await mongo.close();
await pg.end();
}
migrate().catch(console.error);
Chạy:
MONGO_URI="mongodb+srv://..." PG_URI="postgresql://..." npx tsx migrate.ts
Điều cần tránh: Số được lưu dạng string trong MongoDB sẽ insert vào dạng TEXT và lỗi constraint NUMERIC. Thêm type guard vào ETL:
// In the values map, before inserting:
const price = typeof doc.price === 'string'
? parseFloat(doc.price)
: (doc.price as number) ?? null;
Điều cần tránh: ON CONFLICT (mongo_id) DO NOTHING giúp re-run an toàn, nhưng cũng bỏ qua lặng lẽ các row mà lần insert đầu tiên thất bại vì lý do khác. Kiểm tra số lượng row sau mỗi lần chạy (Bước 5).
Bước 4: Thiết lập dual-write với feature flag
Đừng chuyển hẳn trong một lần. Hãy chạy song song cả hai database và dịch chuyển traffic từng bước.
// db-router.ts
type DbTarget = 'mongo' | 'both' | 'postgres';
const DB_TARGET: DbTarget = (process.env.DB_TARGET as DbTarget) ?? 'mongo';
export async function writeUser(data: UserData) {
if (DB_TARGET === 'mongo' || DB_TARGET === 'both') {
await mongoDb.collection('users').insertOne(toMongoDoc(data));
}
if (DB_TARGET === 'both' || DB_TARGET === 'postgres') {
await pgPool.query(
`INSERT INTO users (mongo_id, email, name, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (mongo_id) DO UPDATE SET email = EXCLUDED.email`,
[data.mongoId, data.email, data.name, data.createdAt, data.updatedAt]
);
}
}
export async function readUser(id: string) {
if (DB_TARGET === 'mongo') return mongoDb.collection('users').findOne({ _id: id });
return pgPool.query('SELECT * FROM users WHERE mongo_id = $1', [id]).then(r => r.rows[0]);
}
Bốn giai đoạn:
| Giai đoạn | DB_TARGET | Ảnh hưởng |
|---|---|---|
| 1 — Backfill | mongo | Production đọc/ghi vào Mongo; ETL copy lịch sử sang PG |
| 2 — Dual-write | both | Tất cả write vào cả hai; đọc từ Mongo |
| 3 — PG reads | both (đọc từ PG) | Ghi vào cả hai; đọc từ PG |
| 4 — Cutover | postgres | Mongo không nhận gì nữa |
Rollback ở bất kỳ giai đoạn nào: đặt DB_TARGET về một bước trước. Không mất dữ liệu — Mongo vẫn còn tất cả mọi thứ cho đến điểm cutover.
Điều cần tránh: Nếu service của bạn chạy nhiều instance, đặt DB_TARGET trong config môi trường (Railway, Fly, Kubernetes env), không phải theo từng instance. Flag không nhất quán giữa các instance sẽ tạo ra write split, rất khó để đồng bộ lại.
Bước 5: Kiểm tra dữ liệu
Chạy các kiểm tra này trước Giai đoạn 4.
// validate.ts
import { MongoClient } from 'mongodb';
import { Pool } from 'pg';
async function validate() {
const mongo = new MongoClient(process.env.MONGO_URI!);
const pg = new Pool({ connectionString: process.env.PG_URI! });
await mongo.connect();
const mongoCount = await mongo.db('mydb').collection('users').countDocuments();
const pgResult = await pg.query('SELECT COUNT(*) FROM users');
const pgCount = parseInt(pgResult.rows[0].count, 10);
console.log(`MongoDB: ${mongoCount} rows`);
console.log(`PostgreSQL: ${pgCount} rows`);
if (mongoCount !== pgCount) {
console.error(`Row count mismatch: ${mongoCount - pgCount} rows missing`);
}
// Spot-check 20 random IDs
const samples = await mongo
.db('mydb')
.collection('users')
.aggregate([{ $sample: { size: 20 } }])
.toArray();
for (const doc of samples) {
const pgRow = await pg.query('SELECT * FROM users WHERE mongo_id = $1', [
doc._id.toString(),
]);
if (pgRow.rows.length === 0) {
console.error(`Missing in PG: ${doc._id.toString()}`);
}
}
// NULL check for required fields
const nullEmails = await pg.query(
'SELECT COUNT(*) FROM users WHERE email IS NULL'
);
console.log(`Rows with NULL email: ${nullEmails.rows[0].count}`);
await mongo.close();
await pg.end();
}
validate().catch(console.error);
Nếu số lượng row khớp và spot-check qua hết, bạn đã sẵn sàng cho Giai đoạn 4.
Điều cần tránh: Query SELECT DISTINCT có thể chậm hơn đáng kể trên PostgreSQL so với MongoDB vì Mongo tránh được table scan nhờ mô hình document. Hãy benchmark các read pattern phổ biến nhất trước khi cutover. Nếu một query bị chậm hơn, hãy thêm partial index hoặc viết lại nó.
Bước 6: Chuyển traffic
# Deploy with DB_TARGET=postgres
DB_TARGET=postgres railway up # or fly deploy, kubectl set env, etc.
Theo dõi error rate trong 30 phút. Nếu có gì bất ổn, rollback bằng:
DB_TARGET=mongo railway up
Khi đã chắc chắn, dừng việc ghi vào Mongo bằng cách xóa path both, và lên lịch tắt Mongo sau hai tuần. Đừng xóa database ngay ngày cutover.
Sau khi migration xong, bước tự nhiên tiếp theo là cài ORM để query type-safe. Hướng dẫn cài Drizzle ORM với Postgres bao gồm khai báo schema, migration, và pgvector nếu bạn cần thêm vector search.
8 vấn đề thực tế (từ migration của Voucherify)
Đây là những vấn đề thực tế, được lấy từ bài post-mortem công khai của Voucherify về migration MongoDB→PostgreSQL trên production.
-
Chuyển đổi
ISODate: MongoDB’sISODatelưu milliseconds từ epoch. Dùngnew Date(milliseconds)rõ ràng trong ETL — đừng giả định driver serialize đúng cho mọi field. -
JSON null vs SQL NULL: Document trong MongoDB có thể có field được set thành
nullmột cách rõ ràng. Điều đó khác với field bị thiếu. Normalize bằngdoc.field ?? nullđể tránh insert chuỗi"null"vào cột JSONB. -
Số lưu dạng string: Tính schemaless của MongoDB cho phép
{ price: "9.99" }và{ price: 9.99 }tồn tại trong cùng một collection. Thêm type guard vào ETL trước khi insert vào cộtNUMERIC. -
Đừng thêm
NOT NULLtrước khi backfill xong: Constraint thêm vào trước khi ETL chạy xong sẽ từ chối các row chưa đầy đủ. Thêm constraint trong một migration riêng sau khi Bước 5 đã qua. -
SELECT DISTINCTregression: Postgres có thể chậm hơn vớiDISTINCTso với indexed read của Mongo. Benchmark trước cutover, không phải sau. -
--oplogbị chặn trên Atlas M0/M2/M5: Cluster miễn phí và shared không cho truy cập oplog. Dùngmongoexporttheo từng collection và chấp nhận khoảng lag. -
JSONB bloat khi update nhiều nested doc: Nếu cột JSONB được cập nhật thường xuyên, MVCC của Postgres tạo ra nhiều phiên bản row khiến table bị bloat. Hãy tách các field cập nhật nhiều ra thành cột có kiểu riêng và giữ JSONB chỉ cho dữ liệu ít thay đổi.
-
Tạo lại index: Mỗi index trong Mongo đều cần một
CREATE INDEX CONCURRENTLYtương ứng trong Postgres. Đừng bỏ qua — một index bị thiếu trên table lớn sẽ hiện ra dưới dạng query chậm trên production, không phải là lỗi rõ ràng.
Với migration quy mô lớn hơn: AWS DMS
Nếu dataset vượt 50 GB hoặc schema có các kiểu phức tạp (document đa hình, mảng lồng nhau nhiều cấp được dùng trong query), AWS DMS là lựa chọn thực tế hơn. Nó xử lý CDC replication liên tục, cho phép bạn chạy song song Mongo và Postgres trong nhiều tuần rồi cutover với độ trễ gần bằng không.
Chi phí khoảng $200/tháng cho một replication instance r5.large. Chạy trong thời gian migrate rồi tắt đi.
Để host PostgreSQL đích: Neon đáng cân nhắc cho các deployment serverless — tính năng branching và point-in-time restore giúp đơn giản hóa bước kiểm tra đáng kể. Bài Neon vs Supabase so sánh chi tiết nếu bạn đang cân nhắc giữa hai nền tảng.