Giao diện
Migration trong team
Schema thay đổi liên tục: thêm cột, thêm index, đổi kiểu dữ liệu. Khi nhiều người cùng sửa và hệ thống deploy nhiều lần mỗi ngày, cần một quy trình để mọi môi trường có cùng schema, để deploy không làm hỏng code đang chạy, và để migration không làm treo database. Trang này đi qua từng điểm đó.
Migration có đánh số, chỉ đi tới
Mỗi thay đổi schema là một file migration có số thứ tự hoặc timestamp, nằm cùng repo với code:
migrations/
20251001_0900_create_orders.sql
20251015_1430_add_orders_status.sql
20251020_1100_add_index_orders_user_created.sqlCông cụ migration (Flyway, Liquibase, golang-migrate, Alembic, Prisma Migrate, migration của Knex hay Sequelize) giữ một bảng trong database ghi lại những migration đã chạy. Mỗi lần chạy, nó so danh sách file với bảng đó rồi chạy các file còn thiếu, theo đúng thứ tự. Mọi môi trường (máy dev, CI, staging, production) vì vậy đi qua cùng một chuỗi thay đổi.
Hai quy tắc giữ cho chuỗi này đáng tin:
- Không sửa migration đã chạy ở môi trường dùng chung. Sửa file cũ thì môi trường đã chạy bản cũ sẽ không chạy lại, và schema các nơi lệch nhau. Muốn sửa thì viết migration mới. Nhiều công cụ lưu checksum của từng file và báo lỗi khi phát hiện file cũ bị sửa.
- Đi tới thay vì quay lui. Migration "down" hiếm khi được test kỹ, và quay lui một migration đã xoá cột thì không lấy lại được dữ liệu. Khi migration có vấn đề, cách an toàn thường là viết một migration mới để sửa (roll forward).
Hai người cùng thêm migration trên hai nhánh thì có thể trùng số hoặc chạy sai thứ tự sau khi merge. Đặt tên theo timestamp giảm trùng số, và CI nên chạy toàn bộ migration từ đầu trên database trống để phát hiện xung đột sớm (Test với database).
Code cũ và code mới chạy cùng lúc
Khi deploy kiểu rolling (thay dần từng instance), có một khoảng thời gian code cũ và code mới cùng chạy trên cùng một database. Migration vì vậy phải tương thích với cả hai phiên bản:
- Thêm cột mới phải cho phép NULL hoặc có DEFAULT, vì code cũ chưa biết cột đó và sẽ insert mà không có giá trị.
- Không đổi tên hay xoá cột mà code đang chạy còn dùng.
- Đổi tên hay đổi định dạng một cột thì chia thành nhiều bước qua nhiều lần deploy: thêm cột mới, ghi cả hai cột, backfill, chuyển sang đọc cột mới, ngừng ghi cột cũ, rồi mới xoá cột cũ. Cách này gọi là expand–contract, có mô phỏng từng bước ở MySQL chương 3.
Thứ tự deploy theo đó: migration "expand" (thêm cột, thêm bảng) chạy trước khi deploy code mới, còn migration "contract" (xoá cột cũ) chạy sau khi không còn code nào dùng tới.
Migration có làm treo database không
Mọi lệnh thay đổi schema đều cần lock của bảng: MySQL gọi là metadata lock, PostgreSQL gọi là lock ACCESS EXCLUSIVE. Lock này được giữ ngắn hay dài tuỳ việc lệnh phải làm:
- Chỉ sửa metadata, gần như tức thì: thêm cột (MySQL
ALGORITHM=INSTANT; PostgreSQL thêm cột với DEFAULT là giá trị cố định thì chỉ lưu giá trị đó vào metadata), đổi tên cột, đổi DEFAULT. - Dựng thêm cấu trúc nhưng vẫn cho đọc ghi: thêm index (MySQL
ALGORITHM=INPLACE, LOCK=NONE; PostgreSQLCREATE INDEX CONCURRENTLY). Mất thời gian, tốn I/O, nhưng không chặn ứng dụng. - Viết lại cả bảng và chặn ghi: đổi kiểu cột (INT sang BIGINT), đổi charset. Bảng càng lớn càng lâu. Trên lab, đổi kiểu một cột của bảng 1 triệu dòng chặn mọi lệnh ghi khoảng 2 giây. Với bảng trăm triệu dòng thì là hàng chục phút. Lúc đó dùng công cụ như gh-ost hay pt-online-schema-change, chúng tạo bảng mới, chép dần dữ liệu và bắt kịp thay đổi, rồi đổi tên trong một khoảnh khắc.
Bảng chi tiết các thuật toán DDL của MySQL ở MySQL chương 3. Nên ghi rõ thuật toán mong muốn trong câu lệnh (ALGORITHM=INSTANT), để MySQL báo lỗi ngay khi không làm được theo cách đó, thay vì âm thầm chuyển sang cách chặn ghi.
Bẫy lớn nhất: xếp hàng sau một transaction mở lâu
Kể cả lệnh chỉ sửa metadata cũng cần lock riêng trong chốc lát, nên nó phải chờ mọi transaction đang dùng bảng kết thúc. Lock lại được cấp theo thứ tự xếp hàng. Vì vậy, khi lệnh ALTER đang chờ, mọi query mới trên bảng phải xếp hàng sau nó, kể cả những SELECT vốn chạy song song được với nhau. Chỉ cần một transaction quên commit, hay một job báo cáo chạy lâu, là một migration "tức thì" làm cả hệ thống đứng lại.
Trên lab, MySQL và PostgreSQL cho kết quả giống hệt nhau: một SELECT bình thường gửi lúc 200 ms phải chờ tới 2,7 giây, đúng lúc transaction mở lâu commit. Đi từng bước:
0 msbước 1 / 6
| Phiên | Trạng thái | Đang làm |
|---|---|---|
| A | đang giữ lock | BEGIN; SELECT … (chưa commit) |
A mở transaction và đọc bảng orders (ví dụ một job báo cáo, hay một request quên commit). A giữ lock dùng chung của bảng tới khi commit.
Thời điểm theo số đo trên lab (MySQL 8.0 và PostgreSQL 17 cho kết quả giống nhau).
Cách phòng:
- Đặt lock timeout ngắn cho phiên chạy migration: MySQL
SET SESSION lock_wait_timeout = 5, PostgreSQLSET lock_timeout = '5s'. Mặc định của MySQL là một năm, của PostgreSQL là không giới hạn. Hết timeout thì migration tự bỏ cuộc, ứng dụng chỉ chậm vài giây, và công cụ migration thử lại sau. - Kiểm tra transaction mở lâu trước khi chạy:
information_schema.INNODB_TRXtrên MySQL,pg_stat_activity(cộtxact_start) trên PostgreSQL. - Chạy migration lúc thấp điểm, nhất là migration viết lại bảng.
Tách thay đổi schema khỏi việc sửa dữ liệu
Một migration vừa thêm cột vừa UPDATE cả bảng để điền giá trị là một transaction khổng lồ: giữ lock lâu, sinh nhiều undo log và binlog, làm replica trễ. Nên tách làm hai:
- Migration schema: thêm cột cho phép NULL hoặc có DEFAULT. Chạy nhanh.
- Backfill: một job riêng điền dữ liệu theo từng lô nhỏ, có điều tốc theo độ trễ của replica, chạy lại được từ chỗ dừng (Xử lý dữ liệu lớn theo lô).
Ràng buộc NOT NULL hay foreign key thì thêm sau khi backfill xong. PostgreSQL có cách thêm ràng buộc mà không khoá lâu: ADD CONSTRAINT … NOT VALID (chỉ kiểm dòng mới), rồi VALIDATE CONSTRAINT riêng (kiểm dòng cũ mà không chặn ghi).
Chạy migration ở đâu
Chạy migration như một bước riêng trong pipeline deploy, trước khi đưa code mới lên. Đừng để mỗi instance của app tự chạy migration lúc khởi động:
- Mười instance cùng khởi động thì mười tiến trình cùng chạy một migration. Một số công cụ có lock để chỉ một tiến trình chạy, nhưng các instance còn lại vẫn phải chờ, và health check có thể giết chúng vì khởi động quá lâu.
- Migration lỗi thì app không khởi động được, và lỗi schema biến thành sự cố cả service.
- User của app phải có quyền
ALTER,DROP, trái với nguyên tắc quyền tối thiểu (Bảo mật database).
Bước migration riêng dùng user riêng có quyền đổi schema, chạy xong mới cho deploy code tiếp. Nếu migration lỗi thì dừng pipeline, và code cũ vẫn chạy bình thường.
Checklist khi review migration
| Câu hỏi | Vì sao |
|---|---|
| Code đang chạy có còn hoạt động sau migration này không? | Rolling deploy làm code cũ và mới chạy cùng lúc |
| Lệnh này chỉ sửa metadata, dựng thêm cấu trúc, hay viết lại cả bảng? Bảng lớn cỡ nào? | Viết lại bảng lớn thì chặn ghi rất lâu |
| Có đặt lock timeout chưa? | Tránh xếp hàng sau transaction mở lâu |
Có UPDATE hay DELETE cả bảng trong migration không? | Nên tách thành backfill theo lô |
| Có thêm index trên bảng lớn không? | Dùng cách online, theo dõi độ trễ replica |
| Nếu phải quay lại thì làm thế nào? | Thường là migration mới, không phải migration down |
Ghi nhớ
- Mỗi thay đổi schema là một file migration có thứ tự; không sửa migration đã chạy, sửa bằng migration mới.
- Migration phải chạy được với cả code cũ lẫn code mới; đổi cột thì dùng expand–contract qua nhiều lần deploy.
- Mọi DDL đều cần lock của bảng. Một transaction mở lâu làm ALTER phải chờ, và mọi query sau đó xếp hàng sau ALTER. Luôn đặt lock timeout ngắn cho phiên chạy migration.
- Tách thay đổi schema khỏi backfill dữ liệu; backfill theo lô bằng job riêng.
- Chạy migration ở một bước riêng trong pipeline, bằng user riêng, không để mỗi instance app tự chạy lúc khởi động.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-23):
| Nguồn | Dùng cho phần |
|---|---|
| MySQL 8.0 Reference Manual, Online DDL Operations | Thêm cột mặc định dùng INSTANT; đổi kiểu cột chỉ chạy được với ALGORITHM=COPY |
| PostgreSQL 17 docs, ALTER TABLE | Thêm cột với DEFAULT không đổi: giá trị tính một lần và lưu trong metadata, dùng cho mọi dòng cũ |
Đọc trên lab: lock_wait_timeout mặc định 31.536.000 giây (một năm) trên MySQL 8.0.46; lock_timeout mặc định 0 (không giới hạn) trên PostgreSQL 17. Từ kiến thức chung, chưa đối chiếu lần này: cách các công cụ migration lưu lịch sử và checksum; CREATE INDEX CONCURRENTLY; NOT VALID và VALIDATE CONSTRAINT; cách gh-ost và pt-online-schema-change hoạt động.
Số đo chi tiết
Đo bằng script lab/migration/ddl.mjs ngày 2026-10-23 trên MacBook M2 (MySQL 8.0.46, PostgreSQL 17 trong Docker). Bảng orders 1.000.000 dòng.
ALTER trên MySQL trong lúc một client ghi liên tục
| Lệnh | Thời gian | Lệnh UPDATE chậm nhất của client khác |
|---|---|---|
ADD COLUMN …, ALGORITHM=INSTANT | 35 ms | 32 ms |
ADD INDEX …, ALGORITHM=INPLACE, LOCK=NONE | 2.464 ms | 30 ms |
MODIFY amount BIGINT, ALGORITHM=INPLACE | Lỗi ngay: ALGORITHM=INPLACE is not supported. Reason: Cannot change column type | |
MODIFY amount BIGINT (MySQL tự chọn COPY) | 2.241 ms | 2.235 ms, tức bị chặn suốt thời gian ALTER |
Xếp hàng sau transaction mở lâu
A mở transaction và đọc bảng; B chạy ADD COLUMN lúc 0 ms; C gửi một SELECT lúc khoảng 200 ms; A commit lúc khoảng 2,7 giây.
| Không có timeout | B đặt timeout 1 giây | |
|---|---|---|
| MySQL 8.0 | B xong lúc 2.800 ms, C xong lúc 2.801 ms | B lỗi Lock wait timeout exceeded lúc 1.008 ms, C xong lúc 1.007 ms |
| PostgreSQL 17 | B xong lúc 2.711 ms, C xong lúc 2.713 ms | B lỗi canceling statement due to lock timeout lúc 1.005 ms, C xong lúc 1.006 ms |
Code đã dùng để chạy thử
Chạy bằng npm run lab -- migration sau npm run lab:up, khoảng 20 giây. Xem Lab.
lab/migration/ddl.mjs
js
// Migration trên bảng có dữ liệu và có người đang dùng. Bảng orders 1.000.000 dòng.
// 1. MySQL: thời gian của ADD COLUMN (INSTANT), ADD INDEX (INPLACE), đổi kiểu cột (COPY), và latency lớn nhất
// của một client ghi liên tục trong lúc đó.
// 2. Metadata lock xếp hàng: A mở transaction và đọc bảng rồi để đó; B chạy ALTER (chờ A); C chỉ SELECT nhưng phải chờ sau B.
// Lặp lại khi B đặt lock_wait_timeout (MySQL) / lock_timeout (PostgreSQL) 1 giây.
import mysql from 'mysql2/promise'
import pg from 'pg'
import { MYSQL, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'
const N = 1_000_000
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const ms = (v) => (v >= 100 ? Math.round(v).toLocaleString('vi-VN') : v.toFixed(1).replace('.', ',')) + ' ms'
const now = () => performance.now()
ensureDb('mysql', 'migr')
ensureDb('pg', 'migr')
// ---------- 1. Các loại ALTER trên MySQL ----------
const my = () => mysql.createConnection({ ...MYSQL, database: 'migr' })
const a = await my()
await a.query('DROP TABLE IF EXISTS orders')
await a.query('CREATE TABLE orders (id INT PRIMARY KEY, user_id INT NOT NULL, amount INT NOT NULL, note VARCHAR(50) NOT NULL)')
await a.query('SET SESSION cte_max_recursion_depth = 1000000')
await a.query(`INSERT INTO orders WITH RECURSIVE s(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM s WHERE i < ${N}) SELECT i, i % 50000, i % 1000, 'ghi chú' FROM s`)
async function withWriter(fn) {
const w = await my()
let stop = false, worst = 0, n = 0
const loop = (async () => { while (!stop) { const t0 = now(); await w.query('UPDATE orders SET amount = amount + 1 WHERE id = ?', [1 + (n++ % N)]); worst = Math.max(worst, now() - t0) } })()
await sleep(100)
const t0 = now()
let err = null
try { await fn() } catch (e) { err = e.message }
const took = now() - t0
await sleep(100)
stop = true
await loop
await w.end()
return { took, worst, err }
}
console.log(`\n=== MySQL 8.0: ALTER trên bảng ${N.toLocaleString('vi-VN')} dòng, trong lúc một client ghi liên tục ===`)
for (const [name, sql] of [
['ADD COLUMN, ALGORITHM=INSTANT', 'ALTER TABLE orders ADD COLUMN status TINYINT NOT NULL DEFAULT 0, ALGORITHM=INSTANT'],
['ADD INDEX, ALGORITHM=INPLACE, LOCK=NONE', 'ALTER TABLE orders ADD INDEX idx_user (user_id), ALGORITHM=INPLACE, LOCK=NONE'],
['Đổi kiểu cột INT → BIGINT, ALGORITHM=INPLACE', 'ALTER TABLE orders MODIFY amount BIGINT NOT NULL, ALGORITHM=INPLACE'],
['Đổi kiểu cột INT → BIGINT (MySQL tự chọn COPY)', 'ALTER TABLE orders MODIFY amount BIGINT NOT NULL']
]) {
const r = await withWriter(() => a.query(sql))
console.log(`${name.padEnd(48)} ${r.err ? 'LỖI: ' + r.err.slice(0, 70) : ms(r.took).padStart(9) + ' lệnh UPDATE chậm nhất của client khác: ' + ms(r.worst)}`)
}
// ---------- 2. Metadata lock xếp hàng ----------
async function pileUp(engine, timeout) {
const open = engine === 'mysql'
? async () => { const c = await my(); return { q: (s) => c.query(s), end: () => c.end() } }
: async () => { const c = new pg.Client({ ...PG, database: 'migr' }); await c.connect(); return { q: (s) => c.query(s), end: () => c.end() } }
const A = await open(), B = await open(), C = await open()
const t0 = now(), at = () => Math.round(now() - t0)
const log = []
await A.q('BEGIN')
await A.q('SELECT * FROM orders WHERE id = 1') // A giữ lock đọc của bảng tới khi commit
if (timeout) await B.q(engine === 'mysql' ? 'SET SESSION lock_wait_timeout = 1' : "SET lock_timeout = '1s'")
const alter = B.q(engine === 'mysql' ? 'ALTER TABLE orders ADD COLUMN flag TINYINT NOT NULL DEFAULT 0, ALGORITHM=INSTANT' : 'ALTER TABLE orders ADD COLUMN flag SMALLINT NOT NULL DEFAULT 0')
.then(() => log.push(`${at()} ms: B ALTER xong`), (e) => log.push(`${at()} ms: B ALTER lỗi: ${e.message.slice(0, 60)}`))
await sleep(200)
const cStart = at()
const sel = C.q('SELECT COUNT(*) FROM orders WHERE id < 100').then(() => log.push(`${at()} ms: C SELECT xong (gửi lúc ${cStart} ms)`))
await sleep(2500)
log.push(`${at()} ms: A COMMIT`)
await A.q('COMMIT')
await Promise.all([alter, sel])
for (const c of [A, B, C]) await c.end()
return log
}
if (true) {
const c = new pg.Client({ ...PG, database: 'migr' })
await c.connect()
await c.query('DROP TABLE IF EXISTS orders')
await c.query('CREATE TABLE orders (id INT PRIMARY KEY, user_id INT NOT NULL, amount INT NOT NULL, note VARCHAR(50) NOT NULL)')
await c.query(`INSERT INTO orders SELECT i, i % 50000, i % 1000, 'ghi chú' FROM generate_series(1, ${N}) AS i`)
await c.end()
}
for (const engine of ['mysql', 'pg']) {
for (const timeout of [false, true]) {
if (engine === 'mysql') await a.query('ALTER TABLE orders DROP COLUMN flag').catch(() => {})
else { const c = new pg.Client({ ...PG, database: 'migr' }); await c.connect(); await c.query('ALTER TABLE orders DROP COLUMN IF EXISTS flag'); await c.end() }
console.log(`\n=== ${engine === 'mysql' ? 'MySQL 8.0' : 'PostgreSQL 17'}: A mở transaction, B ALTER lúc 0 ms, C SELECT lúc 200 ms, A commit lúc 2,7 giây${timeout ? ', B có timeout 1 giây' : ''} ===`)
console.log((await pileUp(engine, timeout)).join('\n'))
}
}
await a.end()