· 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

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ô:

ToolPhù hợp vớiChi phí
mongodump + custom ETL scriptIndie dev, SaaS nhỏ (<50 GB)Miễn phí
AWS DMSTeam vừa, >50 GB, kiểu dữ liệu phức tạp~$200/tháng EC2
Airbyte / HevoManaged CDC, sync liên tụcPaid 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 MongoDBTương đương trong PostgreSQL
CollectionTable
_id (ObjectId)mongo_id TEXT + id UUID DEFAULT gen_random_uuid()
Nested documentCột JSONB (hoặc normalize thành bảng con)
Array fieldCột JSONB (hoặc bảng con với FK)
ISODateTIMESTAMP WITH TIME ZONE
Không có schema cứngThê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ạnDB_TARGETẢnh hưởng
1 — BackfillmongoProduction đọc/ghi vào Mongo; ETL copy lịch sử sang PG
2 — Dual-writebothTất cả write vào cả hai; đọc từ Mongo
3 — PG readsboth (đọc từ PG)Ghi vào cả hai; đọc từ PG
4 — CutoverpostgresMongo 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.

  1. Chuyển đổi ISODate: MongoDB’s ISODate lưu milliseconds từ epoch. Dùng new Date(milliseconds) rõ ràng trong ETL — đừng giả định driver serialize đúng cho mọi field.

  2. JSON null vs SQL NULL: Document trong MongoDB có thể có field được set thành null một cách rõ ràng. Điều đó khác với field bị thiếu. Normalize bằng doc.field ?? null để tránh insert chuỗi "null" vào cột JSONB.

  3. Số lưu dạng string: Tính schemaless của MongoDB cho phép { price: "9.99" }{ 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ột NUMERIC.

  4. Đừng thêm NOT NULL trướ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.

  5. SELECT DISTINCT regression: Postgres có thể chậm hơn với DISTINCT so với indexed read của Mongo. Benchmark trước cutover, không phải sau.

  6. --oplog bị chặn trên Atlas M0/M2/M5: Cluster miễn phí và shared không cho truy cập oplog. Dùng mongoexport theo từng collection và chấp nhận khoảng lag.

  7. 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.

  8. Tạo lại index: Mỗi index trong Mongo đều cần một CREATE INDEX CONCURRENTLY tươ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.

Tham khảo