Skip to content

HARRY-16 — GĐ13 — Vòng đời dữ liệu: soft delete, audit, thời gian, tiền, backup

GĐ13 — Vòng đời dữ liệu: soft delete, audit, thời gian, tiền, backup

Study note cho FE engineer (JS/TS mạnh) chuyển sang Backend. Mỗi concept: định nghĩa → tại sao quan trọng → cơ chế → ví dụ → pitfall. Nguyên tắc trung tâm của cả chương: dữ liệu sống lâu hơn code. Bạn viết lại service ba lần trong hai năm, nhưng hàng trong bảng orders từ 2024 vẫn nằm đó. Code sai thì sửa rồi deploy lại. Dữ liệu sai thì sai vĩnh viễn — và thường không ai phát hiện cho tới khi kế toán đối soát cuối năm.


1. Soft delete — xoá mềm

Định nghĩa. Thay vì DELETE hàng khỏi bảng, đánh dấu nó là đã xoá (deleted_at IS NOT NULL) và lọc ra khỏi mọi truy vấn thông thường.

Tại sao quan trọng.

  • Người dùng xoá nhầm. Khôi phục được trong 30 ngày là tính năng, không phải may mắn.
  • Toàn vẹn tham chiếu lịch sử. Xoá cứng một product làm hoá đơn cũ trỏ vào hư không. Hoá đơn phải đọc được mãi mãi vì lý do pháp lý.
  • Điều tra sự cố. "Dữ liệu biến mất lúc 3h sáng" — không có soft delete thì không có gì để điều tra.

Cơ chế.

sql
ALTER TABLE documents ADD COLUMN deleted_at TIMESTAMPTZ;

-- Partial index: chỉ đánh index hàng CÒN SỐNG.
-- Index nhỏ hơn nhiều so với index thường, và mọi query mặc định đều dùng được.
CREATE INDEX idx_documents_active ON documents (tenant_id, created_at DESC)
  WHERE deleted_at IS NULL;

Pitfall (a) — quên lọc. Đây là bug số 1 của soft delete. Một query quên WHERE deleted_at IS NULL → người dùng thấy lại tài liệu đã xoá. Chống bằng cách không dựa vào kỷ luật con người:

ts
// Prisma: extension chặn ở tầng client — mọi findMany tự thêm điều kiện
const prisma = new PrismaClient().$extends({
  query: {
    document: {
      async findMany({ args, query }) {
        args.where = { ...args.where, deletedAt: null };
        return query(args);
      },
      // và findFirst, findUnique, count, aggregate...
    },
  },
});

Hoặc chắc chắn hơn — để database thi hành, không phải ORM:

sql
-- View chỉ chứa hàng sống. Code truy vấn view; muốn thấy hàng đã xoá phải
-- cố ý gọi bảng gốc — quên thì không thể xảy ra.
CREATE VIEW documents_active AS SELECT * FROM documents WHERE deleted_at IS NULL;

Pitfall (b) — vỡ unique constraint. Người dùng xoá tài khoản a@x.com rồi đăng ký lại → UNIQUE(email) chặn, vì hàng cũ vẫn nằm đó.

sql
-- ❌ chặn cả hàng đã xoá
CREATE UNIQUE INDEX ON users (email);

-- ✅ unique CHỈ trong số hàng còn sống
CREATE UNIQUE INDEX users_email_active ON users (email) WHERE deleted_at IS NULL;

Pitfall (c) — cascade không tự động. ON DELETE CASCADE chỉ chạy với DELETE thật. Soft delete một project không soft delete các task con → task mồ côi vẫn hiện trong query toàn cục. Phải xử lý tường minh trong service (trong cùng một transaction).

Khi nào KHÔNG dùng soft delete. Bảng log/event khối lượng lớn (chỉ ghi thêm, không sửa) — dùng partition + drop partition (mục 10). Và dữ liệu buộc phải xoá thật theo GDPR (mục 7).


2. Audit log — ai làm gì, lúc nào

Định nghĩa. Bản ghi bất biến (append-only) về hành động nghiệp vụ: ai, làm gì, lên đối tượng nào, lúc nào, từ đâu, giá trị trước và sau.

Tại sao quan trọng. Khác hoàn toàn với application log (GĐ8 mục 16):

Application logAudit log
Mục đíchDebug kỹ thuậtTrách nhiệm giải trình, tuân thủ
Nơi lưuStdout → Loki/DatadogDatabase (hoặc kho append-only)
Giữ bao lâu7–30 ngàyNhiều năm (thường 7 năm với tài chính)
Được sửa/xoá?Xoay vòng thoải máiKhông bao giờ
Đọc bởiDeveloperAuditor, bộ phận pháp chế, khách hàng B2B

Khách hàng doanh nghiệp sẽ hỏi "ai đã đổi quyền của user này" trong buổi security review. Không trả lời được là mất hợp đồng.

Cơ chế — schema:

sql
CREATE TABLE audit_logs (
  id           BIGSERIAL PRIMARY KEY,
  occurred_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  tenant_id    UUID NOT NULL,
  actor_id     UUID,                    -- NULL = hệ thống/cron
  actor_type   TEXT NOT NULL,           -- 'user' | 'system' | 'api_key' | 'support'
  action       TEXT NOT NULL,           -- 'document.deleted', 'user.role_changed'
  resource     TEXT NOT NULL,           -- 'document'
  resource_id  TEXT NOT NULL,
  before       JSONB,                   -- chỉ field ĐỔI, không phải cả row
  after        JSONB,
  ip           INET,
  user_agent   TEXT,
  request_id   TEXT                     -- nối sang application log (GĐ8)
);

CREATE INDEX ON audit_logs (tenant_id, occurred_at DESC);
CREATE INDEX ON audit_logs (resource, resource_id, occurred_at DESC);

Bất biến thật sự — quyền ở tầng DB, không chỉ tầng code:

sql
-- App user chỉ được INSERT và SELECT. Bug hay kẻ tấn công chiếm được
-- connection của app cũng KHÔNG xoá được dấu vết.
REVOKE UPDATE, DELETE ON audit_logs FROM app_user;
GRANT INSERT, SELECT ON audit_logs TO app_user;

Ví dụ — ghi audit trong cùng transaction:

ts
await prisma.$transaction(async (tx) => {
  const before = await tx.user.findUniqueOrThrow({ where: { id } });
  const after = await tx.user.update({ where: { id }, data: { role: 'ADMIN' } });

  // CÙNG transaction: đổi role mà không có audit là điều không thể xảy ra.
  await tx.auditLog.create({
    data: {
      tenantId: ctx.tenantId, actorId: ctx.userId, actorType: 'user',
      action: 'user.role_changed', resource: 'user', resourceId: id,
      before: { role: before.role }, after: { role: after.role },
      ip: ctx.ip, requestId: ctx.requestId,
    },
  });
});

Pitfall (a). Ghi audit ngoài transaction → nghiệp vụ commit nhưng audit lỗi → mất dấu vết đúng lúc cần nhất.

Pitfall (b) — audit log chứa PII và secret. Lưu nguyên before/after của bảng users là lưu cả password_hash, số điện thoại, địa chỉ — vào một bảng giữ 7 năm và không xoá được. Luôn dùng allowlist field:

ts
const AUDITABLE = ['role', 'email', 'status'] as const;   // liệt kê cái ĐƯỢC ghi
const diff = pick(changedFields, AUDITABLE);

Pitfall (c). Ghi audit cho mọi thao tác kể cả GET → bảng phình hàng trăm triệu hàng/tháng, chậm và tốn kém. Chỉ ghi thay đổi trạng tháitruy cập dữ liệu nhạy cảm (ví dụ nhân viên support xem hồ sơ khách hàng — cái này thì phải ghi).


3. Lịch sử phiên bản (khi audit log chưa đủ)

Định nghĩa. Giữ toàn bộ phiên bản của một bản ghi qua thời gian, truy vấn được "hàng này trông như thế nào ngày 3/1".

Tại sao quan trọng. Audit log trả lời "ai đổi". Bảng lịch sử trả lời "trạng thái tại thời điểm T là gì" — cần cho: hoá đơn dựng lại được, khôi phục từng phần, so sánh phiên bản tài liệu.

Cơ chế — bảng history + trigger:

sql
CREATE TABLE documents_history (LIKE documents INCLUDING ALL, valid_from TIMESTAMPTZ, valid_to TIMESTAMPTZ);

CREATE OR REPLACE FUNCTION snapshot_document() RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO documents_history SELECT OLD.*, OLD.updated_at, now();
  RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER trg_documents_history BEFORE UPDATE ON documents
  FOR EACH ROW EXECUTE FUNCTION snapshot_document();

Pitfall. Bật lịch sử cho mọi bảng. Bảng history lớn gấp nhiều lần bảng gốc và làm mọi UPDATE chậm hơn. Chỉ bật cho thực thể mà lịch sử thực sự là yêu cầu nghiệp vụ (hợp đồng, giá, tài liệu), không phải cho user_sessions.


4. Thời gian — nguồn bug âm thầm nhất

Định nghĩa. Ba khái niệm bị nhầm lẫn liên tục:

  • Instant — một điểm trên trục thời gian tuyệt đối. ("lúc order được tạo")
  • Local date/time — ngày giờ theo lịch, không có múi giờ. ("sinh nhật 1990-05-12", "báo thức 07:00")
  • Timezone — quy tắc chuyển đổi giữa hai cái trên, thay đổi theo chính trị và có DST.

Tại sao quan trọng. Lẫn lộn ba thứ này gây ra bug không crash, không log lỗi, chỉ âm thầm cho ra số sai: báo cáo doanh thu lệch một ngày, subscription hết hạn sớm một giờ, "hôm nay" của người dùng ở Mỹ khác "hôm nay" của server.

Cơ chế — kiểu dữ liệu Postgres:

KiểuLưu gìDùng khi
TIMESTAMPTZInstant (lưu UTC, tự đổi theo TimeZone của session khi đọc)Mặc định cho mọi mốc sự kiện: created_at, paid_at
TIMESTAMPNgày+giờ không có múi giờGần như không bao giờ. Đây là cái bẫy
DATENgày theo lịchSinh nhật, ngày nghỉ lễ — thứ không phụ thuộc múi giờ
TEXT (IANA)'Asia/Ho_Chi_Minh'Lưu kèm khi cần tái hiện giờ địa phương

Quy tắc vàng. Lưu UTC (TIMESTAMPTZ), tính toán bằng UTC, chỉ đổi sang giờ địa phương ở đúng biên hiển thị.

Ví dụ — cái bẫy TIMESTAMP (không tz):

sql
-- ❌ TIMESTAMP: Postgres lưu đúng chuỗi bạn đưa vào, KHÔNG biết nó thuộc múi nào.
-- Server đổi múi giờ, hay hai app dùng hai múi khác nhau → dữ liệu vô nghĩa.
created_at TIMESTAMP NOT NULL DEFAULT now()

-- ✅
created_at TIMESTAMPTZ NOT NULL DEFAULT now()

Ví dụ — bẫy "hôm nay":

ts
// ❌ "hôm nay" theo múi giờ của SERVER (thường UTC), không phải của người dùng.
// Người dùng ở Việt Nam (UTC+7) lúc 06:00 sáng ngày 5 sẽ nhận báo cáo của... ngày 4.
const start = new Date(); start.setHours(0, 0, 0, 0);

// ✅ Tính ranh giới ngày TRONG múi giờ của người dùng, rồi đổi ngược về UTC
import { TZDate } from '@date-fns/tz';
import { startOfDay, endOfDay } from 'date-fns';

function dayRangeInTz(day: Date, tz: string) {
  const local = new TZDate(day, tz);
  return { from: new Date(startOfDay(local).getTime()), to: new Date(endOfDay(local).getTime()) };
}
// → lưu tz của user trong bảng users; đừng đoán từ IP

Ví dụ — bẫy DST với sự kiện lặp lại:

ts
// "Gửi báo cáo 09:00 mỗi sáng theo giờ New York."
// ❌ Lưu instant UTC 14:00 → khi Mỹ đổi giờ, người dùng nhận lúc 08:00 hoặc 10:00.
// ✅ Lưu QUY TẮC (local time + IANA tz), tính instant kế tiếp mỗi lần chạy.
{ scheduleLocalTime: '09:00', timezone: 'America/New_York' }

Đây là lý do lịch tái diễn phải lưu tz name (America/New_York) chứ không lưu offset (-05:00): offset thay đổi hai lần mỗi năm, tz name thì không.

Pitfall — JS Date.

  • new Date('2026-05-12') → parse là UTC midnight. new Date('2026-05-12T00:00') → parse là giờ local. Hai chuỗi gần giống nhau, kết quả lệch nhiều giờ.
  • Date không lưu múi giờ — nó chỉ là số mili-giây từ epoch. toString() hiển thị theo múi của máy đang chạy, tạo ảo giác rằng nó "có" múi giờ.
  • getMonth() bắt đầu từ 0.
  • Trong test và CI: luôn ép TZ=UTC (GĐ12 mục 9), và có ít nhất một test chạy ở múi giờ khác để bắt giả định ngầm.

Pitfall — lưu ngày sinh bằng TIMESTAMPTZ. Sinh nhật là DATE. Lưu instant thì người dùng chuyển múi giờ sẽ thấy sinh nhật lùi một ngày.


5. Tiền — không bao giờ dùng số thực

Định nghĩa. Số tiền phải lưu bằng số nguyên đơn vị nhỏ nhất (cent, đồng) hoặc NUMERIC/DECIMAL, kèm mã tiền tệ.

Tại sao quan trọng. 0.1 + 0.2 !== 0.3 trong IEEE-754. Với tiền, sai số tích luỹ qua hàng triệu giao dịch thành lệch sổ thật — và kế toán sẽ tìm ra.

Cơ chế.

sql
-- ✅ Cách 1: số nguyên minor unit (khuyên dùng — nhanh, không mơ hồ)
amount_cents  BIGINT NOT NULL,
currency      CHAR(3) NOT NULL,          -- ISO 4217: 'VND', 'USD'

-- ✅ Cách 2: NUMERIC (khi cần độ chính xác cao trong tính toán trung gian)
amount        NUMERIC(19, 4) NOT NULL,

-- ❌ TUYỆT ĐỐI KHÔNG
amount        DOUBLE PRECISION
ts
// ❌ Prisma map NUMERIC → Decimal; ép sang Number là mất chính xác
const total = Number(invoice.amount) * 1.1;

// ✅ Số nguyên, và quyết định làm tròn TƯỜNG MINH
const totalCents = Math.round(invoice.amountCents * 1.1);   // rõ ràng: làm tròn ở đâu, kiểu gì

Pitfall (a) — số chữ số thập phân khác nhau. VND và JPY có 0 chữ số thập phân; USD có 2; một số tiền tệ có 3. Hardcode /100 là sai với VND. Luôn tra bảng theo currency.

Pitfall (b) — quên lưu currency. Cột amount không có currency là quả bom hẹn giờ: ngày mở thị trường thứ hai, không ai biết những hàng cũ là tiền gì.

Pitfall (c) — làm tròn giữa chừng. Chia rồi nhân lại (chia hoá đơn cho 3 người) làm mất/thừa vài đồng. Thuật toán đúng: tính phần nguyên cho mỗi người, phần dư phân bổ từng đồng một cho tới hết. Tổng các phần phải luôn bằng tổng gốc — viết test cho bất biến này.


6. Constraint là tài liệu thi hành được

Định nghĩa. Ràng buộc ở tầng DB: NOT NULL, UNIQUE, CHECK, FOREIGN KEY, EXCLUDE.

Tại sao quan trọng. Validate ở tầng app (zod) chỉ bảo vệ đường đi qua app. Dữ liệu còn vào DB từ: migration, script backfill, thao tác thủ công lúc 2h sáng, service thứ hai, tính năng import. DB là chốt chặn cuối cùng và duy nhất không thể đi vòng.

Ví dụ:

sql
CREATE TABLE subscriptions (
  id           UUID PRIMARY KEY,
  tenant_id    UUID NOT NULL REFERENCES tenants(id) ON DELETE RESTRICT,
  status       TEXT NOT NULL CHECK (status IN ('trialing','active','past_due','canceled')),
  seats        INT  NOT NULL CHECK (seats > 0),
  amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0),
  currency     CHAR(3) NOT NULL,
  started_at   TIMESTAMPTZ NOT NULL,
  ended_at     TIMESTAMPTZ,
  -- bất biến nghiệp vụ được DB thi hành, không phụ thuộc code nào
  CHECK (ended_at IS NULL OR ended_at > started_at)
);

-- Một tenant chỉ có ĐÚNG MỘT subscription đang hoạt động
CREATE UNIQUE INDEX ON subscriptions (tenant_id) WHERE status IN ('trialing','active');

Pitfall. ON DELETE CASCADE đặt bừa. Xoá một tenant làm bốc hơi im lặng hàng triệu hàng ở 12 bảng, không thể undo. Với dữ liệu quan trọng dùng ON DELETE RESTRICT — bắt code phải xử lý tường minh việc dọn dẹp.


7. Giữ dữ liệu bao lâu, và xoá thế nào (GDPR)

Định nghĩa. Retention policy = quy định mỗi loại dữ liệu giữ bao lâu rồi xoá/ẩn danh. Right to erasure (GDPR Điều 17) = người dùng có quyền yêu cầu xoá dữ liệu cá nhân.

Tại sao quan trọng. Giữ dữ liệu vô thời hạn là rủi ro pháp lý và bảo mật: dữ liệu không tồn tại thì không thể bị rò rỉ. Và khách hàng B2B sẽ hỏi về retention policy trong quy trình mua hàng.

Cơ chế — mâu thuẫn cốt lõi và cách giải. "Xoá hết dữ liệu của tôi" xung đột với "audit log bất biến giữ 7 năm" và với "hoá đơn phải lưu theo luật kế toán". Cách giải là ẩn danh hoá (anonymize) thay vì xoá:

ts
async function eraseUser(userId: string) {
  await prisma.$transaction(async (tx) => {
    // 1. PII → xoá/thay thế. Giữ hàng để không vỡ khoá ngoại của hoá đơn.
    await tx.user.update({
      where: { id: userId },
      data: {
        email: `deleted-${userId}@invalid.local`,   // giữ tính unique
        name: '[đã xoá]', phone: null, avatarKey: null,
        passwordHash: null, anonymizedAt: new Date(),
      },
    });

    // 2. Nội dung do người dùng tạo → xoá thật (file trên S3 nữa — GĐ11 mục 10)
    await tx.document.deleteMany({ where: { userId } });

    // 3. Audit log → GIỮ NGUYÊN nhưng bỏ định danh trực tiếp.
    //    Cơ sở pháp lý: nghĩa vụ tuân thủ. actor_id vẫn cần cho tính toàn vẹn chuỗi.
    await tx.auditLog.updateMany({ where: { actorId: userId }, data: { ip: null, userAgent: null } });

    // 4. Hoá đơn → giữ (luật kế toán bắt buộc, thường 5–10 năm)
  });

  // 5. Lan sang hệ thống bên ngoài: email provider, analytics, LLM logs, backup
  await erasureQueue.add('propagate-erasure', { userId });
}

Pitfall (a) — quên bản sao. PII còn nằm ở: bản backup, replica, cache Redis, log tập trung, data warehouse, email provider, Sentry breadcrumb, và prompt/log của LLM. Lập bản đồ dữ liệu (data map) trước, đừng đi tìm lúc nhận yêu cầu xoá.

Pitfall (b) — backup. Không thể xoá một hàng khỏi bản backup đã đóng. Cách xử lý được chấp nhận: có retention ngắn cho backup (30–90 ngày) + tài liệu hoá rằng dữ liệu sẽ biến mất khỏi backup sau chu kỳ đó + có quy trình áp lại lệnh xoá nếu phải restore.

Pitfall (c). Chạy job retention xoá cứng mà không chạy thử ở chế độ dry-run trước. Một WHERE created_at < now() - interval '90 days' viết sai (nhầm >) là mất toàn bộ dữ liệu gần đây. Luôn: dry-run → đếm số hàng → so với ước lượng → mới chạy thật.


8. Mã hoá & dữ liệu nhạy cảm

Định nghĩa. In transit — TLS trên đường truyền. At rest — mã hoá đĩa/volume. Application-level — ứng dụng tự mã hoá từng cột trước khi ghi.

Tại sao quan trọng. Mã hoá at rest của cloud (RDS encryption) chỉ chống được mất đĩa vật lý. Nó không bảo vệ khi kẻ tấn công có connection DB — vì DB tự giải mã trong suốt. Với dữ liệu thực sự nhạy cảm (số CMND, token OAuth của người dùng, khoá API bên thứ ba), cần mã hoá ở tầng ứng dụng.

Ví dụ:

ts
// AES-256-GCM: có xác thực (phát hiện dữ liệu bị sửa), khác với AES-CBC
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

export function encrypt(plain: string, key: Buffer) {
  const iv = randomBytes(12);                       // IV phải NGẪU NHIÊN mỗi lần
  const c = createCipheriv('aes-256-gcm', key, iv);
  const enc = Buffer.concat([c.update(plain, 'utf8'), c.final()]);
  // lưu kèm keyVersion để xoay khoá được mà không phải mã hoá lại toàn bộ ngay
  return { iv, tag: c.getAuthTag(), data: enc, keyVersion: 1 };
}

Phân loại — điều quan trọng nhất phải nhớ:

  • Mật khẩuhash (argon2id), không mã hoá. Hash một chiều: không có nhu cầu đọc lại.
  • Token OAuth, khoá APImã hoá (cần dùng lại).
  • Số thẻ tín dụngkhông lưu. Dùng token của Stripe. Tự lưu là bước vào phạm vi PCI-DSS — chi phí tuân thủ khổng lồ.

Pitfall. Khoá mã hoá nằm trong .env cùng repo, không bao giờ xoay. Dùng KMS (AWS KMS, GCP KMS) hoặc secret manager, và thiết kế keyVersion ngay từ đầu — thêm sau rất đau.


9. Backup — và sự thật là bạn chưa có backup

Định nghĩa.

  • Logical backuppg_dump, xuất SQL/định dạng riêng. Linh hoạt, khôi phục từng bảng, nhưng chậm với DB lớn.
  • Physical backup + WAL archiving — sao chép file dữ liệu + nhật ký ghi trước, cho phép PITR (Point-In-Time Recovery): khôi phục về đúng "14:32:07 hôm qua, ngay trước lệnh DELETE hỏng".
  • RPO — mất tối đa bao nhiêu dữ liệu (thời gian). RTO — mất tối đa bao lâu để khôi phục.

Tại sao quan trọng. Backup bảo vệ khỏi thứ mà replica không bảo vệ được. Replica sao chép mọi thứ bao gồm cả lệnh DROP TABLE của bạn, tức thì. Replica chống hỏng phần cứng; backup chống lỗi con người và ransomware.

Cơ chế — quy tắc 3-2-1: 3 bản sao, 2 loại lưu trữ khác nhau, 1 bản ở nơi khác (khác region/tài khoản cloud). Bản off-site đặc biệt quan trọng: kẻ tấn công chiếm được tài khoản AWS của bạn sẽ xoá luôn backup nằm trong đó.

Ví dụ:

bash
# Logical, định dạng custom (nén, restore song song, chọn được từng bảng)
pg_dump --format=custom --compress=9 "$DATABASE_URL" -f "backup-$(date -u +%FT%TZ).dump"

# Khôi phục
pg_restore --clean --if-exists --jobs=4 -d "$TARGET_URL" backup.dump

PITR trên PaaS. RDS/Cloud SQL/Neon/Supabase có PITR sẵn — hãy kiểm tra nó đang BẬT và cửa sổ khôi phục là bao lâu (mặc định thường chỉ 7 ngày, và bản free thường không có).

Bài kiểm tra khôi phục (restore drill) — phần quan trọng nhất của cả mục này:

Backup chưa từng được restore không phải là backup. Nó là một hy vọng.

Lịch hàng quý, thực hiện thật:

  1. Lấy bản backup mới nhất từ prod (không phải bản tạo riêng cho buổi diễn tập).
  2. Restore vào môi trường sạch, bấm giờ → đây chính là RTO thật của bạn.
  3. Chạy kiểm tra toàn vẹn: đếm hàng các bảng chính, so với prod; kiểm tra khoá ngoại; mở app trỏ vào bản restore và đăng nhập thử.
  4. Ghi lại: mất bao lâu, hỏng ở đâu, thiếu bước gì.

Những thứ chỉ lộ ra khi diễn tập thật: backup thiếu extension (pgvector!), thiếu role/quyền, thiếu sequence, dump thực ra rỗng suốt 3 tháng vì cron chết im lặng, không ai biết mật khẩu để giải mã bản backup.

Pitfall. Giám sát job backup bằng "không thấy lỗi trong log". Cron chết là không có log nào cả — im lặng bị hiểu là thành công. Dùng dead man's switch: job backup thành công thì ping một URL; không thấy ping trong 26 giờ → cảnh báo. Và alert riêng khi kích thước file backup giảm bất thường so với lần trước.


10. Migration an toàn trên dữ liệu lớn

Định nghĩa. Đổi schema khi bảng đã có hàng triệu hàng và app đang chạy phục vụ người dùng.

Tại sao quan trọng. ALTER TABLE lấy khoá ACCESS EXCLUSIVE. Trên bảng 50 triệu hàng, điều đó nghĩa là toàn bộ query lên bảng đó xếp hàng chờ — website chết trong 4 phút. Ở GĐ4 bạn học migration trên dữ liệu rỗng; đây là phiên bản thực chiến.

Cơ chế — expand / migrate / contract (3 lần deploy):

Deploy 1 (EXPAND)   — thêm cột mới, NULLABLE. Code ghi CẢ hai cột, đọc cột cũ.
Backfill            — cập nhật dữ liệu cũ theo lô, ngoài giờ cao điểm.
Deploy 2 (MIGRATE)  — code đọc cột mới. Vẫn ghi cả hai (còn quay lui được).
Deploy 3 (CONTRACT) — ngừng ghi cột cũ; DROP cột cũ sau vài ngày quan sát.

Ví dụ — backfill theo lô, không khoá bảng:

ts
// ❌ Một câu UPDATE 50 triệu hàng: giữ khoá hàng giờ, WAL phình, replica trễ nặng
await prisma.$executeRaw`UPDATE documents SET slug = slugify(title)`;

// ✅ Chia lô, có nghỉ giữa các lô để replica bắt kịp
let cursor = 0;
for (;;) {
  const n = await prisma.$executeRaw`
    UPDATE documents SET slug = slugify(title)
    WHERE id IN (SELECT id FROM documents WHERE slug IS NULL AND id > ${cursor}
                 ORDER BY id LIMIT 5000)
    RETURNING id`;
  if (n === 0) break;
  cursor += 5000;
  await sleep(200);                       // nhường tài nguyên cho lưu lượng thật
}

Ví dụ — các thao tác nguy hiểm và cách an toàn:

sql
-- ❌ Khoá bảng suốt quá trình build index
CREATE INDEX idx_docs_slug ON documents (slug);
-- ✅ Không khoá ghi. Lâu hơn, và LƯU Ý: không chạy được trong transaction
--    → Prisma cần đặt trong file migration riêng.
CREATE INDEX CONCURRENTLY idx_docs_slug ON documents (slug);

-- ❌ Quét toàn bảng để kiểm tra ràng buộc, khoá lâu
ALTER TABLE documents ADD CONSTRAINT chk CHECK (size > 0);
-- ✅ Hai bước: thêm NOT VALID (nhanh, chỉ áp cho hàng mới) → validate riêng (khoá nhẹ)
ALTER TABLE documents ADD CONSTRAINT chk CHECK (size > 0) NOT VALID;
ALTER TABLE documents VALIDATE CONSTRAINT chk;

-- ❌ Thêm cột NOT NULL có DEFAULT trên Postgres cũ (<11) → viết lại cả bảng
-- ✅ Postgres 11+ xử lý tức thì. Nhưng thêm cột có DEFAULT là hàm biến động
--    (như now()/random()) thì VẪN viết lại cả bảng — thêm NULLABLE rồi backfill.

Luôn đặt timeout cho migration:

sql
SET lock_timeout = '3s';        -- không lấy được khoá trong 3s thì BỎ, đừng xếp hàng
SET statement_timeout = '30s';  -- migration treo còn tệ hơn migration fail

Không có lock_timeout, migration chờ khoá sẽ chặn mọi query đến sau — một migration "vô hại" làm sập site.

Pitfall. DROP COLUMN trong cùng lần deploy với code ngừng dùng nó. Trong lúc rolling deploy, instance cũ và mới chạy song song vài phút → instance cũ SELECT * cột đã biến mất → lỗi 500. Luôn tách: deploy code trước, drop cột sau vài ngày.


11. Bảng lớn — partition & lưu trữ lịch sử

Định nghĩa. Partition chia một bảng logic thành nhiều bảng vật lý theo khoảng giá trị (thường là thời gian).

Tại sao quan trọng. Với bảng chỉ ghi thêm và lớn nhanh (audit_logs, events, usage_records), xoá dữ liệu cũ là vấn đề: DELETE ... WHERE created_at < ... trên 100 triệu hàng chạy hàng giờ, tạo bloat khổng lồ, và phải VACUUM sau đó. Với partition, xoá một tháng dữ liệu là DROP TABLEtức thì.

Ví dụ:

sql
CREATE TABLE usage_records (
  id BIGSERIAL, tenant_id UUID NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, tokens INT NOT NULL
) PARTITION BY RANGE (occurred_at);

CREATE TABLE usage_records_2026_08 PARTITION OF usage_records
  FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');

-- Dọn dữ liệu quá hạn: mili-giây thay vì hàng giờ
DROP TABLE usage_records_2026_02;

Pitfall. Partition từ ngày đầu cho bảng chưa tới một triệu hàng. Bạn nhận thêm: primary key phải chứa cột partition, khoá ngoại trỏ vào bảng partition bị hạn chế, phải có job tự tạo partition tương lai (quên tạo = INSERT lỗi lúc 00:00 ngày 1 tháng sau). Chỉ partition khi bảng thực sự lớn và có mẫu truy vấn/xoá theo thời gian.


Thực hành

Trên Dự án 3:

  1. Thêm deleted_at cho documents: partial index, partial unique index, và Prisma extension tự lọc. Viết test chứng minh query quên lọc không thể xảy ra.
  2. Bảng audit_logs + REVOKE UPDATE, DELETE. Ghi audit trong cùng transaction cho: đổi role, xoá tài liệu, đổi gói. Dùng allowlist field. Thử UPDATE audit_logs bằng user của app → xác nhận bị từ chối.
  3. Rà toàn bộ schema: mọi TIMESTAMPTIMESTAMPTZ; ngày sinh → DATE. Thêm cột timezone cho users.
  4. Viết endpoint báo cáo "hôm nay" đúng theo múi giờ người dùng. Test với Asia/Ho_Chi_MinhAmerica/New_York, và một test rơi vào ngày chuyển DST.
  5. Đổi mọi cột tiền sang BIGINT minor unit + currency. Viết hàm chia hoá đơn cho N người và test bất biến tổng các phần = tổng gốc.
  6. Thêm CHECK constraint cho status, seats, khoảng thời gian. Thử insert dữ liệu sai bằng psql (đi vòng qua app) → xác nhận DB chặn.
  7. Viết eraseUser() ẩn danh hoá. Lập bản đồ mọi nơi PII tồn tại (DB, S3, Redis, log, Sentry, email provider).
  8. Bật pg_dump hàng ngày lên storage khác region + dead man's switch.
  9. Diễn tập khôi phục thật: restore bản backup mới nhất vào DB sạch, bấm giờ, chạy app trỏ vào đó, đăng nhập được. Ghi lại RTO. Xác nhận extension pgvector có trong bản restore.
  10. Seed 2 triệu hàng vào documents. Chạy CREATE INDEX thường (quan sát khoá) rồi CONCURRENTLY. Thực hiện đủ 3 bước expand-migrate-contract để đổi tên một cột mà không có downtime.

Done khi

  • [ ] Triển khai soft delete có partial index + partial unique + lọc tự động; giải thích 3 pitfall (quên lọc, vỡ unique, cascade).
  • [ ] Phân biệt rõ application log vs audit log; biết vì sao audit phải nằm trong DB và bất biến ở tầng quyền.
  • [ ] Ghi audit trong cùng transaction, có allowlist field, không lưu PII/secret.
  • [ ] Giải thích khác biệt TIMESTAMPTZ / TIMESTAMP / DATE, và vì sao TIMESTAMP gần như luôn sai.
  • [ ] Tính đúng "hôm nay" theo múi giờ người dùng; biết vì sao lịch tái diễn lưu tz name chứ không lưu offset.
  • [ ] Nêu được 3 bẫy của JS Date; ép TZ=UTC trong test.
  • [ ] Lưu tiền bằng số nguyên minor unit + currency; giải thích vì sao float là sai; xử lý đúng bài toán chia có dư.
  • [ ] Có CHECK/UNIQUE/FK thi hành bất biến nghiệp vụ ở tầng DB; biết vì sao validate ở app là không đủ.
  • [ ] Có retention policy; triển khai được ẩn danh hoá; lập được bản đồ mọi nơi PII tồn tại kể cả backup và log LLM.
  • [ ] Phân biệt hash vs encrypt; biết vì sao không tự lưu số thẻ.
  • [ ] Backup theo 3-2-1, có bản off-site; giám sát bằng dead man's switch.
  • [ ] Đã thực hiện một lần restore drill thật và biết con số RTO của mình.
  • [ ] Giải thích vì sao replica không thay được backup.
  • [ ] Thực hiện được expand-migrate-contract không downtime; dùng CREATE INDEX CONCURRENTLY, NOT VALID, lock_timeout.
  • [ ] Nói được khi nào nên và khi nào không nên partition.

Câu hỏi mở / chưa giải quyết

  • Soft delete toàn cục hay chỉ chọn lọc? Bật cho mọi bảng làm query phức tạp lên; đề xuất: chỉ cho thực thể người dùng nhìn thấy và có thể xoá nhầm.
  • Audit log lưu trong Postgres chung hay tách kho riêng (S3 + Athena)? Chung thì đơn giản, tách thì rẻ và khó bị xoá hơn — quyết khi khối lượng vượt vài chục triệu hàng/tháng.
  • Ẩn danh hoá tới mức nào là "đã xoá" theo GDPR? Ranh giới pháp lý mờ; nếu có khách hàng EU thì cần tư vấn pháp lý, đừng tự quyết bằng cảm tính kỹ thuật.
  • PITR thường không có ở gói free của PaaS. Xác định RPO chấp nhận được trước khi chọn nhà cung cấp ở GĐ14, đừng phát hiện sau sự cố.

Học bằng cách build. Chứng minh, đừng tin.