Skip to content

Backup & khôi phục ​

Backup chỉ có giá trị khi đã khôi phục thử thành công. Trang này giải thích hai con số dùng để đặt mục tiêu (RPO và RTO), các loại backup, cách khôi phục tới đúng một thời điểm (point-in-time recovery), vì sao replica không phải là backup, và vì sao phải diễn tập khôi phục định kỳ.

RPO và RTO ​

Trước khi chọn cách backup, cần biết hệ thống chấp nhận được những gì khi có sự cố:

  • RPO (recovery point objective): mất tối đa bao nhiêu dữ liệu. "RPO 5 phút" nghĩa là sau sự cố, dữ liệu khôi phục được phải mới tới ít nhất 5 phút trước sự cố.
  • RTO (recovery time objective): mất tối đa bao lâu để hệ thống chạy lại.

Hai con số này do nghiệp vụ quyết định chứ không do kỹ thuật. Hệ thống thanh toán thường cần RPO gần 0 và RTO vài phút. Một hệ thống báo cáo nội bộ có thể chấp nhận mất dữ liệu một ngày và chạy lại sau nửa ngày. Mục tiêu càng chặt thì càng tốn tiền: lưu log liên tục, replica ở vùng khác, backup physical.

Hai loại sự cố cần chuẩn bị ​

Mất máy, hỏng đĩa, mất cả vùngLỗi logic: xoá nhầm, migration sai, bug ghi đè dữ liệu
Replica có cứu được khôngCó: chuyển sang replica (failover), mất vài giây tới vài phútKhông: lệnh sai được sao chép sang replica như mọi lệnh khác
Cần gìReplica ở máy khác, vùng khácBackup cộng log để quay về đúng thời điểm trước lệnh sai

Đây là lý do replica không phải là backup. Trên lab, một lệnh DELETE quên WHERE sang tới replica chỉ sau khoảng một giây, và replica cũng trống trơn. Replica trễ có chủ đích (MySQL CHANGE REPLICATION SOURCE TO SOURCE_DELAY = 3600) cho một khoảng thời gian để kịp dừng nó trước khi lệnh sai tới, và tài liệu MySQL nêu đúng mục đích này là bảo vệ trước lỗi của người dùng. Nhưng nó chỉ bổ sung cho backup, không thay được backup.

Logical backup và physical backup ​

LogicalPhysical
Công cụmysqldump, mysqlpump, MySQL Shell dump; pg_dumpPercona XtraBackup, MySQL Enterprise Backup; pg_basebackup; snapshot đĩa của cloud
Lưu gìCâu SQL (CREATE, INSERT) hoặc dữ liệu đã nénChép thẳng file dữ liệu của database
Khôi phụcChạy lại toàn bộ câu SQL, dựng lại mọi index, nên chậmChép file về rồi khởi động, nên nhanh
Linh hoạtKhôi phục một bảng, sang phiên bản database khác, sang máy khác kiến trúcThường phải cùng phiên bản, cùng cấu hình
Hợp vớiDữ liệu vừa và nhỏ, chuyển dữ liệu, lấy bản sao một bảngDữ liệu lớn, cần RTO ngắn

Khôi phục từ logical backup luôn lâu hơn tạo backup nhiều, vì phải dựng lại index. Trên lab, dump 200.000 dòng của MySQL chỉ mất khoảng 0,15–0,2 giây, nhưng nạp lại mất 1,1–1,6 giây. Với vài trăm GB thì khoảng cách này thành hàng giờ, nên RTO phải tính theo thời gian khôi phục, không theo thời gian backup.

Logical backup phải nhất quán: mọi bảng phải được đọc tại cùng một thời điểm. Với InnoDB, mysqldump --single-transaction đọc mọi bảng trong một transaction duy nhất nhờ MVCC, nên không phải khoá bảng. pg_dump cũng luôn đọc trong một snapshot nhất quán.

Point-in-time recovery ​

Backup đầy đủ chỉ đưa dữ liệu về lúc tạo backup. Muốn về đúng một thời điểm, ví dụ một giây trước lệnh xoá nhầm, cần thêm log ghi mọi thay đổi kể từ lúc đó: binlog của MySQL, WAL của PostgreSQL.

  1. Nạp bản backup đầy đủ gần nhất trước sự cố.
  2. Phát lại log từ vị trí ứng với lúc backup, tới ngay trước lệnh sai.
  3. Bỏ qua lệnh sai, phát tiếp phần còn lại nếu muốn giữ những lệnh ghi đúng sau sự cố.

Để làm được bước 2, bản backup phải ghi lại vị trí log ứng với nó: mysqldump --source-data ghi tên file và vị trí binlog vào đầu bản dump. Với PostgreSQL, cần bật lưu trữ WAL liên tục (archive_command hay archive_library) cùng với một base backup (pg_basebackup). Khi khôi phục, đặt recovery_target_time để dừng ở thời điểm mong muốn.

Trên lab, quy trình chạy được trọn vẹn với MySQL. Bảng có 200.000 dòng lúc backup, thêm 1.000 dòng, rồi một lệnh DELETE quên WHERE xoá sạch, rồi thêm 10 dòng. Khôi phục bằng cách nạp bản dump vào một MySQL tạm, cho nó làm replica của server gốc bắt đầu từ vị trí ghi trong bản dump, dừng ngay trước lệnh DELETE (START REPLICA UNTIL …), nhảy qua lệnh đó rồi chạy tiếp. Kết quả là đúng 201.010 dòng. Ngoài đời, vị trí của lệnh sai thường được tìm bằng mysqlbinlog --verbose hay theo thời điểm, rồi phát lại bằng mysqlbinlog --stop-position hoặc bằng replication như trên.

Log phải được lưu sang nơi khác, không chỉ nằm trên máy database. Nếu máy hỏng, log nằm trên chính máy đó cũng mất theo, và RPO lúc đó bằng khoảng cách tới bản backup gần nhất. Chọn chu kỳ backup, cách lưu log và loại sự cố để xem RPO và RTO thay đổi ra sao:

Sự cố
Log
Cách backup
gần như 0dữ liệu bị mất (RPO)
3,6 giờthời gian khôi phục (RTO, minh hoạ)
backupbackupbackupxoá nhầm0 giờ24 giờ48 giờ72 giờ
backup đầy đủphần khôi phục được bằng logdữ liệu bị mất

Với xoá nhầm, phát lại log tới ngay trước lệnh xoá rồi nhảy qua nó, nên mất ít hay nhiều tuỳ log được lưu tới đâu. Replica không cứu được, vì lệnh xoá đã sang replica trong khoảng một giây.

Tốc độ nạp 20 MB/s cho logical và 300 MB/s cho physical, phát lại log nhanh gấp 20 lần thời gian đã ghi, cộng 15 phút chuẩn bị: đều là giả định để minh hoạ, phải đo bằng diễn tập thật.

Lưu backup ở đâu, giữ bao lâu ​

  • Ở nơi khác: vùng khác, và tốt nhất là tài khoản cloud khác. Kẻ tấn công chiếm được tài khoản production, hay một script xoá nhầm, không được phép xoá luôn được backup. Một quy tắc hay dùng là 3-2-1: 3 bản sao, trên 2 loại lưu trữ khác nhau, 1 bản ở nơi khác.
  • Mã hoá backup, vì nó chứa toàn bộ dữ liệu, kể cả dữ liệu nhạy cảm (Bảo mật database).
  • Thời hạn lưu theo yêu cầu nghiệp vụ và pháp lý, ví dụ bản hằng ngày giữ 30 ngày, bản hằng tháng giữ 1 năm. Log phải được giữ ít nhất từ bản backup cũ nhất còn dùng, không thì bản đó không phát lại tới thời điểm sau được.
  • Kiểm tra định kỳ rằng backup có chạy và có kích thước hợp lý. Job backup lỗi âm thầm suốt nhiều tuần là chuyện hay gặp.

Diễn tập khôi phục ​

Một bản backup chưa từng được khôi phục thử là một giả định chưa kiểm chứng. Diễn tập định kỳ, ví dụ mỗi quý, theo đúng quy trình sẽ dùng khi có sự cố:

  1. Lấy bản backup từ nơi lưu thật, như lúc có sự cố.
  2. Khôi phục vào một máy riêng, phát lại log tới một thời điểm chọn trước.
  3. Kiểm tra dữ liệu: đếm dòng, chạy vài query nghiệp vụ, so với production tại thời điểm đó.
  4. Đo thời gian thật của từng bước, so với RTO đã cam kết.
  5. Ghi lại các bước vào runbook, sửa những chỗ vướng.

Diễn tập thường phát hiện những vấn đề không ai ngờ: backup thiếu một database, khoá giải mã nằm trên chính máy đã "hỏng", log bị xoá sớm, hay khôi phục mất 6 giờ trong khi RTO cam kết là 1 giờ.

Ghi nhớ

  1. RPO là mất tối đa bao nhiêu dữ liệu, RTO là mất tối đa bao lâu để chạy lại; cả hai do nghiệp vụ quyết định.
  2. Replica cứu được khi mất máy, nhưng không cứu được khi xoá nhầm: lệnh sai sang replica trong khoảng một giây.
  3. Logical backup linh hoạt nhưng khôi phục chậm vì dựng lại index; physical backup khôi phục nhanh. RTO tính theo thời gian khôi phục.
  4. Point-in-time recovery cần backup đầy đủ cộng log (binlog, WAL) lưu ở nơi khác; phát lại log tới ngay trước lệnh sai.
  5. Backup chỉ có giá trị khi đã khôi phục thử: diễn tập định kỳ và đo thời gian thật.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-23):

NguồnDùng cho phần
PostgreSQL 17 docs, Continuous Archiving and Point-in-Time RecoveryBase backup cộng WAL lưu liên tục cho phép khôi phục về bất kỳ thời điểm nào sau base backup
MySQL 8.0 Reference Manual, Delayed ReplicationSOURCE_DELAY; một mục đích là bảo vệ trước lỗi của người dùng trên source

Kiểm bằng lab: mysqldump --source-data=2 ghi vị trí binlog vào đầu bản dump (bản 8.0.46 vẫn ghi bằng cú pháp CHANGE MASTER TO); image mysql:8.0 không có mysqlbinlog; khôi phục bằng START REPLICA UNTIL. Từ kiến thức chung, chưa đối chiếu lần này: --single-transaction và snapshot nhất quán của pg_dump, archive_command/archive_library, recovery_target_time, quy tắc 3-2-1, các công cụ physical backup.

Số đo chi tiết ​

Đo bằng script lab/backup/pitr.mjs ngày 2026-10-23 trên MacBook M2 (MySQL 8.0 có replica, PostgreSQL 17 trong Docker), hai lượt.

Backup, sự cố và khôi phục
BướcKết quả
mysqldump --single-transaction --source-data=2 200.000 dòng146–181 ms, 6,2 MB
Thêm 1.000 dòng, rồi DELETE FROM orders không có WHEREBảng còn 0 dòng
ReplicaCũng còn 0 dòng sau khoảng 1,2 giây
Thêm 10 dòng sau sự cố
Nạp bản dump vào MySQL tạm1,1–1,6 giây, 200.000 dòng
Phát lại binlog tới ngay trước DELETE201.000 dòng
Nhảy qua DELETE, phát tiếp tới cuối201.010 dòng, đúng như mong đợi; phát lại mất khoảng 0,7 giây
PostgreSQL pg_dump -Fc cùng lượng dữ liệu183–233 ms, 1,1 MB (đã nén)
pg_restore199–286 ms

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- backup sau npm run lab:up, khoảng 20 giây. Script dựng tạm container lab-mysql-restore rồi xoá. Xem Lab.

lab/backup/pitr.mjs
js
// Backup và khôi phục tới một thời điểm (point-in-time recovery) trên MySQL 8.0, và vì sao replica không phải là backup.
//   1. Bảng orders 200.000 dòng. Backup đầy đủ bằng mysqldump --single-transaction --source-data=2 (ghi vị trí binlog).
//   2. Ghi thêm 1.000 đơn, rồi "lỡ tay" DELETE FROM orders không có WHERE, rồi ghi thêm 10 đơn sau sự cố.
//   3. Replica nhận lệnh DELETE sau bao lâu.
//   4. Khôi phục vào database mới: nạp bản dump, rồi phát lại binlog từ vị trí trong bản dump tới ngay trước lệnh DELETE,
//      bỏ qua lệnh DELETE, phát tiếp phần sau. Đếm lại số dòng.
//   5. Thời gian dump và restore của PostgreSQL (pg_dump định dạng custom, pg_restore) với cùng lượng dữ liệu.
import mysql from 'mysql2/promise'
import pg from 'pg'
import { execFileSync, execSync } from 'node:child_process'
import { MYSQL, REPLICA, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'

const sh = (cmd) => execSync(cmd, { encoding: 'utf8', maxBuffer: 512 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] })
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 time = (fn) => { const t0 = performance.now(); fn(); return performance.now() - t0 }
const MY = 'docker exec -i lab-mysql'
const AUTH = '-uroot -ppw'

ensureDb('mysql', 'bk')
const db = await mysql.createConnection({ ...MYSQL, database: 'bk' })
const pos = async () => { const [[s]] = await db.query('SHOW MASTER STATUS'); return { file: s.File, pos: Number(s.Position) } }
const count = async (c, d) => Number((await c.query(`SELECT COUNT(*) AS n FROM ${d}.orders`))[0][0].n)
await db.query('DROP TABLE IF EXISTS orders')
await db.query('CREATE TABLE orders (id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, total INT NOT NULL, note VARCHAR(100) NOT NULL, INDEX (user_id))')
await db.query('SET SESSION cte_max_recursion_depth = 200000')
await db.query(`INSERT INTO orders (user_id, total, note) WITH RECURSIVE s(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM s WHERE i < 200000) SELECT i % 5000, i % 1000, 'đơn hàng cũ' FROM s`)

console.log('\n=== 1. Backup đầy đủ bằng mysqldump ===')
let dumpOut = ''
const tDump = time(() => { dumpOut = sh(`${MY} mysqldump ${AUTH} --single-transaction --source-data=2 --set-gtid-purged=OFF bk 2>/dev/null`) })
// MySQL 8.0 vẫn ghi bằng cú pháp cũ: -- CHANGE MASTER TO MASTER_LOG_FILE='…', MASTER_LOG_POS=…;
const m = dumpOut.match(/(?:SOURCE|MASTER)_LOG_FILE='([^']+)', (?:SOURCE|MASTER)_LOG_POS=(\d+)/)
console.log(`mysqldump 200.000 dòng: ${ms(tDump)}, ${(dumpOut.length / 1e6).toFixed(1).replace('.', ',')} MB; vị trí binlog ghi trong bản dump: ${m[1]}:${m[2]}`)

console.log('\n=== 2. Ghi thêm, sự cố, ghi tiếp ===')
for (let i = 0; i < 1000; i++) await db.query("INSERT INTO orders (user_id, total, note) VALUES (?, ?, 'đơn sau backup')", [i, i])
const before = await pos()
const tDel0 = performance.now()
await db.query('DELETE FROM orders')                        // lỗi: quên WHERE
const after = await pos()
console.log(`DELETE FROM orders (quên WHERE): bảng còn ${await count(db, 'bk')} dòng. Binlog của lệnh này nằm từ ${before.file}:${before.pos} tới ${after.file}:${after.pos}`)

console.log('\n=== 3. Replica có cứu được không? ===')
const rep = await mysql.createConnection(REPLICA)
let n = -1
while (n !== 0 && performance.now() - tDel0 < 10000) { n = await count(rep, 'bk'); if (n) await sleep(20) }
console.log(`Replica cũng còn ${n} dòng sau ${ms(performance.now() - tDel0)} kể từ lệnh DELETE: lệnh xoá nhầm được sao chép như mọi lệnh khác`)
await rep.end()
for (let i = 0; i < 10; i++) await db.query("INSERT INTO orders (user_id, total, note) VALUES (?, ?, 'đơn sau sự cố')", [i, i])
const end = await pos()

// Image mysql:8.0 không có mysqlbinlog. Cách phát lại binlog không cần nó: dựng một MySQL tạm, nạp bản dump,
// cho nó làm replica của server gốc bắt đầu từ vị trí ghi trong bản dump, dừng ngay trước lệnh DELETE (START REPLICA UNTIL),
// nhảy qua lệnh DELETE rồi chạy tiếp tới cuối.
console.log('\n=== 4. Khôi phục vào một MySQL tạm (lab-mysql-restore) ===')
if (before.file !== m[1] || after.file !== m[1] || end.file !== m[1]) throw new Error('binlog đã xoay sang file mới giữa chừng, chạy lại script')
try { execFileSync('docker', ['rm', '-f', 'lab-mysql-restore'], { stdio: 'ignore' }) } catch {}
execFileSync('docker', ['run', '-d', '--name', 'lab-mysql-restore', '--network', 'interview-lab_default', '-e', 'MYSQL_ROOT_PASSWORD=pw',
  '-p', '127.0.0.1:33308:3306', 'mysql:8.0', '--server-id=3', '--gtid-mode=ON', '--enforce-gtid-consistency=ON', '--skip-log-bin'], { stdio: 'ignore' })
let rs
for (let i = 0; i < 120 && !rs; i++) { try { rs = await mysql.createConnection({ ...MYSQL, port: 33308 }); await rs.query('SELECT 1') } catch { rs = null; await sleep(1000) } }
try {
  await rs.query('CREATE DATABASE bk')
  const tLoad = time(() => execFileSync('docker', ['exec', '-i', 'lab-mysql-restore', 'mysql', '-uroot', '-ppw', 'bk'], { input: dumpOut, stdio: ['pipe', 'ignore', 'ignore'], maxBuffer: 512 * 1024 * 1024 }))
  console.log(`Nạp bản dump: ${ms(tLoad)}, có ${await count(rs, 'bk')} dòng`)
  const waitUntil = async (p) => {
    for (let i = 0; i < 300; i++) {
      const [[s]] = await rs.query('SHOW REPLICA STATUS')
      if (s.Last_SQL_Error) throw new Error(s.Last_SQL_Error)
      if (s.Replica_SQL_Running === 'No' && Number(s.Exec_Source_Log_Pos) >= p) return
      await sleep(100)
    }
    throw new Error('replica chưa tới vị trí ' + p)
  }
  const t0 = performance.now()
  await rs.query(`CHANGE REPLICATION SOURCE TO SOURCE_HOST = 'mysql', SOURCE_USER = 'root', SOURCE_PASSWORD = 'pw', GET_SOURCE_PUBLIC_KEY = 1,
    SOURCE_AUTO_POSITION = 0, SOURCE_LOG_FILE = '${m[1]}', SOURCE_LOG_POS = ${m[2]}`)
  await rs.query('CHANGE REPLICATION FILTER REPLICATE_DO_DB = (bk)')
  await rs.query(`START REPLICA UNTIL SOURCE_LOG_FILE = '${m[1]}', SOURCE_LOG_POS = ${before.pos}`)
  await waitUntil(before.pos)
  console.log(`Phát lại binlog ${m[2]} → ${before.pos} (dừng trước DELETE): có ${await count(rs, 'bk')} dòng`)
  await rs.query('STOP REPLICA')
  await rs.query(`CHANGE REPLICATION SOURCE TO SOURCE_LOG_FILE = '${m[1]}', SOURCE_LOG_POS = ${after.pos}`) // nhảy qua lệnh DELETE
  await rs.query(`START REPLICA UNTIL SOURCE_LOG_FILE = '${m[1]}', SOURCE_LOG_POS = ${end.pos}`)
  await waitUntil(end.pos)
  console.log(`Nhảy qua DELETE, phát tiếp ${after.pos} → ${end.pos}: có ${await count(rs, 'bk')} dòng (mong đợi 200.000 + 1.000 + 10 = 201.010), phát lại mất ${ms(performance.now() - t0)}`)
} finally {
  await rs?.end()
  execFileSync('docker', ['rm', '-f', 'lab-mysql-restore'], { stdio: 'ignore' })
}
await db.end()

console.log('\n=== 5. PostgreSQL: pg_dump và pg_restore cùng lượng dữ liệu ===')
ensureDb('pg', 'bk')
const p = new pg.Client({ ...PG, database: 'bk' })
await p.connect()
await p.query('DROP TABLE IF EXISTS orders')
await p.query('CREATE TABLE orders (id SERIAL PRIMARY KEY, user_id INT NOT NULL, total INT NOT NULL, note VARCHAR(100) NOT NULL)')
await p.query(`INSERT INTO orders (user_id, total, note) SELECT i % 5000, i % 1000, 'đơn hàng cũ' FROM generate_series(1, 200000) AS i`)
await p.query('CREATE INDEX ON orders (user_id)')
await p.end()
const tPgDump = time(() => sh('docker exec lab-pg pg_dump -U postgres -Fc -f /tmp/bk.dump bk'))
const size = Number(sh('docker exec lab-pg stat -c %s /tmp/bk.dump').trim())
sh('docker exec lab-pg sh -c "dropdb -U postgres --if-exists bk_restore && createdb -U postgres bk_restore"')
const tPgRestore = time(() => sh('docker exec lab-pg pg_restore -U postgres -d bk_restore /tmp/bk.dump'))
console.log(`pg_dump -Fc: ${ms(tPgDump)}, ${(size / 1e6).toFixed(1).replace('.', ',')} MB (đã nén); pg_restore: ${ms(tPgRestore)}`)