PostgreSQL 18 vs MySQL 8.4 (InnoDB) — khác nhau ở đâu, ảnh hưởng nghiệp vụ ra sao

Câu "chọn PostgreSQL hay MySQL?" gần như chắc chắn xuất hiện khi phỏng vấn Senior Backend / Tech Lead — nhất là khi CV có cả hai: LMS Uni chạy MySQL, LMS Micro-learning chạy PostgreSQL. Câu trả lời tệ nhất là "PostgreSQL mạnh hơn" hoặc "MySQL nhanh hơn". Câu trả lời tốt chỉ ra được khác nhau ở cơ chế nào, cơ chế đó làm hỏng nghiệp vụ gì nếu không biết (trừ tiền sai, đặt trùng chỗ, mất dữ liệu im lặng, migration hỏng giữa chừng), và khi nào mỗi bên là lựa chọn đúng.

Bài này đi theo trục khác biệt, không theo khung 9 câu hỏi (đã chốt với Minato — bảng ánh xạ ở dưới cho thấy 9 câu hỏi vẫn được trả lời ở đâu). Mỗi trục có đủ 6 phần: (1) cơ chế mỗi bên, (2) output lab thật của cả hai, (3) trade-off, (4) ảnh hưởng nghiệp vụ kèm ví dụ cụ thể, (5) "Cách nói khi phỏng vấn", (6) một diagram nhỏ. Cuối bài: ma trận quyết định, góc nhìn tech lead / ADR, áp vào 2 dự án LMS, anti-pattern, câu hỏi phụ.

Không viết lại những gì KB đã có sâu: MVCC/bloat ở Dead tuple và index bloat, wraparound ở Transaction ID wraparound, đổi schema không downtime ở Migration PostgreSQL và Migration MySQL 8.4. Bài này tóm tắt và trỏ sang.

Thuật ngữ dùng trong bài
InnoDB (storage engine mặc định của MySQL — phần thật sự lưu dữ liệu, lock, transaction) · heap (file bảng của PostgreSQL, dòng nằm không theo thứ tự) · clustered index (InnoDB lưu chính dòng dữ liệu bên trong B-tree của primary key) · secondary index (index phụ, ngoài PK) · MVCC (multi-version concurrency control — giữ nhiều bản của dòng để đọc không chặn ghi) · isolation level (mức cô lập — transaction nhìn thấy thay đổi của người khác tới đâu) · gap lock / next-key lock (InnoDB khoá cả khoảng trống giữa các giá trị index, không chỉ dòng có thật) · SSI (Serializable Snapshot Isolation — PostgreSQL phát hiện xung đột rồi huỷ 1 transaction thay vì khoá) · collation (luật so sánh/sắp xếp chuỗi: có phân biệt hoa/thường, dấu không) · sql_mode (công tắc hành vi của MySQL, quyết định dữ liệu sai bị từ chối hay bị sửa im lặng) · transactional DDL (lệnh đổi schema nằm trong transaction, rollback được) · upsert (insert, nếu trùng thì update) · WAL (write-ahead log của PostgreSQL) · binlog (binary log của MySQL, dùng cho replication + PITR) · GTID (global transaction ID — mã định danh mỗi transaction trong replication MySQL) · connection pooler (lớp gom nhiều kết nối client vào ít kết nối DB, vd PgBouncer).

Môi trường lab (mọi output trong bài lấy từ đây): PostgreSQL 18.4 (container test my-test-pgdb của repo postgresql-learning, DB isolation_demo, bảng tmp_pgm_*; standby my-test-pgstandby cho phần replication; container tạm postgres:18.4-alpine khi cần đổi cấu hình server) và MySQL 8.4.13 (image mysql:8.4.13, cấu hình mặc định — chỉ chỉnh buffer pool và bật GTID; container tmp-pgm-*, network riêng, không publish port). Thêm PgBouncer 1.26.1, PostgreSQL 17.11 và MySQL 8.0.46 cho phần nâng cấp. Lab chỉ đo hành vi (lỗi gì, ai chờ ai, dữ liệu cuối ra sao, dung lượng) — không có benchmark tốc độ. Thời gian (ms) xuất hiện trong output chạy trên host dùng chung, chỉ để thấy "chờ / không chờ". Script và log: labs/database/pg-vs-mysql/, bảng tra ở Phụ lục.

Trả lời 30 giây (khi interviewer hỏi thẳng "khác nhau thế nào?")

Cách nói khi phỏng vấn: "Em không so nhanh chậm vì cái đó phụ thuộc workload. Em so ở chỗ hai bên mặc định bảo vệ dữ liệu khác nhau. PostgreSQL chặt hơn: dữ liệu sai là báo lỗi, DDL nằm trong transaction nên migration hỏng thì rollback sạch, có partial index, exclusion constraint, SERIALIZABLE kiểu phát hiện xung đột. Đổi lại phải vận hành VACUUM, cần pooler vì mỗi kết nối là một process. MySQL InnoDB lưu bảng theo khoá chính nên đọc theo PK rất gọn, replication và công cụ vận hành rất phổ biến, nhưng phải biết các bẫy: collation mặc định coi 'Hòa' bằng 'hoa', gap lock gây deadlock, DDL commit ngầm, và nếu tắt strict mode thì dữ liệu bị cắt im lặng. Chọn bên nào là chọn bộ rủi ro mình kiểm soát được."

Bản đồ các trục khác biệt

#TrụcPostgreSQL 18MySQL 8.4 InnoDBRủi ro nghiệp vụ nếu không biết
1Kết nối1 process / kết nối1 thread / kết nốiCao điểm hết slot, app báo lỗi đăng nhập/nộp bài
2Lưu trữHeap + index tách rờiClustered theo PK, secondary chứa PKUUIDv4 làm bảng phình, thứ tự trả về "tự nhiên" bị dựa dẫm
3MVCC + dọn rácBản cũ ở heap, VACUUMBản cũ ở undo, purgeBloat, wraparound (PG); undo phình, rollback chậm (MySQL)
4Isolation + lockRC mặc định, SSIRR mặc định, gap lockLost update (trừ tiền sai), đặt trùng chỗ, deadlock
5Toàn vẹn dữ liệuLuôn strict; partial/exclusion/deferrablePhụ thuộc sql_mode; không partial indexDữ liệu bị cắt im lặng; email trùng khi soft delete
6CollationDeterministic mặc địnhutf8mb4_0900_ai_ci: bỏ qua hoa/thường + dấu"Hòa" không đăng ký được vì đã có "hoa"
7DDLTransactional DDLCommit ngầmMigration hỏng giữa chừng, schema dở dang
8UpsertON CONFLICT + RETURNINGON DUPLICATE KEY UPDATE, không RETURNINGUpsert nhầm dòng, mất dữ liệu con với REPLACE
9Kiểu dữ liệuKiểu chặt, timestamptz, JSONB, arrayÉp kiểu ngầm, BOOLEAN = TINYINT, TIMESTAMP tới 2038Báo cáo chia sai, hạn nộp lệch múi giờ, so sánh chuỗi = số
10Query / index / extensionGIN/GiST/BRIN, FULL JOIN, extensionB-tree, FULLTEXT, multi-valued; ít cú pháp hơnViết lại query khi đổi DB; tính năng phụ thuộc extension
11ReplicationWAL vật lý (+ logical)Binlog ROW + GTID (logical)Đọc replica thấy dữ liệu cũ; replica bị ghi lệch
12Vận hànhpg_upgrade/dump, 5 năm/bảnNâng tại chỗ, không hạ lạiKẹt bản EOL (MySQL 8.0 hết hỗ trợ 04/2026)
flowchart LR
  Q["Chọn PostgreSQL<br/>hay MySQL?"] --> D1["Dữ liệu sai có được lọt vào không?<br/>(trục 4, 5, 6, 9)"]
  Q --> D2["Schema đổi nhiều, cần rollback sạch?<br/>(trục 7)"]
  Q --> D3["Đội quen gì, hệ thống cũ là gì?<br/>(trục 1, 3, 11, 12)"]
  Q --> D4["Cần tính năng đặc thù?<br/>(trục 10: GIN, PostGIS, pgvector)"]
  D1 --> R["Ma trận quyết định<br/>+ ADR"]
  D2 --> R
  D3 --> R
  D4 --> R

9 câu hỏi của khung KB nằm ở đâu trong bài

Câu hỏi khungNằm ở
1. Bài toán gốc, bối cảnh ra đờiMục 0
2. Mô hình tư duy cốt lõiMục 0 (heap + WAL vs clustered B-tree + undo + binlog)
3. Internals và trade-offPhần "cơ chế" + "trade-off" của từng trục 1–12
4. Đảm bảo và giới hạnTrục 4 (isolation), 5 (constraint), 9 (giới hạn kiểu), 11 (replication async)
5. Failure modes (có lab)Mỗi trục đều có lab tái hiện lỗi: hết slot, lost update, deadlock, cắt dữ liệu, migration dở dang...
6. Vận hành productionTrục 1, 3, 11, 12
7. So sánh, ma trận quyết địnhMục "Ma trận quyết định"
8. Góc nhìn tech leadMục "Góc nhìn tech lead / ADR" + "Áp vào 2 dự án LMS"
9. Anti-patternsMục "Anti-patterns"
10. Câu hỏi phụMục cuối

0. Bài toán gốc và mô hình tư duy của mỗi bên

0.1. Hai xuất phát điểm khác nhau

0.2. Core abstraction — mọi khác biệt phía sau là hệ quả của 2 hình này

flowchart TD
  subgraph PG["PostgreSQL: heap + WAL"]
    direction TB
    P1["Heap: dòng nằm lộn xộn,<br/>mỗi UPDATE = 1 tuple mới"] --> P2["Mọi index trỏ vào<br/>vị trí vật lý (ctid)"]
    P2 --> P3["WAL: 1 log cho cả<br/>crash recovery + replication"]
    P3 --> P4["VACUUM dọn tuple cũ"]
  end
  subgraph MY["InnoDB: B-tree theo PK + undo + binlog"]
    direction TB
    M1["Clustered index: dòng nằm<br/>trong cây PK, sửa tại chỗ"] --> M2["Secondary index chứa PK<br/>(tra 2 lần)"]
    M2 --> M3["Undo log giữ bản cũ,<br/>purge dọn"]
    M3 --> M4["Redo log (InnoDB) + binlog<br/>(server, replication/PITR)"]
  end

Cách nói khi phỏng vấn: "Em hình dung PostgreSQL là một đống dòng có đánh số phiên bản: mỗi lần sửa là viết thêm một bản mới, index trỏ thẳng vào vị trí vật lý, một cuốn nhật ký WAL dùng cho cả phục hồi lẫn nhân bản. Còn InnoDB là một cây B-tree sắp theo khoá chính, sửa tại chỗ, bản cũ đẩy sang undo, và có thêm binlog ở tầng server để nhân bản. Hầu hết khác biệt về bloat, khoá chính, replication đều suy ra từ hai hình này."


1. Kết nối: process-per-connection vs thread-per-connection

1.1. Cơ chế

1.2. Lab: 5 session rảnh trên mỗi bên

pid trong pg_stat_activity: 1576631|1576630|1576639|1576638|1576640
ps trong container (mỗi session = 1 process 'postgres: <user> <db> ...'):
  1576630     1 postgres: postgres isolation_demo [local] SELECT
  1576631     1 postgres: postgres isolation_demo [local] SELECT
  1576638     1 postgres: postgres isolation_demo [local] SELECT
  1576639     1 postgres: postgres isolation_demo [local] SELECT
  1576640     1 postgres: postgres isolation_demo [local] SELECT
tổng process postgres trong container: 17
-- bộ nhớ riêng (private memory context) của chính backend đang chạy câu này, lúc rảnh
SELECT pg_size_pretty(sum(total_bytes)) AS backend_private_mem FROM pg_backend_memory_contexts;
 backend_private_mem
---------------------
 1447 kB
(1 row)
process mysqld: 1 ; thread OS của mysqld trước: 36
thread OS của mysqld khi có 5 session: 40
SELECT COUNT(*) AS foreground_threads, COUNT(DISTINCT THREAD_OS_ID) AS os_threads
  FROM performance_schema.threads WHERE TYPE='FOREGROUND' AND PROCESSLIST_COMMAND <> 'Daemon'

+--------------------+------------+
| foreground_threads | os_threads |
+--------------------+------------+
|                  6 |          6 |
+--------------------+------------+
1 row in set (0.09 sec)

SELECT THREAD_ID, THREAD_OS_ID, PROCESSLIST_INFO FROM performance_schema.threads
 WHERE PROCESSLIST_INFO LIKE 'SELECT SLEEP%' ORDER BY THREAD_ID

+-----------+--------------+------------------+
| THREAD_ID | THREAD_OS_ID | PROCESSLIST_INFO |
+-----------+--------------+------------------+
|        49 |          234 | SELECT SLEEP(6)  |
|        50 |          297 | SELECT SLEEP(6)  |
|        51 |          298 | SELECT SLEEP(6)  |
|        52 |          299 | SELECT SLEEP(6)  |
|        53 |          300 | SELECT SLEEP(6)  |
+-----------+--------------+------------------+
5 rows in set (0.01 sec)

SELECT thread_id, user, current_allocated FROM sys.memory_by_thread_by_current_bytes WHERE thread_id = PS_CURRENT_THREAD_ID()

+-----------+----------------+-------------------+
| thread_id | user           | current_allocated |
+-----------+----------------+-------------------+
|        55 | root@localhost | 1.34 MiB          |
+-----------+----------------+-------------------+
1 row in set (0.02 sec)

Đọc output: bên PG có 5 process postgres: postgres isolation_demo [local] SELECT mới (tổng 17 process tính cả background worker); bên MySQL vẫn 1 process mysqld, số thread OS tăng từ 36 lên 40 (một thread được lấy lại từ thread cache — THREAD_OS_ID 234 thấp hơn hẳn các thread mới). Bộ nhớ riêng của một kết nối rảnh xấp xỉ nhau (1447 kB vs 1,34 MiB) — khác biệt thật nằm ở chi phí process (fork, context switch, mỗi process giữ cache riêng) và ở bộ nhớ khi query chạy (work_mem × số node sort/hash × số kết nối), không phải lúc rảnh.

Cách nói khi phỏng vấn: "Em mở năm kết nối rảnh vào mỗi bên. PostgreSQL sinh ra năm process hệ điều hành mới, MySQL vẫn một process và chỉ thêm thread. Lúc rảnh mỗi kết nối tốn khoảng một rưỡi megabyte ở cả hai, nên vấn đề của PostgreSQL không phải bộ nhớ lúc rảnh mà là chi phí của hàng trăm process khi cùng chạy và bộ nhớ sort nhân theo số kết nối."

1.3. Lab: hết slot kết nối

== B1. PostgreSQL (container tạm, max_connections=6, superuser_reserved_connections=1): hết slot
-- kết nối thứ 6 bằng user app:
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL:  remaining connection slots are reserved for roles with the SUPERUSER attribute
exit=2 (expected: hết slot thường)
-- kết nối bằng superuser (slot dự phòng):
superuser vào được, backend đang có: 6

== B2. MySQL: max_connections=5 -> lỗi 1040; user có CONNECTION_ADMIN còn 1 slot dự phòng
-- kết nối thứ 6 bằng user app:
ERROR 1040 (08004): Too many connections
exit=1 (expected: Too many connections)
-- root (CONNECTION_ADMIN) vào slot dự phòng max_connections+1:
root vào được, Threads_connected=6
reset max_connections=151

Cả hai đều giữ lại chỗ cho admin để vào cứu hệ thống: PG qua superuser_reserved_connections, MySQL qua slot max_connections + 1 cho CONNECTION_ADMIN. Điều quan trọng cho vận hành: app không bao giờ được chạy bằng user superuser/root, nếu không slot cứu hộ đó cũng bị app ăn mất.

Cách nói khi phỏng vấn: "Khi hết kết nối, PostgreSQL báo các slot còn lại dành cho superuser, MySQL báo lỗi 1040 Too many connections. Cả hai đều chừa một chỗ cho quản trị viên vào xử lý, nên em luôn tách user của ứng dụng khỏi user quản trị, không thì lúc sự cố chính mình cũng không vào được."

1.4. Lab: PgBouncer transaction pooling — và hai cái bẫy của nó

PgBouncer 1.26.1
-- giây thứ 3: số backend thật trên PostgreSQL cho user app (client đang chờ ở PgBouncer):
2
 database  |   user    | cl_active | cl_waiting | cl_active_cancel_req | cl_waiting_cancel_req | sv_active
 lab       | app       |         2 |         12 |                    0 |                     0 |         2
-- 20 client x 1 giây qua pool 2 connection xong sau: 10 giây (xếp hàng, không lỗi)

-- Bẫy 1: SET ở cấp session rò sang client khác (pool lab1 chỉ có 1 server connection)
SET
                      note
------------------------------------------------
 client A đã SET statement_timeout=1s rồi thoát
(1 row)

         note         | statement_timeout_client_b_thay
----------------------+---------------------------------
 client B chưa SET gì | 1s
(1 row)

ERROR:  canceling statement due to statement timeout
exit=1 (expected: client B bị timeout bởi SET của client A)

-- Bẫy 2: advisory lock cấp session — A giữ lock rồi thoát, server connection vẫn giữ; B dùng lại đúng connection đó
 client_a_lay_lock_42 | server_pid
----------------------+------------
                      |        313
(1 row)

 client_b_thu_lay_lock_42 | server_pid
--------------------------+------------
 t                        |        313
(1 row)

Cách nói khi phỏng vấn: "Em đặt PgBouncer chế độ transaction pooling, hai mươi client mà PostgreSQL chỉ thấy hai kết nối, phần còn lại xếp hàng chứ không lỗi. Nhưng có hai cái bẫy em đã tái hiện: lệnh SET của client này rò sang client khác, và advisory lock cấp session bị dùng chung nên hai tiến trình cùng tưởng mình giữ khoá. Vì vậy dùng transaction pooling thì code phải dùng SET LOCAL và advisory lock cấp transaction."

flowchart TD
  A["5 app node × N worker<br/>(mỗi worker giữ 1 kết nối)"] --> B{"DB nào?"}
  B -- "PostgreSQL" --> C["Mỗi kết nối = 1 process"]
  C --> D["Vài trăm process đồng thời:<br/>context switch, work_mem × N"]
  D --> E["Đặt PgBouncer / pooler"]
  E --> F["Transaction pooling:<br/>cấm SET session, advisory lock session,<br/>LISTEN, PREPARE SQL"]
  B -- "MySQL" --> G["Mỗi kết nối = 1 thread + thread cache"]
  G --> H["Chịu được nhiều kết nối hơn trước khi cần pooler<br/>(ProxySQL khi cần read/write split, failover)"]

1.5. Trade-off và ảnh hưởng nghiệp vụ


2. Lưu trữ: heap + index tách rời vs clustered index theo PK

2.1. Cơ chế

2.2. Lab: thứ tự dòng khi không có ORDER BY

INSERT INTO tmp_order VALUES (3,'c'),(1,'a'),(2,'b')
SELECT * FROM tmp_order
+----+------+
| id | v    |
+----+------+
|  1 | a    |
|  2 | b    |
|  3 | c    |
+----+------+
3 rows in set (0.01 sec)
UPDATE tmp_order SET v = 'c-updated' WHERE id = 3
SELECT * FROM tmp_order
+----+-----------+
| id | v         |
+----+-----------+
|  1 | a         |
|  2 | b         |
|  3 | c-updated |
+----+-----------+
3 rows in set (0.00 sec)
INSERT INTO tmp_pgm_order VALUES (3,'c'),(1,'a'),(2,'b');
INSERT 0 3
SELECT ctid, * FROM tmp_pgm_order;
 ctid  | id | v
-------+----+---
 (0,1) |  3 | c
 (0,2) |  1 | a
 (0,3) |  2 | b
(3 rows)

UPDATE tmp_pgm_order SET v = 'c-updated' WHERE id = 3;
UPDATE 1
SELECT ctid, * FROM tmp_pgm_order;
 ctid  | id |     v
-------+----+-----------
 (0,2) |  1 | a
 (0,3) |  2 | b
 (0,4) |  3 | c-updated
(3 rows)

InnoDB luôn trả theo PK (vì đọc lá của cây PK); PG trả theo vị trí vật lý, và sau UPDATE dòng id=3 "nhảy" xuống cuối vì bản mới nằm ở (0,4). Không bên nào đảm bảo thứ tự khi thiếu ORDER BY — nhưng code chạy lâu trên MySQL hay vô tình dựa vào thứ tự theo PK, và vỡ khi chuyển sang PG.

Cách nói khi phỏng vấn: "Cùng ba dòng chèn theo thứ tự ba, một, hai: MySQL trả về một, hai, ba vì nó đọc theo cây khoá chính; PostgreSQL trả đúng thứ tự vật lý và sau một lần sửa thì dòng đó nhảy xuống cuối. Nên em coi mọi trang danh sách không có ORDER BY là bug, nhất là khi phân trang."

2.3. Lab: secondary index tự chứa PK (covering miễn phí ở InnoDB)

EXPLAIN SELECT id FROM tmp_users WHERE email = 'u777@lms.vn'
+----+-------------+-----------+------------+------+---------------+---------+---------+-------+------+----------+-------------+
| id | select_type | table     | partitions | type | possible_keys | key     | key_len | ref   | rows | filtered | Extra       |
+----+-------------+-----------+------------+------+---------------+---------+---------+-------+------+----------+-------------+
|  1 | SIMPLE      | tmp_users | NULL       | ref  | k_email       | k_email | 258     | const |    1 |   100.00 | Using index |
+----+-------------+-----------+------------+------+---------------+---------+---------+-------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)
EXPLAIN SELECT id, name FROM tmp_users WHERE email = 'u777@lms.vn'
+----+-------------+-----------+------------+------+---------------+---------+---------+-------+------+----------+-------+
| id | select_type | table     | partitions | type | possible_keys | key     | key_len | ref   | rows | filtered | Extra |
+----+-------------+-----------+------------+------+---------------+---------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | tmp_users | NULL       | ref  | k_email       | k_email | 258     | const |    1 |   100.00 | NULL  |
+----+-------------+-----------+------------+------+---------------+---------+---------+-------+------+----------+-------+
1 row in set, 1 warning (0.01 sec)
EXPLAIN (COSTS OFF) SELECT id FROM tmp_pgm_users WHERE email = 'u777@lms.vn';
                      QUERY PLAN
-------------------------------------------------------
 Index Scan using tmp_pgm_users_email on tmp_pgm_users
   Index Cond: ((email)::text = 'u777@lms.vn'::text)
(2 rows)

DROP INDEX tmp_pgm_users_email;
DROP INDEX
CREATE INDEX tmp_pgm_users_email_inc ON tmp_pgm_users (email) INCLUDE (id);
CREATE INDEX
VACUUM ANALYZE tmp_pgm_users;
VACUUM
EXPLAIN (COSTS OFF) SELECT id FROM tmp_pgm_users WHERE email = 'u777@lms.vn';
                           QUERY PLAN
----------------------------------------------------------------
 Index Only Scan using tmp_pgm_users_email_inc on tmp_pgm_users
   Index Cond: (email = 'u777@lms.vn'::text)
(2 rows)

Using index ở MySQL nghĩa là chỉ đọc index k_email — vì nó đã chứa id. PG phải dùng INCLUDE (id) mới được Index Only Scan. Ngược lại, lấy thêm cột name thì MySQL phải tra sang cây PK (Extra = NULL), PG phải sang heap — hai bên đều tốn thêm một lần tra.

Cách nói khi phỏng vấn: "Ở InnoDB mỗi index phụ đã mang sẵn khoá chính, nên truy vấn chỉ lấy id theo email được phục vụ hoàn toàn từ index. PostgreSQL thì index trỏ vào vị trí vật lý, muốn được như vậy phải thêm INCLUDE id. Đổi lại, khoá chính của InnoDB càng to thì mọi index phụ càng to theo."

2.4. Lab: kiểu primary key quyết định kích thước — 200.000 dòng mỗi bảng

| table_name            | index_name | size_mb | leaf_pages |
+-----------------------+------------+---------+------------+
| pk_bigint             | PRIMARY    |    29.6 |       1887 |
| pk_bigint             | k_email    |    10.5 |        566 |
| pk_uuid4_bin16        | PRIMARY    |    50.6 |       2828 |
| pk_uuid4_bin16        | k_email    |    12.5 |        704 |
| pk_uuid4_char36       | PRIMARY    |    58.8 |       3227 |
| pk_uuid4_char36       | k_email    |    20.6 |       1112 |
| pk_uuid_ordered_bin16 | PRIMARY    |    31.6 |       1991 |
| pk_uuid_ordered_bin16 | k_email    |    12.5 |        704 |
+-----------------------+------------+---------+------------+
    table_name     | heap  | pkey_index | email_index
-------------------+-------+------------+-------------
 tmp_pgm_pk_bigint | 30 MB | 4408 kB    | 10 MB
 tmp_pgm_pk_uuid4  | 32 MB | 8640 kB    | 10 MB
 tmp_pgm_pk_uuid7  | 32 MB | 6184 kB    | 10 MB
Kiểu PKMySQL: bảng (PRIMARY)MySQL: index emailPG: heapPG: pkeyPG: index email
bigint tăng dần29,6 MB10,5 MB30 MB4408 kB10 MB
UUID có thứ tự (MySQL UUID_TO_BIN(UUID(),1) / PG uuidv7())31,6 MB12,5 MB32 MB6184 kB10 MB
UUIDv4 ngẫu nhiên, 16 byte50,6 MB12,5 MB32 MB8640 kB10 MB
UUIDv4 dạng text char(36)58,8 MB20,6 MB———

Cách nói khi phỏng vấn: "Em chèn hai trăm nghìn dòng với bốn kiểu khoá chính. Ở MySQL, UUID ngẫu nhiên làm cả bảng phình khoảng một phẩy bảy lần vì bảng chính là cây khoá chính bị tách trang liên tục, lưu dạng chuỗi ba mươi sáu ký tự còn làm index phụ to gấp đôi. Ở PostgreSQL chỉ riêng index khoá chính phình, bảng và index khác không đổi. Nên nếu cần UUID em dùng loại có thứ tự thời gian như UUIDv7, và ở MySQL lưu dạng nhị phân mười sáu byte."

flowchart TD
  U["App sinh UUIDv4 ngẫu nhiên làm PK"] --> I{"Engine"}
  I -- "InnoDB" --> I1["Chèn vào giữa cây PK = chính bảng"]
  I1 --> I2["Page split, page lá ~nửa đầy"]
  I2 --> I3["Bảng ×1,7; mọi secondary index<br/>mang theo PK dài"]
  I3 --> I4["Buffer pool chứa ít dòng nóng hơn"]
  I -- "PostgreSQL" --> P1["Heap ghi nối đuôi, không đổi"]
  P1 --> P2["Chỉ index PK bị chèn ngẫu nhiên"]
  P2 --> P3["pkey ×2, index khác không đổi"]
  U2["UUIDv7 / bigint"] --> OK["Chèn cuối cây: cả hai bên gọn"]

2.5. Trade-off và ảnh hưởng nghiệp vụ


3. MVCC và dọn rác: tuple versions + VACUUM vs undo + purge (tóm tắt)

Chi tiết cơ chế và số đo lớn đã có ở Dead tuple và index bloat (PG heap ×6 sau 5 lần UPDATE toàn bảng, InnoDB đọc snapshot cũ chậm ×29–×74, OPTIMIZE TABLE = rebuild) và Transaction ID wraparound (PG có thể ngừng nhận ghi). Ở đây chỉ chạy lại một lab nhỏ để nhìn hai kiến trúc cạnh nhau.

3.1. Lab nhỏ: 3 lần UPDATE 50.000 dòng

SELECT pg_size_pretty(pg_relation_size('tmp_pgm_mvcc')) AS heap_truoc;
 heap_truoc
 1776 kB
UPDATE tmp_pgm_mvcc SET n = n + 1;
UPDATE 50000
UPDATE tmp_pgm_mvcc SET n = n + 1;
UPDATE 50000
UPDATE tmp_pgm_mvcc SET n = n + 1;
UPDATE 50000
SELECT n_live_tup, n_dead_tup, pg_size_pretty(pg_relation_size('tmp_pgm_mvcc')) AS heap_sau FROM pg_stat_user_tables WHERE relname = 'tmp_pgm_mvcc';
 n_live_tup | n_dead_tup | heap_sau
------------+------------+----------
      50000 |     149952 | 7080 kB
VACUUM tmp_pgm_mvcc;
VACUUM
SELECT n_live_tup, n_dead_tup, pg_size_pretty(pg_relation_size('tmp_pgm_mvcc')) AS heap_sau_vacuum FROM pg_stat_user_tables WHERE relname = 'tmp_pgm_mvcc';
 n_live_tup | n_dead_tup | heap_sau_vacuum
------------+------------+-----------------
      50000 |          0 | 7080 kB
SELECT FILE_SIZE / 1048576 AS ibd_mb_truoc FROM information_schema.INNODB_TABLESPACES WHERE NAME = 'lab/mvcc'
| ibd_mb_truoc |
|       9.0000 |
SELECT `count` AS history_list_truoc FROM information_schema.INNODB_METRICS WHERE NAME = 'trx_rseg_history_len'
| history_list_truoc |
|                 34 |
UPDATE mvcc SET n = n + 1
Query OK, 50000 rows affected (0.19 sec)
Rows matched: 50000  Changed: 50000  Warnings: 0
CALL bump(1500)
Query OK, 1 row affected (7.22 sec)
SELECT `count` AS history_list_khi_snapshot_cu_mo FROM information_schema.INNODB_METRICS WHERE NAME = 'trx_rseg_history_len'
| history_list_khi_snapshot_cu_mo |
|                            1502 |
SELECT FILE_SIZE / 1048576 AS ibd_mb_sau FROM information_schema.INNODB_TABLESPACES WHERE NAME = 'lab/mvcc'
| ibd_mb_sau |
|     9.0000 |
SELECT trx_id, trx_state, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_s, trx_isolation_level FROM information_schema.INNODB_TRX
| trx_id          | trx_state | age_s | trx_isolation_level |
| 417124894727384 | RUNNING   |     8 | REPEATABLE READ     |
-- snapshot cũ đã COMMIT; theo dõi purge dọn history list (mỗi 3s, tối đa 90s):
  +0s history_list=1503
  +3s history_list=1503
  +6s history_list=1503
  +9s history_list=1503
  +12s history_list=1503
  +15s history_list=1503
  +18s history_list=1503
  +21s history_list=1503
  +24s history_list=1503
  +27s history_list=1503
  +30s history_list=0

Cách nói khi phỏng vấn: "Cùng ba lần sửa năm mươi nghìn dòng: PostgreSQL để lại gần một trăm năm mươi nghìn bản chết ngay trong bảng, bảng to gấp bốn, VACUUM dọn xong nhưng file không co lại. MySQL thì file bảng đứng yên, rác nằm ở undo; chỉ cần một transaction đọc mở từ trước là purge bị chặn và hàng đợi undo cứ dài ra. Hai bên đều sợ transaction dài, chỉ khác chỗ rác nằm và ai trả giá."

3.2. Lab: rollback một UPDATE 200.000 dòng

\timing on
Timing is on.
BEGIN;
BEGIN
Time: 0.188 ms
UPDATE tmp_pgm_rb SET n = n + 1;
UPDATE 200000
Time: 1527.242 ms (00:01.527)
ROLLBACK;
ROLLBACK
Time: 0.540 ms
START TRANSACTION
Query OK, 0 rows affected (0.01 sec)
UPDATE rb SET n = n + 1
Query OK, 200000 rows affected (0.94 sec)
Rows matched: 200000  Changed: 200000  Warnings: 0
ROLLBACK
Query OK, 0 rows affected (1.00 sec)

PG rollback 0,54 ms (chỉ đánh dấu transaction abort trong commit log, bản mới thành rác cho VACUUM); InnoDB rollback 1,00 s — cùng cỡ thời gian với chính câu UPDATE (0,94 s), vì phải áp undo ngược lại từng dòng. Với transaction hàng chục triệu dòng, "kill query" trên MySQL có thể kéo theo hàng giờ rollback (thời gian tuyệt đối ở đây chỉ để so tương đối).

Cách nói khi phỏng vấn: "Huỷ một lệnh sửa hai trăm nghìn dòng: PostgreSQL xong gần như tức thì vì chỉ đánh dấu transaction thất bại, rác để VACUUM dọn sau. InnoDB mất gần bằng thời gian chạy lệnh vì phải chép ngược từ undo. Nên trên MySQL em không bao giờ chạy một lệnh UPDATE khổng lồ rồi định kill nếu chậm, em chia batch."

flowchart TD
  W["UPDATE 1 dòng"] --> PGX["PostgreSQL: ghi tuple mới vào heap,<br/>tuple cũ đánh dấu xmax"]
  W --> MYX["InnoDB: sửa tại chỗ trong cây PK,<br/>giá trị cũ chép sang undo"]
  PGX --> PGC["Rác ở heap + index<br/>→ mọi reader quét qua rác"]
  MYX --> MYC["Rác ở undo<br/>→ reader snapshot cũ phải dựng lại bản cũ"]
  PGC --> PGD["VACUUM / autovacuum<br/>bị chặn bởi tx dài, slot, standby feedback"]
  MYC --> MYD["Purge thread<br/>bị chặn bởi read view cũ nhất"]
  PGD --> PGE["Rủi ro cực đoan: wraparound, DB ngừng ghi"]
  MYD --> MYE["Rủi ro cực đoan: undo phình, rollback hàng giờ"]

3.3. Trade-off và ảnh hưởng nghiệp vụ


4. Isolation và locking: READ COMMITTED + SSI vs REPEATABLE READ + gap lock

4.1. Cơ chế

4.2. Lab 1: đọc lại trong cùng transaction

######## 1. Mặc định: A đọc 2 lần trong 1 transaction, B commit +50 ở giữa — PostgreSQL 18.4

----- A (mặc định READ COMMITTED) -----
BEGIN;
BEGIN
SHOW transaction_isolation;
 transaction_isolation
-----------------------
 read committed

[t=0.2s] A đọc lần 1
SELECT balance FROM tmp_pgm_acc WHERE id = 1;
 balance
---------
     100

[t=2.2s] A đọc lần 2
SELECT balance FROM tmp_pgm_acc WHERE id = 1;
 balance
---------
     150

COMMIT;
COMMIT

----- B -----

UPDATE tmp_pgm_acc SET balance = balance + 50 WHERE id = 1;
UPDATE 1
[t=1.2s] B đã commit +50

----- kết quả cuối -----
 balance
---------
     150
######## 1. Mặc định: A đọc 2 lần trong 1 transaction, B commit +50 ở giữa — MySQL 8.4.13
----- A (mặc định REPEATABLE READ) -----
START TRANSACTION
SELECT @@transaction_isolation
| @@transaction_isolation |
| REPEATABLE-READ         |
[t=0.8s] A đọc lần 1
SELECT balance FROM acc WHERE id = 1
| balance |
|     100 |
[t=2.8s] A đọc lần 2
SELECT balance FROM acc WHERE id = 1
| balance |
|     100 |
COMMIT
----- B -----
UPDATE acc SET balance = balance + 50 WHERE id = 1
Query OK, 1 row affected
[t=1.9s] B đã commit +50
----- kết quả cuối -----
SELECT balance FROM acc
| balance |
|     150 |

Cách nói khi phỏng vấn: "Cùng một transaction đọc số dư hai lần, ở giữa có người khác cộng năm mươi. PostgreSQL mặc định đọc lại ra một trăm năm mươi vì mỗi câu lệnh lấy ảnh chụp mới. MySQL mặc định vẫn ra một trăm vì ảnh chụp giữ từ lần đọc đầu. Không bên nào sai, nhưng code viết cho bên này mang sang bên kia sẽ cho kết quả khác."

4.3. Lab 2: lost update — đọc số dư vào app rồi ghi giá trị tuyệt đối

Kịch bản kinh điển của thanh toán / ví / tồn kho: A đọc số dư 100 vào biến, tính 100 − 30 trong code, ghi SET balance = 70; trong lúc đó B cộng 50. Đúng phải là 120.

######## 2. Lost update: A đọc số dư vào biến, B +50, A ghi (số đọc được - 30) — PG READ COMMITTED — PostgreSQL 18.4
----- A -----
A đọc được balance = 100
[t=2.1s] A ghi balance = b - 30
UPDATE tmp_pgm_acc SET balance = 100 - 30 WHERE id = 1;
UPDATE 1
----- B -----
UPDATE tmp_pgm_acc SET balance = balance + 50 WHERE id = 1;
UPDATE 1
[t=1.1s] B đã commit +50
----- kết quả cuối -----
 balance_cuoi
           70
######## 2. Lost update: A đọc số dư vào biến, B +50, A ghi (số đọc được - 30) — PG REPEATABLE READ — PostgreSQL 18.4
----- A -----
A đọc được balance = 100
[t=2.6s] A ghi balance = b - 30
UPDATE tmp_pgm_acc SET balance = 100 - 30 WHERE id = 1;
  >>> ERROR 40001: 'could not serialize access due to concurrent update'
ROLLBACK
ERROR:  could not serialize access due to concurrent update
----- B -----
UPDATE tmp_pgm_acc SET balance = balance + 50 WHERE id = 1;
UPDATE 1
[t=1.6s] B đã commit +50
----- kết quả cuối -----
 balance_cuoi
          150
######## 2. Lost update: cùng thao tác — MySQL REPEATABLE READ (mặc định) — MySQL 8.4.13
----- A -----
SELECT CONCAT('A đọc được balance = ', @b) AS note
| A đọc được balance = 100        |
[t=2.3s] A ghi balance = b - 30
UPDATE acc SET balance = @b - 30 WHERE id = 1
----- B -----
UPDATE acc SET balance = balance + 50 WHERE id = 1
[t=1.3s] B đã commit +50
----- kết quả cuối -----
SELECT balance AS balance_cuoi FROM acc
| balance_cuoi |
|           70 |
######## 2b. MySQL RR: SELECT thấy snapshot cũ, nhưng UPDATE đọc bản MỚI NHẤT (current read) — MySQL 8.4.13
----- A -----
SELECT balance AS a_doc_snapshot FROM acc WHERE id = 1
| a_doc_snapshot |
|            100 |
SELECT balance AS a_doc_lai_van_snapshot FROM acc WHERE id = 1
| a_doc_lai_van_snapshot |
|                    100 |
UPDATE acc SET balance = balance - 30 WHERE id = 1
SELECT balance AS a_doc_sau_update FROM acc WHERE id = 1
| a_doc_sau_update |
|              120 |
----- B -----
UPDATE acc SET balance = balance + 50 WHERE id = 1
----- kết quả cuối -----
SELECT balance FROM acc
| balance |
|     120 |

Và hiện tượng lạ của InnoDB RR: A đọc 100, đọc lại vẫn 100 (snapshot), nhưng UPDATE balance = balance − 30 lại dựa trên bản mới nhất (150) → đọc sau update ra 120. Trong cùng một transaction, "thế giới" A nhìn thấy nhảy từ 100 sang 120 mà A chỉ trừ 30.

Cách nói khi phỏng vấn: "Em tái hiện lỗi trừ tiền kinh điển: đọc số dư vào code, tính, rồi ghi đè. PostgreSQL mức mặc định và MySQL mức mặc định đều mất tiền cộng của người kia mà không báo lỗi. PostgreSQL ở mức repeatable read thì từ chối transaction ghi sau và bắt mình thử lại. Còn ở MySQL, câu UPDATE luôn đọc bản mới nhất chứ không đọc ảnh chụp, nên cách an toàn là cộng trừ ngay trong câu UPDATE, hoặc khoá dòng bằng FOR UPDATE, hoặc optimistic locking theo version."

flowchart TD
  A1["A: SELECT balance (=100) vào biến"] --> B1["B: UPDATE balance = balance + 50, COMMIT (=150)"]
  B1 --> A2["A: UPDATE balance = 70 (giá trị tính trong app)"]
  A2 --> X{"Isolation"}
  X -- "PG READ COMMITTED" --> L1["Ghi đè: 70 — mất 50"]
  X -- "MySQL REPEATABLE READ (mặc định)" --> L2["Ghi đè: 70 — mất 50, không lỗi"]
  X -- "PG REPEATABLE READ / SERIALIZABLE" --> L3["ERROR 40001 → app retry → 120"]
  L1 --> F["Chữa ở cả 2 DB: UPDATE ... SET balance = balance - 30<br/>hoặc SELECT ... FOR UPDATE<br/>hoặc WHERE version = ?"]
  L2 --> F

4.4. Lab 3: gap lock — khoá cả khoảng trống

######## 3. Gap lock: A khoá khoảng id 11..19 (không có dòng nào), B chèn id=15 và id=25 — PostgreSQL 18.4
----- A -----
SELECT * FROM tmp_pgm_slots WHERE id BETWEEN 11 AND 19 FOR UPDATE;
[t=3.0s] A commit
----- B -----
[t=1.0s] B chèn 15
INSERT INTO tmp_pgm_slots VALUES (15);
INSERT 0 1
[t=1.0s] B chèn 15 xong
INSERT INTO tmp_pgm_slots VALUES (25);
INSERT 0 1
[t=1.0s] B chèn 25 xong
----- kết quả cuối -----
 10
 15
 20
 25
 30
######## 3. Gap lock: A khoá khoảng id 11..19 (không có dòng nào), B chèn id=15 và id=25 — MySQL 8.4.13
----- A -----
SELECT * FROM slots WHERE id BETWEEN 11 AND 19 FOR UPDATE
SELECT index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_name = 'slots'
| index_name | lock_type | lock_mode | lock_data |
| NULL       | TABLE     | IX        | NULL      |
| PRIMARY    | RECORD    | X,GAP     | 20        |
[t=3.1s] A commit
----- B -----
[t=1.1s] B chèn 25
INSERT INTO slots VALUES (25)
[t=1.1s] B chèn 25 xong
[t=1.1s] B chèn 15
INSERT INTO slots VALUES (15)
[t=3.1s] B chèn 15 xong
----- kết quả cuối -----
SELECT * FROM slots ORDER BY id
| 10 |
| 15 |
| 20 |
| 25 |
| 30 |

A chỉ SELECT ... WHERE id BETWEEN 11 AND 19 FOR UPDATE — không có dòng nào. PG: B chèn 15 xong ngay (cùng mốc t=1,0 s). MySQL: performance_schema.data_locks cho thấy khoá X,GAP trên bản ghi 20 (khoảng (10, 20)); B chèn 25 ngay (khoảng khác), nhưng chèn 15 phải chờ tới t=3,1 s, đúng lúc A commit (t=3,1 s).

Cách nói khi phỏng vấn: "Một transaction khoá khoảng mười một đến mười chín dù không có dòng nào. Ở MySQL, người khác chèn mười lăm phải đứng chờ tới khi transaction kia commit, vì InnoDB khoá cả khoảng trống trước bản ghi hai mươi; chèn hai mươi lăm thì qua ngay. PostgreSQL không có khoá khoảng trống nên chèn mười lăm qua ngay. Gap lock là thứ làm MySQL chống được phantom, và cũng là nguồn deadlock bất ngờ."

Gap lock dẫn tới deadlock thế nào (hai bên cùng FOR UPDATE một khoá chưa tồn tại rồi cùng INSERT, khác khoá nhưng cùng khoảng, READ COMMITTED, cách chữa) và toàn bộ các trường hợp deadlock khác ở cả hai DB nằm trong bài riêng: Deadlock ở PostgreSQL và MySQL — mục 3.2.

4.5. Lab 4: đặt chỗ "kiểm tra còn trống rồi INSERT" (giữ chỗ, đăng ký lớp có sĩ số, đặt lịch thi)

######## 4. Đặt phòng: kiểm tra còn trống rồi INSERT — PG READ COMMITTED + SELECT ... FOR UPDATE — PostgreSQL 18.4
----- A -----
SELECT id AS da_co_id FROM tmp_pgm_booking WHERE room = 1 AND day = '2026-11-02' FOR UPDATE;
[t=1.0s] A INSERT
INSERT INTO tmp_pgm_booking (room, day, who) VALUES (1, '2026-11-02', 'A');
INSERT 0 1
[t=1.0s] A COMMIT
COMMIT;
----- B -----
SELECT id AS da_co_id FROM tmp_pgm_booking WHERE room = 1 AND day = '2026-11-02' FOR UPDATE;
[t=1.0s] B INSERT
INSERT INTO tmp_pgm_booking (room, day, who) VALUES (1, '2026-11-02', 'B');
INSERT 0 1
[t=1.0s] B COMMIT
COMMIT;
----- kết quả cuối -----
    1 | 2026-11-02 | B
    1 | 2026-11-02 | A
######## 4. Đặt phòng: kiểm tra còn trống rồi INSERT — MySQL REPEATABLE READ + SELECT ... FOR UPDATE — MySQL 8.4.13
----- A -----
SELECT id AS da_co_id FROM booking WHERE room = 1 AND day = '2026-11-02' FOR UPDATE
Empty set
[t=1.0s] A INSERT
INSERT INTO booking (room, day, who) VALUES (1, '2026-11-02', 'A')
[t=1.0s] A COMMIT
----- B -----
SELECT id AS da_co_id FROM booking WHERE room = 1 AND day = '2026-11-02' FOR UPDATE
Empty set
[t=1.0s] B INSERT
INSERT INTO booking (room, day, who) VALUES (1, '2026-11-02', 'B')
ERROR 1213 (40001) at line 6: Deadlock found when trying to get lock; try restarting transaction
[t=1.0s] B COMMIT
----- kết quả cuối -----
|    1 | 2026-11-02 | A    |
######## 4. Đặt phòng: kiểm tra còn trống rồi INSERT — PG SERIALIZABLE (SELECT thường) — PostgreSQL 18.4
----- A -----
[t=0.9s] A INSERT
INSERT INTO tmp_pgm_booking (room, day, who) VALUES (1, '2026-11-02', 'A');
INSERT 0 1
[t=0.9s] A COMMIT
----- B -----
[t=0.9s] B INSERT
INSERT INTO tmp_pgm_booking (room, day, who) VALUES (1, '2026-11-02', 'B');
  >>> ERROR 40001: 'could not serialize access due to read/write dependencies among transactions'
[t=0.9s] B INSERT
ERROR:  could not serialize access due to read/write dependencies among transactions
DETAIL:  Reason code: Canceled on identification as a pivot, during write.
ERROR:  current transaction is aborted, commands ignored until end of transaction block
----- kết quả cuối -----
    1 | 2026-11-02 | A
######## 4. Đặt phòng: kiểm tra còn trống rồi INSERT — MySQL SERIALIZABLE (SELECT thường) — MySQL 8.4.13
----- A -----
[t=1.2s] A INSERT
INSERT INTO booking (room, day, who) VALUES (1, '2026-11-02', 'A')
ERROR 1213 (40001) at line 6: Deadlock found when trying to get lock; try restarting transaction
[t=1.2s] A COMMIT
----- B -----
[t=1.2s] B INSERT
INSERT INTO booking (room, day, who) VALUES (1, '2026-11-02', 'B')
[t=1.2s] B COMMIT
----- kết quả cuối -----
|    1 | 2026-11-02 | B    |
Cách viếtPostgreSQLMySQL InnoDB
SELECT ... FOR UPDATE trên 0 dòng rồi INSERT (PG RC / MySQL RR)2 booking — FOR UPDATE trên 0 dòng không khoá gì1 booking — gap lock của 2 bên chặn insert của nhau → deadlock 1213, 1 bên bị huỷ
SERIALIZABLE, SELECT thường1 booking — một bên nhận 40001 ("Canceled on identification as a pivot, during write" — lần chạy này huỷ ngay lúc ghi; SSI cũng có thể huỷ ở lúc commit)1 booking — SELECT thành FOR SHARE → deadlock 1213

Cách nói khi phỏng vấn: "Kiểm tra phòng còn trống rồi mới chèn là chỗ hai database khác nhau nguy hiểm nhất. PostgreSQL ở mức mặc định, kể cả có FOR UPDATE, vẫn cho hai người đặt trùng vì không có dòng nào để khoá. MySQL nhờ gap lock nên một trong hai bị deadlock và huỷ. Ở mức serializable thì cả hai đều chỉ còn một booking, PostgreSQL huỷ một bên bằng lỗi serialization, MySQL huỷ bằng deadlock. Dù ở đâu, app đều phải bắt lỗi và thử lại."

4.6. Lab 5: write skew — "luôn còn ít nhất một giảng viên trực"

######## 5. Write skew: 2 người cùng xin nghỉ trực khi thấy còn 2 người — PG REPEATABLE READ — PostgreSQL 18.4
----- An -----
         2
[t=1.0s] An COMMIT
----- Binh -----
         2
[t=0.9s] Binh COMMIT
----- kết quả cuối -----
 con_truc
        0
######## 5. Write skew: 2 người cùng xin nghỉ trực khi thấy còn 2 người — PG SERIALIZABLE — PostgreSQL 18.4
----- An -----
         2
[t=0.9s] An COMMIT
----- Binh -----
         2
  >>> ERROR 40001: 'could not serialize access due to read/write dependencies among transactions'
ERROR:  could not serialize access due to read/write dependencies among transactions
ERROR:  current transaction is aborted, commands ignored until end of transaction block
----- kết quả cuối -----
 con_truc
        1
######## 5. Write skew: cùng thao tác — MySQL REPEATABLE READ — MySQL 8.4.13
----- An -----
|         2 |
[t=1.0s] An COMMIT
----- Binh -----
|         2 |
[t=1.0s] Binh COMMIT
----- kết quả cuối -----
SELECT SUM(on_duty) AS con_truc FROM oncall
| con_truc |
|        0 |
######## 5. Write skew: cùng thao tác — MySQL SERIALIZABLE — MySQL 8.4.13
----- An -----
|         2 |
ERROR 1213 (40001) at line 5: Deadlock found when trying to get lock; try restarting transaction
[t=1.0s] An COMMIT
----- Binh -----
|         2 |
[t=1.0s] Binh COMMIT
----- kết quả cuối -----
SELECT SUM(on_duty) AS con_truc FROM oncall
| con_truc |
|        1 |

Hai người cùng thấy "đang có 2 người trực" rồi mỗi người tự rút mình ra. RR ở cả hai DB: còn 0 người trực — mỗi transaction đúng một mình, cộng lại sai. SERIALIZABLE: PG huỷ một bên (40001), MySQL deadlock một bên → còn 1.

Cách nói khi phỏng vấn: "Write skew là khi hai người cùng đọc một điều kiện chung rồi mỗi người sửa một dòng khác nhau, nên không có xung đột trên cùng dòng để database phát hiện. Ở mức repeatable read cả hai database đều để lọt, còn không ai trực. Muốn chặn thì serializable, hoặc khoá tường minh dòng đại diện cho điều kiện đó."

4.7. Lab 6: cách chữa tận gốc ở cả hai DB — ràng buộc UNIQUE

######## 6. Chữa bằng ràng buộc: UNIQUE (room, day) — 2 INSERT đồng thời — PostgreSQL 18.4
----- A -----
INSERT INTO tmp_pgm_booking (room, day, who) VALUES (1, '2026-11-02', 'A');
INSERT 0 1
----- B -----
[t=0.3s] B INSERT (chờ A quyết định)
INSERT INTO tmp_pgm_booking (room, day, who) VALUES (1, '2026-11-02', 'B');
  >>> ERROR 23505: 'duplicate key value violates unique constraint "tmp_pgm_booking_room_day_key"'
[t=1.0s] B xong
ERROR:  duplicate key value violates unique constraint "tmp_pgm_booking_room_day_key"
----- kết quả cuối -----
    1 | 2026-11-02 | A
######## 6. Chữa bằng ràng buộc: UNIQUE (room, day) — 2 INSERT đồng thời — MySQL 8.4.13
----- A -----
INSERT INTO booking (room, day, who) VALUES (1, '2026-11-02', 'A')
----- B -----
[t=0.3s] B INSERT (chờ A quyết định)
INSERT INTO booking (room, day, who) VALUES (1, '2026-11-02', 'B')
ERROR 1062 (23000) at line 3: Duplicate entry '1-2026-11-02' for key 'booking.uq'
[t=1.1s] B xong
----- kết quả cuối -----
|    1 | 2026-11-02 | A    |

B INSERT sau A một chút và phải chờ (unique check chờ transaction đang giữ giá trị đó chưa commit); A commit xong thì B nhận lỗi trùng khoá — mốc [t=…] của B nhảy từ lúc INSERT tới lúc A commit. Không cần isolation cao, không cần retry vòng lặp phức tạp — chỉ cần bắt lỗi 23505 / 1062 và trả "đã có người đặt".

Cách nói khi phỏng vấn: "Cách em tin nhất cho đặt chỗ là để database giữ luật bằng ràng buộc duy nhất. Người thứ hai chèn sẽ đứng chờ người thứ nhất, người thứ nhất commit thì người thứ hai nhận lỗi trùng khoá, ở cả hai database. Khi luật không viết được thành unique, ví dụ khoảng thời gian chồng nhau, thì PostgreSQL có exclusion constraint, còn MySQL phải khoá một dòng đại diện."

flowchart TD
  S["Luật nghiệp vụ cần giữ khi đồng thời"] --> T{"Viết được thành constraint?"}
  T -- "Có: 1 slot / 1 người / 1 key" --> U["UNIQUE (cả 2 DB)<br/>bắt 23505 / 1062"]
  T -- "Khoảng chồng nhau" --> E["PG: EXCLUDE USING gist<br/>MySQL: khoá dòng cha (FOR UPDATE)"]
  T -- "Điều kiện trên nhiều dòng (sĩ số, ≥1 người trực)" --> V{"DB"}
  V -- "PostgreSQL" --> V1["SERIALIZABLE (SSI) + retry 40001<br/>hoặc FOR UPDATE dòng cha"]
  V -- "MySQL" --> V2["FOR UPDATE dòng cha (vd courses.id)<br/>+ retry 1213 khi deadlock"]

4.8. Trade-off và ảnh hưởng nghiệp vụ


5. Toàn vẹn dữ liệu: "luôn strict" vs "strict tuỳ sql_mode"

5.1. Cơ chế

5.2. Lab: 4 giá trị sai — strict, không strict, INSERT IGNORE, PostgreSQL

INSERT INTO orders VALUES ('ABCDEFGH', 1, '2026-11-02', 1)

ERROR 1406 (22001) at line 3: Data too long for column 'code' at row 1
INSERT INTO orders VALUES ('A1', 'abc', '2026-11-02', 1)

ERROR 1366 (HY000) at line 4: Incorrect integer value: 'abc' for column 'qty' at row 1
INSERT INTO orders VALUES ('A2', 1, '2026-02-30', 1)

ERROR 1292 (22007) at line 5: Incorrect date value: '2026-02-30' for column 'due' at row 1
INSERT INTO orders VALUES ('A3', 1, '2026-11-02', 12345.678)

ERROR 1264 (22003) at line 6: Out of range value for column 'price' at row 1
SELECT COUNT(*) AS so_dong_vao_bang FROM orders

| so_dong_vao_bang |
|                0 |
SET SESSION sql_mode = ''

INSERT INTO orders VALUES ('ABCDEFGH', 1, '2026-11-02', 1)

Query OK, 1 row affected, 1 warning (0.01 sec)

Warning (Code 1265): Data truncated for column 'code' at row 1
INSERT INTO orders VALUES ('A1', 'abc', '2026-11-02', 1)

Query OK, 1 row affected, 1 warning (0.01 sec)

Warning (Code 1366): Incorrect integer value: 'abc' for column 'qty' at row 1
INSERT INTO orders VALUES ('A2', 1, '2026-02-30', 1)

Query OK, 1 row affected, 1 warning (0.01 sec)

Warning (Code 1264): Out of range value for column 'due' at row 1
INSERT INTO orders VALUES ('A3', 1, '2026-11-02', 12345.678)

Query OK, 1 row affected, 1 warning (0.02 sec)

Warning (Code 1264): Out of range value for column 'price' at row 1
SELECT * FROM orders

| code  | qty  | due        | price  |
| ABCDE |    1 | 2026-11-02 |   1.00 |
| A1    |    0 | 2026-11-02 |   1.00 |
| A2    |    1 | 0000-00-00 |   1.00 |
| A3    |    1 | 2026-11-02 | 999.99 |
4 rows in set (0.00 sec)
INSERT IGNORE INTO orders VALUES ('ABCDEFGH', 1, '2026-02-30', 12345.678)

Query OK, 1 row affected, 3 warnings (0.02 sec)

Warning (Code 1265): Data truncated for column 'code' at row 1
Warning (Code 1264): Out of range value for column 'due' at row 1
Warning (Code 1264): Out of range value for column 'price' at row 1
SELECT * FROM orders

| code  | qty  | due        | price  |
| ABCDE |    1 | 0000-00-00 | 999.99 |
1 row in set (0.00 sec)
INSERT INTO tmp_pgm_orders VALUES ('ABCDEFGH', 1, '2026-11-02', 1);
ERROR:  value too long for type character varying(5)
INSERT INTO tmp_pgm_orders VALUES ('A1', 'abc', '2026-11-02', 1);
ERROR:  invalid input syntax for type integer: "abc"
INSERT INTO tmp_pgm_orders VALUES ('A2', 1, '2026-02-30', 1);
ERROR:  date/time field value out of range: "2026-02-30"
INSERT INTO tmp_pgm_orders VALUES ('A3', 1, '2026-11-02', 12345.678);
ERROR:  numeric field overflow
DETAIL:  A field with precision 5, scale 2 must round to an absolute value less than 10^3.
SELECT count(*) AS so_dong_vao_bang FROM tmp_pgm_orders;
 so_dong_vao_bang
------------------
                0
(1 row)
Giá trị gửi vàoMySQL strict (mặc định)MySQL sql_mode=''MySQL strict + INSERT IGNOREPostgreSQL
'ABCDEFGH' vào varchar(5)ERROR 1406ABCDE + warningABCDEERROR
'abc' vào intERROR 13660 + warning—ERROR
'2026-02-30' vào dateERROR 12920000-00-000000-00-00ERROR
12345.678 vào decimal(5,2)ERROR 1264999.99999.99ERROR

Cách nói khi phỏng vấn: "Em gửi bốn giá trị sai: chuỗi quá dài, chữ vào cột số, ngày ba mươi tháng hai, số tiền vượt độ chính xác. MySQL ở strict mode mặc định từ chối cả bốn, giống PostgreSQL. Nhưng chỉ cần một phiên đặt sql_mode rỗng, hoặc dùng INSERT IGNORE, thì cả bốn đều vào bảng sau khi bị cắt: mã đơn bị cụt, số lượng thành không, ngày thành không-không-không-không, số tiền thành chín trăm chín mươi chín. PostgreSQL không có công tắc đó nên không có loại lỗi này."

5.3. Lab: CHECK constraint — cả hai đều chặn

INSERT INTO grades VALUES (1, 11)

ERROR 3819 (HY000) at line 3: Check constraint 'chk_score' is violated.
INSERT INTO tmp_pgm_grades VALUES (1, 11);
ERROR:  new row for relation "tmp_pgm_grades" violates check constraint "chk_score"
DETAIL:  Failing row contains (1, 11.00).

MySQL chỉ thực thi CHECK từ 8.0.16 (bản cũ hơn parse rồi bỏ qua). DB nâng cấp từ 5.7 có thể còn CHECK "viết cho vui" từ thời trước — nên rà lại. Bẫy CHECK ... NOT ENFORCED đã có ở bài Migration MySQL.

Cách nói khi phỏng vấn: "Điểm mười một trên thang mười bị chặn ở cả hai database. Chỉ lưu ý MySQL từ 8.0.16 mới thật sự kiểm CHECK, trước đó nó nhận cú pháp rồi bỏ qua, nên hệ thống nâng cấp từ 5.7 có thể đang có những ràng buộc tưởng có mà không có."

5.4. Lab: soft delete + email duy nhất — partial unique index vs workaround của MySQL

Laravel SoftDeletes đánh dấu deleted_at thay vì xoá. Luật nghiệp vụ: "email phải duy nhất trong số user còn sống; user đã xoá đăng ký lại được".

CREATE UNIQUE INDEX tmp_pgm_users_email_live ON tmp_pgm_users (email) WHERE deleted_at IS NULL;
CREATE INDEX
INSERT INTO tmp_pgm_users (email) VALUES ('an@lms.vn');
UPDATE tmp_pgm_users SET deleted_at = now() WHERE email = 'an@lms.vn';
INSERT INTO tmp_pgm_users (email) VALUES ('an@lms.vn');
INSERT INTO tmp_pgm_users (email) VALUES ('an@lms.vn');
ERROR:  duplicate key value violates unique constraint "tmp_pgm_users_email_live"
DETAIL:  Key (email)=(an@lms.vn) already exists.
SELECT id, email, deleted_at IS NOT NULL AS da_xoa FROM tmp_pgm_users ORDER BY id;
 id |   email   | da_xoa
----+-----------+--------
  1 | an@lms.vn | t
  2 | an@lms.vn | f
(2 rows)
CREATE UNIQUE INDEX x ON grades (id) WHERE id > 0

ERROR 1064 (42000) at line 2: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'WHERE id > 0' at line 1
CREATE TABLE users (id int AUTO_INCREMENT PRIMARY KEY, email varchar(100) NOT NULL, deleted_at timestamp NULL,
  UNIQUE KEY uq_email_deleted (email, deleted_at))

INSERT INTO users (email) VALUES ('an@lms.vn')

INSERT INTO users (email) VALUES ('an@lms.vn')

SELECT id, email, deleted_at FROM users

| id | email     | deleted_at |
|  1 | an@lms.vn | NULL       |
|  2 | an@lms.vn | NULL       |
2 rows in set (0.00 sec)
  email_live varchar(100) AS (IF(deleted_at IS NULL, email, NULL)) VIRTUAL,
  UNIQUE KEY uq_email_live (email_live))

INSERT INTO users (email) VALUES ('an@lms.vn')

UPDATE users SET deleted_at = NOW() WHERE email = 'an@lms.vn'

INSERT INTO users (email) VALUES ('an@lms.vn')

INSERT INTO users (email) VALUES ('an@lms.vn')

ERROR 1062 (23000) at line 8: Duplicate entry 'an@lms.vn' for key 'users.uq_email_live'
SELECT id, email, deleted_at IS NOT NULL AS da_xoa, email_live FROM users

| id | email     | da_xoa | email_live |
|  1 | an@lms.vn |      1 | NULL       |
|  2 | an@lms.vn |      0 | an@lms.vn  |
2 rows in set (0.00 sec)
  UNIQUE KEY uq_email_live ((IF(deleted_at IS NULL, email, NULL))))

INSERT INTO users (email) VALUES ('an@lms.vn')

INSERT INTO users (email) VALUES ('an@lms.vn')

ERROR 1062 (23000) at line 5: Duplicate entry 'an@lms.vn' for key 'users.uq_email_live'

Cách nói khi phỏng vấn: "Với soft delete, luật là email duy nhất trong số tài khoản còn hoạt động. PostgreSQL viết thẳng bằng unique index có điều kiện deleted_at là null. MySQL không có index có điều kiện; cách nhiều người làm là unique trên cặp email và deleted_at, nhưng em chạy thử thì hai tài khoản sống cùng email vẫn lọt vì null không bằng null. Cách đúng trên MySQL là cột sinh hoặc index theo biểu thức, trả về email khi còn sống và null khi đã xoá."

flowchart TD
  R["Luật: email duy nhất trong user còn sống"] --> P["PostgreSQL"]
  R --> M["MySQL"]
  P --> P1["CREATE UNIQUE INDEX ... (email)<br/>WHERE deleted_at IS NULL"]
  M --> M1["UNIQUE(email, deleted_at)"]
  M1 --> M2["NULL ≠ NULL → 2 user sống cùng email LỌT"]
  M --> M3["Cột sinh IF(deleted_at IS NULL, email, NULL)<br/>+ UNIQUE, hoặc functional index"]
  M3 --> OK2["Đúng luật"]
  P1 --> OK1["Đúng luật"]

5.5. Lab: NULL trong UNIQUE, exclusion constraint

CREATE TABLE tmp_pgm_n (a int UNIQUE, b int UNIQUE NULLS NOT DISTINCT);
CREATE TABLE
INSERT INTO tmp_pgm_n VALUES (NULL, 1), (NULL, 2);
INSERT 0 2
INSERT INTO tmp_pgm_n VALUES (1, NULL), (2, NULL);
ERROR:  duplicate key value violates unique constraint "tmp_pgm_n_b_key"
DETAIL:  Key (b)=(null) already exists.
SELECT * FROM tmp_pgm_n;
 a | b
---+---
   | 1
   | 2
(2 rows)
CREATE TABLE tmp_pgm_room_booking (room int, during tstzrange,
  EXCLUDE USING gist (room WITH =, during WITH &&));
CREATE TABLE
INSERT INTO tmp_pgm_room_booking VALUES (101, '[2026-11-02 08:00+07, 2026-11-02 10:00+07)');
INSERT 0 1
INSERT INTO tmp_pgm_room_booking VALUES (101, '[2026-11-02 09:30+07, 2026-11-02 11:00+07)');
ERROR:  conflicting key value violates exclusion constraint "tmp_pgm_room_booking_room_during_excl"
DETAIL:  Key (room, during)=(101, ["2026-11-02 02:30:00+00","2026-11-02 04:00:00+00")) conflicts with existing key (room, during)=(101, ["2026-11-02 01:00:00+00","2026-11-02 03:00:00+00")).
INSERT INTO tmp_pgm_room_booking VALUES (101, '[2026-11-02 10:00+07, 2026-11-02 11:00+07)');
INSERT 0 1
INSERT INTO tmp_pgm_room_booking VALUES (102, '[2026-11-02 09:30+07, 2026-11-02 11:00+07)');
INSERT 0 1
SELECT * FROM tmp_pgm_room_booking ORDER BY room, during;
 room |                       during
------+-----------------------------------------------------
  101 | ["2026-11-02 01:00:00+00","2026-11-02 03:00:00+00")
  101 | ["2026-11-02 03:00:00+00","2026-11-02 04:00:00+00")
  102 | ["2026-11-02 02:30:00+00","2026-11-02 04:00:00+00")
(3 rows)

Exclusion constraint giữ luật "một phòng không có hai lịch chồng giờ" ở tầng DB, an toàn khi đồng thời (không cần SERIALIZABLE, không cần khoá tay). Lịch 10:00–11:00 nối tiếp 08:00–10:00 hợp lệ vì range nửa mở [ ). MySQL không có tương đương — phải khoá dòng phòng (SELECT ... FROM rooms WHERE id = ? FOR UPDATE) rồi kiểm tra chồng giờ trong cùng transaction. PG 18 còn thêm temporal constraint (WITHOUT OVERLAPS cho PRIMARY KEY/UNIQUE — release notes PG 18; chưa verify trong lab).

Cách nói khi phỏng vấn: "Lịch phòng thi tám đến mười giờ rồi, ai đặt chín rưỡi đến mười một sẽ bị database từ chối vì chồng giờ, còn mười giờ đến mười một thì hợp lệ. Luật này nằm trong một exclusion constraint của PostgreSQL nên đúng cả khi hàng trăm người đặt cùng lúc. Trên MySQL em phải khoá dòng phòng rồi tự kiểm tra trong transaction."

5.6. Lab: deferrable FK vs FOREIGN_KEY_CHECKS=0; index cho cột FK

BEGIN;
BEGIN
INSERT INTO tmp_pgm_lesson VALUES (1, 7);
INSERT 0 1
INSERT INTO tmp_pgm_course VALUES (7);
INSERT 0 1
COMMIT;
COMMIT
BEGIN;
BEGIN
INSERT INTO tmp_pgm_lesson VALUES (2, 999);
INSERT 0 1
COMMIT;
ERROR:  insert or update on table "tmp_pgm_lesson" violates foreign key constraint "tmp_pgm_lesson_course_id_fkey"
DETAIL:  Key (course_id)=(999) is not present in table "tmp_pgm_course".
SELECT * FROM tmp_pgm_lesson;
 id | course_id
----+-----------
  1 |         7
(1 row)
CREATE TABLE lesson (id int PRIMARY KEY, course_id int, FOREIGN KEY (course_id) REFERENCES course(id) DEFERRABLE INITIALLY DEFERRED)

ERROR 1064 (42000) at line 2: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'DEFERRABLE INITIALLY DEFERRED)' at line 1
CREATE TABLE lesson (id int PRIMARY KEY, course_id int, FOREIGN KEY (course_id) REFERENCES course(id))

SET FOREIGN_KEY_CHECKS = 0

INSERT INTO lesson VALUES (2, 999)

SET FOREIGN_KEY_CHECKS = 1

SELECT l.id, l.course_id, c.id AS course_ton_tai FROM lesson l LEFT JOIN course c ON c.id = l.course_id

| id | course_id | course_ton_tai |
|  2 |       999 |           NULL |
1 row in set (0.00 sec)

INSERT INTO lesson VALUES (3, 999)

ERROR 1452 (23000) at line 8: Cannot add or update a child row: a foreign key constraint fails (`lab`.`lesson`, CONSTRAINT `lesson_ibfk_1` FOREIGN KEY (`course_id`) REFERENCES `course` (`id`))
SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'lab' AND TABLE_NAME = 'lesson'

| INDEX_NAME | COLUMN_NAME |
| course_id  | course_id   |
| PRIMARY    | id          |
2 rows in set (0.02 sec)

SELECT indexrelid::regclass AS index_name, pg_get_indexdef(indexrelid) FROM pg_index WHERE indrelid = 'tmp_pgm_lesson'::regclass;
     index_name      |                                  pg_get_indexdef
---------------------+-----------------------------------------------------------------------------------
 tmp_pgm_lesson_pkey | CREATE UNIQUE INDEX tmp_pgm_lesson_pkey ON public.tmp_pgm_lesson USING btree (id)
(1 row)

Cách nói khi phỏng vấn: "PostgreSQL cho khoá ngoại kiểm tra lúc commit, nên trong một transaction em chèn con trước cha vẫn được, còn nếu cha không bao giờ có thì commit bị từ chối. MySQL không có cơ chế này, người ta hay tắt kiểm tra khoá ngoại, và em chạy thử thì bật lại nó không quét dữ liệu cũ, dòng mồ côi nằm đó vĩnh viễn. Ngược lại MySQL tự tạo index cho cột khoá ngoại, còn PostgreSQL bắt mình tự nhớ."

5.7. Trade-off và ảnh hưởng nghiệp vụ


6. Collation và charset: "Hòa" có bằng "hoa" không?

6.1. Cơ chế

6.2. Lab: so sánh chuỗi tiếng Việt

SELECT 'a' = 'A' AS hoa_thuong, 'a' = 'á' AS dau, 'Hoa' = 'Hòa' AS hoa_vs_hoa_dau, 'Nguyễn' = 'nguyen' AS nguyen,
       'd' = 'đ' AS d_vs_dd, 'a' = 'a ' AS khoang_trang_cuoi

| hoa_thuong | dau | hoa_vs_hoa_dau | nguyen | d_vs_dd | khoang_trang_cuoi |
|          1 |   1 |              1 |      1 |       1 |                 0 |

SELECT 'Hoa' = 'Hòa' COLLATE utf8mb4_vi_0900_ai_ci AS vi_ai_ci, 'Hoa' = 'Hòa' COLLATE utf8mb4_vi_0900_as_cs AS vi_as_cs,
       'Hoa' = 'hoa' COLLATE utf8mb4_0900_as_ci AS as_ci, 'Hoa' = 'hoa' COLLATE utf8mb4_bin AS bin

| vi_ai_ci | vi_as_cs | as_ci | bin  |
|        1 |        0 |     1 |    0 |

SELECT 'a' = 'a ' COLLATE utf8mb4_unicode_ci AS unicode_ci_pad_space, 'a' = 'a ' COLLATE utf8mb4_general_ci AS general_ci_pad_space,
       'Hoa' = 'Hòa' COLLATE utf8mb4_unicode_ci AS unicode_ci_dau, 'HOA' = 'hoa' COLLATE utf8mb4_unicode_ci AS unicode_ci_hoa_thuong

| unicode_ci_pad_space | general_ci_pad_space | unicode_ci_dau | unicode_ci_hoa_thuong |
|                    1 |                    1 |              1 |                     1 |

SELECT COLLATION_NAME, PAD_ATTRIBUTE FROM information_schema.COLLATIONS
 WHERE COLLATION_NAME IN ('utf8mb4_0900_ai_ci','utf8mb4_unicode_ci','utf8mb4_general_ci','utf8mb4_vi_0900_ai_ci')

| COLLATION_NAME        | PAD_ATTRIBUTE |
| utf8mb4_0900_ai_ci    | NO PAD        |
| utf8mb4_general_ci    | PAD SPACE     |
| utf8mb4_unicode_ci    | PAD SPACE     |
| utf8mb4_vi_0900_ai_ci | NO PAD        |
SELECT 'a' = 'A' AS hoa_thuong, 'a' = 'á' AS dau, 'Hoa' = 'Hòa' AS hoa_vs_hoa_dau, 'a' = 'a ' AS khoang_trang_cuoi;
 hoa_thuong | dau | hoa_vs_hoa_dau | khoang_trang_cuoi
------------+-----+----------------+-------------------
 f          | f   | f              | f
(1 row)

Với utf8mb4_0900_ai_ci: 'a' = 'A' = 'á', 'Hoa' = 'Hòa', 'Nguyễn' = 'nguyen', và cả 'd' = 'đ'. Collation riêng cho tiếng Việt utf8mb4_vi_0900_ai_ci vẫn coi 'Hoa' = 'Hòa' (ai = bỏ dấu); muốn phân biệt dấu phải dùng _as_ (accent sensitive) hoặc _bin. Collation Moodle tạo DB mặc định — utf8mb4_unicode_ci — cũng coi 'Hoa' = 'Hòa' và 'a' = 'a '.

Cách nói khi phỏng vấn: "Với collation mặc định của MySQL 8, chữ hoa bằng chữ thường, có dấu bằng không dấu, Nguyễn bằng nguyen, thậm chí đ bằng d. Collation tiếng Việt riêng vẫn bỏ qua dấu, phải chọn loại phân biệt dấu hoặc so theo nhị phân. PostgreSQL mặc định thì ngược lại, mọi khác biệt đều tính, kể cả khoảng trắng cuối."

6.3. Lab: UNIQUE username — hai người khác nhau bị coi là trùng

INSERT INTO accounts (username, full_name) VALUES ('hoa', 'Lê Thị Hoa')

INSERT INTO accounts (username, full_name) VALUES ('Hòa', 'Trần Văn Hòa')

ERROR 1062 (23000) at line 4: Duplicate entry 'Hòa' for key 'accounts.username'
INSERT INTO accounts (username, full_name) VALUES ('HOA', 'Nguyễn Hoa')

ERROR 1062 (23000) at line 5: Duplicate entry 'HOA' for key 'accounts.username'
INSERT INTO accounts (username, full_name) VALUES ('nguyenvana', 'Nguyễn Văn A'), ('ngoc', 'Phạm Ngọc')

SELECT username, full_name FROM accounts WHERE username = 'HÒA'

| username | full_name     |
| hoa      | Lê Thị Hoa    |

SELECT username, full_name FROM accounts WHERE full_name LIKE 'nguyen%'

| username   | full_name       |
| nguyenvana | Nguyễn Văn A    |

SELECT full_name FROM accounts GROUP BY full_name

| full_name       |
| Lê Thị Hoa      |
| Nguyễn Văn A    |
| Phạm Ngọc       |
INSERT INTO tmp_pgm_accounts (username) VALUES ('hoa'), ('Hoa'), ('HOA');
INSERT 0 3
SELECT username FROM tmp_pgm_accounts WHERE username = 'HOA';
 username
----------
 HOA
(1 row)
-- cách 1: unique index trên lower()
CREATE UNIQUE INDEX tmp_pgm_accounts_lower ON tmp_pgm_accounts (lower(username));
CREATE INDEX
INSERT INTO tmp_pgm_accounts (username) VALUES ('hoa');
INSERT 0 1
INSERT INTO tmp_pgm_accounts (username) VALUES ('HOA');
ERROR:  duplicate key value violates unique constraint "tmp_pgm_accounts_lower"
DETAIL:  Key (lower(username))=(hoa) already exists.
INSERT INTO tmp_pgm_accounts (username) VALUES ('Hòa');
INSERT 0 1
-- cách 2: ICU nondeterministic collation (ks-level2 = bỏ qua hoa/thường, GIỮ dấu; ks-level1 = bỏ cả dấu)
CREATE COLLATION tmp_pgm_ci    (provider = icu, locale = 'und-u-ks-level2', deterministic = false);
CREATE COLLATION
CREATE COLLATION tmp_pgm_ci_ai (provider = icu, locale = 'und-u-ks-level1', deterministic = false);
CREATE COLLATION
SELECT 'Hoa' = 'HOA' COLLATE tmp_pgm_ci AS ci_hoa_thuong, 'Hoa' = 'Hòa' COLLATE tmp_pgm_ci AS ci_dau,
       'Hoa' = 'Hòa' COLLATE tmp_pgm_ci_ai AS ci_ai_dau, 'd' = 'đ' COLLATE tmp_pgm_ci_ai AS ci_ai_d_dd;
 ci_hoa_thuong | ci_dau | ci_ai_dau | ci_ai_d_dd
---------------+--------+-----------+------------
 t             | f      | t         | t
(1 row)

CREATE TABLE tmp_pgm_accounts (id serial PRIMARY KEY, username text COLLATE tmp_pgm_ci NOT NULL UNIQUE, full_name text COLLATE tmp_pgm_ci_ai);
CREATE TABLE
INSERT INTO tmp_pgm_accounts (username, full_name) VALUES ('hoa', 'Lê Thị Hoa');
INSERT 0 1
INSERT INTO tmp_pgm_accounts (username, full_name) VALUES ('HOA', 'Nguyễn Hoa');
ERROR:  duplicate key value violates unique constraint "tmp_pgm_accounts_username_key"
DETAIL:  Key (username)=(HOA) already exists.
INSERT INTO tmp_pgm_accounts (username, full_name) VALUES ('Hòa', 'Trần Văn Hòa'), ('nguyenvana', 'Nguyễn Văn A');
INSERT 0 2
-- PG 18: LIKE chạy được trên nondeterministic collation
SELECT username, full_name FROM tmp_pgm_accounts WHERE full_name LIKE 'nguyen%';
  username  |  full_name
------------+--------------
 nguyenvana | Nguyễn Văn A
(1 row)

PG cho chọn chính xác luật mình muốn: tmp_pgm_ci (ICU level 2) bỏ qua hoa/thường nhưng giữ dấu → HOA trùng hoa, còn Hòa là người khác; cột full_name dùng level 1 (bỏ cả dấu) cho tìm kiếm, và LIKE 'nguyen%' chạy được trên PG 18.

Cách nói khi phỏng vấn: "Trên MySQL mặc định, anh Hòa không đăng ký được tên đăng nhập vì chị Hoa đã có, unique coi hai cái là một. Trên PostgreSQL mặc định thì ngược lại, hoa viết thường và viết hoa thành ba tài khoản. Em tách hai nhu cầu: định danh như username, email thì không phân biệt hoa thường nhưng giữ dấu; còn ô tìm kiếm tên thì bỏ cả dấu. PostgreSQL làm được bằng collation ICU cho từng cột; MySQL thì chọn collation từng cột, kiểu phân biệt dấu cho định danh."

6.4. Lab: bẫy charset của kết nối — mojibake khi client nói latin1

-- client không chỉ định charset (mặc định của client trong image mysql:8.4.13):
| @@character_set_client | @@character_set_connection | @@character_set_results |
| latin1                 | latin1                     | latin1                  |
| name | HEX(name)    | CHAR_LENGTH(name) |
| Hòa | 48C383C2B261 |                 4 |
-- đọc lại bằng connection utf8mb4 (như app PHP/Node cấu hình đúng):
SELECT name, HEX(name), CHAR_LENGTH(name), name = 'Hòa' AS bang_hoa FROM charset_trap

| name   | HEX(name)    | CHAR_LENGTH(name) | bang_hoa |
| Hòa   | 48C383C2B261 |                 4 |        0 |

Bảng là utf8mb4 nhưng client mysql trong image mặc định nói latin1: chữ "Hòa" (UTF-8 2 byte cho "ò") bị server hiểu là 2 ký tự latin1 rồi mã hoá lại sang utf8mb4 → lưu 48C383C2B261 (4 ký tự, double encoding). Client latin1 đọc lại thấy "Hòa" (mã hoá ngược khớp nhau, nên lỗi vô hình), app utf8mb4 đọc ra Hòa và name = 'Hòa' là 0. Chính lab này phải ép --default-character-set=utf8mb4 mới chạy đúng.

Cách nói khi phỏng vấn: "Bảng đã là utf8mb4 nhưng nếu kết nối nói latin1 thì chữ có dấu bị mã hoá hai lần. Đọc lại bằng chính client đó vẫn thấy đúng nên không ai phát hiện, tới khi ứng dụng cấu hình đúng đọc ra chữ vỡ, và tìm kiếm không khớp. Khi nâng cấp hệ thống cũ lên utf8mb4, em kiểm tra charset của từng client và kiểm tra dữ liệu bằng HEX chứ không tin vào mắt."

flowchart TD
  N["Nhu cầu so sánh chuỗi"] --> I["Định danh: username, email, mã SV<br/>→ không phân biệt hoa/thường, GIỮ dấu"]
  I --> I2["PG: unique lower() / ICU ks-level2<br/>MySQL: collation _as_ci cho cột đó"]
  N --> S["Tìm kiếm: họ tên, tên khoá học<br/>→ bỏ qua hoa/thường + dấu"]
  S --> S2["PG: ICU ks-level1 / unaccent<br/>MySQL: 0900_ai_ci mặc định đã vậy"]
  N --> C["Kết nối: charset = utf8mb4<br/>ở MỌI client (kiểm bằng HEX)"]

6.5. Trade-off và ảnh hưởng nghiệp vụ


7. DDL: transactional DDL vs implicit commit

7.1. Cơ chế

7.2. Lab: DDL commit ngầm luôn cả DML đang dở

START TRANSACTION

INSERT INTO audit_log VALUES (1)

CREATE TABLE side_table (id int)

INSERT INTO audit_log VALUES (2)

ROLLBACK

SELECT * FROM audit_log

| id |
|  1 |
|  2 |

SHOW TABLES

| Tables_in_lab |
| audit_log     |
| side_table    |
BEGIN;
BEGIN
INSERT INTO tmp_pgm_audit_log VALUES (1);
INSERT 0 1
CREATE TABLE tmp_pgm_side_table (id int);
CREATE TABLE
INSERT INTO tmp_pgm_audit_log VALUES (2);
INSERT 0 1
ROLLBACK;
ROLLBACK
SELECT * FROM tmp_pgm_audit_log;
 id
----
(0 rows)

SELECT to_regclass('tmp_pgm_side_table') AS side_table_con_khong;
 side_table_con_khong
----------------------

(1 row)

MySQL: ROLLBACK không huỷ được gì — CREATE TABLE đã commit ngầm dòng 1, sau đó dòng 2 chạy ở autocommit; bảng side_table còn. PG: không còn dòng nào, không còn bảng.

Cách nói khi phỏng vấn: "Trong một transaction em chèn một dòng, tạo một bảng, chèn dòng nữa, rồi rollback. MySQL giữ nguyên cả hai dòng và cái bảng, vì lệnh tạo bảng đã tự commit giữa chừng và phần sau chạy ở chế độ tự commit. PostgreSQL rollback sạch cả ba. Nghĩa là trên MySQL không có khái niệm migration nguyên tử gồm nhiều bước."

7.3. Lab: migration 3 bước hỏng ở bước 3

START TRANSACTION

CREATE TABLE enrolments (id int PRIMARY KEY, course_id int)

ALTER TABLE enrolments ADD COLUMN status varchar(10) NOT NULL DEFAULT 'active'

ALTER TABLE enrolments ADD CONSTRAINT fk_course FOREIGN KEY (course_id) REFERENCES coursez(id)

ERROR 1824 (HY000) at line 5: Failed to open the referenced table 'coursez'
ROLLBACK

SHOW CREATE TABLE enrolments

*************************** 1. row ***************************
       Table: enrolments
Create Table: CREATE TABLE `enrolments` (
  `id` int NOT NULL,
  `course_id` int DEFAULT NULL,
  `status` varchar(10) NOT NULL DEFAULT 'active',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)

CREATE TABLE enrolments (id int PRIMARY KEY, course_id int)

ERROR 1050 (42S01) at line 8: Table 'enrolments' already exists
BEGIN;
BEGIN
CREATE TABLE tmp_pgm_enrolments (id int PRIMARY KEY, course_id int);
CREATE TABLE
ALTER TABLE tmp_pgm_enrolments ADD COLUMN status varchar(10) NOT NULL DEFAULT 'active';
ALTER TABLE
ALTER TABLE tmp_pgm_enrolments ADD CONSTRAINT fk_course FOREIGN KEY (course_id) REFERENCES tmp_pgm_coursez(id);
ERROR:  relation "tmp_pgm_coursez" does not exist
ROLLBACK;
ROLLBACK
SELECT to_regclass('tmp_pgm_enrolments') AS enrolments_con_khong;
 enrolments_con_khong
----------------------

(1 row)

BEGIN;
BEGIN
CREATE TABLE tmp_pgm_enrolments (id int PRIMARY KEY, course_id int);
CREATE TABLE
ALTER TABLE tmp_pgm_enrolments ADD COLUMN status varchar(10) NOT NULL DEFAULT 'active';
ALTER TABLE
ALTER TABLE tmp_pgm_enrolments ADD CONSTRAINT fk_course FOREIGN KEY (course_id) REFERENCES tmp_pgm_courses(id);
ALTER TABLE
COMMIT;
COMMIT
SELECT to_regclass('tmp_pgm_enrolments') AS enrolments_sau_khi_chay_lai;
 enrolments_sau_khi_chay_lai
-----------------------------
 tmp_pgm_enrolments
(1 row)
DROP TABLE courses, no_such_table

ERROR 1051 (42S02) at line 1: Unknown table 'lab.no_such_table'
SHOW TABLES

| Tables_in_lab |
| courses       |
| enrolments    |
BEGIN;
BEGIN
CREATE INDEX CONCURRENTLY tmp_pgm_idx ON tmp_pgm_enrolments (course_id);
ERROR:  CREATE INDEX CONCURRENTLY cannot run inside a transaction block
ROLLBACK;
ROLLBACK
CREATE TYPE tmp_pgm_status AS ENUM ('active', 'dropped');
CREATE TYPE
BEGIN;
BEGIN
ALTER TYPE tmp_pgm_status ADD VALUE 'paused';
ALTER TYPE
SELECT 'paused'::tmp_pgm_status;
ERROR:  unsafe use of new value "paused" of enum type tmp_pgm_status
HINT:  New enum values must be committed before they can be used.
ROLLBACK;
ROLLBACK
-- DDL trong transaction giữ ACCESS EXCLUSIVE tới lúc COMMIT: transaction migration càng dài, bảng càng bị chặn lâu
BEGIN;
BEGIN
ALTER TABLE tmp_pgm_enrolments ADD COLUMN note text;
ALTER TABLE
SELECT relation::regclass, mode, granted FROM pg_locks WHERE relation = 'tmp_pgm_enrolments'::regclass;
      relation      |        mode         | granted
--------------------+---------------------+---------
 tmp_pgm_enrolments | AccessExclusiveLock | t
(1 row)

ROLLBACK;
ROLLBACK

Giới hạn của PG: CREATE INDEX CONCURRENTLY không chạy trong transaction; giá trị enum mới chỉ dùng được sau commit; và DDL trong transaction giữ AccessExclusiveLock tới lúc commit — migration càng dài thì bảng càng bị chặn lâu (phần lock queue ở bài Migration PostgreSQL).

Cách nói khi phỏng vấn: "Em giả lập một migration ba bước, bước ba tham chiếu sai bảng. MySQL để lại một bảng dở có cột mới, chạy lại migration thì báo bảng đã tồn tại, phải sửa tay trên production. PostgreSQL rollback sạch, sửa xong chạy lại. Đổi lại, ở PostgreSQL migration bọc trong transaction giữ khoá độc quyền tới lúc commit, nên em vẫn chia nhỏ migration và đặt lock timeout, còn tạo index concurrently thì phải chạy ngoài transaction."

flowchart TD
  M0["php artisan migrate: 1 migration = 3 câu DDL"] --> G{"Grammar hỗ trợ schema transaction?"}
  G -- "PostgresGrammar: transactions = true" --> T1["Laravel bọc trong DB::transaction"]
  T1 --> T2["Câu 3 lỗi → ROLLBACK cả 3"]
  T2 --> T3["Sửa, chạy lại: sạch"]
  G -- "MySqlGrammar: transactions = false" --> N1["Chạy từng câu, mỗi câu tự commit"]
  N1 --> N2["Câu 3 lỗi → câu 1, 2 đã nằm trên production"]
  N2 --> N3["Không ghi vào bảng migrations<br/>→ chạy lại vấp 'already exists'"]
  N3 --> N4["Sửa tay / viết migration idempotent"]

7.4. Trade-off và ảnh hưởng nghiệp vụ


8. Upsert và RETURNING: ON CONFLICT vs ON DUPLICATE KEY UPDATE

8.1. Cơ chế

8.2. Lab: lưu tiến độ học (user, lesson) — cùng thao tác ở hai bên

INSERT INTO progress (user_id, lesson_id, pct) VALUES (1, 10, 20) AS new ON DUPLICATE KEY UPDATE pct = new.pct
Query OK, 1 row affected (0.01 sec)
SELECT ROW_COUNT() AS affected_insert_moi, LAST_INSERT_ID()
| affected_insert_moi | LAST_INSERT_ID() |
|                   1 |                1 |
INSERT INTO progress (user_id, lesson_id, pct) VALUES (1, 10, 50) AS new ON DUPLICATE KEY UPDATE pct = new.pct
Query OK, 2 rows affected (0.01 sec)
SELECT ROW_COUNT() AS affected_update
| affected_update |
|               2 |
INSERT INTO progress (user_id, lesson_id, pct) VALUES (1, 10, 50) AS new ON DUPLICATE KEY UPDATE pct = new.pct
SELECT ROW_COUNT() AS affected_khong_doi
| affected_khong_doi |
|                  0 |
INSERT INTO progress (user_id, lesson_id, pct) VALUES (2, 10, 5)
Query OK, 1 row affected (0.00 sec)
SELECT id, user_id, lesson_id, pct FROM progress
| id | user_id | lesson_id | pct  |
|  1 |       1 |        10 |   50 |
|  4 |       2 |        10 |    5 |
2 rows in set (0.00 sec)
SELECT AUTO_INCREMENT AS auto_increment_tiep_theo FROM information_schema.TABLES WHERE TABLE_SCHEMA = 'lab' AND TABLE_NAME = 'progress'
| auto_increment_tiep_theo |
|                        5 |
-- muốn biết id của dòng bị update: mẹo LAST_INSERT_ID(id)
INSERT INTO progress (user_id, lesson_id, pct) VALUES (1, 10, 60) AS new ON DUPLICATE KEY UPDATE pct = new.pct, id = LAST_INSERT_ID(id)
Query OK, 2 rows affected (0.00 sec)
SELECT LAST_INSERT_ID() AS id_dong_bi_update
| id_dong_bi_update |
|                 1 |
-- cùng key xuất hiện 2 lần trong 1 câu: MySQL xử lý tuần tự, giá trị sau thắng
INSERT INTO progress (user_id, lesson_id, pct) VALUES (3, 10, 30), (3, 10, 40) AS new ON DUPLICATE KEY UPDATE pct = new.pct
Query OK, 3 rows affected (0.01 sec)
Records: 2  Duplicates: 1  Warnings: 0
SELECT user_id, pct FROM progress WHERE user_id = 3
| user_id | pct  |
|       3 |   40 |
INSERT INTO tmp_pgm_progress (user_id, lesson_id, pct) VALUES (1, 10, 20)
  ON CONFLICT (user_id, lesson_id) DO UPDATE SET pct = EXCLUDED.pct
  RETURNING id, old.pct AS pct_cu, new.pct AS pct_moi, (old.id IS NULL) AS la_insert;
 id | pct_cu | pct_moi | la_insert
----+--------+---------+-----------
  1 |        |      20 | t
(1 row)
INSERT 0 1
INSERT INTO tmp_pgm_progress (user_id, lesson_id, pct) VALUES (1, 10, 50)
  ON CONFLICT (user_id, lesson_id) DO UPDATE SET pct = EXCLUDED.pct
  RETURNING id, old.pct AS pct_cu, new.pct AS pct_moi, (old.id IS NULL) AS la_insert;
 id | pct_cu | pct_moi | la_insert
----+--------+---------+-----------
  1 |     20 |      50 | f
(1 row)
INSERT 0 1
-- tiến độ chỉ được tăng: điều kiện WHERE trong DO UPDATE (gửi 30 < 50 -> không làm gì, INSERT 0 0)
INSERT INTO tmp_pgm_progress (user_id, lesson_id, pct) VALUES (1, 10, 30)
  ON CONFLICT (user_id, lesson_id) DO UPDATE SET pct = EXCLUDED.pct WHERE tmp_pgm_progress.pct < EXCLUDED.pct
  RETURNING id, new.pct;
 id | pct
----+-----
(0 rows)
INSERT 0 0
INSERT INTO tmp_pgm_progress (user_id, lesson_id, pct) VALUES (2, 10, 5) RETURNING id;
 id
----
  4
(1 row)
INSERT 0 1
SELECT pg_get_serial_sequence('tmp_pgm_progress', 'id') AS seq, (SELECT last_value FROM tmp_pgm_progress_id_seq) AS seq_last_value;
              seq               | seq_last_value
--------------------------------+----------------
 public.tmp_pgm_progress_id_seq |              4
(1 row)
-- cùng key 2 lần trong 1 câu: PG từ chối cả câu
INSERT INTO tmp_pgm_progress (user_id, lesson_id, pct) VALUES (3, 10, 30), (3, 10, 40)
  ON CONFLICT (user_id, lesson_id) DO UPDATE SET pct = EXCLUDED.pct;
ERROR:  ON CONFLICT DO UPDATE command cannot affect row a second time
HINT:  Ensure that no rows proposed for insertion within the same command have duplicate constrained values.
SELECT id, user_id, lesson_id, pct FROM tmp_pgm_progress ORDER BY id;
 id | user_id | lesson_id | pct
----+---------+-----------+-----
  1 |       1 |        10 |  50
  4 |       2 |        10 |   5
(2 rows)

Cách nói khi phỏng vấn: "Em lưu tiến độ học bằng upsert ở cả hai bên. MySQL báo một dòng là chèn mới, hai dòng là cập nhật, không dòng là giá trị không đổi, và mỗi lần trùng vẫn đốt một số tự tăng nên id nhảy cóc. PostgreSQL cũng đốt số của sequence, nhưng trả về được cả giá trị cũ và mới, và cho điều kiện kiểu chỉ cập nhật khi tiến độ mới lớn hơn. Nếu một lô dữ liệu có hai dòng trùng khoá, MySQL lấy dòng sau, PostgreSQL từ chối cả lô."

8.3. Lab: bảng có 2 UNIQUE — MySQL update nhầm dòng

INSERT INTO members VALUES (3, 'an@lms.vn', 'binh', 'moi') ON DUPLICATE KEY UPDATE note = 'bi upsert'
Query OK, 2 rows affected (0.00 sec)
SELECT ROW_COUNT() AS affected
| affected |
|        2 |
SELECT * FROM members
| id | email       | username | note      |
|  1 | an@lms.vn   | an       | bi upsert |
|  2 | binh@lms.vn | binh     |           |
-- app định "upsert theo email" cho chi@lms.vn (email mới) nhưng username 'binh' đã có -> MySQL update NHẦM member #2
INSERT INTO members VALUES (4, 'chi@lms.vn', 'binh', 'moi') ON DUPLICATE KEY UPDATE note = 'upsert chi'
Query OK, 2 rows affected (0.01 sec)
SELECT * FROM members
| id | email       | username | note       |
|  1 | an@lms.vn   | an       | bi upsert  |
|  2 | binh@lms.vn | binh     | upsert chi |
INSERT INTO tmp_pgm_members VALUES (3, 'an@lms.vn', 'binh', 'moi') ON CONFLICT (email) DO UPDATE SET note = 'bi upsert';
INSERT 0 1
INSERT INTO tmp_pgm_members VALUES (3, 'chi@lms.vn', 'binh', 'moi') ON CONFLICT (email) DO UPDATE SET note = 'bi upsert';
ERROR:  duplicate key value violates unique constraint "tmp_pgm_members_username_key"
DETAIL:  Key (username)=(binh) already exists.
SELECT * FROM tmp_pgm_members ORDER BY id;
 id |    email    | username |   note
----+-------------+----------+-----------
  1 | an@lms.vn   | an       | bi upsert
  2 | binh@lms.vn | binh     |
(2 rows)

App muốn "upsert theo email" cho chi@lms.vn — email chưa có, đúng ra phải tạo user mới. Nhưng username binh đã thuộc member #2 → MySQL coi là trùng và ghi đè ghi chú của #2, không lỗi, affected 2. PG với ON CONFLICT (email) chỉ xử lý trùng email; trùng username là ERROR → app biết ngay có vấn đề.

Cách nói khi phỏng vấn: "Bảng thành viên có hai cột duy nhất là email và username. Em upsert một email mới nhưng username trùng người khác: MySQL lặng lẽ cập nhật nhầm người kia, vì nó kích hoạt khi trùng bất kỳ khoá duy nhất nào. PostgreSQL bắt mình nói rõ trùng theo email, trùng chỗ khác thì báo lỗi. Nên trên MySQL em tránh upsert trên bảng có nhiều unique, đúng như tài liệu của MySQL khuyên."

8.4. Lab: REPLACE INTO = DELETE + INSERT, và không có RETURNING

REPLACE INTO enrol (user_id, course_id, status) VALUES (1, 100, 'paused')
Query OK, 2 rows affected (0.01 sec)
SELECT * FROM enrol
| id | user_id | course_id | status |
|  2 |       1 |       100 | paused |
SELECT COUNT(*) AS so_diem_con_lai FROM enrol_grades
| so_diem_con_lai |
|               0 |
INSERT INTO enrol (user_id, course_id, status) VALUES (2, 100, 'active') RETURNING id
ERROR 1064 (42000) at line 9: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'RETURNING id' at line 1

REPLACE xoá dòng cũ (id 1) rồi chèn dòng mới (id 2) → ON DELETE CASCADE xoá sạch 2 điểm của enrolment đó. Người viết tưởng "update trạng thái", thực tế "xoá điểm của sinh viên".

Cách nói khi phỏng vấn: "REPLACE của MySQL không phải cập nhật, nó xoá rồi chèn lại. Em chạy thử: đổi trạng thái một lượt ghi danh bằng REPLACE thì id đổi từ một sang hai và hai điểm số gắn với nó bị xoá theo khoá ngoại cascade. Em cấm REPLACE trên bảng có bảng con."

flowchart TD
  I["INSERT trùng khoá"] --> P["PostgreSQL ON CONFLICT (cột):<br/>chỉ đích danh 1 constraint"]
  P --> P2["Trùng constraint khác → ERROR<br/>DO UPDATE ... WHERE, RETURNING old/new"]
  I --> Q["MySQL ON DUPLICATE KEY UPDATE:<br/>trùng BẤT KỲ unique nào → update 1 dòng"]
  Q --> Q2["Nhiều unique → update nhầm dòng<br/>REPLACE = DELETE + INSERT (mất con)<br/>INSERT IGNORE nuốt cả lỗi dữ liệu"]

8.5. Ảnh hưởng nghiệp vụ


9. Kiểu dữ liệu và ép kiểu ngầm

9.1. Lab: phép tính, so sánh chuỗi với số

SELECT 7/2 AS chia, 7 DIV 2 AS chia_nguyen, 1/0 AS chia_0
| chia   | chia_nguyen | chia_0 |
| 3.5000 |           3 |   NULL |
1 row in set, 1 warning (0.00 sec)
Warning (Code 1365): Division by 0
SELECT 'a' || 'b' AS noi_chuoi
| noi_chuoi |
|         0 |
1 row in set, 3 warnings (0.00 sec)
Warning (Code 1287): '|| as a synonym for OR' is deprecated and will be removed in a future release. Please use OR instead
Warning (Code 1292): Truncated incorrect DOUBLE value: 'a'
Warning (Code 1292): Truncated incorrect DOUBLE value: 'b'
CREATE TABLE codes (code varchar(10), KEY k_code (code))
INSERT INTO codes VALUES ('abc'), ('0'), ('1abc'), ('007')
SELECT code FROM codes WHERE code = 0
| code |
| 0    |
| abc  |
2 rows in set, 2 warnings (0.00 sec)
Warning (Code 1292): Truncated incorrect DOUBLE value: '1abc'
Warning (Code 1292): Truncated incorrect DOUBLE value: 'abc'
SELECT code FROM codes WHERE code = 7
| code |
| 007  |
1 row in set, 2 warnings (0.00 sec)
Warning (Code 1292): Truncated incorrect DOUBLE value: '1abc'
Warning (Code 1292): Truncated incorrect DOUBLE value: 'abc'
EXPLAIN SELECT code FROM codes WHERE code = 7
| id | select_type | table | partitions | type  | possible_keys | key    | key_len | ref  | rows | filtered | Extra                    |
|  1 | SIMPLE      | codes | NULL       | index | k_code        | k_code | 43      | NULL |    4 |    25.00 | Using where; Using index |
1 row in set, 3 warnings (0.00 sec)
Warning (Code 1739): Cannot use ref access on index 'k_code' due to type or collation conversion on field 'code'
Warning (Code 1739): Cannot use range access on index 'k_code' due to type or collation conversion on field 'code'
EXPLAIN SELECT code FROM codes WHERE code = '7'
| id | select_type | table | partitions | type | possible_keys | key    | key_len | ref   | rows | filtered | Extra       |
|  1 | SIMPLE      | codes | NULL       | ref  | k_code        | k_code | 43      | const |    1 |   100.00 | Using index |
1 row in set, 1 warning (0.00 sec)
SELECT 7/2 AS chia, 7/2.0 AS chia_so_thuc;
 chia |    chia_so_thuc
------+--------------------
    3 | 3.5000000000000000
(1 row)
SELECT 1/0 AS chia_0;
ERROR:  division by zero
SELECT 'a' || 'b' AS noi_chuoi;
 noi_chuoi
-----------
 ab
(1 row)
INSERT INTO tmp_pgm_codes VALUES ('abc'), ('0');
SELECT code FROM tmp_pgm_codes WHERE code = 0;
ERROR:  operator does not exist: character varying = integer
HINT:  No operator matches the given name and argument types. You might need to add explicit type casts.

Cách nói khi phỏng vấn: "Bảy chia hai ra ba phẩy năm ở MySQL nhưng ra ba ở PostgreSQL, nên báo cáo tỉ lệ hoàn thành chuyển database là sai ngay nếu không ép kiểu. Chia cho không thì MySQL trả null, PostgreSQL báo lỗi. Nguy hiểm nhất là so cột chuỗi với số: MySQL ép chuỗi thành số, mã abc bằng không, mã không không bảy bằng bảy, và index bị bỏ qua. PostgreSQL từ chối luôn câu đó."

9.2. Lab: BOOLEAN và ENUM

INSERT INTO flags VALUES (1, TRUE), (2, 2), (3, -1)
SELECT id, is_active, is_active = TRUE AS bang_true, IF(is_active, 'truthy', 'falsy') AS trong_if FROM flags
| id   | is_active | bang_true | trong_if |
|    1 |         1 |         1 | truthy   |
|    2 |         2 |         0 | truthy   |
|    3 |        -1 |         0 | truthy   |
SHOW CREATE TABLE flags
       Table: flags
Create Table: CREATE TABLE `flags` (
  `id` int DEFAULT NULL,
  `is_active` tinyint(1) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
INSERT INTO tmp_pgm_flags VALUES (1, true);
INSERT 0 1
INSERT INTO tmp_pgm_flags VALUES (2, 2);
ERROR:  column "is_active" is of type boolean but expression is of type integer
HINT:  You will need to rewrite or cast the expression.
INSERT INTO tmp_pgm_flags VALUES (3, 'yes');
INSERT 0 1
SELECT * FROM tmp_pgm_flags;
 id | is_active
----+-----------
  1 | t
  3 | t
(2 rows)
INSERT INTO tickets VALUES (4, 'archived')
ERROR 1265 (01000) at line 3: Data truncated for column 'status' at row 1
SELECT id, status, status + 0 AS chi_so FROM tickets ORDER BY status
| id   | status  | chi_so |
|    2 | open    |      1 |
|    3 | pending |      2 |
|    1 | closed  |      3 |

MySQL BOOLEAN là tinyint(1): lưu được 2 và −1, và is_active = TRUE chỉ khớp đúng giá trị 1 — trong khi IF(is_active, ...) coi cả 3 là đúng. Hai cách viết điều kiện cho hai kết quả khác nhau. ENUM ở cả hai bên sắp xếp theo thứ tự khai báo (open, pending, closed), không theo chữ cái.

Cách nói khi phỏng vấn: "Kiểu boolean của MySQL thực chất là số nguyên nhỏ, nên lưu được hai và âm một. Lọc bằng bằng true chỉ ra một dòng, lọc kiểu điều kiện đúng sai lại ra ba dòng. PostgreSQL có boolean thật, chèn số hai là lỗi. Còn enum thì cả hai sắp xếp theo thứ tự khai báo chứ không theo chữ cái, dễ làm báo cáo sắp sai."

9.3. Lab: hạn nộp bài 23:59 giờ Việt Nam — TIMESTAMP, DATETIME, timestamptz

SET time_zone = '+07:00'
CREATE TABLE deadlines (id int, due_ts TIMESTAMP NULL, due_dt DATETIME)
INSERT INTO deadlines VALUES (1, '2026-11-02 23:59:00', '2026-11-02 23:59:00')
SELECT @@session.time_zone AS tz, due_ts, due_dt FROM deadlines
| tz     | due_ts              | due_dt              |
| +07:00 | 2026-11-02 23:59:00 | 2026-11-02 23:59:00 |
SET time_zone = '+00:00'
SELECT @@session.time_zone AS tz, due_ts, due_dt, UNIX_TIMESTAMP(due_ts) AS epoch_ts, UNIX_TIMESTAMP(due_dt) AS epoch_dt FROM deadlines
| tz     | due_ts              | due_dt              | epoch_ts   | epoch_dt   |
| +00:00 | 2026-11-02 16:59:00 | 2026-11-02 23:59:00 | 1793638740 | 1793663940 |
INSERT INTO deadlines VALUES (2, '2038-01-19 03:14:07', '2038-01-19 03:14:07')
INSERT INTO deadlines VALUES (3, '2038-01-19 03:14:08', '2038-01-19 03:14:08')
ERROR 1292 (22007) at line 11: Incorrect datetime value: '2038-01-19 03:14:08' for column 'due_ts' at row 1
INSERT INTO deadlines (id, due_dt) VALUES (4, '9999-12-31 23:59:59')
SELECT id, due_ts, due_dt FROM deadlines ORDER BY id
| id   | due_ts              | due_dt              |
|    1 | 2026-11-02 16:59:00 | 2026-11-02 23:59:00 |
|    2 | 2038-01-19 03:14:07 | 2038-01-19 03:14:07 |
|    4 | NULL                | 9999-12-31 23:59:59 |
SET TimeZone = 'Asia/Ho_Chi_Minh';
INSERT INTO tmp_pgm_deadlines VALUES (1, '2026-11-02 23:59:00', '2026-11-02 23:59:00');
INSERT 0 1
SELECT current_setting('TimeZone') AS tz, due_tz, due_naive FROM tmp_pgm_deadlines;
        tz        |         due_tz         |      due_naive
------------------+------------------------+---------------------
 Asia/Ho_Chi_Minh | 2026-11-02 23:59:00+07 | 2026-11-02 23:59:00
(1 row)
SET TimeZone = 'UTC';
SELECT current_setting('TimeZone') AS tz, due_tz, due_naive FROM tmp_pgm_deadlines;
 tz  |         due_tz         |      due_naive
-----+------------------------+---------------------
 UTC | 2026-11-02 16:59:00+00 | 2026-11-02 23:59:00
(1 row)
INSERT INTO tmp_pgm_deadlines VALUES (2, '2038-01-19 03:14:08+00', '2038-01-19 03:14:08'), (3, 'infinity', '294276-12-31 23:59:59');
INSERT 0 2
SELECT id, due_tz, due_naive FROM tmp_pgm_deadlines ORDER BY id;
 id |         due_tz         |       due_naive
----+------------------------+-----------------------
  1 | 2026-11-02 16:59:00+00 | 2026-11-02 23:59:00
  2 | 2038-01-19 03:14:08+00 | 2038-01-19 03:14:08
  3 | infinity               | 294276-12-31 23:59:59
(3 rows)

Cách nói khi phỏng vấn: "Hạn nộp hai mươi ba giờ năm mươi chín giờ Việt Nam: cột TIMESTAMP của MySQL lưu đúng một thời điểm và hiển thị theo múi giờ phiên, cột DATETIME lưu nguyên chữ, nên hai cột lệch nhau bảy tiếng khi đọc bằng phiên UTC. TIMESTAMP còn dừng ở năm 2038. Trên PostgreSQL em dùng timestamptz cho mọi mốc thời gian nghiệp vụ, chỉ dùng kiểu không múi giờ cho thứ thật sự là giờ địa phương như giờ mở cửa."

9.4. Lab: JSON vs JSONB và index

SELECT CAST('{"b": 1, "a": 2, "a": 3}' AS JSON) AS mysql_json
| mysql_json       |
| {"a": 3, "b": 1} |
SELECT '{"b": 1, "a": 2, "a": 3}'::json AS pg_json, '{"b": 1, "a": 2, "a": 3}'::jsonb AS pg_jsonb;
         pg_json          |     pg_jsonb
--------------------------+------------------
 {"b": 1, "a": 2, "a": 3} | {"a": 3, "b": 1}
(1 row)
EXPLAIN SELECT id FROM posts WHERE 't5' MEMBER OF (doc->'$.tags')
|  1 | SIMPLE      | posts | NULL       | ref  | k_tags        | k_tags | 83      | const |  429 |   100.00 | Using where |
EXPLAIN SELECT id FROM posts WHERE JSON_CONTAINS(doc->'$.tags', '["t5"]')
|  1 | SIMPLE      | posts | NULL       | range | k_tags        | k_tags | 83      | NULL |  429 |   100.00 | Using where |
EXPLAIN SELECT id FROM posts WHERE author_id = 42
|  1 | SIMPLE      | posts | NULL       | ref  | k_author      | k_author | 5       | const |   40 |   100.00 | Using index |
|           429 |
EXPLAIN (COSTS OFF) SELECT id FROM tmp_pgm_posts WHERE doc @> '{"tags": ["t5"]}';
 Bitmap Heap Scan on tmp_pgm_posts
   Recheck Cond: (doc @> '{"tags": ["t5"]}'::jsonb)
   ->  Bitmap Index Scan on tmp_pgm_posts_doc
         Index Cond: (doc @> '{"tags": ["t5"]}'::jsonb)
(4 rows)
EXPLAIN (COSTS OFF) SELECT id FROM tmp_pgm_posts WHERE doc @> '{"author_id": 42}';
 Bitmap Heap Scan on tmp_pgm_posts
   Recheck Cond: (doc @> '{"author_id": 42}'::jsonb)
   ->  Bitmap Index Scan on tmp_pgm_posts_doc
         Index Cond: (doc @> '{"author_id": 42}'::jsonb)
(4 rows)
EXPLAIN (COSTS OFF) SELECT id FROM tmp_pgm_posts WHERE tags @> ARRAY['t5'];
 Bitmap Heap Scan on tmp_pgm_posts
   Recheck Cond: (tags @> '{t5}'::text[])
   ->  Bitmap Index Scan on tmp_pgm_posts_tags
         Index Cond: (tags @> '{t5}'::text[])
(4 rows)
SELECT count(*) AS so_bai_tag_t5 FROM tmp_pgm_posts WHERE tags @> ARRAY['t5'];
 so_bai_tag_t5
           429
(1 row)

Cả hai đều index được JSON: MySQL qua multi-valued index (CAST(... AS CHAR(20) ARRAY), 8.0.17+) cho mảng và cột sinh + index cho giá trị vô hướng; PG qua một index GIN jsonb_path_ops phục vụ mọi truy vấn chứa (@>) trên mọi key, cộng kiểu text[] thật với GIN. Khác biệt là độ linh hoạt: MySQL phải biết trước đường dẫn sẽ lọc; PG GIN không cần.

Cách nói khi phỏng vấn: "Cả hai database đều index được dữ liệu JSON. MySQL làm bằng index nhiều giá trị cho mảng và cột sinh cho từng trường, tức là phải biết trước mình lọc theo trường nào. PostgreSQL có một index GIN dùng chung cho mọi phép chứa trên mọi trường, và có kiểu mảng thật. Nhưng em không chọn PostgreSQL vì JSONB, vì hệ thống của em đã có MongoDB cho dữ liệu dạng document."

flowchart TD
  T["Giá trị đi vào / so sánh"] --> M["MySQL: cố ép cho chạy<br/>'abc' = 0 đúng · 7/2 = 3.5 · 1/0 = NULL<br/>BOOLEAN = tinyint · TIMESTAMP ≤ 2038"]
  T --> P["PostgreSQL: từ chối nếu không rõ kiểu<br/>varchar = int → ERROR · 7/2 = 3 · 1/0 → ERROR<br/>boolean thật · timestamptz · infinity"]
  M --> R["Nghiệp vụ: báo cáo sai, hạn nộp lệch,<br/>lọc trạng thái sai, index bị bỏ"]
  P --> R2["Nghiệp vụ: lỗi lộ ngay ở dev,<br/>nhưng port SQL cũ phải sửa ép kiểu"]

9.5. Ảnh hưởng nghiệp vụ


10. Tính năng SQL, loại index, extension, song song trong một query

10.1. Lab: 9 tính năng SQL ở hai bên

WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < 3) SELECT GROUP_CONCAT(n) AS cte_de_quy FROM s
SELECT id, grp, v, SUM(v) OVER (PARTITION BY grp ORDER BY id) AS window_fn FROM a
SELECT a.id, t.top_v FROM a, LATERAL (SELECT MAX(b.v) AS top_v FROM b WHERE b.id >= a.id) t
SELECT id FROM a INTERSECT SELECT id FROM b
SELECT a.id, b.id FROM a FULL OUTER JOIN b ON a.id = b.id
ERROR 1064 (42000) at line 9: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'OUTER JOIN b ON a.id = b.id' at line 1
SELECT DISTINCT ON (grp) grp, id FROM a ORDER BY grp, id
ERROR 1064 (42000) at line 10: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'ON (grp) grp, id FROM a ORDER BY grp, id' at line 1
SELECT COUNT(*) FILTER (WHERE v > 10) FROM a
ERROR 1064 (42000) at line 11: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near '(WHERE v > 10) FROM a' at line 1
MERGE INTO a USING b ON a.id = b.id WHEN MATCHED THEN UPDATE SET v = b.v
ERROR 1064 (42000) at line 12: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'MERGE INTO a USING b ON a.id = b.id WHEN MATCHED THEN UPDATE SET v = b.v' at line 1
CREATE TABLE emb (id int, v VECTOR(3))
ERROR 1064 (42000) at line 13: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'VECTOR(3))' at line 1
SELECT a.id, b.id FROM tmp_pgm_a a FULL OUTER JOIN tmp_pgm_b b ON a.id = b.id ORDER BY 1, 2;
 id | id
----+----
  1 |
  2 |  2
  3 |
    |  4
(4 rows)
SELECT DISTINCT ON (grp) grp, id FROM tmp_pgm_a ORDER BY grp, id;
 grp | id
-----+----
   1 |  1
   2 |  3
(2 rows)
SELECT count(*) FILTER (WHERE v > 10) AS filter_clause FROM tmp_pgm_a;
 filter_clause
---------------
             2
(1 row)
MERGE INTO tmp_pgm_a a USING tmp_pgm_b b ON a.id = b.id WHEN MATCHED THEN UPDATE SET v = b.v RETURNING merge_action(), a.*;
 merge_action | id | grp |  v
--------------+----+-----+-----
 UPDATE       |  2 |   1 | 200
(1 row)
MERGE 1
SELECT name, default_version FROM pg_available_extensions WHERE name IN ('vector', 'postgis', 'timescaledb', 'pg_trgm', 'btree_gist', 'pg_stat_statements') ORDER BY 1;
        name        | default_version
--------------------+-----------------
 btree_gist         | 1.8
 pg_stat_statements | 1.12
 pg_trgm            | 1.6
(3 rows)
SELECT count(*) AS so_extension_co_san_trong_image FROM pg_available_extensions;
 so_extension_co_san_trong_image
---------------------------------
                              60
(1 row)
Tính năngMySQL 8.4.13PostgreSQL 18.4
CTE đệ quy, window function, LATERAL, INTERSECTCó (lab chạy)Có (lab chạy)
FULL OUTER JOINLỗi cú phápCó
DISTINCT ONLỗi cú phápCó
Aggregate FILTER (WHERE ...)Lỗi cú pháp (dùng SUM(IF(...)))Có
MERGELỗi cú phápCó, kèm RETURNING merge_action()
RETURNINGLỗi cú pháp (mục 8)Có (OLD/NEW từ 18)
Kiểu VECTORLỗi cú pháp ở 8.4 (có từ dòng 9.x — tài liệu MySQL, chưa verify)Qua extension pgvector (không có sẵn trong image chính thức)

Cách nói khi phỏng vấn: "Những thứ hay dùng như CTE đệ quy, window function, LATERAL thì MySQL 8 đã có đủ. Khác biệt còn lại là các cú pháp như full outer join, distinct on, filter trong hàm gộp, merge, returning: PostgreSQL có, MySQL 8.4 báo lỗi cú pháp. Nghĩa là đổi database thì phải viết lại một phần truy vấn, đặc biệt là báo cáo."

10.2. Lab: loại index

CREATE TABLE h (id int PRIMARY KEY, k int, KEY k_hash (k) USING HASH)

Note (Code 3502): This storage engine does not support the HASH index algorithm, storage engine default was used instead.
SELECT INDEX_NAME, INDEX_TYPE FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = 'lab' AND TABLE_NAME = 'h'

| INDEX_NAME | INDEX_TYPE |
| k_hash     | BTREE      |
| PRIMARY    | BTREE      |
SELECT string_agg(amname, ', ' ORDER BY amname) AS index_access_methods FROM pg_am WHERE amtype = 'i';
         index_access_methods
 brin, btree, gin, gist, hash, spgist
(1 row)
SELECT pg_size_pretty(pg_relation_size('tmp_pgm_events')) AS bang,
       pg_size_pretty(pg_relation_size('tmp_pgm_events_at_btree')) AS btree,
       pg_size_pretty(pg_relation_size('tmp_pgm_events_at_brin')) AS brin;
 bang  |  btree  | brin
-------+---------+-------
 28 MB | 4408 kB | 24 kB
(1 row)
EXPLAIN (COSTS OFF) SELECT count(*) FROM tmp_pgm_events WHERE at >= '2026-03-01' AND at < '2026-03-02';
                                                                      QUERY PLAN
 Aggregate
   ->  Bitmap Heap Scan on tmp_pgm_events
         Recheck Cond: ((at >= '2026-03-01 00:00:00+00'::timestamp with time zone) AND (at < '2026-03-02 00:00:00+00'::timestamp with time zone))
         ->  Bitmap Index Scan on tmp_pgm_events_at_brin
               Index Cond: ((at >= '2026-03-01 00:00:00+00'::timestamp with time zone) AND (at < '2026-03-02 00:00:00+00'::timestamp with time zone))
(5 rows)

MySQL nhận USING HASH trên InnoDB rồi lặng lẽ tạo BTREE (chỉ một Note 3502). PG có 6 access method; BRIN cho bảng log tăng dần theo thời gian chỉ 24 kB so với B-tree 4408 kB trên cùng 200.000 dòng, và vẫn được dùng cho truy vấn theo khoảng ngày.

Cách nói khi phỏng vấn: "InnoDB về cơ bản chỉ có B-tree, cộng full-text, spatial và index nhiều giá trị cho JSON; xin hash thì nó âm thầm tạo B-tree. PostgreSQL có thêm GIN cho mảng và JSON, GiST cho khoảng và hình học, BRIN cho bảng log. Em đo trên hai trăm nghìn dòng log, BRIN chỉ hai mươi tư kilobyte so với hơn bốn megabyte của B-tree mà vẫn phục vụ được truy vấn theo ngày."

10.3. Lab: song song trong một query, hash join

SHOW max_parallel_workers_per_gather;
 max_parallel_workers_per_gather
 2
EXPLAIN (COSTS OFF) SELECT count(*) FROM tmp_pgm_events WHERE payload <> 'y';
                      QUERY PLAN
 Finalize Aggregate
   ->  Gather
         Workers Planned: 2
         ->  Partial Aggregate
               ->  Parallel Seq Scan on tmp_pgm_events
                     Filter: (payload <> 'y'::bpchar)
(6 rows)
SELECT @@innodb_parallel_read_threads
|                              4 |
EXPLAIN FORMAT=TREE SELECT COUNT(*) FROM events WHERE payload <> 'y'
| -> Aggregate: count(0)  (cost=38001 rows=1)
    -> Filter: (`events`.payload <> 'y')  (cost=20001 rows=180000)
        -> Table scan on events  (cost=20001 rows=200000)
EXPLAIN FORMAT=TREE SELECT e1.id FROM events e1 JOIN events e2 ON e1.payload = e2.payload AND e2.id < 3 WHERE e1.id < 5
| -> Filter: (e1.payload = e2.payload)  (cost=2.31 rows=0.08)
    -> Inner hash join (<hash>(e1.payload)=<hash>(e2.payload))  (cost=2.31 rows=0.08)
        -> Filter: (e1.id < 5)  (cost=0.681 rows=0.4)
            -> Index range scan on e1 using PRIMARY over (id < 5)  (cost=0.681 rows=4)
        -> Hash
            -> Filter: (e2.id < 3)  (cost=0.91 rows=2)
                -> Index range scan on e2 using PRIMARY over (id < 3)  (cost=0.91 rows=2)

PG lên plan Gather với 2 worker cho quét bảng 28 MB. MySQL chạy cùng truy vấn bằng một Table scan đơn luồng; innodb_parallel_read_threads chỉ dùng cho đọc clustered index song song kiểu CHECK TABLE (tài liệu MySQL 8.4, mục tham số InnoDB). MySQL 8 có hash join (plan thứ hai). Không suy ra "PG nhanh hơn": parallel giúp truy vấn quét lớn kiểu báo cáo, ít ý nghĩa với OLTP truy vấn theo khoá — phụ thuộc workload, bài này không đo tốc độ.

Cách nói khi phỏng vấn: "PostgreSQL có thể chia một truy vấn quét bảng lớn cho nhiều worker, em thấy plan có Gather hai worker. MySQL chạy một luồng cho truy vấn đó. Điều này có ích cho báo cáo quét nhiều, còn với truy vấn web theo khoá chính thì gần như không khác, nên em không dùng nó để nói database nào nhanh hơn."

flowchart LR
  N["Nhu cầu"] --> A["Lọc trong mảng / JSON tuỳ ý<br/>PG: GIN · MySQL: multi-valued index theo path"]
  N --> B["Khoảng thời gian không chồng nhau<br/>PG: GiST + EXCLUDE · MySQL: khoá tay"]
  N --> C["Bảng log rất lớn, tăng theo thời gian<br/>PG: BRIN · MySQL: partition + B-tree"]
  N --> D["Tìm gần đúng, vector, GIS<br/>PG: pg_trgm, pgvector, PostGIS · MySQL: FULLTEXT, SPATIAL"]

10.4. Ảnh hưởng nghiệp vụ


11. Replication và HA: WAL vật lý vs binlog ROW + GTID

11.1. Cơ chế

11.2. Lab: standby PG từ chối mọi ghi; WAL là block vật lý

SELECT application_name, state, sync_state, replay_lag FROM pg_stat_replication;
 application_name |   state   | sync_state |   replay_lag
------------------+-----------+------------+-----------------
 walreceiver      | streaming | async      | 00:00:05.198452
SHOW synchronous_commit;
 synchronous_commit
 on
SHOW wal_level;
 wal_level
 replica
INSERT INTO tmp_pgm_repl VALUES (1, 'a');
SELECT pg_is_in_recovery() AS la_standby;
 la_standby
 t
SELECT * FROM tmp_pgm_repl;
 id | v
----+---
  1 | a
INSERT INTO tmp_pgm_repl VALUES (2, 'ghi tren standby');
ERROR:  cannot execute INSERT in a read-only transaction
CREATE INDEX tmp_pgm_repl_v ON tmp_pgm_repl (v);
ERROR:  cannot execute CREATE INDEX in a read-only transaction
UPDATE tmp_pgm_repl SET v='b' WHERE id=1  -> WAL từ 14/4EC71E48 tới 14/4EC71EB8 (filenode của bảng = 772085)
Heap len (rec/tot): 69/ 69, tx: 720659, lsn: 14/4EC71E48, prev 14/4EC71E20, desc: HOT_UPDATE old_xmax: 720659, old_off: 1, old_infobits: [], flags: 0x20, new_xmax: 0, new_off: 2, blkref #0: rel 1663/1
Transaction len (rec/tot): 34/ 34, tx: 720659, lsn: 14/4EC71E90, prev 14/4EC71E48, desc: COMMIT 2026-11-01 12:34:51.276416 UTC

Một UPDATE = một bản ghi WAL Heap ... HOT_UPDATE old_off: 1 ... new_off: 2, blkref #0: rel 1663/… — "trong block X của file Y, offset 1 hết hạn, offset 2 là bản mới" — rồi một bản ghi COMMIT. Standby chỉ việc chép lại thay đổi block, không chạy SQL.

Cách nói khi phỏng vấn: "Standby của PostgreSQL nhận nhật ký WAL ở mức trang dữ liệu, em xem bằng pg_waldump thì một câu UPDATE chỉ là trong block này, vị trí một hết hạn, vị trí hai là bản mới. Vì là bản sao vật lý nên standby từ chối mọi lệnh ghi, kể cả tạo index, và phải cùng phiên bản lớn với primary."

11.3. Lab: replica MySQL vẫn ghi được — và errant GTID

SELECT c.SERVICE_STATE AS io_thread, a.SERVICE_STATE AS sql_thread, c.SOURCE_UUID FROM performance_schema.replication_connection_status c JOIN performance_schema.replication_applier_status a USING (CHANNEL_NAME)
| io_thread | sql_thread | SOURCE_UUID                          |
| ON        | ON         | df290de8-d5ef-11f1-9e6e-ca19ac9b05e3 |
== B1. MySQL: read_only=ON nhưng root (SUPER/CONNECTION_ADMIN) vẫn ghi được -> lệch dữ liệu + errant GTID
CREATE TABLE repl (id int PRIMARY KEY, v varchar(30))
INSERT INTO repl VALUES (1, 'a')
SELECT @@read_only, @@super_read_only
| @@read_only | @@super_read_only |
|           1 |                 0 |
SELECT * FROM repl
| id | v    |
|  1 | a    |
INSERT INTO repl VALUES (2, 'ghi tren replica')
CREATE INDEX k_v ON repl (v)
SELECT @@server_uuid AS replica_uuid, @@gtid_executed AS replica_gtid_executed
         replica_uuid: a491db30-d5f0-11f1-81be-3ac23e26c818
replica_gtid_executed: a491db30-d5f0-11f1-81be-3ac23e26c818:1-2,
df290de8-d5ef-11f1-9e6e-ca19ac9b05e3:1-1672
SET GLOBAL super_read_only = ON
INSERT INTO repl VALUES (3, 'sau super_read_only')
ERROR 1290 (HY000) at line 7: The MySQL server is running with the --super-read-only option so it cannot execute this statement
df290de8-d5ef-11f1-9e6e-ca19ac9b05e3
SELECT @@binlog_format AS format, @@binlog_row_image AS row_image
| format | row_image |
| ROW    | FULL      |
SHOW BINLOG EVENTS IN 'binlog.000003'
| Log_name      | Pos | Event_type     | Server_id | End_log_pos | Info                                                                 |
| binlog.000003 |   4 | Format_desc    |         1 |         127 | Server ver: 8.4.13, Binlog ver: 4                                    |
| binlog.000003 | 127 | Previous_gtids |         1 |         198 | df290de8-d5ef-11f1-9e6e-ca19ac9b05e3:1-1672                          |
| binlog.000003 | 198 | Gtid           |         1 |         277 | SET @@SESSION.GTID_NEXT= 'df290de8-d5ef-11f1-9e6e-ca19ac9b05e3:1673' |
| binlog.000003 | 277 | Query          |         1 |         360 | BEGIN                                                                |
| binlog.000003 | 360 | Table_map      |         1 |         417 | table_id: 144 (lab.repl)                                             |
| binlog.000003 | 417 | Update_rows    |         1 |         467 | table_id: 144 flags: STMT_END_F                                      |
| binlog.000003 | 467 | Xid            |         1 |         498 | COMMIT /* xid=5335 */                                                |

Cách nói khi phỏng vấn: "Replica MySQL là một server độc lập áp lại từng dòng thay đổi. Em bật read only rồi vẫn dùng root ghi được vào replica và tạo index riêng; replica sinh ra hai giao dịch mà primary không có, gọi là errant GTID, làm hỏng failover về sau. Phải bật super read only. Tính độc lập đó cũng là điểm mạnh: replica được phép có index riêng cho báo cáo, hoặc chạy phiên bản mới hơn khi nâng cấp."

11.4. Lab: read-your-writes khi replica trễ — cùng một kỹ thuật ở hai bên

primary: UPDATE xong, LSN của commit = 14/4EC71F38
SELECT pg_get_wal_replay_pause_state() AS replay;
 replay
 paused
SELECT v AS standby_doc_ngay FROM tmp_pgm_repl WHERE id = 1;
 standby_doc_ngay
 b
SELECT pg_wal_replay_resume();
SELECT pg_last_wal_replay_lsn() >= '14/4EC71F38'::pg_lsn AS da_toi_lsn, v AS standby_doc_sau_khi_cho FROM tmp_pgm_repl WHERE id = 1;
 da_toi_lsn | standby_doc_sau_khi_cho
------------+-------------------------
 t          | da doi mat khau
primary: UPDATE xong, gtid_executed = df290de8-d5ef-11f1-9e6e-ca19ac9b05e3:1-1674
SELECT v AS replica_doc_ngay FROM repl WHERE id = 1
| replica_doc_ngay |
| b                |
START REPLICA SQL_THREAD
SELECT WAIT_FOR_EXECUTED_GTID_SET('df290de8-d5ef-11f1-9e6e-ca19ac9b05e3:1-1674', 5) AS cho_gtid_0_la_xong
| cho_gtid_0_la_xong |
|                  0 |
SELECT v AS replica_doc_sau_khi_cho FROM repl WHERE id = 1
| replica_doc_sau_khi_cho |
| da doi mat khau         |

Giả lập trễ một cách tất định (dừng replay / dừng SQL thread): đọc ngay ở replica ra giá trị cũ ("b" thay vì "da doi mat khau"). Cách chữa giống nhau: sau khi ghi, lấy vị trí của commit trên primary (LSN ở PG, GTID set ở MySQL) và bắt lần đọc kế tiếp chờ replica tới vị trí đó (pg_last_wal_replay_lsn() >= lsn / WAIT_FOR_EXECUTED_GTID_SET(gtid, timeout) trả 0), hoặc đọc từ primary trong một khoảng ngắn sau khi ghi.

Cách nói khi phỏng vấn: "Em cho replica tạm dừng áp thay đổi để giả lập trễ: sinh viên vừa đổi mật khẩu, đọc ở replica vẫn thấy giá trị cũ. Ở cả hai database cách chữa giống nhau: nhớ vị trí của lần ghi, LSN bên PostgreSQL hay GTID bên MySQL, rồi lần đọc sau chờ replica tới vị trí đó, hoặc đơn giản là đọc từ primary vài giây sau khi người đó vừa ghi. Laravel có sẵn tuỳ chọn sticky và middleware chống đọc cũ cho việc này."

flowchart TD
  W["Sinh viên nộp bài (ghi primary)"] --> L["Lấy vị trí commit:<br/>PG pg_current_wal_lsn() / MySQL GTID"]
  L --> R{"Request đọc kế tiếp"}
  R -- "Đọc replica ngay" --> S["Replica trễ → 'chưa nộp' → sinh viên nộp lại / khiếu nại"]
  R -- "Chờ replica tới vị trí" --> OK1["PG: replay_lsn ≥ lsn<br/>MySQL: WAIT_FOR_EXECUTED_GTID_SET = 0"]
  R -- "Đọc primary N giây sau khi ghi" --> OK2["Laravel sticky / PreventStaleDatabaseReads<br/>(mặc định 2 giây)"]

11.5. Trade-off và ảnh hưởng nghiệp vụ


12. Vận hành: nâng cấp, backup, theo dõi

12.1. Lab: nâng major version

PostgreSQL 17.11 on x86_64-pc-linux-musl
-- trạng thái container PG 18: exited exit=1
2026-11-01 12:36:05.520 UTC [1] FATAL:  database files are incompatible with server
2026-11-01 12:36:05.520 UTC [1] DETAIL:  The data directory was initialized by PostgreSQL version 17, which is not compatible with this version 18.4.
pg_upgrade (PostgreSQL) 18.4
binary PG 17 trong image 18 (pg_upgrade cần --old-bindir): không có
8.0.46
1 [System] [MY-013413] [Server] Data dictionary upgrade from version '80023' to '80300' completed.
4 [System] [MY-013381] [Server] Server upgrade from '80046' to '80413' started.
4 [System] [MY-013381] [Server] Server upgrade from '80046' to '80413' completed.
8.4.13
1	truoc khi nang cap
-- thử chạy lại 8.0 trên data directory đã nâng lên 8.4:
trạng thái container 8.0: exited exit=1
1 [ERROR] [MY-014061] [InnoDB] Invalid MySQL server downgrade: Cannot downgrade from 80413 to 80046. Downgrade is only permitted between patch releases.
1 [ERROR] [MY-010334] [Server] Failed to initialize DD Storage Engine
0 [ERROR] [MY-010020] [Server] Data Dictionary initialization failed.
0 [ERROR] [MY-010119] [Server] Aborting

Cách nói khi phỏng vấn: "Em thử nâng cấp thật cả hai. PostgreSQL mười bảy lên mười tám: binary mới từ chối mở thư mục dữ liệu cũ, phải dùng pg_upgrade với cả hai bộ binary, hoặc dump và restore. MySQL tám không lên tám bốn: chỉ cần khởi động bản mới là nó tự nâng cấp, rất tiện, nhưng sau đó chạy lại bản cũ thì bị từ chối hạ cấp. Nên với MySQL kế hoạch rollback phải là backup hoặc một replica còn ở bản cũ, không phải đổi lại image."

12.2. Lab: công cụ backup và theo dõi có sẵn

pg_dump            pg_dump (PostgreSQL) 18.4
pg_dumpall         pg_dumpall (PostgreSQL) 18.4
pg_basebackup      pg_basebackup (PostgreSQL) 18.4
pg_restore         pg_restore (PostgreSQL) 18.4
pg_combinebackup   pg_combinebackup (PostgreSQL) 18.4
mysqldump    mysqldump  Ver 8.4.13 for Linux on x86_64 (MySQL Community Server - GPL)
mysqlpump    không có trong image
mysqlbinlog  không có trong image
mysqlsh      Cannot set LC_ALL to locale en_US.UTF-8: No such file or directory
pg_stat_statements
SELECT calls, round(total_exec_time::numeric, 1) AS total_ms, left(regexp_replace(query, '\s+', ' ', 'g'), 60) AS query
  FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 3;
  calls  |  total_ms  |                            query
  290343 | 29158326.4 | SELECT current_database() datname, schemaname, relname, seq_
 1451715 |  6421826.1 | SELECT pg_database_size($1)
  290343 |  1446437.2 | SELECT current_database() datname, schemaname, relname, heap
(3 rows)
SELECT COUNT_STAR AS calls, ROUND(SUM_TIMER_WAIT / 1e9, 1) AS total_ms, LEFT(DIGEST_TEXT, 60) AS query
  FROM performance_schema.events_statements_summary_by_digest WHERE SCHEMA_NAME = 'lab' ORDER BY SUM_TIMER_WAIT DESC LIMIT 3
| calls | total_ms | query                                                        |
|    19 |  62316.4 | DO `SLEEP` (?)                                               |
|     5 |  25031.4 | SELECT `SLEEP` (?)                                           |
|     1 |  10279.9 | INSERT INTO `pk_uuid4_char36` WITH RECURSIVE `s` ( `n` ) AS  |

Cách nói khi phỏng vấn: "Công cụ backup của PostgreSQL đi kèm sẵn, có cả backup gia tăng từ bản mười bảy. MySQL tám bốn đã bỏ mysqlpump, còn mysqldump và MySQL Shell, backup vật lý nóng thì dùng XtraBackup. Theo dõi truy vấn nặng thì PostgreSQL phải bật pg_stat_statements lúc khởi động, MySQL có performance schema bật sẵn."

flowchart TD
  U["Kế hoạch nâng major version"] --> P["PostgreSQL: pg_upgrade (cần binary cũ + mới)<br/>hoặc dump/restore, hoặc logical replication"]
  P --> R["Rollback: giữ cluster cũ / backup"]
  U --> M["MySQL: in-place (khởi động bản mới, tự nâng)<br/>hoặc replica bản mới → switchover"]
  M --> R2["Rollback: KHÔNG hạ lại được<br/>→ chỉ backup hoặc replica bản cũ"]

12.3. Vòng đời phiên bản và ảnh hưởng nghiệp vụ


13. Ma trận quyết định

Tiêu chíPostgreSQL 18MySQL 8.4 InnoDBGhi chú
Toàn vẹn dữ liệu mặc định★★★ luôn strict; partial, exclusion, deferrable★★ strict nếu mọi client giữ sql_modeTrục 5
Ngữ nghĩa đồng thời "an toàn khi quên"★★ RC mặc định để lọt race; SSI mạnh khi bật★★ RR + gap lock chặn nhiều race nhưng deadlock, lost update vẫn lọtTrục 4 — hoà, khác kiểu
An toàn migration★★★ transactional DDL★ commit ngầm; ★★ có atomic DDL từng câu, online DDL INSTANTTrục 7
Chi phí kết nối★ process, cần pooler sớm★★★ threadTrục 1
Gánh nặng vận hành thường ngày★★ VACUUM, bloat, wraparound, pooler★★ undo/history list, online DDL tool, errant GTIDTrục 3, 11 — khác loại, không bên nào "không cần DBA"
Replication / HA tooling★★ streaming + logical; Patroni (ngoài lab)★★★ binlog/GTID rất phổ biến, replica linh hoạtTrục 11
Truy vấn phức tạp / báo cáo★★★ FULL JOIN, FILTER, parallel query, nhiều loại index★★ đủ dùng từ 8.0 (CTE, window)Trục 10 — tốc độ phụ thuộc workload
Hệ sinh thái mở rộng★★★ extension (PostGIS, pgvector, ...)★ ít; vector ở 9.xTrục 10
Tương thích phần mềm có sẵn★★ Moodle, Laravel hỗ trợ đủ★★★ gần như mọi PHP app (WordPress chỉ MySQL/MariaDB — chưa verify bản mới nhất)Hệ thống kế thừa thường quyết định thay ta
Nâng cấp★★ pg_upgrade/dump, lùi được nếu giữ cluster cũ★★ in-place tiện, không hạ lạiTrục 12
Đội đã quen / tuyển ngườiPhụ thuộc thị trường và đội hiện tại — đánh giá theo đội thật, không có số liệu chung trong bàiMục 14

(★ là đánh giá định tính của bài này dựa trên các lab ở trên, không phải điểm benchmark.)

Dùng PostgreSQL khi

Dùng MySQL khi

Tránh


14. Góc nhìn tech lead: tác động tổ chức, ADR, exit strategy

14.1. Những thứ quyết định thật sự (không nằm trong benchmark)

14.2. Chi phí exit / chuyển đổi (MySQL → PostgreSQL hoặc ngược lại)

  1. Schema: kiểu dữ liệu (tinyint(1) → boolean, DATETIME → timestamptz cần quyết định múi giờ, char(36) → uuid, ENUM inline → type), collation (unique nào sẽ bắt đầu fail hoặc bắt đầu lọt khi đổi luật so sánh — trục 6).
  2. Dữ liệu bẩn: 0000-00-00, chuỗi đã bị cắt, số bị kẹp, dòng FK mồ côi (trục 5) — PG từ chối nạp. Phải làm sạch trước, đây thường là phần tốn thời gian nhất.
  3. SQL: chia nguyên, ||, so sánh chuỗi–số, upsert, LIMIT trong UPDATE/DELETE, FULL JOIN, hàm riêng (trục 8–10).
  4. Hành vi đồng thời: code dựa vào RR + gap lock có thể sai trên PG RC (trục 4). Phải rà mọi chỗ check-then-insert.
  5. Vận hành: thay toàn bộ runbook, monitoring, backup, HA, công cụ đổi schema.
  6. Công cụ: pgloader, AWS DMS, logical replication / CDC (Debezium) cho cutover gần zero-downtime — chưa verify trong lab này.

14.3. Mẫu ADR (chép được vào RFC nội bộ)

ADR-0xx: Chọn PostgreSQL 18 cho LMS Micro-learning (dự án mới)

Bối cảnh
- Dự án mới, schema đổi nhiều trong 12 tháng đầu, deploy 2–3 lần/tuần qua GitLab CI.
- Luật nghiệp vụ cần giữ ở DB: email duy nhất trong user còn sống (soft delete), 1 tiến độ / (user, bài học),
  idempotency key duy nhất, điểm trong khoảng 0–10.
- Stack đã có MongoDB cho dữ liệu dạng document, Redis cho cache/lock.

Quyết định
- PostgreSQL 18 làm DB giao dịch chính.

Lý do (theo thứ tự quan trọng)
1. Ràng buộc ở tầng DB: partial unique index, CHECK, exclusion constraint, FK deferrable.
2. Transactional DDL: migration Laravel tự bọc transaction trên PG, hỏng thì rollback sạch.
3. Luôn strict: dữ liệu sai bị từ chối, không phụ thuộc sql_mode của từng client.
(KHÔNG phải vì JSONB — đã có MongoDB.)

Hệ quả / chi phí chấp nhận
- Vận hành autovacuum, theo dõi bloat + xmin horizon + wraparound; fillfactor cho bảng update nhiều.
- Pooler (PgBouncer transaction mode) khi số kết nối tăng: cấm SET session, advisory lock session.
- Đội học thêm: isolation RC/SSI, retry 40001, collation (lower()/ICU cho email).

Phương án đã loại
- MySQL 8.4: đội quen hơn, nhưng thiếu partial index / exclusion, DDL commit ngầm, phụ thuộc sql_mode.

Exit strategy
- Tránh extension không có trên managed phổ biến; SQL nghiệp vụ đi qua Eloquent/query builder;
  ghi rõ chỗ dùng tính năng riêng PG (ON CONFLICT, partial index) trong danh sách lock-in.

Cách nói khi phỏng vấn: "Khi chốt database em viết thành ADR: bối cảnh, quyết định, ba lý do xếp theo thứ tự, cái giá chấp nhận, phương án đã loại và đường lui. Với Micro-learning, ba lý do là ràng buộc ở tầng database, migration nằm trong transaction và chế độ luôn chặt; cái giá là phải vận hành VACUUM và pooler. Em ghi rõ không chọn vì JSONB vì hệ thống đã có MongoDB, để người đọc sau không hiểu nhầm."


15. Áp vào 2 dự án LMS trong CV

Bối cảnh (theo cv-v3.md): LMS Uni (2020 – nay) — Moodle (PHP) + student portal NestJS/Next.js dùng chung một DB MySQL, primary/replica tách đọc/ghi, Redis, Nginx LB trước 5 app node, NFS moodledata, >100.000 sinh viên, cao điểm ~400 request/giây. LMS Micro-learning (2024 – nay, xây mới, 4 trường ~10.000 sinh viên) — Laravel/Livewire, NestJS, Next.js, PostgreSQL, Redis, MongoDB, MinIO. Hai dự án chạy song song, không phải "chuyển từ MySQL sang PostgreSQL".

flowchart TD
  subgraph UNI["LMS Uni — giữ MySQL"]
    direction TB
    U1["Moodle + plugin SQL thô<br/>+ portal NestJS/Next.js<br/>+ dữ liệu >100k SV nhiều năm"] --> UDB["MySQL primary/replica<br/>(dùng chung)"]
  end
  subgraph MIC["LMS Micro-learning — chọn PostgreSQL"]
    direction TB
    M1["Laravel/Livewire + NestJS<br/>(xây mới)"] --> MDB["PostgreSQL: constraint,<br/>partial index, transactional DDL"]
    M1 --> MG["MongoDB (document)<br/>+ Redis (cache, SET NX)"]
  end
  UDB -. "Không migrate:<br/>rủi ro cao, lợi ích thấp" .- MDB

15.1. Câu trả lời mẫu ~1 phút: "Vì sao LMS Uni dùng MySQL còn Micro-learning dùng PostgreSQL?"

Cách nói khi phỏng vấn: "Hai hệ thống chạy song song, không phải em chuyển từ MySQL sang PostgreSQL. LMS Uni là Moodle em kế thừa, đã chạy MySQL nhiều năm với hơn một trăm nghìn sinh viên, có plugin tuỳ biến viết SQL thô và một student portal NestJS đọc ghi chung database đó. Moodle hỗ trợ cả hai database, nhưng chuyển dữ liệu, rà lại toàn bộ SQL của plugin và portal, đổi luôn quy trình vận hành replica là rủi ro lớn mà lợi ích cho sinh viên gần như không có, nên em giữ MySQL và đầu tư vào scale out, tách đọc ghi, migration an toàn. Micro-learning là dự án mới, nhiều luật nghiệp vụ em muốn database giữ hộ: email duy nhất trong tài khoản còn hoạt động dù có soft delete, mỗi sinh viên một bản ghi tiến độ mỗi bài, khoá chống nộp trùng. PostgreSQL làm được bằng partial unique index và ràng buộc, migration của Laravel tự nằm trong transaction nên hỏng là rollback sạch, và nó không có chế độ cắt dữ liệu im lặng. Cái giá em chấp nhận là phải vận hành VACUUM, theo dõi bloat, hạ fillfactor cho bảng tiến độ, và dùng pooler khi kết nối tăng. Em không chọn vì JSONB, vì dữ liệu dạng document đã nằm ở MongoDB."

15.2. Câu hỏi nối tiếp và cách trả lời

15.3. Những gì CV nói được, và chỗ interviewer sẽ đào

Bullet / câu nóiBài này chống lưng bằngChỗ bị đào
"Toàn vẹn dữ liệu ở tầng DB (constraint, unique index, partial index)"Trục 5 (partial unique, exclusion, CHECK), trục 4.7"MySQL làm được không?" → được với cột sinh/functional index (lab 5.4), nhưng phải biết mẹo
"Idempotency key: Redis SET NX + unique constraint"Trục 4.7, 8.5 (ON CONFLICT DO NOTHING RETURNING)"Redis đã chặn rồi cần unique làm gì?" → Redis là lớp nhanh, unique là lớp cuối khi key hết hạn / Redis failover
"MySQL primary/replica tách đọc/ghi" (LMS Uni)Trục 11.4 read-your-writes"Sinh viên nộp xong F5 không thấy bài?" → sticky / chờ GTID
"Migration expand–contract" (LMS Uni)Trục 7 + bài Migration MySQL"Migration hỏng giữa chừng trên MySQL thì sao?" → lab 7.3, quy tắc 1 thay đổi / migration

16. Anti-patterns và bài học

  1. "Đọc vào app rồi ghi giá trị tuyệt đối" cho số dư/tồn kho/sĩ số — lost update ở cả hai DB mặc định (lab 4.3). Viết SET x = x - ? có điều kiện, hoặc FOR UPDATE, hoặc version.
  2. Check-then-insert không có constraint — "chạy đúng" trên MySQL nhờ gap lock (kèm deadlock), sai trên PG RC (lab 4.5). Constraint trước, isolation sau.
  3. INSERT IGNORE / sql_mode='' cho script import — dữ liệu bị cắt im lặng (lab 5.2).
  4. UNIQUE(email, deleted_at) cho soft delete trên MySQL — không chặn được gì (lab 5.4).
  5. SET FOREIGN_KEY_CHECKS=0 để "import cho nhanh" — bật lại không kiểm dữ liệu cũ (lab 5.6).
  6. Dựa vào collation mặc định cho định danh — 'Hòa' = 'hoa' trên MySQL, 'HOA' ≠ 'hoa' trên PG (lab 6.3). Chọn collation theo cột.
  7. Không kiểm charset của từng client khi chuyển sang utf8mb4 — double encoding vô hình (lab 6.4).
  8. Migration nhiều bước trên MySQL như thể có transaction (lab 7.3); và ngược lại trên PG: migration dài trong một transaction giữ AccessExclusiveLock tới commit.
  9. ODKU trên bảng nhiều UNIQUE, REPLACE INTO trên bảng có con (lab 8.3, 8.4).
  10. UUIDv4 dạng char(36) làm PK trên InnoDB (lab 2.4) — dùng UUIDv7 / bigint + public_id.
  11. Bind tham số sai kiểu (số cho cột chuỗi) trên MySQL — khớp nhầm + mất index (lab 9.1).
  12. Replica MySQL chỉ read_only — root vẫn ghi, errant GTID (lab 11.3). Bật super_read_only.
  13. PgBouncer transaction mode + SET/advisory lock cấp session (lab 1.4).
  14. Nâng MySQL in-place không có đường lùi (lab 12.1) — luôn có backup đã thử restore hoặc replica bản cũ.
  15. So sánh DB bằng benchmark người khác — workload khác, cấu hình khác. Benchmark chính workload của mình, hoặc đừng dùng tốc độ làm lý do.

17. Câu hỏi phụ interviewer hay hỏi tiếp

MySQL REPEATABLE READ có chống lost update không?
Không (lab 4.3): đọc snapshot rồi ghi giá trị tuyệt đối vẫn ghi đè. UPDATE trên InnoDB luôn đọc bản mới nhất. PG RR thì từ chối bằng 40001.
Gap lock là gì, khi nào gây deadlock?
Khoá khoảng trống giữa các giá trị index ở RR để chặn phantom; hai transaction cùng giữ gap lock trên cùng khoảng rồi cùng INSERT vào đó → deadlock (lab 4.5). Giảm bằng index đúng, RC (bỏ gap lock cho phần lớn trường hợp — tài liệu MySQL), hoặc constraint thay vì check-then-insert.
SSI của PostgreSQL khác SERIALIZABLE của MySQL thế nào?
PG không khoá thêm, theo dõi phụ thuộc đọc–ghi và huỷ một transaction khi phát hiện vòng (40001, có thể ở lúc commit). MySQL biến SELECT thành FOR SHARE — khoá thật, xung đột thành chờ/deadlock. Cả hai đều cần retry.
Vì sao InnoDB khuyên PK ngắn và tăng dần?
Bảng là cây PK, secondary index chứa PK. PK ngẫu nhiên → page split, bảng ×1,7; PK dài → mọi index to theo (lab 2.4, tài liệu MySQL 17.6.2.1).
PostgreSQL có "clustered index" không?
Lệnh CLUSTER sắp lại heap theo một index một lần, không duy trì khi ghi tiếp. Gần nhất là index-only scan + INCLUDE, hoặc BRIN khi dữ liệu tự nhiên có thứ tự.
Vì sao PG cần PgBouncer còn MySQL ít khi?
Process-per-connection (lab 1.2): hàng trăm process chạy đồng thời đắt hơn thread. MySQL cũng dùng proxy (ProxySQL) nhưng thường vì read/write split và failover hơn là vì số kết nối.
Transactional DDL có làm migration PG zero-downtime không?
Không. Nó làm migration rollback được; còn downtime đến từ lock (AccessExclusiveLock giữ tới commit) và table rewrite — xem bài Migration PostgreSQL.
MySQL 8 có atomic DDL rồi, khác gì transactional DDL?
Atomic DDL: một câu DDL hoặc xong hết hoặc không gì (lab 7.3 DROP TABLE courses, no_such_table). Transactional DDL: nhiều câu DDL + DML trong một transaction rollback cùng nhau. MySQL không có cái thứ hai (tài liệu 15.3.3).
Upsert của hai bên khác nhau chỗ nào nguy hiểm nhất?
MySQL ODKU kích hoạt khi trùng bất kỳ unique nào → update nhầm dòng không lỗi (lab 8.3). PG bắt chỉ định một ràng buộc, trùng chỗ khác là lỗi.
Đọc replica bị dữ liệu cũ, xử lý sao?
Lấy LSN/GTID của lần ghi, chờ replica tới vị trí đó, hoặc đọc primary trong vài giây sau ghi (Laravel sticky / PreventStaleDatabaseReads) — lab 11.4.
Vì sao utf8mb4 rồi mà vẫn ra "Hòa"?
Charset của kết nối là latin1 → double encoding lúc ghi (lab 6.4). Kiểm bằng HEX(), sửa client, rồi chuyển đổi dữ liệu đã hỏng.
PostgreSQL có nhanh hơn MySQL không?
Phụ thuộc workload và cấu hình; bài này cố ý không benchmark. Có thể nói cơ chế: PG có parallel query cho quét lớn (lab 10.3), InnoDB đọc theo PK rất gọn nhờ clustered index (lab 2.3). Muốn kết luận thì benchmark đúng workload của mình.

Phụ lục: lab đã chạy

Thư mục: labs/database/pg-vs-mysql/. Chạy lại theo thứ tự số (hoặc ./run-all.sh); mỗi script ghi out/<tên script>.log. MySQL: container/network/volume tmp-pgm-*, network riêng tmp-pgm-net, không publish port; mật khẩu sinh ngẫu nhiên vào .env (gitignored). PostgreSQL: container test my-test-pgdb (repo postgresql-learning), bảng/collation/type tmp_pgm_* tự drop; standby my-test-pgstandby được bật tạm cho 12-replication rồi tắt lại đúng trạng thái trước lab. Bài HTML được dựng bằng build.py từ tpl/*.html + log: mỗi khối output trong bài là dòng nguyên văn của out/*.log (script assert từng dòng) — sau khi chạy lại lab, chạy lại build.py và đọc lại các con số trong phần chữ.

Script / fileLàm gìMục
lib.shHàm chung: m/mq (MySQL, gộp stderr trong container để ERROR đúng thứ tự, ép utf8mb4), p/pq (PG my-test-pgdb), pt, pgt_up, wait_mysql—
files/race.pyChạy 2 session song song: PG qua pgtest.parallel, MySQL qua 2 connection; mốc [t=..s] lấy từ đồng hồ server4
files/isolation.pyCác kịch bản isolation/locking (đọc lại, lost update, gap lock, đặt chỗ, write skew, UNIQUE)4
00-env.shSinh .env, kéo image, in version—
01-up.shDựng MySQL 8.4.13, in các giá trị mặc định (isolation, collation, sql_mode, max_connections) của cả hai bên4, 5, 6
02-connections.shProcess vs thread, hết slot kết nối, PgBouncer transaction pooling + 2 bẫy1
03-storage.shThứ tự dòng không ORDER BY, secondary index chứa PK, kiểu PK → kích thước (bigint / UUIDv4 / UUID có thứ tự / char(36))2
04-mvcc.shDead tuple + VACUUM, history list khi có snapshot cũ, rollback UPDATE 200.000 dòng3
05-isolation.shGọi files/isolation.py4
06-integrity.shsql_mode strict/không strict, INSERT IGNORE, CHECK, partial unique + soft delete, NULL trong UNIQUE, exclusion, deferrable FK vs FOREIGN_KEY_CHECKS, index cho FK5
07-collation.shSo sánh chuỗi tiếng Việt, UNIQUE username, ICU nondeterministic + LIKE (PG 18), double encoding do client latin16
08-ddl.shCommit ngầm, migration 3 bước hỏng giữa chừng, atomic DDL, giới hạn transactional DDL của PG7
09-upsert.shODKU vs ON CONFLICT, affected rows, gap ID, RETURNING OLD/NEW, nhiều UNIQUE, REPLACE + CASCADE8
10-types.shChia, ||, chuỗi = số + index, BOOLEAN, ENUM, TIMESTAMP/DATETIME/timestamptz, 2038, JSON/JSONB + index9
11-query.sh9 tính năng SQL, USING HASH, access method + BRIN, parallel seq scan, hash join10
12-replication.shStandby PG read-only, pg_waldump, replica MySQL ghi được + errant GTID, binlog event, read-your-writes (LSN / GTID)11
13-ops.shNâng 17→18 (từ chối), 8.0.46→8.4.13 (tự nâng, không hạ lại), công cụ backup, pg_stat_statements vs performance_schema12
99-cleanup.shXoá mọi container/volume/network tmp-pgm-*, kiểm tra không còn tmp_pgm_* trên my-test-pgdb—
run-all.sh, build.py, tpl/Chạy lại cả lab; dựng bài từ template + log (assert nguyên văn)—
cd labs/database/pg-vs-mysql
./00-env.sh && ./01-up.sh          # MySQL 8.4.13 tạm + kiểm tra my-test-pgdb
./02-connections.sh                # có container PG tạm + PgBouncer, tự xoá
./03-storage.sh && ./04-mvcc.sh && ./05-isolation.sh
./06-integrity.sh && ./07-collation.sh && ./08-ddl.sh && ./09-upsert.sh && ./10-types.sh && ./11-query.sh
./12-replication.sh                # bật tạm my-test-pgstandby nếu đang tắt, dựng replica MySQL
./13-ops.sh                        # cần thêm image postgres:17-alpine, mysql:8.0
./99-cleanup.sh
python3 build.py ../../../docs/database/postgresql-vs-mysql.html   # dựng lại bài từ log

Cách nói khi phỏng vấn: "Mọi khác biệt em nói đều đã chạy thật trên PostgreSQL 18.4 và MySQL 8.4.13 với cấu hình mặc định, mỗi kịch bản là một script ghi log, và bài viết được dựng từ chính log đó. Em chỉ đo hành vi như lỗi gì, ai chờ ai, dữ liệu cuối ra sao, chứ không benchmark tốc độ, vì tốc độ phụ thuộc workload."

Chưa verify (nguồn đang dựa vào)

Nguồn