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

Kiểm chứng ngày 2026-10-05 (Node 24.21, Prisma 7.10.0 với @prisma/adapter-pg, PGlite 0.5.8 là Postgres chạy trong tiến trình, trong thư mục tạm): đã chạy SQL bảng history, sổ cái chỉ ghi thêm và outbox xoá người dùng; truy vấn include lồng qua Prisma; kiểu BigInt/Decimal của Prisma; demo crypto-shredding bằng node:crypto (tsc --strict sạch). Chưa chạy: Prisma CLI migrate với hai role thật (thử prisma db execute báo P1001 vì server giả không đáp), KMS/Vault, backup và restore thật. Về luật Việt Nam chỉ ghi điều đọc được từ văn bản chính thức: Luật 91/2025/QH15 và Nghị định 356/2025/NĐ-CP; nội dung các điều của Luật chưa xác minh (bản PDF là ảnh quét).

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ế.

textReady
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:

typescriptReady
// Prisma: extension chặn ở tầng client — mọi findMany tự thêm điều kiệnconst 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:

textReady
-- 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 đó.

textReady
-- ❌ chặn cả hàng đã xoáCREATE UNIQUE INDEX ON users (email);-- ✅ unique CHỈ trong số hàng còn sốngCREATE 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).

Soft delete qua quan hệ lồng nhau (include)#

Extension ở trên chỉ chặn truy vấn bắt đầu từ model đó. Khi đọc project kèm include: { tasks: true }, Prisma nạp tasks như một phần của truy vấn project, nên hook của task không chạy và task đã xoá quay lại. Đã chạy (Prisma 7.10.0, PGlite; extension của task thêm deletedAt: null cho findMany; một task sống, một task đã xoá):

Truy vấnTask trả về
prisma.task.findMany()chỉ task sống
prisma.project.findMany({ include: { tasks: true } })cả hai task
prisma.project.findMany({ include: { tasks: { where: { deletedAt: null } } } })chỉ task sống

Ba cách chữa, từ yếu đến chắc:

  • Viết where: { deletedAt: null } trong mọi include và select lồng. Hiệu quả nhưng lại dựa vào kỷ luật con người, nên cần một test cho từng quan hệ.
  • Đọc con bằng truy vấn riêng đi qua extension: prisma.task.findMany({ where: { projectId } }) (dòng đầu của bảng trên) thay cho include.
  • Để database lọc, đọc qua view *_active như đã nói ở trên: không còn đường đi vòng ở tầng ORM. Chưa chạy việc ánh xạ model Prisma sang view.

Pitfall: test soft delete chỉ gọi findMany trên model gốc sẽ qua dù include vẫn lộ dữ liệu. Bài 13 ở phần thực hành bắt đúng lỗi này.


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Đ09 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:

textReady
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Đ09));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:

textReady
-- Hai vai trò khác nhau: `migrator` sở hữu bảng và chạy migration;-- `app_user` chỉ dùng lúc chạy. Bảng PHẢI do migrator sở hữu: chủ bảng tự GRANT lại-- được quyền của mình và DROP được bảng, nên REVOKE trên chính chủ bảng không ngăn nổi gì.REVOKE ALL ON audit_logs FROM PUBLIC;GRANT INSERT, SELECT ON audit_logs TO app_user;GRANT USAGE ON SEQUENCE audit_logs_id_seq TO app_user;   -- BIGSERIAL: thiếu dòng này INSERT báo lỗi sequence-- Thu hồi tường minh phòng khi ALTER DEFAULT PRIVILEGES đã cấp rộng cho app_user:REVOKE UPDATE, DELETE, TRUNCATE ON audit_logs FROM app_user;

App user chỉ INSERT và SELECT. Bug hay kẻ tấn công chiếm được connection của app cũng không sửa hay xoá được dấu vết. Lệnh migrate (Prisma Migrate hay công cụ khác) phải kết nối bằng URL của migrator, còn ứng dụng dùng URL của app_user. Với Prisma 7, hai URL nằm ở hai nơi khác nhau:

typescriptReady
// prisma.config.ts: lệnh CLI (migrate, db execute) đọc URL ở đây -> role migratorimport { defineConfig } from 'prisma/config'export default defineConfig({  schema: 'prisma/schema.prisma',  datasource: { url: process.env['MIGRATE_DATABASE_URL'] },})// lúc chạy ứng dụng: client nhận URL qua driver adapter -> role app_userconst prisma = new PrismaClient({  adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),})

Nếu cả hai là một role, rào chắn này chỉ có tác dụng trên giấy. Vì CLI đọc MIGRATE_DATABASE_URL, globalSetup của GĐ13 phải đặt cả MIGRATE_DATABASE_URL: url khi chạy migrate deploy (đã sửa ở GĐ13); nếu chỉ đặt DATABASE_URL, lệnh migrate nhắm sai đích. Đã chạy (Prisma 7.10.0, PGlite làm hai server giả ở hai cổng): client dựng bằng adapter kết nối đúng server của URL app, bất kể prisma.config.ts; prisma generate chạy được khi MIGRATE_DATABASE_URL không đặt. Chưa quan sát được DDL của CLI thành công: prisma db execute đã nhắm đúng host và cổng của MIGRATE_DATABASE_URL nhưng báo P1001 vì server giả không đáp.

Mức kiểm chứng: SQL ở đây đã chạy trên PostgreSQL 17.9 với ba role. Khi app_user là chủ bảng: REVOKE UPDATE có chặn UPDATE nhưng chủ bảng GRANT lại và DROP TABLE được. Khi migrator là chủ bảng: UPDATE, DELETE, TRUNCATE bằng app_user đều bị permission denied; thiếu GRANT USAGE ON SEQUENCE thì INSERT báo permission denied for sequence audit_logs_id_seq. Quy ước đặt tên role và cách tách URL trong Prisma là đề xuất thiết kế, chưa chạy với Prisma.

Quyền xoá/ẩn danh dữ liệu cá nhân mâu thuẫn với bất biến này; cách giải nằm ở mục 7.

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

typescriptReady
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:

typescriptReady
const AUDITABLE = ['role', 'email', 'status'] as const;   // liệt kê cái ĐƯỢC ghiconst 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ái và truy 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:

textReady
-- Giả định documents có cột updated_at NOT NULL và mọi UPDATE đều đặt lại nó.-- INCLUDING DEFAULTS, KHÔNG phải INCLUDING ALL (xem Pitfall bên dưới).CREATE TABLE documents_history (  LIKE documents INCLUDING DEFAULTS,  valid_from TIMESTAMPTZ NOT NULL,  valid_to   TIMESTAMPTZ NOT NULL);CREATE INDEX ON documents_history (id, valid_to DESC);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 — INCLUDING ALL sao cả khoá chính. LIKE documents INCLUDING ALL sao luôn PRIMARY KEY và các UNIQUE của bảng gốc sang bảng history. Mỗi phiên bản mới của cùng một hàng lại chèn cùng id vào history, nên lần UPDATE thứ hai trên một hàng thất bại và kéo cả giao dịch nghiệp vụ theo. Bài kiểm tra "sửa một hàng hai lần" bắt được lỗi này; test chỉ sửa mỗi hàng một lần thì không. Đã chạy (PGlite): với INCLUDING ALL, lần UPDATE thứ nhất qua, lần thứ hai báo duplicate key value violates unique constraint "documents_history_pkey"; với INCLUDING DEFAULTS và index (id, valid_to DESC), ba lần UPDATE cho ba dòng history.

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):

textReady
-- ❌ 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":

typescriptReady
// ❌ "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ề UTCimport { 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:

typescriptReady
// "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Đ13 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ế.

textReady
-- ✅ 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ÔNGamount        DOUBLE PRECISION
typescriptReady
// ❌ Prisma map NUMERIC → Decimal; ép sang Number là mất chính xácconst total = Number(invoice.amount) * 1.1;// ✅ Số nguyên, và quyết định làm tròn TƯỜNG MINHconst 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.

Tiền trong Prisma: BigInt, Decimal và JSON#

Cột BIGINT và NUMERIC đến JS dưới hai kiểu mà Number không chứa được. Đã chạy (Prisma 7.10.0, model có amountCents BigInt và rate Decimal @db.Decimal(19, 4)):

Quan sátKết quả
typeof row.amountCentsbigint; giá trị 9007199254740993n
Number(row.amountCents)9007199254740992: lệch 1 đơn vị khi vượt 2^53
row.ratePrisma.Decimal; rate.plus('0.2') là 0.3 (số thực cho 0.30000000000000004)
JSON.stringify(row)ném TypeError: Do not know how to serialize a BigInt
JSON.stringify({ rate }){"rate":"0.1"}: Decimal ra chuỗi

Hệ quả cho ví dụ ở đầu mục: Math.round(invoice.amountCents * 1.1) giả định number; với bigint, phép nhân với 1.1 ném TypeError: Cannot mix BigInt and other types. Tính bằng số nguyên và nêu rõ quy tắc làm tròn (amount * 11n / 10n làm tròn về 0), hoặc chuyển sang number chỉ khi chắc chắn dưới 2^53. Ở biên API, trả số tiền dưới dạng chuỗi và quyết định điều đó trong hợp đồng API; tránh gán BigInt.prototype.toJSON toàn cục vì nó đổi hành vi của mọi thư viện:

typescriptReady
const toJson = (v: unknown) =>  JSON.stringify(v, (_k, x) => (typeof x === 'bigint' ? x.toString() : x))

Sổ cái chỉ ghi thêm (ledger)#

Khi tiền di chuyển giữa tài khoản, đừng chỉ lưu "số dư" rồi UPDATE balance = balance - x: không còn dấu vết, và hai request đồng thời dễ ghi đè nhau. Lưu bút toán bất biến; số dư là tổng các bút toán. Nhập sai thì thêm bút toán đảo, không sửa dòng cũ. Mỗi bút toán (tx_id) gồm hai dòng trở lên có tổng bằng 0 theo từng tiền tệ. Bảng phải do migrator sở hữu như audit_logs ở mục 2.

textReady
CREATE TABLE ledger_entries (  id           BIGSERIAL PRIMARY KEY,  tx_id        UUID        NOT NULL,                       -- gom các dòng của một bút toán  account_id   UUID        NOT NULL,  amount_cents BIGINT      NOT NULL CHECK (amount_cents <> 0),   -- dương: nợ, âm: có  currency     CHAR(3)     NOT NULL,  reverses_id  BIGINT      REFERENCES ledger_entries(id),        -- đảo bút toán cũ  created_at   TIMESTAMPTZ NOT NULL DEFAULT now());CREATE INDEX ON ledger_entries (account_id, id);-- Lớp 1: quyền (như mục 2). Lớp 2: trigger, phòng migration hoặc script lỡ tay.REVOKE ALL ON ledger_entries FROM PUBLIC;GRANT INSERT, SELECT ON ledger_entries TO app_user;GRANT USAGE ON SEQUENCE ledger_entries_id_seq TO app_user;CREATE FUNCTION ledger_no_mutation() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN  RAISE EXCEPTION 'ledger_entries chỉ ghi thêm (% bị chặn)', TG_OP    USING ERRCODE = 'insufficient_privilege';END $$;CREATE TRIGGER ledger_no_update BEFORE UPDATE OR DELETE ON ledger_entries  FOR EACH ROW EXECUTE FUNCTION ledger_no_mutation();CREATE TRIGGER ledger_no_truncate BEFORE TRUNCATE ON ledger_entries  FOR EACH STATEMENT EXECUTE FUNCTION ledger_no_mutation();-- Mỗi tx_id phải cân bằng theo từng currency, kiểm lúc COMMITCREATE FUNCTION ledger_check_balanced() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN  IF EXISTS (SELECT 1 FROM ledger_entries WHERE tx_id = NEW.tx_id             GROUP BY currency HAVING sum(amount_cents) <> 0) THEN    RAISE EXCEPTION 'bút toán % không cân bằng', NEW.tx_id      USING ERRCODE = 'check_violation';  END IF;  RETURN NULL;END $$;CREATE CONSTRAINT TRIGGER ledger_balanced AFTER INSERT ON ledger_entries  DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION ledger_check_balanced();

Đã chạy (PGlite, một role app_user cấp quyền như trên):

Thao tácKết quả
hai dòng +100000 và -100000 VND cùng tx_id, COMMITthành công
một dòng +500 đứng một mình, COMMITlỗi bút toán ... không cân bằng lúc commit
amount_cents = 0vi phạm CHECK
UPDATE, DELETE, TRUNCATE bằng superusertrigger chặn
UPDATE, TRUNCATE sau SET ROLE app_userpermission denied for table ledger_entries
hai dòng đảo -100000 và +100000 với reverses_idthành công; số dư hai tài khoản về 0

Giới hạn: trigger không chặn được chủ bảng hay superuser chạy ALTER TABLE ... DISABLE TRIGGER, nên quyền (chủ bảng khác app_user) vẫn là lớp chính; trigger là lớp thứ hai. Kiểm "số dư không âm" khi nhiều request đồng thời cần khoá hàng tài khoản hoặc mức cô lập SERIALIZABLE: ngoài phạm vi mục 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ụ:

textReady
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 độngCREATE 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á:

typescriptReady
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Đ12 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.    //    app_user KHÔNG có UPDATE trên audit_logs (mục 2) → đi qua hàm có kiểm soát, không updateMany.    //    Hàm chỉ làm việc với actor ĐÃ bị ẩn danh ở bước 1 (cùng giao dịch nên nhìn thấy).    await tx.$queryRaw`SELECT audit_admin.anonymize_actor(${userId}::uuid)`;    // 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, log LLM, S3): ghi một    //    dòng outbox TRONG CÙNG giao dịch, đừng gọi queue.add sau commit (xem "Lan truyền    //    xoá qua outbox" bên dưới). ON CONFLICT: gọi hai lần vẫn chỉ một dòng.    await tx.$executeRaw`      INSERT INTO outbox (topic, payload)      VALUES ('erase-user', jsonb_build_object('userId', ${userId}::text))      ON CONFLICT DO NOTHING`;  });  // Một relay đọc outbox rồi đẩy 'propagate-erasure' vào erasureQueue (GĐ10 mục 4).}

Mâu thuẫn với REVOKE UPDATE ở mục 2, và cách giải. Nếu giữ nguyên updateMany thì app_user nhận permission denied for table audit_logs và cả giao dịch xoá người dùng thất bại. Đừng "sửa" bằng cách trả quyền UPDATE về cho app_user: bạn mất đúng bất biến cần giữ. Cách giải là một ngoại lệ hẹp, có tên và có kiểm soát: một hàm SECURITY DEFINER do một role riêng sở hữu. Role đó chỉ cập nhật được hai cột định danh (ip, user_agent), chỉ khi người dùng tương ứng đã bị ẩn danh trong bảng users, và hàm tự ghi thêm một dòng audit user.anonymized. app_user chỉ có quyền EXECUTE hàm. Giả định bảng users(id uuid, tenant_id uuid, anonymized_at timestamptz); điều chỉnh theo schema của bạn.

textReady
-- Chạy bằng superuser, một lần khi thiết lập (migrator thường không có quyền CREATE ROLE;-- trên dịch vụ managed, dùng tài khoản quản trị có quyền tương đương — chưa kiểm)CREATE ROLE audit_eraser NOLOGIN;                       -- chỉ là chủ hàm, không ai đăng nhập vàoGRANT SELECT (actor_id, ip, user_agent), UPDATE (ip, user_agent) ON audit_logs TO audit_eraser;GRANT INSERT ON audit_logs TO audit_eraser;             -- để hàm tự ghi dòng auditGRANT USAGE ON SEQUENCE audit_logs_id_seq TO audit_eraser;GRANT SELECT (id, tenant_id, anonymized_at) ON users TO audit_eraser;CREATE SCHEMA audit_admin AUTHORIZATION audit_eraser;GRANT USAGE ON SCHEMA audit_admin TO app_user;SET ROLE audit_eraser;                                   -- hàm phải thuộc audit_eraserCREATE FUNCTION audit_admin.anonymize_actor(p_actor uuid) RETURNS integerLANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp AS $$DECLARE n integer; t uuid;BEGIN  -- Chỉ actor đã bị ẩn danh ở bước 1 mới được xoá định danh khỏi audit.  SELECT tenant_id INTO t FROM public.users WHERE id = p_actor AND anonymized_at IS NOT NULL;  IF NOT FOUND THEN RETURN 0; END IF;  UPDATE public.audit_logs SET ip = NULL, user_agent = NULL WHERE actor_id = p_actor;  GET DIAGNOSTICS n = ROW_COUNT;  INSERT INTO public.audit_logs (tenant_id, actor_id, actor_type, action, resource, resource_id, after)  VALUES (t, NULL, 'system', 'user.anonymized', 'user', p_actor::text,          jsonb_build_object('audit_rows_cleared', n));  RETURN n;END $$;REVOKE ALL ON FUNCTION audit_admin.anonymize_actor(uuid) FROM PUBLIC;GRANT EXECUTE ON FUNCTION audit_admin.anonymize_actor(uuid) TO app_user;RESET ROLE;

Vì sao đủ chặt: SET search_path cố định (pg_temp đặt cuối, mọi bảng viết kèm schema) chặn tấn công đổi đường dẫn tìm kiếm; REVOKE ... FROM PUBLIC thu quyền gọi mặc định; quyền theo cột khiến chủ hàm không sửa được action, không xoá được hàng; và điều kiện anonymized_at khiến kẻ chiếm connection app_user không gọi hàm với một UUID tuỳ ý để xoá dấu vết của người khác. Bản ghi audit vẫn nguyên về hành vi (ai làm gì, lúc nào); chỉ mất dấu vết định danh trực tiếp, và việc đó để lại một dòng user.anonymized. actor_id giữ lại như một mã giả danh vì hàng người dùng đã được ẩn danh ở bước 1. Hàm chạy trong cùng giao dịch của eraseUser vì dùng chung kết nối. Nếu before/after có thể chứa PII, dùng allowlist ở mục 2 ngay từ lúc ghi, vì hàm trên không dọn hai cột JSONB đó.

Giới hạn còn lại. app_user vẫn ghi được users.anonymized_at, nên kẻ chiếm connection có thể đánh dấu một tài khoản là "đã ẩn danh" rồi gọi hàm để xoá ip/user_agent của tài khoản đó. Hàm không ngăn được việc này, nhưng nó giới hạn thiệt hại ở hai cột định danh (hành vi vẫn còn) và buộc để lại dòng user.anonymized; muốn chặt hơn, đặt việc ẩn danh sau một role/hàng đợi riêng thay vì cho app_user tự ghi anonymized_at.

Mức kiểm chứng: các khối SQL ở mục 2 và mục này đã chạy nguyên văn trên PostgreSQL 17.9 (migrator tạo bảng users và audit_logs, superuser chạy khối thiết lập). Gọi hàm với UUID chưa ẩn danh hoặc không tồn tại trả 0 và không đổi hàng nào; sau khi users.anonymized_at được đặt, gọi hàm xoá ip/user_agent đúng actor và thêm một dòng user.anonymized; UPDATE, DELETE, TRUNCATE trực tiếp bằng app_user vẫn bị permission denied; chủ hàm thử UPDATE ... SET action hay DELETE cũng bị từ chối. Lời gọi qua $queryRaw của Prisma chưa chạy. Đây là một đề xuất thiết kế phổ biến, không phải yêu cầu của GDPR hay của luật nào: luật chỉ đòi kết quả (dữ liệu định danh được xoá/ẩn danh đúng hạn), còn cơ chế do bạn chọn và phải giải thích được với người kiểm toán.

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.

Lan truyền xoá qua outbox#

Bước 5 của eraseUser từng gọi erasureQueue.add sau commit. Đó là lỗi hai-lần-ghi đã nói ở GĐ10 mục 4: commit xong mà Redis hoặc tiến trình chết trước khi add xong, người dùng đã bị ẩn danh trong DB nhưng bản sao ở email provider, analytics, S3 không bao giờ bị xoá, và không ai biết. Sửa: ghi dòng outbox trong cùng giao dịch (mã ở trên), relay của GĐ10 mục 4 đẩy sang queue. Thêm một unique index một phần để yêu cầu xoá gọi hai lần chỉ để lại một dòng, và để job xoá chịu được giao tin lặp (at-least-once, GĐ10 mục 3): xoá thứ đã xoá phải là thành công, không phải lỗi.

textReady
CREATE UNIQUE INDEX outbox_erase_user_once ON outbox ((payload->>'userId'))  WHERE topic = 'erase-user';

Đã chạy (PGlite, bảng outbox như GĐ10 mục 4, thêm index trên):

Tình huốngKết quả
giao dịch ẩn danh + insert outbox, rồi SELECT 1/0 trước COMMITROLLBACK: users.email giữ giá trị cũ, outbox có 0 dòng
cùng khối chạy hai lần, COMMITusers đã ẩn danh, outbox đúng một dòng
relay nhặt bằng FOR UPDATE SKIP LOCKED, đánh dấu published_atnhặt một dòng; lần nhặt sau trả rỗng

PGlite chỉ có một kết nối nên chưa quan sát được hai relay chạy song song bỏ qua nhau; lời gọi qua $executeRaw của Prisma chưa chạy.

Crypto-shredding: xoá bằng cách huỷ khoá#

Pitfall (b) ở trên chấp nhận để dữ liệu nằm trong backup tới hết retention. Crypto-shredding đổi bài toán: mã hoá trường PII (hoặc cả blob) bằng một khoá riêng cho từng người dùng (DEK), lưu khoá ở kho tách khỏi database và khỏi backup của database. Muốn xoá người dùng, huỷ khoá: mọi bản sao của bản mã, kể cả trong backup và replica, thành dữ liệu không đọc được. Mã hoá AES-256-GCM giống mục 8, thêm setAAD(userId) để bản mã gắn với chủ sở hữu:

typescriptReady
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'// Kho khoá TÁCH khỏi DB và khỏi backup của DB (KMS, Vault, hoặc một kho riêng).export interface KeyStore {  getOrCreate(userId: string): Promise<Buffer>   // 32 byte; ném KeyDestroyedError nếu đã huỷ  destroy(userId: string): Promise<void>}export class KeyDestroyedError extends Error {  constructor(userId: string) { super(`khoá của ${userId} đã bị huỷ`) }}export interface Sealed { iv: Buffer; tag: Buffer; data: Buffer }export async function seal(store: KeyStore, userId: string, plain: string): Promise<Sealed> {  const key = await store.getOrCreate(userId)  const iv = randomBytes(12)  const c = createCipheriv('aes-256-gcm', key, iv)  c.setAAD(Buffer.from(userId))                   // gắn bản mã với chủ sở hữu  const data = Buffer.concat([c.update(plain, 'utf8'), c.final()])  return { iv, tag: c.getAuthTag(), data }}export async function open(store: KeyStore, userId: string, s: Sealed): Promise<string> {  const key = await store.getOrCreate(userId)     // ném KeyDestroyedError nếu đã huỷ  const d = createDecipheriv('aes-256-gcm', key, s.iv)  d.setAAD(Buffer.from(userId))  d.setAuthTag(s.tag)  return Buffer.concat([d.update(s.data), d.final()]).toString('utf8')}

Đã chạy (Node 24.21, tsc --strict sạch; kho khoá là một Map trong bộ nhớ, bản mã nằm trong một Map khác đóng vai bảng và backup):

BướcKết quả
seal hai người, open của alicetrả lại số điện thoại
open bản mã của alice bằng userId của bobUnsupported state or unable to authenticate data (AAD khác)
destroy('alice'), rồi open bản mã hiện tại và bản trong backup chụp trước đócả hai ném KeyDestroyedError
open của bobvẫn đọc được

Điều kiện để nó có nghĩa:

  • DEK không nằm cùng database hay cùng backup với bản mã. Nếu bảng user_keys cùng DB thì backup chứa cả hai và việc "huỷ" chỉ là xoá một hàng, bản backup cũ vẫn giải mã được. Kho khoá có backup riêng thì backup đó cũng phải hết hạn nhanh.
  • Mô hình thực tế: DEK sinh bởi KMS hoặc Vault và được bọc bằng khoá chủ (KEK); huỷ nghĩa là xoá bản bọc và thu hồi quyền dùng KEK. Chưa chạy với KMS, đây là mô hình.
  • Cái giá: không tìm, sắp xếp, UNIQUE được trên cột đã mã hoá. Nếu cần tra theo email thì dùng một chỉ mục mù (HMAC của email bằng khoá riêng), và chỉ mục đó là dữ liệu cá nhân: phải xoá cùng lúc.
  • Chỉ bảo vệ trường đã mã hoá. Bản sao dạng rõ ở log, cache, email provider vẫn cần bản đồ dữ liệu ở Pitfall (a).
  • Việc xem "huỷ khoá" có được coi là đã xoá hay không là câu hỏi pháp lý (xem câu hỏi mở cuối file), không phải câu hỏi kỹ thuật.

Luật bảo vệ dữ liệu cá nhân của Việt Nam: điều đã xác minh#

Nếu có người dùng ở Việt Nam, các cơ chế ở mục này phải khớp luật Việt Nam, không chỉ GDPR. Dưới đây chỉ là điều đọc được trực tiếp từ văn bản chính thức ngày 2026-10-05; đây không phải tư vấn pháp lý, hãy hỏi chuyên gia pháp lý trước khi đưa vào chính sách của sản phẩm.

  • Luật Bảo vệ dữ liệu cá nhân, số 91/2025/QH15, ban hành 26/06/2025, hiệu lực 01/01/2026. Nguồn: trang văn bản của Cổng thông tin Chính phủ, bản PDF.
  • Nghị định 356/2025/NĐ-CP (31/12/2025) hướng dẫn Luật, hiệu lực 01/01/2026 (Điều 42 khoản 1); Nghị định 13/2023/NĐ-CP hết hiệu lực từ cùng ngày (Điều 42 khoản 2). Nguồn: trang văn bản, bản PDF.
  • Dữ liệu cá nhân nhạy cảm (Nghị định 356, Điều 4 khoản 1) gồm, trong số các nhóm khác: tình trạng sức khoẻ, dữ liệu sinh trắc học, vị trí xác định qua dịch vụ định vị, tài khoản và thẻ ngân hàng cùng lịch sử giao dịch, hình ảnh thẻ căn cước, dữ liệu theo dõi hành vi trên không gian mạng. Khi xử lý loại này phải có quy định phân quyền giới hạn truy cập, quy trình xử lý và biện pháp bảo mật (Nghị định 356, Điều 4 khoản 2). Nghị định 356 Điều 3 liệt kê dữ liệu cơ bản.
  • Thông báo vi phạm: nội dung tối thiểu gồm tính chất vi phạm (thời gian, địa điểm, hành vi, loại và số lượng dữ liệu), đầu mối liên lạc, hậu quả có thể xảy ra, biện pháp khắc phục; gửi cơ quan chuyên trách hoặc qua Cổng thông tin quốc gia theo Mẫu số 08 (Nghị định 356, Điều 28).
  • Mốc 72 giờ trong Nghị định 356 xuất hiện ở hai chỗ, đã đọc trực tiếp: lộ hoặc mất dữ liệu nhạy cảm trong lĩnh vực tài chính, ngân hàng, tín dụng (thông báo cơ quan chuyên trách và chủ thể, Nghị định 356 Điều 8 khoản 3); sự cố liên quan dữ liệu vị trí hoặc sinh trắc học (thông báo chủ thể, Nghị định 356 Điều 29 khoản 1). Luật 91, Điều 23 khoản 1, theo bản chép lại không chính thức (hethongphapluat.com), chưa đối chiếu bản chính thức (PDF của Luật là bản quét, chưa đọc được), dường như yêu cầu bên kiểm soát, bên kiểm soát và xử lý, và bên thứ ba thông báo cơ quan chuyên trách chậm nhất 72 giờ kể từ khi phát hiện vi phạm có thể gây tổn hại đến quốc phòng, an ninh, trật tự, an toàn xã hội hoặc tính mạng, sức khoẻ, danh dự, nhân phẩm, tài sản của chủ thể. Nếu bản chép đúng thì 72 giờ là mốc chung chứ không chỉ hai trường hợp hẹp của Nghị định 356; hỏi chuyên gia pháp lý trước khi áp dụng.
  • Miễn trừ cho doanh nghiệp nhỏ (Nghị định 356, Điều 41): hộ kinh doanh và doanh nghiệp siêu nhỏ không phải thực hiện Điều 21, 22 và khoản 2 Điều 33 của Luật; doanh nghiệp nhỏ và khởi nghiệp được chọn không thực hiện trong 5 năm kể từ ngày Luật có hiệu lực. Ngoại lệ: doanh nghiệp kinh doanh dịch vụ xử lý dữ liệu cá nhân, trực tiếp xử lý dữ liệu nhạy cảm, hoặc xử lý từ 100 nghìn chủ thể trở lên thì không được miễn.

Điều nên rút ra về kỹ thuật (gợi ý của tài liệu này, không phải lời văn của luật): nội dung thông báo vi phạm đòi hỏi biết dữ liệu nào, của bao nhiêu người, vào lúc nào, tức là cần bản đồ dữ liệu ở Pitfall (a), audit log ở mục 2 và truy cập dữ liệu nhạy cảm có kiểm soát.

Chưa xác minh: nội dung các điều của Luật (bản PDF là ảnh quét nên không đọc được văn bản), trong đó có Điều 21, 22 và 33 mà Nghị định 356 nhắc tới, và thời hạn thông báo chung trong Luật. Ngoài phạm vi tài liệu này: căn cứ xử lý và đồng ý, quyền của chủ thể (xoá, rút lại đồng ý) cùng thời hạn đáp ứng, đánh giá tác động, chuyển dữ liệu ra nước ngoài, xử phạt: hỏi chuyên gia pháp lý.


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ụ:

typescriptReady
// AES-256-GCM: có xác thực (phát hiện dữ liệu bị sửa), khác với AES-CBCimport { 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ẩu → hash (argon2id), không mã hoá. Hash một chiều: không có nhu cầu đọc lại.
  • Token OAuth, khoá API → mã hoá (cần dùng lại).
  • Số thẻ tín dụng → khô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 backup — pg_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ụ:

bashReady
# 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ụcpg_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Đ05 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):

textReady
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.
Sơ đồ: ai ghi, ai đọc ở từng bước
textReady
            cột cũ (title)   cột mới (name)   instance cũ còn chạy? EXPAND     ghi + đọc        ghi (nullable)   an toàn (bỏ qua cột mới) BACKFILL   ghi + đọc        điền hàng cũ     an toàn MIGRATE    ghi              ghi + đọc        an toàn (vẫn có title) CONTRACT   thôi ghi, DROP   ghi + đọc        DROP khi hết instance cũ

Mỗi hàng là một lần deploy có thể dừng hoặc quay lui mà không mất dữ liệu; DROP cùng lúc với code ngừng dùng cột là chỗ duy nhất không quay lui được.

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

typescriptReady
// ❌ Một câu UPDATE 50 triệu hàng: giữ khoá hàng giờ, WAL phình, replica trễ nặngawait prisma.$executeRaw`UPDATE documents SET slug = slugify(title)`;// ✅ Chia lô, có nghỉ giữa các lô để replica bắt kịpfor (;;) {  // Hàng đã backfill hết còn `slug IS NULL`, nên không cần con trỏ: mỗi vòng tự nhặt lô kế tiếp.  // (Không dùng `id > cursor` với cursor tăng 5000: id không liên tục/UUID thì bỏ sót hàng.)  // `slugify` là hàm tự định nghĩa. Nếu nó trả NULL/'' (title NULL), hàng đó mãi `slug IS NULL`,  // UPDATE vẫn đếm 5000 hàng mỗi vòng và vòng lặp không bao giờ dừng: luôn COALESCE ra giá trị khác NULL.  // (Sau này thêm UNIQUE cho slug thì phải xử lý trùng; `id::text` ở đây chỉ để hàng hết NULL.)  const n = await prisma.$executeRaw`    UPDATE documents SET slug = COALESCE(NULLIF(slugify(title), ''), id::text)    WHERE id IN (SELECT id FROM documents WHERE slug IS NULL ORDER BY id LIMIT 5000)`;  if (n === 0) break;                     // $executeRaw trả số hàng bị ảnh hưởng  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:

textReady
-- ❌ Khoá bảng suốt quá trình build indexCREATE 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âuALTER 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ì với DEFAULT không biến động (hằng số, now(), ...).--    now() là STABLE nên cũng tức thì: Postgres tính giá trị MỘT lần lúc ALTER và dùng--    giá trị đó cho mọi hàng cũ. DEFAULT là hàm biến động (VOLATILE) như random(),--    clock_timestamp() thì VẪN viết lại cả bảng — thêm cột NULLABLE rồi backfill.

Mức kiểm chứng: đã chạy trên PostgreSQL 17.9 với bảng 300.000 hàng, so sánh pg_relation_filenode trước và sau (filenode đổi nghĩa là bảng bị viết lại). DEFAULT now(): filenode giữ nguyên, mọi hàng cũ mang cùng một giá trị. DEFAULT clock_timestamp() và DEFAULT random(): filenode đổi, mỗi hàng một giá trị. pg_proc.provolatile: now = s (stable), clock_timestamp và random = v (volatile).

Luôn đặt timeout cho migration:

textReady
SET lock_timeout = '3s';        -- không lấy được khoá trong 3s thì BỎ, đừng xếp hàngSET 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 TABLE — tức thì.

Ví dụ:

textReady
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.

    Lời giải và cách kiểm tra

    Hướng làm: thêm cột, partial index, partial unique, rồi chặn "quên lọc" ở hai tầng.

    textReady
    ALTER TABLE documents ADD COLUMN deleted_at TIMESTAMPTZ;ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;CREATE INDEX idx_documents_active ON documents (tenant_id, created_at DESC) WHERE deleted_at IS NULL;CREATE UNIQUE INDEX users_email_active ON users (email) WHERE deleted_at IS NULL;CREATE VIEW documents_active AS SELECT * FROM documents WHERE deleted_at IS NULL;

    Đã chạy: chèn a@x.com đã xoá rồi chèn a@x.com mới. Với UNIQUE(email) thường: duplicate key value violates unique constraint. Với partial unique: chèn lần hai thành công, lần ba (hai hàng sống) mới bị chặn. Bảng gốc 6 hàng, documents_active 3 hàng (hàng chẵn đã xoá). EXPLAIN có WHERE deleted_at IS NULL AND tenant_id = ... dùng Index Scan using idx_documents_active, với điều kiện chạy SET enable_seqscan = off (bảng chỉ 6 hàng nên không đặt thì planner chọn Seq Scan, đó là bình thường; trên bảng lớn không cần ép).

    Test "không thể quên lọc": gọi method được phép của extension và xác nhận hàng xoá không xuất hiện; gọi thêm mọi method đọc (findFirst, findUnique, count, aggregate, groupBy) vì mỗi cái cần một hook riêng.

    typescriptReady
    // Prisma 7: client cần driver adapter (xem GĐ05). `$allOperations` bao phủ mọi method đọc/ghi của model.const READS = new Set(['findMany', 'findFirst', 'findFirstOrThrow', 'findUnique', 'findUniqueOrThrow', 'count', 'aggregate', 'groupBy'])export const prisma = base.$extends({  query: {    document: {      $allOperations({ operation, args, query }) {        if (READS.has(operation)) args.where = { ...args.where, deletedAt: null }        return query(args)      },    },  },})// vitestit('hàng đã xoá không lọt qua bất kỳ method đọc nào', async () => {  const d = await prisma.document.create({ data: { tenantId, title: 't' } })  await base.document.update({ where: { id: d.id }, data: { deletedAt: new Date() } })  expect(await prisma.document.findMany({ where: { tenantId } })).toHaveLength(0)  expect(await prisma.document.findUnique({ where: { id: d.id } })).toBeNull()  expect(await prisma.document.count({ where: { tenantId } })).toBe(0)})

    Mong đợi: ba assertion qua. Điểm yếu: $queryRaw và base đi vòng extension; chốt chặn chắc hơn là query qua view documents_active. Lỗi hay gặp: where: { id } của findUnique bị ghi đè khi spread sai thứ tự; quên aggregate/groupBy; soft delete project mà không soft delete task con (làm trong cùng transaction).

  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.

    Lời giải và cách kiểm tra

    Hướng làm: tạo bảng bằng migrator, cấp quyền như mục 2, ghi audit trong $transaction với allowlist (mã mục 2). Đã chạy: bằng app_user, INSERT và SELECT thành công; UPDATE audit_logs ..., DELETE FROM audit_logs, TRUNCATE audit_logs đều ERROR: permission denied for table audit_logs. Ghi audit cùng transaction: UPDATE users rồi INSERT audit_logs với tenant_id NULL → violates not-null constraint → ROLLBACK, users.email giữ giá trị cũ (đổi dữ liệu mà không có audit là không thể xảy ra). Allowlist:

    typescriptReady
    const AUDITABLE = ['role', 'email', 'status'] as constconst pickAuditable = (o: Record<string, unknown>) =>  Object.fromEntries(Object.entries(o).filter(([k]) => (AUDITABLE as readonly string[]).includes(k)))// pickAuditable({ role: 'ADMIN', passwordHash: 'x', phone: '09..' }) → { role: 'ADMIN' }  (mong đợi)

    Lỗi hay gặp: app_user là chủ bảng (REVOKE vô tác dụng); thiếu GRANT USAGE ON SEQUENCE nên INSERT báo permission denied for sequence audit_logs_id_seq; audit ghi sau COMMIT.

  3. Rà toàn bộ schema: mọi TIMESTAMP → TIMESTAMPTZ; ngày sinh → DATE. Thêm cột timezone cho users.

    Lời giải và cách kiểm tra

    Tìm cột sai bằng truy vấn catalog, rồi sửa từng bảng bằng migration (đổi kiểu TIMESTAMP → TIMESTAMPTZ viết lại bảng và lấy khoá nặng: làm theo mục 10 cho bảng lớn). Code tham chiếu, chưa chạy:

    textReady
    SELECT table_name, column_name, data_type FROM information_schema.columnsWHERE table_schema = 'public' AND data_type = 'timestamp without time zone';   -- mong đợi: 0 hàng sau khi sửaALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'  CHECK (timezone <> '');          -- tên IANA, ví dụ 'Asia/Ho_Chi_Minh'; ngày sinh: birth_date DATE

    Giá trị cũ của TIMESTAMP phải biết nó đang ở múi nào mới đổi được: ALTER ... TYPE timestamptz USING col AT TIME ZONE 'UTC'. Đã chạy (khác biệt kiểu): cùng now(), TimeZone = 'UTC' hiện 11:48... cho cả hai cột; đổi TimeZone = 'Asia/Ho_Chi_Minh' thì cột timestamptz thành 18:48...+07 còn cột timestamp vẫn 11:48... (không biết nó thuộc múi nào).

  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_Minh và America/New_York, và một test rơi vào ngày chuyển DST.

    Lời giải và cách kiểm tra

    Dùng dayRangeInTz của mục 4, đọc users.timezone từ DB (không đoán từ IP). Đã chạy (Node v24.21, date-fns 4.4, @date-fns/tz 1.5; kết quả giống hệt khi đặt TZ=UTC, Asia/Ho_Chi_Minh, America/New_York):

    Trường hợpfrom (UTC)to (UTC)độ dài
    Asia/Ho_Chi_Minh, instant 2026-10-04T23:00Z (06:00 ngày 5)2026-10-04T17:00:00.000Z2026-10-05T16:59:59.999Z24 h
    America/New_York, 2026-03-08 (chuyển DST)2026-03-08T05:00:00.000Z2026-03-09T03:59:59.999Z23 h
    America/New_York, 2026-03-092026-03-09T04:00:00.000Z2026-03-10T03:59:59.999Z24 h

    Cách "ngây thơ" (setHours(0,0,0,0)) cho 2026-10-04T00:00Z khi server chạy UTC: ngày 4, sai với người dùng ở VN. Test DST nên khẳng định to - from + 1 === 23 h cho ngày 2026-03-08. Truy vấn SQL cho báo cáo dùng nửa khoảng occurred_at >= from AND occurred_at < to_exclusive (bỏ mili-giây .999). Lỗi hay gặp: cộng 24 * 3600 * 1000 để sang ngày hôm sau (sai ngày 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.

    Lời giải và cách kiểm tra

    Đổi cột: amount_cents BIGINT NOT NULL, currency CHAR(3) NOT NULL (backfill theo mục 10 nếu bảng lớn). Hàm chia và test bất biến (đã chạy, Node v24.21):

    typescriptReady
    export function splitEvenly(total: number, n: number): number[] {  if (!Number.isSafeInteger(total) || total < 0 || !Number.isInteger(n) || n < 1) throw new RangeError('bad input')  const base = Math.floor(total / n), rem = total % n  return Array.from({ length: n }, (_, i) => base + (i < rem ? 1 : 0))   // `rem` người đầu nhận thêm 1 đồng}// property test (vitest): với total, n ngẫu nhiên → sum(parts) === total && max - min <= 1

    Kết quả quan sát: splitEvenly(100, 3) = [34, 33, 33] (tổng 100); splitEvenly(100000, 7) = hai phần 14285 và năm phần 14286, tổng 100000; 20 000 cặp ngẫu nhiên: 0 vi phạm. Cách sai Array(3).fill(Math.round(100/3)) = [33,33,33], tổng 99. 0.1 + 0.2 = 0.30000000000000004; 19.99 * 100 = 1998.9999999999998 (phải Math.round). Số chữ số thập phân tra theo currency (VND/JPY 0, USD 2), đừng hardcode /100.

  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.

    Lời giải và cách kiểm tra

    Dùng bảng subscriptions ở mục 6. Cách tự thử bằng psql (đi vòng qua app), code tham chiếu, chưa chạy: kết quả mong đợi mỗi lệnh dưới đây bị ERROR: ... violates check constraint hoặc unique constraint.

    textReady
    INSERT INTO subscriptions (id, tenant_id, status, seats, amount_cents, currency, started_at)VALUES (gen_random_uuid(), :tenant, 'weird', 1, 0, 'USD', now());                 -- status ngoài danh sách-- seats = 0, amount_cents = -1, ended_at < started_at: mỗi cái vi phạm một CHECK-- hai dòng status 'active' cùng tenant_id: vi phạm unique index một phần

    Đã chạy một phần liên quan: UNIQUE một phần chặn hàng sống thứ hai ở bài 1 và NOT NULL chặn audit ở bài 2. ALTER TABLE ... ADD CONSTRAINT ... CHECK trên bảng đã có dữ liệu: dùng NOT VALID rồi VALIDATE (mục 10).

  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).

    Lời giải và cách kiểm tra

    Dùng hàm ở mục 7. Đã chạy: gọi audit_admin.anonymize_actor với actor chưa ẩn danh hoặc UUID ngẫu nhiên trả 0, không đổi hàng nào; sau UPDATE users SET anonymized_at = now() trong cùng transaction hàm trả 1, ip và user_agent của dòng audit đó thành NULL, actor_id giữ nguyên, có thêm dòng user.anonymized với {"audit_rows_cleared": 1}. Bản đồ PII mẫu (sao chép rồi điền vào tài liệu của dự án):

    NơiChứa gìCách xử lý khi xoá
    Postgres users, documentsemail, tên, nội dungẩn danh / xoá trong transaction
    audit_logsip, user_agent, actor_idhàm anonymize_actor
    S3/R2file người dùng tải lênjob propagate-erasure xoá object
    Rediscache, sessionxoá khoá theo userId, TTL ngắn
    Log tập trung, Sentryemail, IP trong breadcrumbscrub khi ghi; retention 7–30 ngày
    Email provider, analyticsđịa chỉ, sự kiệnAPI xoá của nhà cung cấp
    Prompt/log LLMnội dung người dùngkhông log thô; retention ngắn
    Backuptất cảhết hạn theo retention; ghi lại lệnh xoá để áp lại khi restore
  8. Bật pg_dump hàng ngày lên storage khác region + dead man's switch.

    Lời giải và cách kiểm tra

    Code tham chiếu, chưa chạy (URL và bucket giả định):

    bashReady
    #!/bin/shset -euf="backup-$(date -u +%FT%TZ).dump"pg_dump --format=custom --compress=9 "$DATABASE_URL" -f "$f"[ "$(wc -c < "$f")" -gt 1024 ] || { echo "dump rỗng"; exit 1; }       # chống dump rỗng im lặngaws s3 cp "$f" "s3://backup-other-region/$f" --region eu-west-1          # nơi khác region/tài khoảncurl -fsS --retry 3 "$HEARTBEAT_URL" >/dev/null                          # CHỈ ping khi mọi bước trên thành công

    Lên lịch bằng cron/GitHub Actions schedule. Dịch vụ heartbeat (healthchecks.io hoặc tương đương) đặt chu kỳ 24 h, grace 2 h → cảnh báo khi không thấy ping trong khoảng 26 h. Mong đợi: tắt cron một ngày thì có cảnh báo; dump nhỏ bất thường so với lần trước cũng cảnh báo (so kích thước với bản gần nhất trước khi ping). Lỗi hay gặp: ping ở đầu script (cron hỏng giữa chừng vẫn "xanh").

  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.

    Lời giải và cách kiểm tra

    Các bước và số cần ghi:

    bashReady
    createdb app_restore/usr/bin/time -p pg_restore --jobs=4 --no-owner -d app_restore backup.dump     # t_restore = phần chính của RTOpsql app_restore -c "SELECT extname FROM pg_extension"                           # phải thấy 'vector'psql app_restore -c "SELECT count(*) FROM users"                                  # so với prod (cùng thời điểm dump)

    Mong đợi: pg_restore kết thúc không lỗi (hoặc chỉ lỗi bạn hiểu và ghi lại), số hàng khớp, app trỏ vào app_restore đăng nhập được. Code tham chiếu, chưa chạy. RTO của bạn = thời gian từ lúc quyết định khôi phục đến lúc app phục vụ lại (gồm tìm bản backup, restore, đổi connection string, kiểm tra), không chỉ thời gian pg_restore. Ghi vào sổ: ngày, kích thước dump, thời gian từng bước, lỗi gặp, extension thiếu. Lỗi hay gặp: DB đích chưa có extension vector (managed DB cần bật trước khi restore), role chủ sở hữu không tồn tại ở DB đích (dùng --no-owner rồi tạo lại quyền).

  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.

    Lời giải và cách kiểm tra

    Seed: INSERT ... SELECT ... FROM generate_series(1, 2000000).

    textReady
     Phiên A: CREATE INDEX idx ON documents (title);          -- chặn INSERT/UPDATE/DELETE đến khi xong Phiên B: INSERT INTO documents ...          -- treo (chờ khoá SHARE); xem bằng pg_stat_activity Phiên A: CREATE INDEX CONCURRENTLY idx2 ON ...          -- B vẫn chạy bình thường; build lâu hơn, ngoài transaction

    Quan sát: với CREATE INDEX thường, SELECT pid, wait_event_type, wait_event, query FROM pg_stat_activity ở phiên khác thấy phiên INSERT ở wait_event_type = Lock. Với CONCURRENTLY không thấy chờ. Đây là kết quả mong đợi theo tài liệu PostgreSQL, chưa chạy lại ở 2 triệu hàng. Đổi tên cột title → name:

    textReady
    -- Deploy 1 (expand): migration riêng, đặt timeoutSET lock_timeout = '3s';ALTER TABLE documents ADD COLUMN name TEXT;                 -- nullable: tức thì-- code ghi cả `title` và `name`, vẫn đọc `title`-- backfill theo lô như vòng lặp ở mục 10, nhưng đổi câu UPDATE thành:--   UPDATE documents SET name = title--   WHERE id IN (SELECT id FROM documents WHERE name IS NULL AND title IS NOT NULL ORDER BY id LIMIT 5000)-- (hàng có title NULL không bao giờ hết `name IS NULL`: không lọc thì vòng lặp không dừng)-- kiểm tra: SELECT count(*) FROM documents WHERE name IS NULL AND title IS NOT NULL;  -- → 0CREATE INDEX CONCURRENTLY idx_documents_name ON documents (name);   -- nếu cần-- (giả định title NOT NULL; nếu title nullable thì bỏ hai dòng CHECK dưới đây)ALTER TABLE documents ADD CONSTRAINT documents_name_nn CHECK (name IS NOT NULL) NOT VALID;ALTER TABLE documents VALIDATE CONSTRAINT documents_name_nn;-- Deploy 2 (migrate): code đọc `name`, vẫn ghi cả hai (quay lui được)-- Deploy 3 (contract): code ngừng ghi `title`; vài ngày sau:ALTER TABLE documents DROP COLUMN title;

    Giữ lock_timeout ngắn để migration thất bại sớm thay vì xếp hàng chặn mọi query sau nó; nếu ALTER lỗi hết thời gian chờ khoá, chạy lại ngoài giờ cao điểm. Lỗi hay gặp: CREATE INDEX CONCURRENTLY đặt chung file migration có transaction (lỗi cannot run inside a transaction block); đổi tên bằng RENAME COLUMN trực tiếp làm instance cũ đang chạy lỗi ngay; slugify/hàm backfill có thể trả NULL khiến vòng lặp không dừng.

  11. Bật lịch sử cho documents bằng bảng documents_history và trigger (mục 3). Cập nhật cùng một hàng ba lần. Chứng minh vì sao phiên bản LIKE ... INCLUDING ALL hỏng.

    Lời giải và cách kiểm tra

    Dùng SQL ở mục 3, tạo documents(id uuid PRIMARY KEY, tenant_id, title, updated_at) rồi chèn một hàng. Đã chạy (PGlite): với INCLUDING ALL, UPDATE lần một qua, lần hai báo duplicate key value violates unique constraint "documents_history_pkey", history chỉ có v1. Với INCLUDING DEFAULTS và index (id, valid_to DESC): ba lần UPDATE (v2, v3, v4) để lại history v1, v2, v3; hàng hiện tại v4 nằm ở documents. Truy vấn "trạng thái tại T" cần UNION bảng gốc với history: valid_from <= T AND valid_to > T cho history (code tham chiếu, chưa chạy). Lỗi hay gặp: không đặt lại updated_at khi UPDATE nên valid_from sai; test chỉ sửa mỗi hàng một lần.

  12. Tạo ledger_entries chỉ ghi thêm (mục 5). Chứng minh: bút toán lệch bị từ chối lúc COMMIT, UPDATE/DELETE/TRUNCATE bị chặn, và sửa sai bằng bút toán đảo.

    Lời giải và cách kiểm tra

    Chạy khối SQL ở mục 5 bằng role sở hữu bảng, rồi thử từng dòng của bảng kết quả. Đã chạy (PGlite): hai dòng +100000/-100000 cùng tx_id COMMIT thành công; một dòng +500 đứng một mình báo bút toán ... không cân bằng lúc COMMIT; amount_cents = 0 vi phạm CHECK; UPDATE/DELETE/TRUNCATE bằng superuser bị trigger chặn; UPDATE và TRUNCATE sau SET ROLE app_user báo permission denied. Bút toán đảo (hai dòng ngược dấu, reverses_id trỏ tới hai dòng gốc) thành công và SELECT account_id, sum(amount_cents) ... GROUP BY 1 cho 0 ở cả hai tài khoản. Lỗi hay gặp: kiểm cân bằng bằng trigger thường (không DEFERRABLE INITIALLY DEFERRED) nên dòng đầu tiên luôn bị từ chối; cấp UPDATE cho app_user "để sửa nhanh".

  13. Viết test cho project có include: { tasks: true } với extension soft delete ở mục 1. Test phải đỏ trước khi sửa. Sửa theo hai cách khác nhau.

    Lời giải và cách kiểm tra

    Tạo một project, một task sống và một task có deletedAt; đọc project kèm include: { tasks: true }. Đã chạy (Prisma 7.10.0, PGlite): prisma.task.findMany() trả ['sống'], còn project.findMany({ include: { tasks: true } }) trả ['sống', 'đã xoá'], tức test đỏ ở assertion "chỉ còn task sống". Sửa 1: include: { tasks: { where: { deletedAt: null } } } trả ['sống']. Sửa 2: đọc con bằng prisma.task.findMany({ where: { projectId } }) đi qua extension. Mong đợi: test xanh sau mỗi cách. Lỗi hay gặp: kết luận extension "đã bao phủ" chỉ vì test findMany trực tiếp qua; quên rằng select lồng cũng cần where.

  14. Chạy demo crypto-shredding ở mục 7: huỷ khoá của một người, chứng minh bản mã trong backup chụp trước đó không còn đọc được, rồi liệt kê điều kiện để nó có nghĩa.

    Lời giải và cách kiểm tra

    Lưu mã ở mục 7 thành shred.ts, dựng KeyStore trong bộ nhớ, seal hai người, chụp bản sao Map của bản mã, destroy('alice'). Đã chạy (Node 24.21): open của alice trước khi huỷ trả lại giá trị; sau khi huỷ, cả bản mã hiện tại và bản trong backup ném KeyDestroyedError; open của bob vẫn đọc được; đưa bản mã của alice cho userId của bob thì GCM từ chối (Unsupported state or unable to authenticate data). Điều kiện: khoá không nằm cùng DB hay cùng backup với bản mã; kho khoá cũng hết hạn backup nhanh; chỉ trường đã mã hoá được bảo vệ; chỉ mục tra cứu (HMAC) phải bị xoá cùng. Lỗi hay gặp: để user_keys cùng database, rồi tin rằng backup đã "hết đọc được".

  15. Viết kiểm tra rằng ẩn danh người dùng và dòng outbox 'erase-user' luôn đi cùng nhau: cả hai cùng có hoặc cùng không.

    Lời giải và cách kiểm tra

    Dùng khối UPDATE users + INSERT INTO outbox ... ON CONFLICT DO NOTHING ở mục 7. Đã chạy (PGlite): chèn SELECT 1/0 giữa khối và COMMIT thì ROLLBACK, users.email giữ giá trị cũ và outbox có 0 dòng; chạy khối hai lần rồi COMMIT cho users đã ẩn danh và đúng một dòng outbox (nhờ unique index một phần). Nhặt dòng bằng FOR UPDATE SKIP LOCKED và đánh dấu published_at: lần nhặt sau rỗng. Chưa chạy qua Prisma và chưa chạy hai relay song song. Lỗi hay gặp: gọi queue.add sau COMMIT thay vì ghi outbox; job xoá không idempotent nên chạy lần hai báo lỗi.

Khung và mã dùng chung

Tự làm trước, rồi mở. Giả định bảng documents(id, tenant_id, title, created_at, deleted_at), users, audit_logs như các mục 1, 2, 7. Các khối SQL ghi "Đã chạy" đã chạy trên PostgreSQL 17.9 cục bộ (role migrator sở hữu bảng, app_user chạy ứng dụng); khối TypeScript/YAML/bash là code tham chiếu, chưa chạy trên Prisma hay hạ tầng thật.


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).

    Đáp án

    deleted_at + partial index WHERE deleted_at IS NULL + UNIQUE ... WHERE deleted_at IS NULL + lọc ở tầng client/view. Ba pitfall: quên lọc (người dùng thấy lại hàng đã xoá), vỡ unique (hàng xoá vẫn chiếm giá trị; đã chạy: UNIQUE(email) thường báo duplicate key, partial unique cho đăng ký lại), cascade (ON DELETE CASCADE chỉ chạy với DELETE thật nên con mồ côi). Xem mục 1.

  • 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.

    Đáp án

    App log để debug, ra stdout, giữ 7–30 ngày, xoay vòng; audit log là bằng chứng giải trình, nằm trong DB, giữ nhiều năm, không sửa/xoá. Bất biến ở tầng quyền: app_user chỉ INSERT, SELECT, và bảng phải do role khác (migrator) sở hữu (chủ bảng tự GRANT lại được). Đã chạy: UPDATE/DELETE/TRUNCATE bằng app_user → permission denied for table audit_logs. Sai thường gặp: chỉ REVOKE trên chính chủ bảng. Xem mục 2.

  • Ghi audit trong cùng transaction, có allowlist field, không lưu PII/secret.

    Đáp án

    Đổi dữ liệu và ghi audit trong cùng $transaction: audit lỗi thì nghiệp vụ rollback (đã chạy, tenant_id NULL làm cả giao dịch rollback). Chỉ ghi các field trong danh sách cho phép (role, email, status), không ghi password_hash, số điện thoại: bảng này giữ nhiều năm và không xoá được. Xem mục 2.

  • Giải thích khác biệt TIMESTAMPTZ / TIMESTAMP / DATE, và vì sao TIMESTAMP gần như luôn sai.

    Đáp án

    TIMESTAMPTZ lưu một instant (hiển thị theo TimeZone của session); TIMESTAMP lưu chuỗi ngày giờ không múi giờ; DATE là ngày lịch (sinh nhật). Đã chạy: cùng giá trị, đổi TimeZone sang Asia/Ho_Chi_Minh thì cột timestamptz đổi giờ hiển thị đúng, cột timestamp đứng yên và không ai biết nó thuộc múi nào, nên gần như luôn sai cho mốc sự kiện. Xem mục 4.

  • 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.

    Đáp án

    Tính ranh giới ngày trong múi giờ của người dùng rồi đổi về UTC (đã chạy: ngày 5 của người dùng VN là 2026-10-04T17:00Z đến 2026-10-05T16:59:59.999Z; ngày DST của New York dài 23 h). Lịch tái diễn lưu quy tắc 09:00 + tên IANA, không lưu offset, vì offset đổi hai lần mỗi năm (đã chạy: 09:00 New York là 14:00 UTC trước DST, 13:00 UTC sau DST). Xem mục 4.

  • Nêu được 3 bẫy của JS Date; ép TZ=UTC trong test.

    Đáp án

    (a) new Date('2026-05-12') là nửa đêm UTC còn new Date('2026-05-12T00:00') là giờ local (đã chạy với TZ=Asia/Ho_Chi_Minh: ...05-12T00:00Z và ...05-11T17:00Z); (b) Date chỉ là mili-giây từ epoch, toString() mượn múi của máy; (c) getMonth() bắt đầu từ 0. Test chạy với TZ=UTC (xem GĐ13) và ít nhất một test ở múi khác.

  • 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ư.

    Đáp án

    BIGINT minor unit + currency (hoặc NUMERIC), vì 0.1 + 0.2 = 0.30000000000000004 và float cộng dồn sai số. Chia có dư: phần nguyên cho mỗi người, rem người đầu nhận thêm 1 đồng; đã chạy: splitEvenly(100, 3) = [34, 33, 33] tổng 100, trong khi làm tròn từng phần cho [33, 33, 33] tổng 99. Số chữ số thập phân tra theo currency. Xem mục 5.

  • 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 đủ.

    Đáp án

    CHECK cho status/seats/khoảng thời gian, UNIQUE một phần cho "một subscription đang hoạt động mỗi tenant", FK với ON DELETE RESTRICT. Validate ở app chỉ bảo vệ đường đi qua app; migration, script backfill, thao tác tay và service khác đi vòng. Tự kiểm: chèn dữ liệu sai bằng psql phải nhận ERROR ... violates .... Xem mục 6.

  • 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.

    Đáp án

    Mỗi loại dữ liệu có thời hạn và cách xử lý (xoá, ẩn danh, giữ theo luật). Ẩn danh hoá trong transaction, audit đi qua hàm SECURITY DEFINER chỉ chạy khi anonymized_at IS NOT NULL (đã chạy: UUID chưa ẩn danh trả 0; sau khi ẩn danh xoá đúng ip/user_agent và để lại dòng user.anonymized). Bản đồ PII liệt kê DB, S3, Redis, log, Sentry, email provider, prompt/log LLM và backup. Xem mục 7.

  • Phân biệt hash vs encrypt; biết vì sao không tự lưu số thẻ.

    Đáp án

    Mật khẩu: hash một chiều (argon2id), không cần đọc lại. Token OAuth/khoá API: mã hoá (AES-256-GCM, IV ngẫu nhiên mỗi lần, keyVersion) vì phải dùng lại. Số thẻ: không tự lưu, dùng token của nhà cung cấp thanh toán để không vào phạm vi PCI-DSS. Xem mục 8.

  • Backup theo 3-2-1, có bản off-site; giám sát bằng dead man's switch.

    Đáp án

    3 bản sao, 2 loại lưu trữ, 1 bản khác region/tài khoản. Script backup chỉ ping heartbeat sau khi dump và upload thành công; không thấy ping trong khoảng 26 h thì cảnh báo, thêm cảnh báo khi kích thước dump giảm bất thường. Sai thường gặp: coi "không có lỗi trong log" là thành công. Xem mục 9.

  • Đã thực hiện một lần restore drill thật và biết con số RTO của mình.

    Đáp án

    Tự kiểm: có sổ ghi ngày, kích thước dump, thời gian restore, đếm hàng khớp prod, app đăng nhập được trên bản restore, extension vector có mặt. RTO là tổng thời gian đến khi app phục vụ lại. Nếu chưa từng bấm giờ một lần restore thật thì mục này chưa xong. Xem mục 9.

  • Giải thích vì sao replica không thay được backup.

    Đáp án

    Replica sao chép mọi thay đổi gần như tức thì, kể cả DROP TABLE và DELETE nhầm; nó chống hỏng phần cứng. Backup (và PITR) cho phép quay về thời điểm trước lỗi con người hoặc ransomware. Xem mục 9.

  • Thực hiện được expand-migrate-contract không downtime; dùng CREATE INDEX CONCURRENTLY, NOT VALID, lock_timeout.

    Đáp án

    Thêm cột nullable → backfill theo lô → code đọc cột mới → ngừng ghi cột cũ rồi DROP sau vài ngày; mỗi bước là một lần deploy riêng. Công cụ: CREATE INDEX CONCURRENTLY (ngoài transaction), ADD CONSTRAINT ... NOT VALID rồi VALIDATE CONSTRAINT, SET lock_timeout để migration bỏ cuộc thay vì xếp hàng chặn query khác. ADD COLUMN ... DEFAULT now() không viết lại bảng (đã chạy, so pg_relation_filenode). Xem mục 10.

  • Nói được khi nào nên và khi nào không nên partition.

    Đáp án

    Nên khi bảng chỉ ghi thêm, rất lớn, và xoá/truy vấn theo thời gian (xoá cũ bằng DROP partition thay DELETE hàng giờ). Không nên cho bảng chưa tới vài triệu hàng: primary key phải chứa cột partition, khoá ngoại bị hạn chế, phải có job tạo partition tương lai (quên là INSERT lỗi). Xem mục 11.

  • Giải thích vì sao LIKE ... INCLUDING ALL làm hỏng bảng history; giải thích vì sao soft delete bằng extension không bao phủ include lồng.

    Đáp án

    INCLUDING ALL sao cả PRIMARY KEY và UNIQUE, nên lần UPDATE thứ hai trên cùng một hàng chèn trùng id vào history và thất bại (đã chạy: duplicate key ... "documents_history_pkey"); dùng INCLUDING DEFAULTS kèm index (id, valid_to DESC). Extension của model con chỉ chạy khi truy vấn bắt đầu từ model đó; include lồng đi qua truy vấn cha nên task đã xoá quay lại (đã chạy: ['sống', 'đã xoá']); chữa bằng where tường minh trong include, đọc con riêng, hoặc view. Xem mục 3 và mục 1.

  • Thiết kế được sổ cái chỉ ghi thêm (bút toán cân bằng, đảo thay vì sửa) và biết bigint/Decimal của Prisma gây lỗi hoặc mất độ chính xác khi đi qua Number hay JSON.stringify.

    Đáp án

    Mỗi bút toán có tổng bằng 0 theo từng tiền tệ, kiểm lúc COMMIT bằng constraint trigger; UPDATE/DELETE/TRUNCATE bị chặn bằng quyền và trigger; sửa sai bằng bút toán đảo (đã chạy: số dư về 0). Prisma trả BIGINT là bigint: Number mất 1 đơn vị khi vượt 2^53 (đã chạy: 9007199254740993 thành 9007199254740992), JSON.stringify ném TypeError, nhân bigint với 1.1 cũng ném; Decimal serialize thành chuỗi. Xem mục 5.

  • Viết eraseUser ghi dòng outbox trong cùng giao dịch; giải thích crypto-shredding giải bài toán backup thế nào và điều kiện để nó có nghĩa.

    Đáp án

    Dòng outbox 'erase-user' được ghi trong cùng giao dịch với việc ẩn danh nên hai việc cùng có hoặc cùng không (đã chạy: ROLLBACK để lại 0 dòng outbox); relay đẩy sang queue và job xoá phải idempotent. Crypto-shredding: mã hoá PII bằng khoá riêng từng người, huỷ khoá thì mọi bản sao bản mã, kể cả backup, không đọc được (đã chạy: KeyDestroyedError cho bản trong backup). Điều kiện: khoá nằm ngoài DB và ngoài backup của DB; không tra cứu được cột mã hoá; chỉ mục mù cũng phải xoá. Xem mục 7.

  • Phân biệt điều đã xác minh với điều cần hỏi chuyên gia về luật dữ liệu cá nhân Việt Nam: nêu được văn bản, ngày hiệu lực, và vì sao không nói "72 giờ" như quy tắc chung.

    Đáp án

    Luật 91/2025/QH15 và Nghị định 356/2025/NĐ-CP đều hiệu lực 01/01/2026; Nghị định 13/2023 hết hiệu lực cùng ngày. Hiệu chỉnh cho câu hỏi: không nên khẳng định "72 giờ không phải quy tắc chung", vì điều đó vượt quá điều đã xác minh. Đã xác minh (đọc trực tiếp Nghị định 356): mốc 72 giờ ở Điều 8 khoản 3 (dữ liệu nhạy cảm trong tài chính, ngân hàng, tín dụng) và Điều 29 khoản 1 (dữ liệu vị trí và sinh trắc học). Chưa xác minh: Luật 91 Điều 23 khoản 1, theo bản chép lại không chính thức (hethongphapluat.com), chưa đối chiếu bản chính thức vì PDF của Luật là bản quét, dường như đặt mốc 72 giờ chung để thông báo cơ quan chuyên trách khi vi phạm có thể gây tổn hại nghiêm trọng. Nếu đúng, 72 giờ là mốc chung. Phần cần chuyên gia: phạm vi áp dụng của Điều 23 và mọi quyết định áp dụng. Xem mục 7.


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.

    Hướng trả lời hiện tại

    Soft delete: chọn lọc, chỉ thực thể người dùng thấy và xoá nhầm được (tài liệu, dự án); bảng log/sự kiện dùng partition.

    (Chưa chốt, không phải khẳng định chắc chắn.)

  • 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.

    Hướng trả lời hiện tại

    Audit log: giữ chung Postgres cho đến khi khối lượng cho thấy chi phí/vận hành là vấn đề; có thể xuất định kỳ sang kho chỉ-ghi-thêm (S3 với object lock) làm bản thứ hai khó xoá.

    (Chưa chốt, không phải khẳng định chắc chắn.)

  • Ẩ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.

    Hướng trả lời hiện tại

    Ranh giới "đã xoá": coi đã ẩn danh khi không còn truy ngược được tới cá nhân bằng dữ liệu hệ thống nắm giữ; actor_id giả danh trên audit chưa chắc đạt mức đó, nên cần tư vấn pháp lý nếu có khách hàng EU.

    (Chưa chốt, không phải khẳng định chắc chắn.)

  • 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Đ15, đừng phát hiện sau sự cố.

    Hướng trả lời hiện tại

    PITR: ghi RPO mục tiêu (ví dụ 15 phút) thành yêu cầu khi chọn nhà cung cấp, rồi đối chiếu với gói có PITR và cửa sổ khôi phục của họ.

    (Chưa chốt, không phải khẳng định chắc chắn.)