Skip to content

16.7 Data migration strategy

บทนี้เจาะกลยุทธ์การ migrate ข้อมูลในขั้นตอน modernization — ทั้ง cross-schema write, dual-write, และ schema evolution

ทำไม data migration ยาก

ปัญหาคำอธิบาย
ระบบไม่หยุดmigration ต้องทำในขณะที่ระบบกำลังรับ request
ข้อมูลมหาศาลตาราง Session ใหญ่หลายล้านแถว, payroll history หลายปี
Foreign key พัวพันแยกตารางออกมาอาจทำให้ JOIN ข้าม schema ช้า
Rollback ยากถ้า migrate แล้วพัง ย้อนกลับยาก
Business rule ฝังในข้อมูลบาง constraint อยู่ใน code ไม่ใช่ใน schema

หลักการพื้นฐาน — Expand-then-Contract

มาตรฐาน industry สำหรับ schema evolution ที่ปลอดภัย

3 phase

Phase 1 — Expand (เพิ่ม)
─────────────────────────
   เพิ่ม schema ใหม่ โดยไม่ลบเก่า
   เขียนข้อมูลทั้งสองที่
   อ่านจากเก่า

Phase 2 — Migrate (ย้าย)
─────────────────────────
   ค่อย ๆ ย้าย reader ไปอ่านจากใหม่
   ตรวจสอบผลลัพธ์
   ปิดการเขียนไปเก่า (ถ้า migration เสร็จ)

Phase 3 — Contract (ลบ)
─────────────────────────
   ลบ schema เก่า

ตัวอย่าง — ย้าย Session lookup จาก JSON เป็น indexed column

(เกี่ยวข้องกับ debt PERF-3)

Phase 1 — Expand

sql
ALTER TABLE Session ADD COLUMN jwtId VARCHAR(64) NULL;
ALTER TABLE Session ADD INDEX idx_jwtId (jwtId);
javascript
// ใน code — เขียนทั้งสองที่
const session = await Session.create({
  // ...
  userSession: JSON.stringify({ /* jwtId อยู่ในนี้ */ }),
  jwtId: extractedJwtId     // เพิ่มใหม่
});

Phase 2 — Migrate

sql
-- backfill ข้อมูลเก่า (batch ทีละ chunk)
UPDATE Session
SET jwtId = JSON_UNQUOTE(JSON_EXTRACT(userSession, '$.jwtId'))
WHERE jwtId IS NULL
  AND createdAt BETWEEN '2023-01-01' AND '2023-06-30'
LIMIT 10000;
-- ทำซ้ำจนครบ
javascript
// ค่อย ๆ สลับ reader ไปใช้ jwtId
const session = await Session.findOne({
  where: { jwtId: token.jwtId }    // เดิม: LIKE '%jwtId%'
});

Phase 3 — Contract

sql
ALTER TABLE Session DROP INDEX idx_userSession;
-- (อาจเก็บไว้จนกว่าจะมั่นใจ 100%)

Database-per-service หรือ shared database

ตัวเลือก

ตัวเลือกข้อดีข้อเสีย
Shared database (ทุก context ใช้ DB เดียวกัน)migration ง่าย, JOIN ได้, transaction ข้าม context ได้coupling สูง, ไม่ได้ isolated จริง
Database-per-service (แต่ละ context มี DB ของตัวเอง)isolation สมบูรณ์, deploy อิสระmigration ยาก, ต้องใช้ event/saga

คำแนะนำสำหรับ VTRC

Wave 2 (ต้น): shared database — ใช้ schema isolation (vtrc.leave, vtrc.identity, ...) แทน

  • migration ง่าย
  • ลด risk ของ distributed transaction
  • ยังได้ code isolation (bounded context ในระดับ service)

Wave 2 (ปลาย) → Wave 4 (ถ้ามี): แยก database สำหรับ context ที่ mature แล้ว

Cross-schema write strategy

ปัจจุบัน vtrc-api เขียนทั้ง schema vtrc และ vtrc-centralize ใน transaction เดียวกัน (debt CORR-1) — เป็น anti-pattern

ระยะสั้น (Wave 1)

แยก write ออกจากกัน — ใช้ eventual consistency

javascript
// เดิม — cross-schema transaction
const t = await db.transaction();
try {
  await vtrcDB.User.create(..., { transaction: t });
  await centralizeDB.User.create(..., { transaction: t });
  await t.commit();
} catch (e) { await t.rollback(); }

// ใหม่ — eventual consistency
await vtrcDB.User.create(...);
await queue.publish('user.created', { ... });
// consumer แยกตัว จะเขียนไป centralizeDB

ระยะยาว (Wave 2)

context ใหม่ใน Go จะเป็น source of truth ของ domain ตัวเอง และ sync ผ่าน event ไป context อื่น

Dual-write pattern

เมื่อใช้

  • เมื่อ migrate จาก table เก่า → table ใหม่ (ภายใน schema เดียวกัน)
  • เมื่อย้าย source of truth จาก service หนึ่งไปอีก service

รูปแบบ

javascript
async function createLeave(input) {
  // เขียนที่เก่า (source of truth ในขณะนี้)
  const leave = await legacyRepo.create(input);

  // เขียนที่ใหม่ (async, ถ้า fail ไม่กระทบผู้ใช้)
  try {
    await newRepo.create(input);
  } catch (err) {
    logger.error('dual-write fail', { input, err });
    metric.increment('dual_write_fail');
  }

  return leave;
}

Verifier

script แยกต่างหากที่รันทุกชั่วโมง เปรียบเทียบจำนวน record และ checksum

sql
-- ตัวอย่าง
SELECT COUNT(*) FROM vtrc.LeaveRequest;
SELECT COUNT(*) FROM vtrc_new.LeaveRequest;

SELECT
  MD5(GROUP_CONCAT(id ORDER BY id)) AS checksum
FROM vtrc.LeaveRequest
WHERE createdAt > NOW() - INTERVAL 1 DAY;

ถ้า checksum ไม่ตรง → alert

กลยุทธ์สำหรับ migration ขนาดใหญ่

ตัวอย่าง: ย้ายตาราง WDHospital (1 ล้าน+ แถว)

ขั้นที่ 1 · Setup

sql
CREATE TABLE vtrc_new.WDHospital LIKE vtrc.WDHospital;
ALTER TABLE vtrc_new.WDHospital ADD COLUMN migratedAt TIMESTAMP NULL;

ขั้นที่ 2 · Backfill (หลายวัน)

sql
-- ทำ batch ละ 10,000 แถว
INSERT INTO vtrc_new.WDHospital
SELECT *, NOW() FROM vtrc.WDHospital
WHERE id BETWEEN ? AND ?
  AND id NOT IN (SELECT id FROM vtrc_new.WDHospital);

script ที่รันทุกคืน จนกว่าจะครบ

ขั้นที่ 3 · CDC (Change Data Capture) สำหรับ delta

ติดตั้ง trigger หรือใช้ Debezium เพื่อ sync การเปลี่ยนแปลงที่เกิดหลัง backfill

sql
CREATE TRIGGER wd_hospital_sync_update
AFTER UPDATE ON vtrc.WDHospital
FOR EACH ROW
INSERT INTO vtrc_new.WDHospital ... ON DUPLICATE KEY UPDATE ...;

ขั้นที่ 4 · Cutover

สลับ reader จาก vtrc.WDHospital → vtrc_new.WDHospital ทีละ endpoint

ขั้นที่ 5 · Decommission

หลัง cutover 100% ≥ 2 สัปดาห์:

sql
RENAME TABLE vtrc.WDHospital TO vtrc.WDHospital_deprecated;
-- รอ 1 เดือนก่อน DROP
DROP TABLE vtrc.WDHospital_deprecated;

Rollback strategy

กฎทอง

ทุก migration ต้อง rollback ได้ — แม้แต่ที่ "ดูเหมือนทำลาย"

วิธี

ประเภท migrationRollback
Add columnALTER TABLE ... DROP COLUMN
Add tableDROP TABLE
Move dataเก็บ row เก่าไว้ (soft delete)
Drop columnไม่ rollback ได้ → ห้ามทำโดยไม่สำรองข้อมูล
Drop tableไม่ rollback ได้ → rename แทน รอ 1 เดือนก่อน drop

Rule: "Two-deploy rule"

database migration ที่ทำลายของ (drop column, drop table) ต้องทำใน 2 deploy:

  • Deploy 1: code เลิกใช้ column/table (แต่ยังเก็บไว้)
  • Deploy 2 (≥ 2 สัปดาห์หลัง deploy 1): drop column/table

Schema versioning

ใช้ migration tool

เลือก 1 ตัว:

  • golang-migrate (ใช้ได้กับ Go service ใหม่)
  • sequelize-cli migration (ใช้ใน vtrc-api ที่เหลือ)

กฎ

  • ทุก schema change ต้องมี migration script ใน repo
  • migration ต้อง reversible (มี up และ down)
  • รัน migration ใน CI เป็น dry run
  • แต่ละ migration มี version number ที่เพิ่มขึ้นเท่านั้น

ตัวอยา่ง — migration file

go
// migrations/20260801_add_jwtId_to_session.up.sql
ALTER TABLE Session ADD COLUMN jwtId VARCHAR(64) NULL;
ALTER TABLE Session ADD INDEX idx_jwtId (jwtId);

// migrations/20260801_add_jwtId_to_session.down.sql
ALTER TABLE Session DROP INDEX idx_jwtId;
ALTER TABLE Session DROP COLUMN jwtId;

Checklist สำหรับทุก migration

ก่อนรัน migration ใน production:

  • [ ] ทดสอบใน staging ด้วยข้อมูลที่ใกล้เคียง production (copy ล่าสุด)
  • [ ] backup database ก่อน
  • [ ] ประเมินเวลารัน (ทดสอบบน staging)
  • [ ] วางแผน rollback — เก็บ script ไว้ใกล้มือ
  • [ ] แจ้งทีมงานก่อนรัน (ห้ามรันเงียบ ๆ)
  • [ ] ตั้ง monitor error rate ระหว่าง migration

สรุป

Data migration เป็นจุดที่ modernization เสี่ยงที่สุด หลักการสำคัญ:

  1. Expand-then-Contract เสมอ
  2. dual-write + verify ก่อน cutover
  3. rollback plan ทุกขั้นตอน
  4. migration script เป็น code — version control + review

บทถัดไปจะครอบคลุม infrastructure evolution