GĐ01 — Systems Foundation: OS, Linux, Database Internals & Networking

Backend chạy trên operating system, nói chuyện qua network và lưu dữ liệu bằng database. Giai đoạn này xây mental model đủ dùng để debug production; không nhằm đào tạo kernel, network hay database engineer.

Học song song với GĐ02–05. Mỗi khái niệm phải gắn với một quan sát hoặc thí nghiệm trên máy.

Kiểm chứng ngày 2026-10-05: các thí nghiệm 1 đến 11 ở phần Thực hành đã chạy trên macOS với Node 24.21, curl 8.7.1, driver pg 8.23.1 và PostgreSQL 17.9 (một instance riêng khởi tạo trong thư mục tạm, cổng 54410, đã dừng sau khi chạy). server.keepAliveTimeoutBuffer có từ Node 24.6.0 (tài liệu http). Chưa xác minh: cột Linux của bảng công cụ debug (không có máy Linux để kiểm), tcpdump và dtruss (cần quyền root).


Phần A — Operating System#

1. Process và Thread#

Process có không gian địa chỉ và tài nguyên riêng. Thread chia sẻ bộ nhớ của process nhưng có stack và trạng thái thực thi riêng.

Trong Node.js:

  • JavaScript thường chạy trên một main thread.
  • libuv dùng thread pool cho một số tác vụ như filesystem, DNS và crypto.
  • worker_threads phù hợp CPU-bound JavaScript.
  • child_process tạo process riêng, isolation mạnh hơn nhưng giao tiếp đắt hơn.

Cần trả lời được:

  • Vì sao một vòng lặp CPU-bound chặn mọi request trên Node process?
  • Khi nào tăng số worker giúp, khi nào chỉ làm database quá tải?
  • Process crash ảnh hưởng gì tới memory và connection đang giữ?

2. Scheduling và Context Switch#

OS scheduler chia CPU time cho thread/process có thể chạy. Context switch lưu trạng thái tác vụ hiện tại và khôi phục tác vụ khác.

Nhiều concurrency không đồng nghĩa nhiều throughput:

  • Quá nhiều runnable thread làm tăng context switch.
  • Quá nhiều request đồng thời làm queue dài và tăng latency.
  • CPU utilization cao kéo dài thường làm tail latency tăng mạnh.

Thực hành: chạy một workload CPU-bound bằng một process, nhiều process và worker thread; đo throughput, p95 và CPU thay vì chỉ cảm nhận.


3. Virtual Memory, Stack và Heap#

Virtual memory cho mỗi process một không gian địa chỉ riêng, được OS ánh xạ sang physical memory.

  • Stack: call frame, biến cục bộ, lifetime theo lời gọi hàm.
  • Heap: object có lifetime linh hoạt, do runtime/garbage collector quản lý.
  • RSS: lượng memory process đang giữ trong RAM, không đồng nhất với V8 heap.
  • Swap: có thể tránh crash tức thì nhưng latency rất xấu cho server.

Trong Node, cần quan sát:

  • process.memoryUsage().
  • Heap growth qua thời gian.
  • Object bị giữ tham chiếu ngoài ý muốn.
  • Buffer/native memory không xuất hiện đầy đủ trong heapUsed.

4. System Call và File Descriptor#

Application vào kernel qua system call để đọc file, mở socket, tạo process và thực hiện I/O.

File descriptor là handle số đại diện cho file, socket, pipe và nhiều tài nguyên I/O khác.

Backend có thể cạn file descriptor vì:

  • Không đóng file/socket.
  • Connection pool đặt quá lớn.
  • Client không reuse connection.
  • Server giữ quá nhiều connection nhàn rỗi.

Khi gặp EMFILE hoặc too many open files, tăng limit chỉ là chữa triệu chứng nếu code đang leak descriptor.


Phần B — Linux Fundamentals#

5. Filesystem, Permission và User#

Cần nắm:

  • Absolute vs relative path.
  • File, directory, symbolic link.
  • Owner/group/other và quyền read/write/execute.
  • chmod, chown, umask ở mức dùng được.
  • Vì sao container/app không nên chạy bằng root.
  • Log và uploaded file cần owner/permission phù hợp.

Không commit secret hoặc .env; permission không thay thế secret manager.


6. Process, Signal và Exit Code#

Các signal quan trọng:

  • SIGTERM: yêu cầu shutdown có kiểm soát.
  • SIGINT: thường từ Ctrl+C.
  • SIGKILL: dừng ngay, process không cleanup được.
  • SIGHUP: truyền thống dùng cho reload terminal/config tuỳ application.

Exit code 0 nghĩa là thành công; khác 0 báo lỗi cho shell, CI và orchestrator.

Backend nhận SIGTERM phải:

  1. Ngừng nhận request mới.
  2. Chờ request/job đang chạy trong giới hạn thời gian.
  3. Đóng DB, queue và telemetry connection sau khi HTTP đã drain xong, không phải trước.
  4. Thoát với code phù hợp.

server.close() chỉ ngừng nhận kết nối mới và callback chỉ chạy khi mọi kết nối đã đóng, nên phải await nó (kèm hẹn giờ thoát cưỡng bức; từ Node 19 close() đã tự đóng kết nối keep-alive đang rảnh); gọi rồi process.exit(0) ngay sẽ cắt request đang chạy. Mẫu đầy đủ, đã chạy thử, nằm ở GĐ09 mục 18; không chép lại ở đây.


7. Pipe, Redirect và Shell#

Shell là công cụ quan sát production, không chỉ nơi chạy npm start.

Cần dùng được:

  • Pipe output giữa các command.
  • Redirect stdout/stderr.
  • Environment variable theo process.
  • Tìm process và port đang lắng nghe.
  • Đọc log lớn bằng filter thay vì mở cả file.
  • Hiểu quoting để tránh command injection trong script.

Nguyên tắc: production automation cần script có set -euo pipefail, input được quote, exit code rõ và không in secret.


Phần C — Database Internals#

8. Page, B-tree và Index#

Database đọc/ghi theo page, không theo từng field độc lập. B-tree giảm số page cần đọc bằng một cây thấp, nhiều nhánh.

Cần hiểu:

  • Index lookup thường gần O(log n) nhưng cost thật phụ thuộc I/O và cache.
  • Composite index tuân theo thứ tự cột.
  • Index-only scan cần dữ liệu phù hợp và visibility information.
  • Nhiều index làm write chậm hơn và tốn storage.
  • Low-cardinality column đứng một mình thường không chọn lọc tốt.

Luôn kiểm bằng EXPLAIN (ANALYZE, BUFFERS), không phỏng đoán từ tên index.


9. WAL và Durability#

Write-Ahead Log ghi mô tả thay đổi bền vững trước khi data page được flush.

WAL giúp:

  • Recovery sau crash.
  • Replication.
  • Point-in-time recovery khi kết hợp base backup và archive phù hợp.

COMMIT thành công không nhất thiết nghĩa mọi data page đã được ghi xuống vị trí cuối; nó nghĩa durability contract của database/WAL đã được đáp ứng theo cấu hình.

Sơ đồ: thứ tự ghi của một COMMIT

Sơ đồ khái niệm (suy ra từ tài liệu PostgreSQL về WAL), không phải đo đạc.

textReady
 UPDATE (trong transaction)   |  1. sửa data page trong shared buffers (RAM), page thành "dirty"   |  2. ghi bản ghi mô tả thay đổi vào WAL buffer COMMIT   |  3. ghi WAL xuống đĩa + fsync   <- tới đây COMMIT mới trả OK   v client nhận "COMMIT"   ...  (về sau, bất kỳ lúc nào)   |  4. checkpoint/background writer ghi dirty page xuống file dữ liệu Crash giữa bước 3 và 4: khởi động lại, đọc WAL, phát lại thay đổi chưa có trong file dữ liệu (redo)  -> không mất giao dịch đã COMMIT. Crash trước bước 3: COMMIT chưa trả OK -> giao dịch coi như chưa xảy ra.

Điểm cần nhớ: ghi tuần tự vào WAL rẻ hơn ghi ngẫu nhiên nhiều data page, nên đó là chỗ database chọn để đảm bảo durability. synchronous_commit = off đổi một khoảng mất dữ liệu ngắn lấy độ trễ thấp hơn, không làm hỏng dữ liệu.


10. MVCC và Vacuum#

MVCC cho transaction đọc snapshot mà không chặn mọi writer. PostgreSQL tạo version mới của row khi update thay vì sửa tại chỗ theo cách đơn giản.

Hệ quả:

  • Transaction giữ quá lâu giữ lại old row versions.
  • Dead tuples làm table/index phình.
  • Autovacuum là thành phần correctness và operations quan trọng, không phải housekeeping tuỳ chọn.
  • “Không có lock ở application” không nghĩa database không quản lý concurrency.
Sơ đồ: vì sao transaction dài giữ lại dead tuple

Sơ đồ khái niệm; lệnh để tự quan sát nằm ở lời giải thí nghiệm 8 trong "Thực hành bắt buộc".

textReady
 thời gian -> A: BEGIN (snapshot) ............ vẫn còn mở ..................... COMMIT B:        UPDATE row (v1 -> v2)   VACUUM        VACUUM (sau khi A COMMIT) row v1:   [sống]──[dead với B]──[chưa dọn được]──────[dọn được]                                 ^ A vẫn có thể cần thấy v1 (snapshot cũ) nên VACUUM phải giữ lại v1. Hệ quả: bảng và index phình dần cho tới khi transaction cũ nhất kết thúc.

11. Locking, Isolation và Anomaly#

Cần phân biệt:

  • Row lock, table lock, advisory lock.
  • Read Committed, Repeatable Read, Serializable.
  • Dirty read, non-repeatable read, phantom, lost update, write skew.
  • Blocking vs deadlock.

Khi deadlock xảy ra, database huỷ một transaction để phá vòng chờ. Application phải rollback và retry có giới hạn nếu operation an toàn để retry.

Thực hành: mở hai session PostgreSQL, cố ý tạo lost update và deadlock; quan sát lock và sửa bằng transaction/locking phù hợp.


Phần D — Networking sâu hơn#

12. Socket Lifecycle#

Một TCP server đi qua các bước chính:

  1. Tạo socket.
  2. Bind địa chỉ/port.
  3. Listen.
  4. Accept connection.
  5. Read/write.
  6. Close.

TCP connection được nhận diện bởi source IP/port và destination IP/port. Sau khi đóng, trạng thái như TIME_WAIT tồn tại để xử lý packet trễ và tránh nhầm connection cũ.

Sơ đồ: vòng đời socket phía server và client
textReady
  SERVER                                  CLIENT  socket() -> bind(:port) -> listen()  (state LISTEN)                          socket()                                          connect() ---- SYN ------->  accept() <- kết nối nằm trong hàng đợi  <-- SYN-ACK ---  (sinh socket mới cho kết nối này)       ---- ACK -----> (ESTABLISHED)  read()/write()  <==== dữ liệu hai chiều ====>  write()/read()  close()  ---- FIN ----->                         ...  (bên đóng TRƯỚC giữ TIME_WAIT một thời gian)

Trong Node, server.listen() gồm socket + bind + listen, còn accept do libuv làm và đưa ra sự kiện connection. Lỗi EADDRINUSE là bước bind thất bại vì process khác đang giữ port; EMFILE là hết file descriptor khi mở socket mới. Quan sát trạng thái: lsof -nP -iTCP:<port> (macOS) hoặc ss -tan (Linux).


13. Connection Pool và Connection Exhaustion#

Mỗi connection có chi phí ở client, server, proxy và database.

Nếu mỗi application instance mở pool 20 connection và scale lên 20 instance, database có thể nhận tới 400 connection chưa tính worker và migration.

Nguyên tắc:

  • Budget connection trên toàn hệ thống, không riêng từng process.
  • Queue có giới hạn tốt hơn mở connection vô hạn.
  • Đặt acquire timeout.
  • Theo dõi active, idle, waiting và saturation.
  • Scale app phải xem lại DB pool.
Lời giải: tính connection budget

Công thức: tổng kết nối = số process × pool mỗi process, cộng thêm phần dành cho thứ không phải app. Ví dụ với max_connections = 100 (giá trị mẫu, đọc giá trị thật bằng SHOW max_connections):

textReady
 Dành riêng: superuser/giám sát/migration/psql của người trực   10 Còn cho app:                                                    90 API : 4 instance × pool 10                                      40 Worker: 2 instance × pool 5                                     10 Cộng:                                                           50 (còn 40 đệm) Scale API lên 8 instance (pool 10)                80 + 10 = 90 (vừa kịp) Scale API lên 10 instance                         110 > 90 -> hết kết nối

Khi cạn: giảm pool, đặt PgBouncer ở chế độ transaction pooling, hoặc tách đọc/ghi. Trong lúc rolling deploy, instance cũ và mới tồn tại cùng lúc nên phải tính cả hai. Số 100 và ví dụ trên chỉ để minh hoạ phép tính.


14. Keep-Alive và Timeout Budget#

Keep-alive tái sử dụng connection, giảm chi phí TCP/TLS handshake. Nhưng connection nhàn rỗi cũng giữ tài nguyên.

Các timeout phải phối hợp:

  • Client request timeout.
  • Load balancer/proxy timeout.
  • Server timeout.
  • Database acquire/query timeout.
  • Upstream API timeout.

Timeout ngoài nên lớn hơn timeout trong vừa đủ để tầng trong có cơ hội fail rõ ràng; không đặt mọi tầng cùng một con số.

Sơ đồ: timeout lồng nhau

Các con số là ví dụ minh hoạ, không phải khuyến nghị cố định.

textReady
 client timeout   |----------------------------------------| 30 s LB / proxy       |------------------------------------|     28 s Node request     |---------------------------------|        25 s DB query / API   |---------------|                          10 s Ngoài lớn hơn trong: tầng trong fail trước và trả lỗi RÕ (504/timeout có mã lỗi), thay vì client bỏ đi trong khi server vẫn làm việc vô ích. Keep-alive: timeout nhàn rỗi của server phải LỚN hơn timeout nhàn rỗi của LB phía trước (vd LB 60 s thì server 65 s), nếu không LB gửi request vào kết nối server vừa đóng và client thấy 502 ngẫu nhiên.

Giá trị mặc định và mốc phiên bản của Node (keepAliveTimeout 5 s) xem GĐ03 mục 12a và GĐ15. Thí nghiệm 9 ở phần Thực hành chứng minh cuộc đua này bằng số liệu.


15. HTTP/1.1, HTTP/2 và HTTP/3#

HTTP/1.1#

  • Nhiều request có thể reuse connection với keep-alive.
  • Browser thường mở nhiều connection để tăng song song.
  • Pipelining ít được dùng rộng rãi vì head-of-line blocking và complexity.

HTTP/2#

  • Multiplex nhiều stream trên một TCP connection.
  • Header compression.
  • TCP packet loss vẫn có thể ảnh hưởng mọi stream trên connection.

HTTP/3#

  • Chạy trên QUIC/UDP.
  • Stream độc lập hơn khi packet loss.
  • Connection migration hữu ích khi client đổi mạng.
  • Deployment, proxy và observability phức tạp hơn.

Đối với application engineer, hiểu trade-off và kiểm tra support của CDN/load balancer là đủ; không cần tự triển khai protocol.


Thực hành bắt buộc#

  1. Viết Node server xử lý SIGTERM và chứng minh request đang chạy hoàn tất: tạo route /slow mất vài giây, curl nó, gửi kill -TERM <pid> giữa chừng, và kiểm tra curl vẫn nhận đủ body (exit code 0) rồi process mới thoát. Dùng mẫu ở GĐ09 mục 18.

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

    Hướng làm: route /slow mất 3 giây; trình xử lý signal đặt cờ, hẹn giờ thoát cưỡng bức, await server.close(), rồi mới đóng dependency và exit(0) (mẫu đầy đủ ở GĐ09 mục 18).

    typescriptReady
    // server.mjsimport http from 'node:http'const server = http.createServer((req, res) => {  if (req.url === '/slow') return void setTimeout(() => res.end('done\n'), 3000)  res.end('ok\n')})let shuttingDown = falseasync function shutdown(signal) {  if (shuttingDown) return  shuttingDown = true  console.log(`${signal}: draining`)  setTimeout(() => process.exit(1), 10_000).unref() // nhỏ hơn grace period của orchestrator  await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())))  // đóng DB pool, queue, telemetry ở đây, SAU khi HTTP đã drain  console.log('closed')  process.exit(0)}process.on('SIGTERM', () => void shutdown('SIGTERM'))process.on('SIGINT', () => void shutdown('SIGINT'))server.listen(4410, '127.0.0.1')
    bashReady
    node server.mjs & PID=$!sleep 1curl -sS localhost:4410/slow & CURL=$!sleep 1; kill -TERM $PIDwait $CURL; echo "curl exit=$?"; wait $PID; echo "server exit=$?"

    Kết quả mong đợi: curl in done, curl exit=0; server log SIGTERM: draining rồi closed, server exit=0, và process thoát sau khi request xong (khoảng 2 giây sau kill, không phải ngay). Đối chứng (bỏ await, gọi server.close(); process.exit(0)): request bị cắt, curl in lỗi "Empty reply from server" và curl exit=52. Đã chạy (Node 24.21, macOS): curl in done, curl exit=0; server log SIGTERM: draining, closed, server exit=0 sau 2 giây. Bản đối chứng server.close() rồi thoát ngay: curl: (52) Empty reply from server, curl exit=52, server thoát ngay (0 giây). Lỗi hay gặp: SIGINT + SIGTERM cùng đến gọi shutdown hai lần (đó là lý do có cờ shuttingDown); chạy qua sh -c hoặc shell script làm PID 1 trong container thì signal đến shell chứ không đến node (dùng dạng exec của CMD hoặc exec node ...; chạy npm start cũng thêm một tầng trung gian nên nên chạy node trực tiếp).

  2. Quan sát PID, port, open file/socket và memory của server.

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

    Hướng làm: chạy server (thí nghiệm 1) rồi đọc từ bên ngoài, sau đó so sánh với số liệu bên trong process.

    bashReady
    PID=$(lsof -nP -iTCP:4410 -sTCP:LISTEN -t)          # ai giữ port 4410lsof -nP -iTCP:4410                                    # kết nối đang mở tới portlsof -p "$PID" | wc -l                                 # số descriptor (xấp xỉ)ps -o pid,rss,vsz,command -p "$PID"                    # rss, vsz tính bằng KB# Linux: ss -ltnp | grep 4410 ; ls /proc/$PID/fd | wc -l ; grep VmRSS /proc/$PID/status
    typescriptReady
    // thêm vào server.mjs: route /mem cấp phát Buffer ngoài heapconst keep = []// trong handler: if (req.url === '/mem') { keep.push(Buffer.alloc(200 * 1024 * 1024)); }console.log(process.memoryUsage()) // rss, heapTotal, heapUsed, external, arrayBuffers

    Kết quả mong đợi: sau mỗi lần gọi /mem, arrayBuffers tăng khoảng 200 MB còn heapUsed gần như không đổi (Buffer nằm ngoài V8 heap); đó là lý do Done khi đòi phân biệt heap, native memory và RSS. Để rss tăng đúng bằng đó, ghi vào buffer (ví dụ Buffer.alloc(n).fill(1)), vì OS thường chỉ cấp page vật lý khi page được chạm tới. Để thấy leak descriptor, mở 200 kết nối rồi giữ: for i in $(seq 200); do nc 127.0.0.1 4410 & done, đếm lại lsof -p "$PID" | wc -l sẽ tăng khoảng 200; đóng nc thì số giảm. Chạm ulimit -n thì mở thêm descriptor lỗi EMFILE; thử trên một process con (sh đặt cả soft lẫn hard limit):

    bashReady
    sh -c 'ulimit -n 256; node -e "const fs=require(\"fs\");const fds=[];try{for(;;)fds.push(fs.openSync(\"/dev/null\",\"r\"))}catch(e){console.log(e.code,fds.length)}"'

    Đã chạy: in EMFILE 245 (256 trừ khoảng chục descriptor có sẵn của chính Node). Với server chịu limit 256 và 400 kết nối cùng lúc, server không ném lỗi hay phát sự kiện error: kernel trả EMFILE cho accept, libuv bỏ kết nối thừa, và phía client thấy khoảng 150 trên 400 kết nối bị đóng ngay. Hai điểm cần biết: trong zsh, ulimit -n 64 chỉ hạ soft limit và Node nâng soft limit lên bằng hard limit khi khởi động (đã thấy mở được hơn 184 000 descriptor, xấp xỉ sysctl kern.maxfilesperproc = 184320), nên dùng sh -c hoặc đặt cả ulimit -Hn; limit quá thấp (ví dụ 64) thì Node không khởi động nổi (kqueue(): Too many open files). Đã đo thêm với server có route /mem: hai lần gọi cho arrayBuffers 200 rồi 400 MB, heapUsed quanh 6 đến 7 MB, rss 262 rồi 463 MB (có fill(1)); 200 kết nối nc đưa lsof -p "$PID" | wc -l từ 22 lên 222, đóng nc thì về 22. Lỗi hay gặp: nâng ulimit -n mà không tìm leak.

  3. Cố ý tạo CPU-bound route, đo ảnh hưởng lên request khác, sau đó chuyển sang worker thread.

    Lời giải và cách kiểm tra
    typescriptReady
    // cpu.mjsimport http from 'node:http'import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads'const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2))if (!isMainThread) {  parentPort.postMessage(fib(workerData))} else {  http    .createServer((req, res) => {      if (req.url === '/cpu') return void res.end(`${fib(40)}\n`) // chặn main thread      if (req.url === '/cpu-worker') {        const w = new Worker(new URL(import.meta.url), { workerData: 40 })        w.once('message', (v) => res.end(`${v}\n`))        w.once('error', () => res.writeHead(500).end())        return      }      res.end('pong\n')    })    .listen(4411, '127.0.0.1')}
    bashReady
    curl -s localhost:4411/cpu &   sleep 0.1curl -s -o /dev/null -w 'ping while /cpu: %{time_total}s\n' localhost:4411/pingwaitcurl -s localhost:4411/cpu-worker &   sleep 0.1curl -s -o /dev/null -w 'ping while /cpu-worker: %{time_total}s\n' localhost:4411/pingwait

    Kết quả mong đợi: /ping khi /cpu chạy phải chờ gần hết thời gian của fib(40) (cỡ giây, tuỳ máy); khi dùng /cpu-worker thì /ping trả trong vài ms. Để so sánh một process, nhiều process, worker thread, ghi mỗi cấu hình ba số (throughput, p95, CPU) bằng cùng một công cụ tải; throughput chỉ tăng đến khoảng số core rồi phẳng, p95 xấu đi khi số tác vụ chạy được vượt số core. Đã chạy: ping while /cpu 0,45 đến 0,51 giây (máy 12 core; fib(40) chạy khoảng 0,5 giây nên chờ gần trọn thời gian đó), ping while /cpu-worker 0,0007 giây. Lỗi hay gặp: new Worker mỗi request không nên dùng ngoài thí nghiệm (dùng worker pool, xem GĐ03 mục 10).

  4. Benchmark HTTP request với keep-alive bật và tắt.

    Lời giải và cách kiểm tra
    typescriptReady
    // bench-keepalive.mjs: chạy cùng server ở thí nghiệm 1import http from 'node:http'async function run(keepAlive, total = 2000) {  const agent = new http.Agent({ keepAlive, maxSockets: 1 })  const t0 = process.hrtime.bigint()  for (let i = 0; i < total; i++) {    await new Promise((resolve, reject) => {      http        .get({ host: '127.0.0.1', port: 4410, path: '/', agent }, (res) => {          res.resume()          res.on('end', resolve)        })        .on('error', reject)    })  }  agent.destroy()  const ms = Number(process.hrtime.bigint() - t0) / 1e6  console.log(`keepAlive=${keepAlive}: ${ms.toFixed(0)} ms cho ${total} request`)}await run(true, 200) // khởi động nóng (JIT), bỏ dòng log nàyawait run(true)await run(false)

    Kết quả mong đợi: keepAlive=true nhanh hơn false, nhưng chênh lệch trên loopback nhỏ vì không có latency mạng hay TLS; chênh lệch lớn hơn khi gọi qua mạng thật hoặc HTTPS (mỗi kết nối mới tốn thêm round-trip TCP và TLS). Kiểm chứng cơ chế bằng đếm kết nối: sau lần false, netstat -an | grep 4410 | grep -c TIME_WAIT phải ra cỡ hàng nghìn dòng (mỗi request một kết nối đã đóng, macOS và Linux), còn lần true chỉ dùng một socket. Đã chạy: keepAlive=true 110 ms, keepAlive=false 208 ms cho 2 000 request (loopback, chênh khoảng 2 lần, đúng nhận định chênh nhỏ ở đây); sau lần false, netstat -an | grep 4410 | grep -c TIME_WAIT ra 2002 dòng (2000 kết nối của lượt false cộng 2 socket keep-alive của hai lượt true mà agent.destroy() đóng; TIME_WAIT chỉ nằm ở phía chủ động đóng, ở đây là client: cả 2002 dòng có cột foreign là cổng 4410, không dòng nào ở đầu server; mỗi kết nối hiện một lần, không phải hai; chạy lại nhiều lần liên tiếp trong cửa sổ TIME_WAIT thì số dòng cộng dồn). Lỗi hay gặp: benchmark mà không khởi động nóng (code trên chạy trước một lượt 200 request và bỏ dòng log đầu), bản chạy đầu chịu chi phí JIT.

  5. Làm cạn một connection pool có giới hạn, quan sát waiting và timeout.

    Lời giải và cách kiểm tra
    typescriptReady
    // pool.mjs (npm i pg)import pg from 'pg'const pool = new pg.Pool({  host: '127.0.0.1', port: 54410, user: 'lab', database: 'postgres',  max: 3, connectionTimeoutMillis: 1000,})const started = Date.now()const tasks = Array.from({ length: 10 }, (_, i) =>  pool.query('select pg_sleep(2)').then(    () => console.log(`#${i} ok sau ${Date.now() - started} ms`),    (err) => console.log(`#${i} ${err.message} sau ${Date.now() - started} ms`),  ),)setTimeout(() => console.log({ total: pool.totalCount, idle: pool.idleCount, waiting: pool.waitingCount }), 500)await Promise.all(tasks)await pool.end()

    Kết quả mong đợi: tại 500 ms thấy { total: 3, idle: 0, waiting: 7 }; 3 query đầu hoàn tất sau khoảng 2000 ms; 7 query còn lại bị từ chối sau khoảng 1000 ms với timeout exceeded when trying to connect (vì acquire timeout 1 s ngắn hơn thời gian chờ 2 s để có slot). Đặt connectionTimeoutMillis đủ lớn (từ 7000 trở lên, ví dụ 10000) thì cả 10 query đều thành công theo bốn đợt (3, 3, 3, 1): query thứ 10 phải chờ khoảng 6 s, nên 5000 vẫn timeout. Bài học: waiting tăng là tín hiệu bão hoà, và pool không có timeout thì request treo vô hạn (tham số connectionTimeoutMillis mặc định là 0 tức không giới hạn, theo tài liệu node-postgres). Đã chạy (pg 8.23.1, PostgreSQL 17.9, cổng 54410): tại 500 ms in { total: 3, idle: 0, waiting: 7 }; với 1000 ms bảy query lỗi timeout exceeded when trying to connect sau 1002 đến 1003 ms, ba query còn lại xong ở 2007 ms; với 5000 ms query thứ 10 lỗi ở 5002 ms trong khi các query khác xong ở 2, 4 và 6 giây; với 10000 ms cả 10 query xong, query cuối ở 8012 ms. Lỗi hay gặp: quên pool.end() nên script không thoát.

  6. Tạo index PostgreSQL, so sánh EXPLAIN (ANALYZE, BUFFERS) trước/sau.

    Lời giải và cách kiểm tra
    textReady
    CREATE TABLE orders AS  SELECT g AS id, (random() * 100000)::int AS customer_id, now() - random() * interval '365 days' AS created_at  FROM generate_series(1, 1000000) g;ANALYZE orders;EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 4242;   -- trướcCREATE INDEX orders_customer_id_idx ON orders (customer_id);ANALYZE orders;EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 4242;   -- sau

    Kết quả mong đợi: trước đây là Seq Scan on orders (với bảng này planner chọn Parallel Seq Scan dưới Gather) kèm Filter và Rows Removed by Filter xấp xỉ cả bảng, Buffers đọc gần toàn bộ page của bảng; sau là Index Scan hoặc Bitmap Heap Scan trên orders_customer_id_idx với Buffers nhỏ hơn rất nhiều và Execution Time giảm tương ứng (khoảng 10 row mỗi customer_id). Cách đọc: so actual rows với rows (ước lượng), đọc Buffers: shared hit (trong cache) và read (từ đĩa). Thử thêm WHERE customer_id < 90000 (không chọn lọc) để thấy planner vẫn chọn Seq Scan: index không luôn thắng. Đã chạy (PostgreSQL 17.9, 1 triệu dòng): trước là Parallel Seq Scan 11,96 ms, Buffers: shared hit=2304 read=3136; sau là Bitmap Index Scan kèm Bitmap Heap Scan 0,053 ms, Buffers: shared hit=6 read=7; customer_id < 90000 vẫn Seq Scan 48,8 ms. Lỗi hay gặp: quên ANALYZE nên thống kê cũ; dùng EXPLAIN không ANALYZE (chỉ là ước lượng, không chạy thật); chạy EXPLAIN ANALYZE với UPDATE/DELETE thật (nó thực thi, bọc trong BEGIN ... ROLLBACK).

  7. Tạo deadlock bằng hai transaction và ghi lại thứ tự lock đúng để tránh nó.

    Lời giải và cách kiểm tra
    textReady
    CREATE TABLE accounts (id int PRIMARY KEY, balance int NOT NULL);INSERT INTO accounts VALUES (1, 100), (2, 100);

    Deadlock: mở hai terminal psql, chạy theo thứ tự thời gian.

    textReady
     Session A                                Session B BEGIN;                                   BEGIN; UPDATE accounts SET balance =   balance - 10 WHERE id = 1;             UPDATE accounts SET balance =                                            balance - 10 WHERE id = 2; UPDATE ... WHERE id = 2;  (chờ B)                                          UPDATE ... WHERE id = 1;  (chờ A) sau ~1 s (deadlock_timeout) một bên nhận: ERROR:  deadlock detected   (SQLSTATE 40P01) DETAIL: Process X waits for ShareLock on transaction Y; blocked by ...

    Bên bị huỷ phải ROLLBACK; bên còn lại hoàn tất. Cách sửa: mọi transaction khoá theo cùng một thứ tự, ví dụ luôn khoá id nhỏ trước: SELECT id FROM accounts WHERE id IN (1, 2) ORDER BY id FOR UPDATE; rồi mới UPDATE. Chạy lại: một bên chờ bên kia commit, không còn deadlock. Application bắt 40P01 và retry có giới hạn (chỉ khi thao tác an toàn để lặp).

    Lost update (mức mặc định Read Committed): A và B cùng đọc balance = 100 ở app, A ghi 90, B ghi 80; kết quả 80 nhưng đúng phải là 70.

    textReady
     A: BEGIN; SELECT balance FROM accounts WHERE id = 1;   -- 100 B: BEGIN; SELECT balance FROM accounts WHERE id = 1;   -- 100 A: UPDATE accounts SET balance = 90 WHERE id = 1;  COMMIT; B: UPDATE accounts SET balance = 80 WHERE id = 1;  COMMIT;   -- ghi đè A

    Ba cách sửa, chọn theo ngữ cảnh: (1) cập nhật nguyên tử SET balance = balance - 10 (không đọc rồi ghi ở app); (2) SELECT ... FOR UPDATE để B chờ A rồi đọc giá trị mới; (3) REPEATABLE READ thì B bị lỗi could not serialize access due to concurrent update (SQLSTATE 40001) và app phải retry. Đã chạy (PostgreSQL 17.9, hai tiến trình psql điều phối bằng sleep): deadlock ra ERROR: deadlock detected kèm DETAIL: Process X waits for ShareLock on transaction ...; blocked by process Y, bên bị huỷ ROLLBACK, bên còn lại commit (số dư 90 và 90). Với khoá có thứ tự (ORDER BY id FOR UPDATE) cả hai commit sau khoảng 2 giây chờ, số dư 80 và 80, không có deadlock. Lost update: đọc rồi ghi giá trị tuyệt đối ra 80, cập nhật nguyên tử ra 70; FOR UPDATE khiến B đọc 90 sau khi A commit; REPEATABLE READ cho ERROR: could not serialize access due to concurrent update. Lỗi hay gặp: nghĩ "có transaction là đủ"; transaction ở Read Committed không chặn kiểu đọc rồi ghi này.

  8. Quan sát một long-running transaction ảnh hưởng vacuum/dead tuples trong môi trường local.

    Lời giải và cách kiểm tra
    textReady
    CREATE TABLE t AS SELECT g AS id, 0 AS v FROM generate_series(1, 200000) g;-- Session A: giữ snapshot cũBEGIN ISOLATION LEVEL REPEATABLE READ;SELECT count(*) FROM t;                      -- để lại transaction mở-- Session B:UPDATE t SET v = v + 1;                      -- tạo ~200000 phiên bản cũVACUUM (VERBOSE) t;SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 't';SELECT pid, state, xact_start, backend_xmin FROM pg_stat_activity WHERE state <> 'idle';-- Session A: COMMIT;   rồi Session B: VACUUM (VERBOSE) t;  (chạy lại)

    Kết quả mong đợi: lần VACUUM VERBOSE đầu (A còn mở) báo gần 0 tuple bị loại bỏ và một số lớn "dead but not yet removable"; pg_stat_activity cho thấy session A có xact_start cũ và backend_xmin được đặt; sau khi A COMMIT, lần VACUUM thứ hai loại bỏ khoảng 200000 tuple. Câu chữ chính xác của log thay đổi theo phiên bản, hãy tìm cụm "dead but not yet removable". Với session ở trạng thái idle in transaction kéo dài, đặt idle_in_transaction_session_timeout ở server local để thấy nó bị ngắt. Đã chạy (PostgreSQL 17.9): lần VACUUM đầu in tuples: 0 removed, 400000 remain, 200000 are dead but not yet removable, n_dead_tup bằng 200000, session A hiện idle in transaction với xact_start và backend_xmin đã đặt; sau COMMIT của A, lần hai in tuples: 200000 removed, 200000 remain, 0 are dead but not yet removable và n_dead_tup về 0. Lỗi hay gặp: dùng SELECT trong transaction rồi quên COMMIT trong ứng dụng (đặc biệt khi await gọi API ngoài giữa transaction); mở transaction chỉ để gọi dịch vụ ngoài.

  9. Chứng minh cuộc đua giữa keep-alive của server và idle timeout của load balancer (nguồn của 502 rải rác).

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

    Hướng làm: server Node có keepAliveTimeout 200 ms; một client TCP thô đóng vai load balancer (không đọc gợi ý Keep-Alive của server), dùng lại kết nối sau một khoảng rảnh, lặp 100 lần và đếm kết quả. keepAliveTimeoutBuffer (Node 24.6+, mặc định 1000 ms) được cộng vào keepAliveTimeout; đặt về 0 để thấy cơ chế trần trụi. Server thật đóng kết nối rảnh sau keepAliveTimeout + buffer.

    typescriptReady
    import http from 'node:http'import net from 'node:net'const SERVER_IDLE = Number(process.argv[2] ?? 200) // keepAliveTimeout của server (ms)const BUFFER = Number(process.argv[3] ?? 0) // keepAliveTimeoutBuffer (Node 24.6+, mặc định 1000)const server = http.createServer({ keepAliveTimeoutBuffer: BUFFER }, (_req, res) => res.end('ok'))server.keepAliveTimeout = SERVER_IDLEawait new Promise((r) => server.listen(4413, '127.0.0.1', r))// "LB" = client TCP thô: không đọc gợi ý Keep-Alive của server, tái dùng socket// sau LB_REUSE_AFTER ms rảnh (idle thực tế của server = keepAliveTimeout + buffer)const LB_REUSE_AFTER = Number(process.argv[4] ?? SERVER_IDLE) // LB dùng lại sau chừng này ms rảnhconst REQ = 'GET / HTTP/1.1\r\nHost: x\r\n\r\n'async function attempt() {  const socket = net.connect(4413, '127.0.0.1')  let replies = 0, closed = false, code = null  socket.on('data', () => replies++)  socket.on('close', () => (closed = true))  socket.on('error', (err) => (code = err.code))  await new Promise((r) => socket.once('connect', r))  socket.write(REQ)  while (replies < 1) await new Promise((r) => setTimeout(r, 1))  await new Promise((r) => setTimeout(r, LB_REUSE_AFTER + Math.random() * 10 - 5))  if (closed) return 'đã thấy FIN, mở kết nối mới'  socket.write(REQ) // client chưa biết server đã đóng: FIN còn trên đường đi  const deadline = Date.now() + 500  while (replies < 2 && !closed && Date.now() < deadline) await new Promise((r) => setTimeout(r, 1))  socket.destroy()  return replies >= 2 ? 'ok' : (code ?? 'đóng giữa chừng, không có phản hồi')}const tally = {}for (let i = 0; i < 100; i++) {  const outcome = await attempt()  tally[outcome] = (tally[outcome] ?? 0) + 1}console.log(`server idle ${SERVER_IDLE} ms + buffer ${BUFFER} ms, LB dùng lại sau ~${LB_REUSE_AFTER} ms:`, tally)server.close()server.closeAllConnections()
    bashReady
    node race-lb.mjs 200 0          # buffer 0, LB dùng lại sau 200 msnode race-lb.mjs 200 1000 200   # buffer mặc định, LB vẫn dùng lại sau 200 msnode race-lb.mjs 200 1000 1200  # buffer mặc định, LB dùng lại đúng lúc server đóng

    Đã chạy (Node 24.21, 100 lần mỗi dòng; số lần thay đổi mỗi lượt, nhưng hình dạng giữ nguyên):

    textReady
     buffer 0,    LB dùng lại sau 200 ms : ECONNRESET 15, đóng giữa chừng 13,                                       ok 28, thấy FIN trước 44 buffer 1000, LB dùng lại sau 200 ms : ok 100 buffer 1000, LB dùng lại sau 1200 ms: ECONNRESET 27, đóng giữa chừng 11,                                       ok 17, thấy FIN trước 45

    Cách đọc: lỗi (ECONNRESET, hoặc kết nối bị đóng không có phản hồi) chỉ xuất hiện khi LB dùng lại kết nối đúng quanh lúc server đóng, vì FIN của server còn trên đường đi trong khi LB đã ghi request. Dòng thứ hai ra 100 trên 100 ok chỉ vì LB (socket thô, không đọc gợi ý Keep-Alive) dùng lại ở 200 ms, còn xa mốc đóng thật 1200 ms. Theo tài liệu Node, buffer mặc định 1000 ms tồn tại để client có đọc gợi ý Keep-Alive: timeout rời đi trước khi server đóng; thí nghiệm này không đo điều đó. Buffer không xoá cuộc đua với LB dùng lại đúng mốc đóng (dòng thứ ba). Cách xử lý thật: idle timeout của server phải lớn hơn của LB phía trước (LB 60 giây thì server 65 giây), xem mục 14 và GĐ03 mục 12a. Client Node (http.Agent với keepAlive) đọc gợi ý của server nên không tự gặp cuộc đua này: bản thử với http.get cho 100 trên 100 ok, đó là lý do thí nghiệm dùng socket thô. Lỗi hay gặp: đo trên localhost bằng một client có tôn trọng gợi ý rồi kết luận "không có vấn đề"; đặt keepAliveTimeout của Node nhỏ hơn idle timeout của LB.

  10. Tách thời gian của một request bằng curl -w: DNS, connect, TLS, time-to-first-byte, tổng.

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

    Hướng làm: chạy server của thí nghiệm 1 (route /slow mất 3 giây) rồi in từng mốc thời gian mà curl đo.

    bashReady
    curl -s -o /dev/null -w 'dns %{time_namelookup}s | connect %{time_connect}s | tls %{time_appconnect}s | ttfb %{time_starttransfer}s | total %{time_total}s\n' http://localhost:4410/slow# tái dùng kết nối: hai URL trong một lệnh, in thêm số kết nối mớicurl -s -o /dev/null -w 'connect=%{time_connect}s ttfb=%{time_starttransfer}s num_connects=%{num_connects}\n' http://127.0.0.1:4410/ --next -s -o /dev/null -w 'connect=%{time_connect}s ttfb=%{time_starttransfer}s num_connects=%{num_connects}\n' http://127.0.0.1:4410/

    Đã chạy (curl 8.7.1): dns 0.000016s | connect 0.000489s | tls 0.000000s | ttfb 3.007122s | total 3.007177s. Lần gọi hai trong lệnh thứ hai in connect=0.000000s và num_connects=0: kết nối được dùng lại. Cách đọc: connect - dns là bắt tay TCP; tls - connect là bắt tay TLS (bằng 0 ở đây vì HTTP thường, chưa chạy với HTTPS); ttfb - tls (hoặc ttfb - connect) là thời gian server xử lý cộng một chiều mạng; total - ttfb là tải body. Ở ví dụ gần như toàn bộ 3 giây nằm ở ttfb, nên lỗi ở server chứ không phải mạng. Lỗi hay gặp: lấy time_total rồi kết luận mạng chậm; quên rằng mốc time_* là thời gian cộng dồn từ đầu chứ không phải từng pha.

  11. Tìm ai đang chặn ai trong PostgreSQL: pg_stat_activity, pg_blocking_pids và pg_locks.

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

    Hướng làm: session A mở transaction, cập nhật một dòng và giữ; session B cập nhật cùng dòng nên bị chặn; session thứ ba chạy truy vấn bên dưới (dùng bảng accounts của thí nghiệm 7).

    textReady
    -- Session A: BEGIN; UPDATE accounts SET balance = balance - 10 WHERE id = 1;  (giữ)-- Session B: UPDATE accounts SET balance = balance - 20 WHERE id = 1;          (bị chặn)-- Session C:SELECT pid, pg_blocking_pids(pid) AS blocked_by, state, wait_event_type, wait_event,       left(query, 45) AS queryFROM pg_stat_activityWHERE cardinality(pg_blocking_pids(pid)) > 0;SELECT pid, locktype, mode, granted FROM pg_locks WHERE NOT granted;SELECT a.pid, a.state, now() - a.xact_start AS xact_age, left(a.query, 45) AS last_queryFROM pg_stat_activity aWHERE a.pid IN (SELECT unnest(pg_blocking_pids(pid)) FROM pg_stat_activity);

    Đã chạy (PostgreSQL 17.9): truy vấn đầu trả đúng một dòng, session B với blocked_by = {pidA}, state = active, wait_event_type = Lock, wait_event = transactionid; pg_locks cho dòng transactionid | ShareLock | f của B; truy vấn thứ ba cho thấy A ở trạng thái idle in transaction với xact_age hai giây và câu lệnh cuối là UPDATE. Cách đọc: người gây chặn thường không "đang chạy" gì cả, chỉ là một transaction mở quên COMMIT; xử lý bằng cách sửa ứng dụng, đặt idle_in_transaction_session_timeout, và chỉ khi khẩn cấp mới SELECT pg_terminate_backend(pidA). Lỗi hay gặp: chỉ nhìn pg_stat_activity của session bị chặn rồi không tìm ra thủ phạm; dùng pg_locks mà không nối được về pid (hãy bắt đầu từ pg_blocking_pids).

Khung và mã dùng chung

Tự làm trước, rồi mới mở. Các thí nghiệm 1 đến 11 đã được chạy thật (xem dòng "Đã chạy" trong từng lời giải); riêng những chỗ ghi "chưa chạy" (TLS của curl, tcpdump, dtruss, cột Linux) và mọi mục "mong đợi" không có dòng "Đã chạy" là suy ra từ code và tài liệu Node/PostgreSQL. Số đo của bạn sẽ khác; hãy ghi số đo của máy bạn vào sổ thí nghiệm. Ghi chú môi trường: lệnh lsof/ps viết cho macOS, kèm tương đương Linux; PostgreSQL nên chạy trên một instance local riêng cho học tập (không dùng database chung), Node 24, driver pg. Cổng ví dụ 4410 cho HTTP.

Sổ thí nghiệm (cho dòng cuối của Done khi): mỗi thí nghiệm lưu lệnh, output và một câu kết luận trong thư mục của bạn.

Bảng công cụ debug: macOS và Linux#

Cột macOS là lệnh đã chạy được trên máy kiểm chứng; cột Linux viết theo tài liệu thông dụng và chưa kiểm trên máy này.

Triệu chứngmacOSLinux
Ai đang giữ port (EADDRINUSE)lsof -nP -iTCP:PORT -sTCP:LISTENss -ltnp
Trạng thái kết nối (TIME_WAIT, ESTABLISHED)netstat -an -p tcp | grep PORTss -tan
Số file descriptor và giới hạnlsof -p PID | wc -l; ulimit -n; launchctl limit maxfilesls /proc/PID/fd | wc -l; cat /proc/PID/limits
Bộ nhớ của processps -o pid,rss,vsz,command -p PID; vm_statgrep VmRSS /proc/PID/status; free -m
CPU toàn máy, process nào nóngtop -l 1 -n 0top -H -p PID
Lấy mẫu call stack đang chạysample PID 5perf top -p PID
Xem system callsudo dtruss -p PID (cần root, chưa chạy)strace -f -p PID
DNS có phân giải khôngdig +short HOST; scutil --dnsdig +short HOST; resolvectl status
Cổng có mở khôngnc -vz HOST PORTnc -vz HOST PORT
Băng thông theo processnettop -Pss -tin
Bắt gói tinsudo tcpdump -i lo0 port PORT (chưa chạy)sudo tcpdump -i lo port PORT
Thời gian từng pha của requestcurl -w (thí nghiệm 10)curl -w

vmstat, strace và ss không có sẵn trên macOS; thay bằng vm_stat, dtruss/sample và netstat/lsof.

Done khi#

  • Giải thích được process, thread, event loop và worker thread khác nhau thế nào.

    Đáp án

    Process có không gian địa chỉ riêng; thread chia sẻ bộ nhớ trong một process; event loop là vòng lặp trên MỘT thread JS, chạy callback khi I/O sẵn sàng; worker_threads thêm thread JS có event loop riêng, chia sẻ bộ nhớ có kiểm soát. Hệ quả: vòng lặp CPU-bound trên main thread chặn mọi request; worker thread giúp CPU-bound, không giúp thêm cho việc chờ I/O. Xem GĐ01 mục 1 và thí nghiệm 3.

  • Phân biệt stack, V8 heap, native memory và RSS.

    Đáp án

    Stack: call frame, sống theo lời gọi. V8 heap: object do GC quản lý (heapUsed). Native memory: Buffer, thư viện C++ (external, arrayBuffers) nằm ngoài heap. RSS: phần RAM thật process đang giữ, gồm tất cả cộng code và overhead. Cách tự kiểm: thí nghiệm 2 phải cho thấy cấp phát Buffer làm arrayBuffers tăng mà heapUsed không đổi. Sai thường gặp: đọc heapUsed rồi kết luận không rò rỉ bộ nhớ. Xem mục 3.

  • Chẩn đoán được process giữ port hoặc cạn file descriptor.

    Đáp án

    Dùng lsof -nP -iTCP:<port> -sTCP:LISTEN (hoặc ss -ltnp) để biết PID giữ port (lỗi EADDRINUSE); lsof -p <pid> | wc -l hoặc ls /proc/<pid>/fd | wc -l để đếm descriptor và theo dõi xu hướng. EMFILE mà số descriptor tăng đều thì là leak; nâng ulimit -n chỉ hoãn. Xem mục 4.

  • Dùng được signal để graceful shutdown.

    Đáp án

    Bắt SIGTERM/SIGINT, đặt cờ chống gọi lặp, ngừng nhận kết nối mới, await server.close(), đóng dependency sau cùng, hẹn giờ thoát cưỡng bức nhỏ hơn grace period. Cách tự kiểm: thí nghiệm 1, curl giữa chừng nhận đủ body, exit=0 và server thoát sau request. Xem mục 6 và GĐ09 mục 18.

  • Đọc được permission Linux và không chạy application bằng root khi không cần.

    Đáp án

    Đọc -rw-r----- thành owner đọc ghi, group đọc, other không có; ls -l, id, chmod, chown. App không cần root vì lỗi trong app (hoặc thư viện) sẽ có quyền root trên container/host; chỉ cần quyền đọc code và ghi đúng thư mục log/upload. Cách tự kiểm: ps -o user= -p <pid> không ra root; trong container, USER node trong Dockerfile. Xem mục 5.

  • Giải thích B-tree, WAL và MVCC bằng ngôn ngữ của mình.

    Đáp án

    B-tree: cây thấp nhiều nhánh theo page để mỗi lookup chỉ đọc vài page. WAL: ghi mô tả thay đổi xuống đĩa trước khi data page được ghi xuống đĩa, nên crash thì phát lại được và COMMIT trả OK khi WAL đã bền. MVCC: update tạo phiên bản row mới, reader dùng snapshot không chặn writer; phiên bản cũ do VACUUM dọn khi không còn transaction nào cần. Tự kiểm: giải thích được với ví dụ ở thí nghiệm 6 và 8. Xem mục 8 đến 10 (có sơ đồ).

  • Tạo và sửa được deadlock/lost update trong PostgreSQL local.

    Đáp án

    Deadlock: hai transaction khoá chéo nhau; PostgreSQL huỷ một bên với 40P01; sửa bằng khoá theo thứ tự nhất quán và retry có giới hạn. Lost update: đọc rồi ghi ở app dưới Read Committed; sửa bằng SET x = x - n, FOR UPDATE, hoặc REPEATABLE READ kèm retry 40001. Cách tự kiểm: thí nghiệm 7 tái hiện được rồi hết lỗi sau khi sửa. Xem mục 11.

  • Tính được connection budget khi application scale nhiều instance.

    Đáp án

    process × pool cho mọi loại process (API, worker, migration, cron), cộng phần dành riêng, so với max_connections; tính cả rolling deploy khi hai thế hệ instance cùng chạy. Ví dụ: 20 instance × pool 20 = 400, vượt hẳn cấu hình max_connections thông thường. Xem tính toán trong mục 13.

  • Giải thích keep-alive và timeout budget.

    Đáp án

    Keep-alive tái dùng kết nối, bỏ chi phí bắt tay TCP/TLS nhưng giữ tài nguyên khi nhàn rỗi. Timeout ngoài lớn hơn timeout trong vừa đủ để tầng trong fail rõ ràng; keep-alive timeout của server lớn hơn idle timeout của LB phía trước. Sai thường gặp: mọi tầng cùng 30 giây. Xem mục 14 (có sơ đồ).

  • So sánh HTTP/1.1, HTTP/2 và HTTP/3 theo vấn đề chúng giải quyết.

    Đáp án

    HTTP/1.1: một request tại một thời điểm trên một kết nối, nên browser mở nhiều kết nối. HTTP/2: nhiều stream trên một kết nối TCP, nhưng mất một packet làm đứng cả kết nối (head-of-line ở tầng TCP). HTTP/3: chạy trên QUIC/UDP, stream độc lập khi mất packet, kết nối sống sót khi đổi mạng. Xem mục 15.

  • Chứng minh được cuộc đua keep-alive với load balancer và tìm ra transaction đang chặn người khác.

    Đáp án

    Cuộc đua: server đóng kết nối rảnh đúng lúc LB dùng lại nó, nên LB ghi vào kết nối chết và trả 502; phòng bằng idle timeout của server lớn hơn của LB. Chứng minh bằng thí nghiệm 9 (tỉ lệ lỗi khác 0 khi keepAliveTimeoutBuffer bằng 0 và LB dùng lại gần mốc đóng, bằng 0 khi LB dùng lại sớm hơn mốc đó). Tìm người chặn: pg_blocking_pids(pid) trong pg_stat_activity, rồi xem transaction idle in transaction của người gây chặn (thí nghiệm 11). Xem mục 14 và mục 11.

  • Hoàn thành tám thí nghiệm và lưu code/kết quả đo.

    Đáp án

    Mỗi thí nghiệm có thư mục riêng chứa code, lệnh chạy, output thật và một câu kết luận; số của bạn sẽ khác số mong đợi ở khối lời giải, và điều đó bình thường. Đạt khi người khác chạy lại theo ghi chú của bạn và ra kết quả cùng dạng.

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

  • Production target chính là PaaS, container trên VPS hay Kubernetes?

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

    Chưa chốt: nên học nền ở đây theo hướng độc lập với nền tảng, rồi quyết định target ở GĐ15 và GĐ18; với một sản phẩm AI SaaS giai đoạn đầu, PaaS hoặc container đơn giản thường đủ, còn Kubernetes chỉ cần khi có nhiều service.

  • Database managed đang dùng giới hạn connection và backup/WAL như thế nào?

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

    Chưa chốt: giới hạn connection và chính sách backup/WAL của database managed phụ thuộc nhà cung cấp và gói; đọc trang tài liệu của nhà cung cấp đang cân nhắc và ghi số đó vào bảng connection budget ở mục 13.