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ấnincludelồng qua Prisma; kiểuBigInt/Decimalcủa Prisma; demo crypto-shredding bằngnode:crypto(tsc --strictsạch). Chưa chạy: Prisma CLI migrate với hai role thật (thửprisma db executebáoP1001vì 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
orderstừ 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
productlà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ế.
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:
Hoặc chắc chắn hơn — để database thi hành, không phải ORM:
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 đó.
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ấn | Task 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ọiincludevàselectlồ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 choinclude. - Để database lọc, đọc qua view
*_activenhư đã 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 log | Audit log | |
|---|---|---|
| Mục đích | Debug kỹ thuật | Trách nhiệm giải trình, tuân thủ |
| Nơi lưu | Stdout → Loki/Datadog | Database (hoặc kho append-only) |
| Giữ bao lâu | 7–30 ngày | Nhiều năm (thường 7 năm với tài chính) |
| Được sửa/xoá? | Xoay vòng thoải mái | Không bao giờ |
| Đọc bởi | Developer | Auditor, 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:
Bất biến thật sự — quyền ở tầng DB, không chỉ tầng code:
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:
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:
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:
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:
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ểu | Lưu gì | Dùng khi |
|---|---|---|
TIMESTAMPTZ | Instant (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 |
TIMESTAMP | Ngày+giờ không có múi giờ | Gần như không bao giờ. Đây là cái bẫy |
DATE | Ngày theo lịch | Sinh 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):
Ví dụ — bẫy "hôm nay":
Ví dụ — bẫy DST với sự kiện lặp lại:
Đâ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ờ.Datekhô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ế.
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át | Kết quả |
|---|---|
typeof row.amountCents | bigint; giá trị 9007199254740993n |
Number(row.amountCents) | 9007199254740992: lệch 1 đơn vị khi vượt 2^53 |
row.rate | Prisma.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:
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.
Đã chạy (PGlite, một role app_user cấp quyền như trên):
| Thao tác | Kết quả |
|---|---|
hai dòng +100000 và -100000 VND cùng tx_id, COMMIT | thành công |
một dòng +500 đứng một mình, COMMIT | lỗi 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 | trigger chặn |
UPDATE, TRUNCATE sau SET ROLE app_user | permission denied for table ledger_entries |
hai dòng đảo -100000 và +100000 với reverses_id | thà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ụ:
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á:
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.
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.
Đã chạy (PGlite, bảng outbox như GĐ10 mục 4, thêm index trên):
| Tình huống | Kết quả |
|---|---|
giao dịch ẩn danh + insert outbox, rồi SELECT 1/0 trước COMMIT | ROLLBACK: users.email giữ giá trị cũ, outbox có 0 dòng |
cùng khối chạy hai lần, COMMIT | users đã ẩn danh, outbox đúng một dòng |
relay nhặt bằng FOR UPDATE SKIP LOCKED, đánh dấu published_at | nhặ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:
Đã 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ước | Kết quả |
|---|---|
seal hai người, open của alice | trả lại số điện thoại |
open bản mã của alice bằng userId của bob | Unsupported 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 bob | vẫ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_keyscù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ụ:
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ụ:
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:
- 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).
- Restore vào môi trường sạch, bấm giờ → đây chính là RTO thật của bạn.
- 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ử.
- 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):
Sơ đồ: ai ghi, ai đọc ở từng bướ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:
Ví dụ — các thao tác nguy hiểm và cách an toàn:
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:
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ụ:
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:
-
Thêm
deleted_atchodocuments: 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Đã chạy: chèn
a@x.comđã xoá rồi chèna@x.commới. VớiUNIQUE(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_active3 hàng (hàng chẵn đã xoá).EXPLAINcóWHERE deleted_at IS NULL AND tenant_id = ...dùngIndex Scan using idx_documents_active, với điều kiện chạySET 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.typescriptReadyMong đợi: ba assertion qua. Điểm yếu:
$queryRawvàbaseđi vòng extension; chốt chặn chắc hơn là query qua viewdocuments_active. Lỗi hay gặp:where: { id }củafindUniquebị ghi đè khi spread sai thứ tự; quênaggregate/groupBy; soft deleteprojectmà không soft deletetaskcon (làm trong cùng transaction). -
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_logsbằ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$transactionvới allowlist (mã mục 2). Đã chạy: bằngapp_user,INSERTvàSELECTthành công;UPDATE audit_logs ...,DELETE FROM audit_logs,TRUNCATE audit_logsđềuERROR: permission denied for table audit_logs. Ghi audit cùng transaction:UPDATE usersrồiINSERT audit_logsvớitenant_idNULL →violates not-null constraint→ROLLBACK,users.emailgiữ giá trị cũ (đổi dữ liệu mà không có audit là không thể xảy ra). Allowlist:typescriptReadyLỗi hay gặp:
app_userlà chủ bảng (REVOKE vô tác dụng); thiếuGRANT USAGE ON SEQUENCEnênINSERTbáopermission denied for sequence audit_logs_id_seq; audit ghi sauCOMMIT. -
Rà toàn bộ schema: mọi
TIMESTAMP→TIMESTAMPTZ; ngày sinh →DATE. Thêm cộttimezonechousers.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→TIMESTAMPTZviế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:textReadyGiá trị cũ của
TIMESTAMPphả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ùngnow(),TimeZone = 'UTC'hiện11:48...cho cả hai cột; đổiTimeZone = 'Asia/Ho_Chi_Minh'thì cộttimestamptzthành18:48...+07còn cộttimestampvẫn11:48...(không biết nó thuộc múi nào). -
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_Minhvà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
dayRangeInTzcủa mục 4, đọcusers.timezonetừ DB (không đoán từ IP). Đã chạy (Node v24.21, date-fns 4.4,@date-fns/tz1.5; kết quả giống hệt khi đặtTZ=UTC,Asia/Ho_Chi_Minh,America/New_York):Trường hợp from (UTC) to (UTC) độ dài Asia/Ho_Chi_Minh, instant2026-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)) cho2026-10-04T00:00Zkhi server chạy UTC: ngày 4, sai với người dùng ở VN. Test DST nên khẳng địnhto - from + 1 === 23 hcho ngày 2026-03-08. Truy vấn SQL cho báo cáo dùng nửa khoảngoccurred_at >= from AND occurred_at < to_exclusive(bỏ mili-giây.999). Lỗi hay gặp: cộng24 * 3600 * 1000để sang ngày hôm sau (sai ngày DST). -
Đổi mọi cột tiền sang
BIGINTminor 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):typescriptReadyKế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 saiArray(3).fill(Math.round(100/3))=[33,33,33], tổng 99.0.1 + 0.2 = 0.30000000000000004;19.99 * 100 = 1998.9999999999998(phảiMath.round). Số chữ số thập phân tra theo currency (VND/JPY 0, USD 2), đừng hardcode/100. -
Thêm
CHECKconstraint cho status, seats, khoảng thời gian. Thử insert dữ liệu sai bằngpsql(đ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ằngpsql(đ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 constrainthoặcunique constraint.textReadyĐã chạy một phần liên quan:
UNIQUEmột phần chặn hàng sống thứ hai ở bài 1 vàNOT NULLchặn audit ở bài 2.ALTER TABLE ... ADD CONSTRAINT ... CHECKtrên bảng đã có dữ liệu: dùngNOT VALIDrồiVALIDATE(mục 10). -
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_actorvới actor chưa ẩn danh hoặc UUID ngẫu nhiên trả0, không đổi hàng nào; sauUPDATE users SET anonymized_at = now()trong cùng transaction hàm trả1,ipvàuser_agentcủa dòng audit đó thành NULL,actor_idgiữ nguyên, có thêm dònguser.anonymizedvớ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ơi Chứ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_actorS3/R2 file người dùng tải lên job propagate-erasurexoá objectRedis cache, session xoá khoá theo userId, TTL ngắnLog tập trung, Sentry email, IP trong breadcrumb scrub khi ghi; retention 7–30 ngày Email provider, analytics địa chỉ, sự kiện API xoá của nhà cung cấp Prompt/log LLM nội dung người dùng không log thô; retention ngắn Backup tất cả hết hạn theo retention; ghi lại lệnh xoá để áp lại khi restore -
Bật
pg_dumphà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):
bashReadyLê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"). -
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
pgvectorcó trong bản restore.Lời giải và cách kiểm tra
Các bước và số cần ghi:
bashReadyMong đợi:
pg_restorekế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àoapp_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 gianpg_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ó extensionvector(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-ownerrồi tạo lại quyền). -
Seed 2 triệu hàng vào
documents. ChạyCREATE INDEXthường (quan sát khoá) rồiCONCURRENTLY. 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).textReadyQuan sát: với
CREATE INDEXthường,SELECT pid, wait_event_type, wait_event, query FROM pg_stat_activityở phiên khác thấy phiênINSERTởwait_event_type = Lock. VớiCONCURRENTLYkhô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ộttitle→name:textReadyGiữ
lock_timeoutngắn để migration thất bại sớm thay vì xếp hàng chặn mọi query sau nó; nếuALTERlỗ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ỗicannot run inside a transaction block); đổi tên bằngRENAME COLUMNtrực tiếp làm instance cũ đang chạy lỗi ngay;slugify/hàm backfill có thể trảNULLkhiến vòng lặp không dừng. -
Bật lịch sử cho
documentsbằng bảngdocuments_historyvà 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ảnLIKE ... INCLUDING ALLhỏ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ớiINCLUDING ALL,UPDATElần một qua, lần hai báoduplicate key value violates unique constraint "documents_history_pkey", history chỉ cóv1. VớiINCLUDING DEFAULTSvà index(id, valid_to DESC): ba lầnUPDATE(v2,v3,v4) để lại historyv1,v2,v3; hàng hiện tạiv4nằm ởdocuments. Truy vấn "trạng thái tại T" cầnUNIONbảng gốc với history:valid_from <= T AND valid_to > Tcho history (code tham chiếu, chưa chạy). Lỗi hay gặp: không đặt lạiupdated_atkhiUPDATEnênvalid_fromsai; test chỉ sửa mỗi hàng một lần. -
Tạo
ledger_entrieschỉ ghi thêm (mục 5). Chứng minh: bút toán lệch bị từ chối lúcCOMMIT,UPDATE/DELETE/TRUNCATEbị 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/-100000cùngtx_idCOMMITthành công; một dòng+500đứng một mình báobút toán ... không cân bằnglúcCOMMIT;amount_cents = 0vi phạmCHECK;UPDATE/DELETE/TRUNCATEbằng superuser bị trigger chặn;UPDATEvàTRUNCATEsauSET ROLE app_userbáopermission denied. Bút toán đảo (hai dòng ngược dấu,reverses_idtrỏ tới hai dòng gốc) thành công vàSELECT account_id, sum(amount_cents) ... GROUP BY 1cho0ở cả hai tài khoản. Lỗi hay gặp: kiểm cân bằng bằng trigger thường (khôngDEFERRABLE INITIALLY DEFERRED) nên dòng đầu tiên luôn bị từ chối; cấpUPDATEchoapp_user"để sửa nhanh". -
Viết test cho
projectcó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ộttasksống và mộttaskcódeletedAt; đọc project kèminclude: { tasks: true }. Đã chạy (Prisma 7.10.0, PGlite):prisma.task.findMany()trả['sống'], cònproject.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ằngprisma.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ì testfindManytrực tiếp qua; quên rằngselectlồng cũng cầnwhere. -
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ựngKeyStoretrong bộ nhớ,sealhai người, chụp bản saoMapcủa bản mã,destroy('alice'). Đã chạy (Node 24.21):opencủ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émKeyDestroyedError;opencủa bob vẫn đọc được; đưa bản mã của alice chouserIdcủ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_keyscùng database, rồi tin rằng backup đã "hết đọc được". -
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ènSELECT 1/0giữa khối vàCOMMITthìROLLBACK,users.emailgiữ giá trị cũ vàoutboxcó 0 dòng; chạy khối hai lần rồiCOMMITchousersđã ẩn danh và đúng một dòng outbox (nhờ unique index một phần). Nhặt dòng bằngFOR UPDATE SKIP LOCKEDvà đánh dấupublished_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ọiqueue.addsauCOMMITthay 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 indexWHERE 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áoduplicate key, partial unique cho đăng ký lại), cascade (ON DELETE CASCADEchỉ chạy vớiDELETEthậ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_userchỉINSERT, SELECT, và bảng phải do role khác (migrator) sở hữu (chủ bảng tựGRANTlại được). Đã chạy:UPDATE/DELETE/TRUNCATEbằngapp_user→permission denied for table audit_logs. Sai thường gặp: chỉREVOKEtrê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_idNULL làm cả giao dịch rollback). Chỉ ghi các field trong danh sách cho phép (role,email,status), không ghipassword_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ì saoTIMESTAMPgần như luôn sai.Đáp án
TIMESTAMPTZlưu một instant (hiển thị theoTimeZonecủa session);TIMESTAMPlưu chuỗi ngày giờ không múi giờ;DATElà ngày lịch (sinh nhật). Đã chạy: cùng giá trị, đổiTimeZonesangAsia/Ho_Chi_Minhthì cộttimestamptzđổi giờ hiển thị đúng, cộttimestampđứ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đến2026-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ắc09: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; épTZ=UTCtrong test.Đáp án
(a)
new Date('2026-05-12')là nửa đêm UTC cònnew Date('2026-05-12T00:00')là giờ local (đã chạy vớiTZ=Asia/Ho_Chi_Minh:...05-12T00:00Zvà...05-11T17:00Z); (b)Datechỉ 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ớiTZ=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
BIGINTminor unit +currency(hoặcNUMERIC), vì0.1 + 0.2 = 0.30000000000000004và float cộng dồn sai số. Chia có dư: phần nguyên cho mỗi người,remngườ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/FKthi hành bất biến nghiệp vụ ở tầng DB; biết vì sao validate ở app là không đủ.Đáp án
CHECKcho status/seats/khoảng thời gian,UNIQUEmột phần cho "một subscription đang hoạt động mỗi tenant",FKvớiON 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ằngpsqlphải nhậnERROR ... 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 DEFINERchỉ chạy khianonymized_at IS NOT NULL(đã chạy: UUID chưa ẩn danh trả 0; sau khi ẩn danh xoá đúngip/user_agentvà để lại dònguser.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
vectorcó 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 TABLEvàDELETEnhầ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
DROPsau 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 VALIDrồiVALIDATE 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, sopg_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
DROPpartition thayDELETEhà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àINSERTlỗi). Xem mục 11. -
Giải thích vì sao
LIKE ... INCLUDING ALLlàm hỏng bảng history; giải thích vì sao soft delete bằng extension không bao phủincludelồng.Đáp án
INCLUDING ALLsao cảPRIMARY KEYvàUNIQUE, nên lầnUPDATEthứ hai trên cùng một hàng chèn trùngidvào history và thất bại (đã chạy:duplicate key ... "documents_history_pkey"); dùngINCLUDING DEFAULTSkèm index(id, valid_to DESC). Extension của model con chỉ chạy khi truy vấn bắt đầu từ model đó;includelồng đi qua truy vấn cha nên task đã xoá quay lại (đã chạy:['sống', 'đã xoá']); chữa bằngwheretường minh tronginclude, đọ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/Decimalcủa Prisma gây lỗi hoặc mất độ chính xác khi đi quaNumberhayJSON.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
COMMITbằng constraint trigger;UPDATE/DELETE/TRUNCATEbị 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ảBIGINTlàbigint:Numbermất 1 đơn vị khi vượt 2^53 (đã chạy:9007199254740993thành9007199254740992),JSON.stringifynémTypeError, nhânbigintvới1.1cũng ném;Decimalserialize thành chuỗi. Xem mục 5. -
Viết
eraseUserghi 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:KeyDestroyedErrorcho 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_idgiả 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.)