Di chuyển dữ liệu từ MySQL sang PostgreSQL: So sánh các phương pháp và hướng dẫn thực tế

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Chuyển từ MySQL sang PostgreSQL thường bắt đầu bằng một lý do rất cụ thể: cần JSON nâng cao, full-text search ngon hơn, window functions, hoặc đơn giản là team mới quen PostgreSQL hơn. Dù lý do là gì, bước khó nhất không phải cài đặt PostgreSQL — mà là kéo toàn bộ dữ liệu hiện có sang mà không mất mát, không downtime kéo dài.

Mình đã làm việc này với một database ~3GB, khoảng 40 bảng, có quan hệ khóa ngoại chằng chịt và vài stored procedure. Bài này ghi lại những gì thực sự hoạt động — không phải tutorial lý thuyết suôn sẻ.

Tại sao migrate MySQL → PostgreSQL lại dễ sai?

Nhìn qua thì hai hệ thống có vẻ giống nhau, nhưng ẩn bên dưới là hàng loạt điểm khác biệt dễ gây bất ngờ:

  • Kiểu dữ liệu không tương đồng 1-1: TINYINT(1) trong MySQL thường đóng vai boolean, nhưng PostgreSQL có kiểu BOOLEAN riêng. DATETIME của MySQL tương đương TIMESTAMP của PostgreSQL — nhưng cách xử lý timezone lại khác nhau hoàn toàn.
  • AUTO_INCREMENT vs SERIAL/SEQUENCE: MySQL dùng AUTO_INCREMENT, PostgreSQL dùng SERIAL hoặc GENERATED ALWAYS AS IDENTITY. Migrate xong mà quên reset sequence là insert row mới báo lỗi duplicate key ngay.
  • Case sensitivity: MySQL mặc định không phân biệt hoa/thường trong tên bảng (trên Windows/macOS), PostgreSQL thì tùy OS và collation — dễ vỡ app nếu code đang dùng tên bảng viết hoa lộn xộn.
  • Backtick vs double quote: MySQL dùng backtick để escape tên bảng/cột, PostgreSQL dùng double quote. Dump SQL từ MySQL paste thẳng vào PostgreSQL là lỗi ngay.
  • Strict mode mặc định: PostgreSQL từ chối insert string vào cột integer — không tự ép kiểu như MySQL. Đây là nguồn gốc của nhiều lỗi âm thầm khi migrate data bẩn.

Bỏ qua những điểm này, dữ liệu migrate xong có thể bị lỗi type, mất giá trị NULL, hoặc sequence bị reset về 1 trong khi data đã chạy tới ID 50.000.

So sánh 3 cách migrate phổ biến

Cách 1: pgloader — tự động hóa cao nhất

pgloader là công cụ chuyên dụng để migrate database, hỗ trợ MySQL → PostgreSQL natively. Nó tự map kiểu dữ liệu, xử lý encoding, và chạy song song nhiều worker.

Ưu điểm:

  • Một lệnh, tự xử lý phần lớn việc convert
  • Migrate schema + data cùng lúc, không cần tách bước
  • Hỗ trợ live migration — đọc từ MySQL đang chạy, không cần dừng hệ thống

Nhược điểm:

  • Không kiểm soát chi tiết từng bảng nếu có yêu cầu đặc biệt
  • Stored procedure, trigger phải xử lý thủ công — pgloader không đụng vào
  • Cài trên Ubuntu đôi khi phức tạp, dùng Docker cho lành

Cách 2: mysqldump + chỉnh sửa SQL thủ công

Export schema và data từ MySQL, chạy qua sed/awk hoặc script Python để convert SQL, rồi import vào PostgreSQL. Cách cổ điển nhưng vẫn đáng dùng trong một số tình huống.

Ưu điểm:

  • Kiểm soát hoàn toàn từng bước — biết chính xác đang làm gì
  • Debug dễ hơn: lỗi ở dòng nào, file nào, rõ ràng
  • Phù hợp database nhỏ (dưới 500MB) hoặc cần đổi schema nhiều

Nhược điểm:

  • Tốn thời gian, dễ bỏ sót edge case khi viết script convert
  • Database lớn thì file SQL dump nặng, xử lý chậm

Cách 3: ETL script Python

Viết script Python kết nối cả hai database đồng thời, đọc từ MySQL và ghi vào PostgreSQL theo từng batch. Linh hoạt nhất, nhưng cũng tốn công nhất.

Ưu điểm:

  • Transform data thoải mái trước khi ghi — đổi kiểu, chuẩn hóa giá trị, merge bảng
  • Xử lý business logic đặc thù mà không công cụ nào làm thay được
  • Chạy incremental được — sync thêm data mới mà không cần migrate lại từ đầu

Nhược điểm:

  • Phải viết code, tốn công hơn hai cách kia
  • Insert từng row thì cực chậm — cần dùng batch insert, không thì một bảng 1 triệu row mất cả tiếng

Chọn cách nào?

Nguyên tắc mình hay dùng:

  • Database thuần dữ liệu, schema sạch → pgloader
  • Database nhỏ hoặc cần đổi schema nhiều → mysqldump + convert
  • Cần transform data hoặc migrate incremental → ETL Python

Lần migrate ~3GB của mình: dùng pgloader cho 37 bảng thông thường, sau đó viết ETL Python riêng cho 3 bảng có logic phức tạp. Mất khoảng 2 ngày tổng cộng — phần lớn thời gian là kiểm tra và xử lý các bảng ngoại lệ, không phải chạy tool.

Hướng dẫn migrate với pgloader

Bước 1: Cài pgloader

# Ubuntu/Debian
sudo apt-get install pgloader

# Hoặc qua Docker — khuyến nghị, tránh vấn đề dependency
docker pull dimitri/pgloader

Bước 2: Tạo file cấu hình pgloader

cat > migrate.load << 'EOF'
LOAD DATABASE
  FROM mysql://mysql_user:mysql_pass@localhost:3306/mydb
  INTO postgresql://pg_user:pg_pass@localhost:5432/mydb

WITH include drop, create tables,
     create indexes, reset sequences,
     workers = 4, concurrency = 2

SET work_mem to '128MB',
    maintenance_work_mem to '512MB'

CAST type tinyint to boolean
  using tinyint-to-boolean,
     type datetime to timestamptz
  using zero-dates-to-null;
EOF

Bước 3: Chạy migrate

pgloader migrate.load

# Hoặc qua Docker
docker run --rm --network host \
  -v $(pwd)/migrate.load:/migrate.load \
  dimitri/pgloader pgloader /migrate.load

pgloader in log chi tiết từng bảng: số row migrate được, thời gian, và những row lỗi. Điểm hay là nó không dừng khi gặp lỗi một row — ghi vào file lỗi riêng để xử lý sau. Với database ~3GB của mình, pgloader mất khoảng 8 phút với 4 worker.

Hướng dẫn migrate thủ công với Python (cho bảng phức tạp)

Với bảng có logic đặc biệt — ví dụ cột status đang lưu integer 0/1/2 nhưng PostgreSQL cần string — mình viết ETL riêng:

import mysql.connector
import psycopg2
from psycopg2.extras import execute_batch

# Kết nối
mysql_conn = mysql.connector.connect(
    host='localhost', database='mydb',
    user='root', password='mysql_pass'
)
pg_conn = psycopg2.connect(
    host='localhost', dbname='mydb',
    user='pg_user', password='pg_pass'
)

mysql_cur = mysql_conn.cursor(dictionary=True)
pg_cur = pg_conn.cursor()

# Đọc từ MySQL theo batch
BATCH_SIZE = 1000
mysql_cur.execute("SELECT * FROM orders")

while True:
    rows = mysql_cur.fetchmany(BATCH_SIZE)
    if not rows:
        break

    # Transform: convert status từ int sang string
    transformed = []
    for row in rows:
        transformed.append((
            row['id'],
            row['user_id'],
            {0: 'pending', 1: 'completed', 2: 'cancelled'}.get(row['status'], 'pending'),
            row['created_at'],
            row['total_amount']
        ))

    execute_batch(
        pg_cur,
        """INSERT INTO orders (id, user_id, status, created_at, total_amount)
           VALUES (%s, %s, %s, %s, %s)
           ON CONFLICT (id) DO NOTHING""",
        transformed
    )
    pg_conn.commit()
    print(f"Migrated {len(transformed)} rows")

mysql_cur.close()
pg_cur.close()

Dùng execute_batch của psycopg2 — nhanh hơn insert từng row khoảng 10-50 lần tùy dataset. Với bảng 500k row, khác biệt giữa 40 phút và 2 phút là ở đây.

Những điều cần kiểm tra sau khi migrate

1. Kiểm tra row count

-- Chạy trên cả MySQL và PostgreSQL, so sánh kết quả
SELECT table_name, table_rows
FROM information_schema.tables
WHERE table_schema = 'mydb';

2. Reset sequence sau khi import

Lỗi mình gặp nhiều nhất: migrate xong, sequence vẫn ở 1, insert row mới báo duplicate key. Cần reset về max ID hiện tại:

-- Reset một bảng cụ thể
SELECT setval('orders_id_seq', (SELECT MAX(id) FROM orders));

-- Script tự động cho tất cả bảng
DO $$
DECLARE
    r RECORD;
BEGIN
    FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP
        EXECUTE 'SELECT setval(pg_get_serial_sequence(''' || r.tablename || ''', ''id''), COALESCE(MAX(id), 1)) FROM ' || r.tablename;
    END LOOP;
END $$;

3. Kiểm tra foreign key constraints

-- Tìm foreign key vi phạm
SELECT conname, conrelid::regclass
FROM pg_constraint
WHERE contype = 'f'
  AND NOT convalidated;

Một mẹo nhỏ khi chuẩn bị data

Trước khi migrate, mình thường export một số bảng lookup sang CSV để kiểm tra nhanh. Lúc cần convert CSV sang JSON để so sánh với API response, dùng tool tại toolcraft.app/vi/tools/data/csv-to-json nhanh hơn viết script Python cho việc one-off — chạy trên trình duyệt, không lo lộ data.

Checklist trước khi cutover production

  • Row count khớp giữa MySQL và PostgreSQL
  • Sequences đã reset về đúng giá trị (hay bị bỏ quên nhất)
  • Index đã tạo đầy đủ — pgloader thường tự làm, nhưng kiểm tra lại vẫn hơn
  • Foreign key constraints đã valid
  • Stored procedure/trigger đã viết lại theo syntax PostgreSQL
  • Connection string trong ứng dụng đã cập nhật
  • Chạy thử một số query nghiệp vụ quan trọng trên data thật

Stored procedure là phần tốn công nhất và không có shortcut. MySQL dùng DELIMITER với cú pháp riêng, PostgreSQL dùng PL/pgSQL — khác hoàn toàn, phải viết lại tay từng cái.

Database không quá lớn, không có stored procedure phức tạp? pgloader xử lý được 90% trong vài phút. Phần còn lại là kiểm tra kỹ — đặc biệt sequences và index, vì đây là hai thứ bị bỏ qua nhiều nhất và gây lỗi muộn nhất khi đã lên production.

Share: