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
ALTER TABLE Session ADD COLUMN jwtId VARCHAR(64) NULL;
ALTER TABLE Session ADD INDEX idx_jwtId (jwtId);// ใน code — เขียนทั้งสองที่
const session = await Session.create({
// ...
userSession: JSON.stringify({ /* jwtId อยู่ในนี้ */ }),
jwtId: extractedJwtId // เพิ่มใหม่
});Phase 2 — Migrate
-- 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;
-- ทำซ้ำจนครบ// ค่อย ๆ สลับ reader ไปใช้ jwtId
const session = await Session.findOne({
where: { jwtId: token.jwtId } // เดิม: LIKE '%jwtId%'
});Phase 3 — Contract
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
// เดิม — 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
รูปแบบ
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
-- ตัวอย่าง
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
CREATE TABLE vtrc_new.WDHospital LIKE vtrc.WDHospital;
ALTER TABLE vtrc_new.WDHospital ADD COLUMN migratedAt TIMESTAMP NULL;ขั้นที่ 2 · Backfill (หลายวัน)
-- ทำ 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
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 สัปดาห์:
RENAME TABLE vtrc.WDHospital TO vtrc.WDHospital_deprecated;
-- รอ 1 เดือนก่อน DROP
DROP TABLE vtrc.WDHospital_deprecated;Rollback strategy
กฎทอง
ทุก migration ต้อง rollback ได้ — แม้แต่ที่ "ดูเหมือนทำลาย"
วิธี
| ประเภท migration | Rollback |
|---|---|
| Add column | ALTER TABLE ... DROP COLUMN |
| Add table | DROP 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
// 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 เสี่ยงที่สุด หลักการสำคัญ:
- Expand-then-Contract เสมอ
- dual-write + verify ก่อน cutover
- rollback plan ทุกขั้นตอน
- migration script เป็น code — version control + review
บทถัดไปจะครอบคลุม infrastructure evolution