Database Migration จาก MySQL ไป PostgreSQL — คู่มือครบทุกขั้นตอน

การย้ายฐานข้อมูลจาก MySQL ไปยัง PostgreSQL เป็นงานที่หลายองค์กรต้องเผชิญเมื่อระบบเติบโตขึ้นและต้องการฟีเจอร์ขั้นสูงที่ PostgreSQL มีให้ เช่น Full ACID Compliance ที่เข้มงวดกว่า การรองรับ JSON/JSONB แบบ Native การทำ Partitioning ที่ยืดหยุ่น Window Functions ที่หลากหลาย และ Extension System ที่ทรงพลังอย่าง PostGIS สำหรับข้อมูลภูมิศาสตร์

บทความนี้จะอธิบายกระบวนการย้ายข้อมูลอย่างละเอียด ตั้งแต่การวิเคราะห์ความแตกต่างระหว่างสองระบบ การเตรียมเครื่องมือ การแปลง Schema การโอนย้ายข้อมูล ไปจนถึงการทดสอบและตรวจสอบความถูกต้องหลังย้าย

ความแตกต่างสำคัญที่ต้องรู้ก่อนย้าย

ก่อนเริ่มย้ายข้อมูล ต้องเข้าใจความแตกต่างที่สำคัญระหว่าง MySQL กับ PostgreSQL เพื่อวางแผนการแปลงได้ถูกต้อง ความแตกต่างหลัก ๆ มีดังนี้

Data Types ที่แตกต่าง

MySQL ใช้ Data Type หลายตัวที่ PostgreSQL ไม่มีหรือใช้ชื่อต่างกัน เช่น TINYINT ใน MySQL ต้องเปลี่ยนเป็น SMALLINT ใน PostgreSQL, DATETIME ต้องเปลี่ยนเป็น TIMESTAMP, ENUM ต้องสร้างเป็น Custom Type หรือใช้ CHECK Constraint แทน, DOUBLE ต้องเปลี่ยนเป็น DOUBLE PRECISION และ AUTO_INCREMENT ต้องเปลี่ยนเป็น SERIAL หรือ GENERATED ALWAYS AS IDENTITY

ความแตกต่างด้าน SQL Syntax

PostgreSQL เข้มงวดกับมาตรฐาน SQL มากกว่า เช่น การใช้ Double Quotes สำหรับ Identifier ที่มีตัวพิมพ์ใหญ่-เล็ก, ไม่รองรับ Backtick (`) สำหรับครอบชื่อ Column, LIMIT syntax แตกต่างกันในบางกรณี, Boolean ใช้ TRUE/FALSE แทน 1/0 และ String Comparison เป็น Case-sensitive โดย Default

เตรียมความพร้อมก่อนย้าย

สำรวจฐานข้อมูลต้นทาง

เริ่มจากการสำรวจโครงสร้างและขนาดของฐานข้อมูลต้นทางเพื่อประเมินขอบเขตงาน

# ตรวจสอบขนาดฐานข้อมูลทั้งหมด
mysql -u root -p -e "
  SELECT table_schema AS 'Database',
         ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS 'Size (MB)',
         COUNT(*) AS 'Tables'
  FROM information_schema.tables
  WHERE table_schema = 'myapp'
  GROUP BY table_schema;"

# ตรวจสอบ Data Types ที่ใช้
mysql -u root -p -e "
  SELECT column_type, COUNT(*) AS cnt
  FROM information_schema.columns
  WHERE table_schema = 'myapp'
  GROUP BY column_type
  ORDER BY cnt DESC;"

# ตรวจสอบ Stored Procedures, Functions, Triggers
mysql -u root -p -e "
  SELECT routine_type, COUNT(*)
  FROM information_schema.routines
  WHERE routine_schema = 'myapp'
  GROUP BY routine_type;"

mysql -u root -p -e "
  SELECT trigger_name, event_object_table
  FROM information_schema.triggers
  WHERE trigger_schema = 'myapp';"

ติดตั้ง PostgreSQL บนเซิร์ฟเวอร์ปลายทาง

# ติดตั้ง PostgreSQL
sudo apt update
sudo apt install -y postgresql postgresql-client

# ตรวจสอบว่าทำงาน
sudo systemctl status postgresql

# สร้างฐานข้อมูลปลายทาง
sudo -u postgres createdb myapp
sudo -u postgres psql -c "CREATE USER appuser WITH PASSWORD 'SecurePassword123!';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE myapp TO appuser;"

วิธีที่ 1 — ใช้ pgloader (แนะนำ)

pgloader เป็นเครื่องมือที่ออกแบบมาสำหรับการย้ายข้อมูลจาก MySQL ไปยัง PostgreSQL โดยเฉพาะ สามารถแปลง Schema และโอนย้ายข้อมูลได้ในขั้นตอนเดียว รองรับการแปลง Data Type อัตโนมัติ และทำงานได้เร็วเพราะใช้ COPY Protocol

ติดตั้ง pgloader

# ติดตั้งบน Ubuntu
sudo apt install -y pgloader

# ตรวจสอบเวอร์ชัน
pgloader --version

สร้างไฟล์ Configuration

# สร้างไฟล์ migration.load
cat > migration.load << 'EOF'
LOAD DATABASE
  FROM mysql://root:MySQLPassword@localhost/myapp
  INTO postgresql://appuser:SecurePassword123!@localhost/myapp

WITH include drop, create tables, create indexes, reset sequences,
     workers = 4, concurrency = 2,
     batch rows = 10000, batch size = 200 MB

SET PostgreSQL PARAMETERS
  maintenance_work_mem to '512MB',
  work_mem to '64MB'

CAST type tinyint to smallint,
     type int with extra auto_increment to serial,
     type bigint with extra auto_increment to bigserial,
     type datetime to timestamptz,
     type double to double precision,
     type enum to text,
     type set to text,
     type mediumtext to text,
     type longtext to text,
     type tinytext to text,
     type mediumblob to bytea,
     type longblob to bytea,
     type blob to bytea,
     type binary to bytea,
     type varbinary to bytea

BEFORE LOAD DO
  $$ DROP SCHEMA IF EXISTS public CASCADE; $$,
  $$ CREATE SCHEMA public; $$
;
EOF

รัน Migration

# รัน pgloader
pgloader migration.load

# ตรวจสอบผลลัพธ์
# pgloader จะแสดงสรุปจำนวน Row ที่ย้ายสำเร็จแต่ละตาราง
# และแจ้ง Error ถ้ามี

วิธีที่ 2 — ย้ายด้วยมือ (Manual Migration)

สำหรับกรณีที่ต้องการควบคุมทุกขั้นตอนหรือมี Schema ที่ซับซ้อนซึ่ง pgloader แปลงได้ไม่สมบูรณ์ การย้ายด้วยมือจะให้ความยืดหยุ่นมากกว่า

ขั้นตอนที่ 1 — Export Schema จาก MySQL

# Export เฉพาะ Schema (ไม่รวมข้อมูล)
mysqldump -u root -p --no-data --routines --triggers myapp > mysql_schema.sql

# Export เฉพาะข้อมูล
mysqldump -u root -p --no-create-info --complete-insert --compatible=postgresql myapp > mysql_data.sql

ขั้นตอนที่ 2 — แปลง Schema เป็น PostgreSQL Format

ขั้นตอนนี้ต้องแก้ไข SQL ที่ Export มาให้เข้ากับรูปแบบของ PostgreSQL ตัวอย่าง Script สำหรับแปลงอัตโนมัติ

#!/bin/bash
# convert_schema.sh — แปลง MySQL Schema เป็น PostgreSQL

INPUT="mysql_schema.sql"
OUTPUT="pg_schema.sql"

sed \
  -e 's/`//g' \
  -e 's/INT NOT NULL AUTO_INCREMENT/SERIAL/gi' \
  -e 's/BIGINT NOT NULL AUTO_INCREMENT/BIGSERIAL/gi' \
  -e 's/TINYINT(1)/BOOLEAN/gi' \
  -e 's/TINYINT[^,)]*/SMALLINT/gi' \
  -e 's/MEDIUMINT[^,)]*/INTEGER/gi' \
  -e 's/INT([0-9]*)/INTEGER/gi' \
  -e 's/BIGINT([0-9]*)/BIGINT/gi' \
  -e 's/DOUBLE /DOUBLE PRECISION /gi' \
  -e 's/FLOAT([0-9,]*)/REAL/gi' \
  -e 's/DATETIME/TIMESTAMPTZ/gi' \
  -e 's/TINYTEXT/TEXT/gi' \
  -e 's/MEDIUMTEXT/TEXT/gi' \
  -e 's/LONGTEXT/TEXT/gi' \
  -e 's/TINYBLOB/BYTEA/gi' \
  -e 's/MEDIUMBLOB/BYTEA/gi' \
  -e 's/LONGBLOB/BYTEA/gi' \
  -e 's/BLOB/BYTEA/gi' \
  -e 's/VARBINARY([0-9]*)/BYTEA/gi' \
  -e 's/BINARY([0-9]*)/BYTEA/gi' \
  -e '/^\/\*/d' \
  -e '/^--/d' \
  -e '/ENGINE=/d' \
  -e '/CHARSET=/d' \
  -e 's/UNSIGNED//gi' \
  -e 's/CHARACTER SET [a-z0-9_]*//gi' \
  -e 's/COLLATE [a-z0-9_]*//gi' \
  -e 's/ON UPDATE CURRENT_TIMESTAMP//gi' \
  "$INPUT" > "$OUTPUT"

echo "Schema converted: $OUTPUT"

ขั้นตอนที่ 3 — แปลง ENUM เป็น PostgreSQL

MySQL ENUM ต้องแปลงเป็น PostgreSQL Custom Type หรือ CHECK Constraint

-- วิธีที่ 1: สร้าง Custom Type
CREATE TYPE order_status AS ENUM ('pending', 'processing', 'shipped', 'delivered', 'cancelled');

CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    status order_status NOT NULL DEFAULT 'pending'
);

-- วิธีที่ 2: ใช้ CHECK Constraint
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    status VARCHAR(20) NOT NULL DEFAULT 'pending'
      CHECK (status IN ('pending', 'processing', 'shipped', 'delivered', 'cancelled'))
);

ขั้นตอนที่ 4 — โอนย้ายข้อมูล

# Import Schema
psql -U appuser -d myapp -f pg_schema.sql

# Export ข้อมูลเป็น CSV จาก MySQL
mysql -u root -p -e "
  SELECT * FROM myapp.orders
  INTO OUTFILE '/tmp/orders.csv'
  FIELDS TERMINATED BY ','
  ENCLOSED BY '\"'
  LINES TERMINATED BY '\n';"

# Import ข้อมูลเข้า PostgreSQL ด้วย COPY
psql -U appuser -d myapp -c "
  COPY orders FROM '/tmp/orders.csv'
  WITH (FORMAT csv, HEADER false, DELIMITER ',', QUOTE '\"');"

วิธีที่ 3 — ใช้ Python Script สำหรับ Migration ที่ซับซ้อน

สำหรับกรณีที่ต้องการ Data Transformation ระหว่างย้าย หรือมี Logic ที่ซับซ้อน การเขียน Python Script จะให้ความยืดหยุ่นสูงสุด

#!/usr/bin/env python3
"""mysql_to_pg.py — Migration Script with Data Transformation"""

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

# Connection Settings
MYSQL_CONFIG = {
    'host': 'localhost',
    'user': 'root',
    'password': 'MySQLPassword',
    'database': 'myapp'
}

PG_CONFIG = {
    'host': 'localhost',
    'user': 'appuser',
    'password': 'SecurePassword123!',
    'dbname': 'myapp'
}

BATCH_SIZE = 5000

def migrate_table(table_name, mysql_cur, pg_conn, transform_fn=None):
    """ย้ายข้อมูลทีละตาราง"""
    mysql_cur.execute(f"SELECT * FROM {table_name}")
    columns = [desc[0] for desc in mysql_cur.description]
    placeholders = ', '.join(['%s'] * len(columns))
    col_names = ', '.join(columns)
    insert_sql = f"INSERT INTO {table_name} ({col_names}) VALUES ({placeholders})"

    pg_cur = pg_conn.cursor()
    batch = []
    total = 0

    for row in mysql_cur:
        if transform_fn:
            row = transform_fn(row, columns)
        batch.append(row)

        if len(batch) >= BATCH_SIZE:
            execute_batch(pg_cur, insert_sql, batch)
            total += len(batch)
            batch = []
            print(f"  {table_name}: {total} rows migrated...")

    if batch:
        execute_batch(pg_cur, insert_sql, batch)
        total += len(batch)

    pg_conn.commit()
    pg_cur.close()
    print(f"  {table_name}: {total} rows total — DONE")

def main():
    mysql_conn = mysql.connector.connect(**MYSQL_CONFIG)
    pg_conn = psycopg2.connect(**PG_CONFIG)

    mysql_cur = mysql_conn.cursor()

    # รายชื่อตารางที่ต้องย้าย (เรียงตาม Foreign Key Dependencies)
    tables = ['customers', 'products', 'orders', 'order_items']

    for table in tables:
        print(f"Migrating {table}...")
        migrate_table(table, mysql_cur, pg_conn)

    mysql_cur.close()
    mysql_conn.close()
    pg_conn.close()
    print("Migration completed!")

if __name__ == '__main__':
    main()

แปลง Stored Procedures และ Functions

Stored Procedures ของ MySQL ต้องเขียนใหม่ใน PostgreSQL เพราะ Syntax แตกต่างกันอย่างมาก PostgreSQL ใช้ PL/pgSQL เป็นภาษาสำหรับ Stored Functions

-- MySQL Stored Procedure
DELIMITER //
CREATE PROCEDURE get_orders_by_status(IN p_status VARCHAR(20))
BEGIN
    SELECT * FROM orders WHERE status = p_status ORDER BY created_at DESC;
END //
DELIMITER ;

-- PostgreSQL Function (เทียบเท่า)
CREATE OR REPLACE FUNCTION get_orders_by_status(p_status VARCHAR)
RETURNS SETOF orders AS $$
BEGIN
    RETURN QUERY
    SELECT * FROM orders WHERE status = p_status ORDER BY created_at DESC;
END;
$$ LANGUAGE plpgsql;
-- MySQL Trigger
CREATE TRIGGER update_modified BEFORE UPDATE ON orders
FOR EACH ROW SET NEW.updated_at = NOW();

-- PostgreSQL Trigger (ต้องสร้าง Function แยก)
CREATE OR REPLACE FUNCTION update_modified_column()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER update_orders_modified
    BEFORE UPDATE ON orders
    FOR EACH ROW
    EXECUTE FUNCTION update_modified_column();

ตรวจสอบความถูกต้องหลังย้าย

หลังย้ายข้อมูลเสร็จ ต้องตรวจสอบหลายด้านเพื่อให้มั่นใจว่าข้อมูลถูกต้องครบถ้วน

ตรวจจำนวน Row

# เปรียบเทียบจำนวน Row ระหว่างต้นทางกับปลายทาง

# MySQL
mysql -u root -p -e "
  SELECT 'orders' AS tbl, COUNT(*) FROM myapp.orders
  UNION ALL
  SELECT 'customers', COUNT(*) FROM myapp.customers
  UNION ALL
  SELECT 'products', COUNT(*) FROM myapp.products
  UNION ALL
  SELECT 'order_items', COUNT(*) FROM myapp.order_items;"

# PostgreSQL
psql -U appuser -d myapp -c "
  SELECT 'orders' AS tbl, COUNT(*) FROM orders
  UNION ALL
  SELECT 'customers', COUNT(*) FROM customers
  UNION ALL
  SELECT 'products', COUNT(*) FROM products
  UNION ALL
  SELECT 'order_items', COUNT(*) FROM order_items;"

ตรวจ Checksum ข้อมูล

# ตรวจ Checksum ของ Column สำคัญ

# MySQL
mysql -u root -p -e "
  SELECT SUM(CRC32(CONCAT_WS(',', id, customer_id, total_amount)))
  AS checksum FROM myapp.orders;"

# PostgreSQL
psql -U appuser -d myapp -c "
  SELECT SUM(hashtext(CONCAT(id::text, ',', customer_id::text, ',', total_amount::text)))
  AS checksum FROM orders;"

ตรวจ Sequences

# ตรวจว่า Sequences ตั้งค่าถูกต้อง (ไม่ทำให้เกิด Duplicate Key)
psql -U appuser -d myapp -c "
  SELECT sequencename, last_value
  FROM pg_sequences
  WHERE schemaname = 'public';"

# Reset Sequence ให้ตรงกับค่าสูงสุดในตาราง
psql -U appuser -d myapp -c "
  SELECT setval('orders_id_seq', (SELECT MAX(id) FROM orders));
  SELECT setval('customers_id_seq', (SELECT MAX(id) FROM customers));
  SELECT setval('products_id_seq', (SELECT MAX(id) FROM products));"

ตรวจ Foreign Keys และ Constraints

# ตรวจว่า Constraints ย้ายมาครบ
psql -U appuser -d myapp -c "
  SELECT conname, conrelid::regclass, confrelid::regclass, contype
  FROM pg_constraint
  WHERE connamespace = 'public'::regnamespace
  ORDER BY contype, conname;"

# ตรวจว่า Index สร้างถูกต้อง
psql -U appuser -d myapp -c "
  SELECT indexname, tablename, indexdef
  FROM pg_indexes
  WHERE schemaname = 'public'
  ORDER BY tablename, indexname;"

ปรับแต่งแอปพลิเคชันหลังย้าย

นอกจากย้ายข้อมูลแล้ว ยังต้องปรับแต่งแอปพลิเคชันให้ทำงานกับ PostgreSQL ได้ถูกต้อง จุดที่ต้องแก้ไขที่พบบ่อยมีหลายประการ ได้แก่ Connection String ที่ต้องเปลี่ยนจาก MySQL Driver เป็น PostgreSQL Driver, คำสั่ง SQL ที่ใช้ Backtick ต้องเปลี่ยนเป็น Double Quotes หรือลบออก, การเรียก LAST_INSERT_ID() ต้องเปลี่ยนเป็น RETURNING clause, ฟังก์ชัน NOW() ยังใช้ได้เหมือนเดิม แต่ IFNULL() ต้องเปลี่ยนเป็น COALESCE() และ GROUP_CONCAT() ต้องเปลี่ยนเป็น STRING_AGG()

-- MySQL: ใช้ LAST_INSERT_ID()
INSERT INTO orders (customer_id, total) VALUES (1, 500.00);
SELECT LAST_INSERT_ID();

-- PostgreSQL: ใช้ RETURNING
INSERT INTO orders (customer_id, total) VALUES (1, 500.00) RETURNING id;

-- MySQL: GROUP_CONCAT
SELECT customer_id, GROUP_CONCAT(product_name SEPARATOR ', ')
FROM order_items GROUP BY customer_id;

-- PostgreSQL: STRING_AGG
SELECT customer_id, STRING_AGG(product_name, ', ')
FROM order_items GROUP BY customer_id;

-- MySQL: IFNULL
SELECT IFNULL(phone, 'N/A') FROM customers;

-- PostgreSQL: COALESCE
SELECT COALESCE(phone, 'N/A') FROM customers;

ข้อผิดพลาดที่พบบ่อยและวิธีแก้ไข

Encoding Issues

MySQL อาจเก็บข้อมูลด้วย Encoding ที่หลากหลาย (latin1, utf8, utf8mb4) ในขณะที่ PostgreSQL ใช้ UTF-8 เป็นค่า Default หากข้อมูลมี Encoding ไม่ถูกต้อง จะเกิด Error ตอน Import

# ตรวจ Encoding ของตารางใน MySQL
mysql -u root -p -e "
  SELECT table_name, table_collation
  FROM information_schema.tables
  WHERE table_schema = 'myapp';"

# แปลง Encoding ก่อน Export
mysql -u root -p -e "ALTER DATABASE myapp CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"

# แปลงทุกตาราง
mysql -u root -p myapp -e "
  SELECT CONCAT('ALTER TABLE ', table_name, ' CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;')
  FROM information_schema.tables
  WHERE table_schema = 'myapp';" --skip-column-names | mysql -u root -p myapp

Zero Date Values

MySQL อนุญาตให้เก็บค่า 0000-00-00 ในคอลัมน์ Date/DateTime ได้ แต่ PostgreSQL ไม่อนุญาต ต้องแปลงเป็น NULL ก่อนย้าย

# แก้ Zero Date ใน MySQL ก่อน Export
mysql -u root -p myapp -e "
  UPDATE orders SET created_at = NULL WHERE created_at = '0000-00-00 00:00:00';
  UPDATE orders SET updated_at = NULL WHERE updated_at = '0000-00-00 00:00:00';"

Rollback Plan

การวางแผนสำหรับกรณีที่ต้องย้อนกลับเป็นสิ่งจำเป็น ควรเตรียมแผนสำรองไว้ด้วย เช่น เก็บ MySQL ไว้ในสถานะ Read-only ระหว่างย้าย เพื่อให้สามารถชี้แอปพลิเคชันกลับไปที่ MySQL ได้ทันทีหากเกิดปัญหา นอกจากนี้ ควรทำ Full Backup ของทั้งสองฝั่งก่อนเริ่มกระบวนการ และกำหนด Cutover Window ที่ชัดเจนว่าจะใช้เวลาเท่าไหร่ หากเกินเวลาที่กำหนดให้ Rollback กลับไปใช้ระบบเดิม

# ตั้ง MySQL เป็น Read-only ระหว่างย้าย
mysql -u root -p -e "SET GLOBAL read_only = ON;"

# Backup MySQL ก่อนเริ่ม
mysqldump -u root -p --all-databases --single-transaction | gzip > /backup/mysql_pre_migration.sql.gz

# Backup PostgreSQL หลังย้ายเสร็จ
pg_dump -U appuser myapp | gzip > /backup/pg_post_migration.sql.gz

# Rollback: ปิด Read-only แล้วชี้ App กลับไปที่ MySQL
mysql -u root -p -e "SET GLOBAL read_only = OFF;"

สรุป

การย้ายฐานข้อมูลจาก MySQL ไปยัง PostgreSQL เป็นกระบวนการที่ต้องวางแผนอย่างรอบคอบ สิ่งสำคัญคือต้องเข้าใจความแตกต่างของ Data Types และ SQL Syntax เลือกเครื่องมือที่เหมาะสม (pgloader สำหรับกรณีทั่วไป หรือ Manual Migration สำหรับกรณีซับซ้อน) ตรวจสอบข้อมูลอย่างละเอียดหลังย้าย และมีแผน Rollback พร้อมเสมอ การทดสอบบนระบบ Staging ก่อนทำจริงจะช่วยลดความเสี่ยงได้อย่างมาก

แนะนำบริการ DE

การย้ายฐานข้อมูลระหว่างระบบต้องการเซิร์ฟเวอร์ที่มี RAM เพียงพอสำหรับรัน Source และ Destination พร้อมกัน รวมถึง Disk I/O ที่เร็วสำหรับ Import ข้อมูลจำนวนมาก Cloud VPS ของ DE รองรับ SSD NVMe ที่มีความเร็วสูง พร้อม Root Access เต็มรูปแบบสำหรับติดตั้งเครื่องมือ Migration ได้อย่างอิสระ

สำหรับโปรเจกต์ที่ไม่ต้องการจัดการเซิร์ฟเวอร์เอง Cloud Hosting ของ DE เป็นทางเลือกที่สะดวกพร้อม Managed Infrastructure ให้ใช้งานได้ทันที