Skip to content

Phân tích bài toán ​

Phỏng vấn database hay có câu hỏi mở: "bảng này đọc nhiều ghi ít thì tối ưu thế nào", "bảng đơn hàng 1 tỷ dòng thì làm sao", "query này chậm, xử lý ra sao". Không có một đáp án đúng. Người phỏng vấn muốn xem cách suy nghĩ: có hỏi lại để biết bài toán to cỡ nào không, có ước lượng được không, có chọn giải pháp rẻ trước giải pháp đắt không, và có nói được mỗi lựa chọn tốn gì không.

Trang này đưa ra một cách trả lời theo năm bước: hỏi lại bằng con số, ước lượng nhanh, đi theo thang lựa chọn từ rẻ đến đắt, nói rõ đánh đổi, và nói cách đo kết quả.

Bước 1: hỏi lại bằng con số ​

"Bảng lớn" có thể là 1 triệu dòng hay 1 tỷ dòng, và hai con số đó dẫn tới hai câu trả lời khác hẳn nhau. Nên trước khi đề xuất gì, hỏi lại những điều quyết định lựa chọn:

Câu hỏiVì sao cần biết
Bảng có bao nhiêu dòng, mỗi ngày thêm bao nhiêu?Quyết định dung lượng sau 1–3 năm, và bảng có còn vừa RAM không
Bao nhiêu lượt đọc, bao nhiêu lượt ghi mỗi giây, lúc cao điểm gấp mấy lần trung bình?Đọc nhiều thì nghĩ tới index, cache, replica; ghi nhiều thì nghĩ tới gom lô, hàng đợi
Query nào chạy nhiều nhất, query nào chậm nhất?Tối ưu đúng query quan trọng, không tối ưu cả bảng
Latency yêu cầu là bao nhiêu?10 ms và 1 giây cần cách làm khác nhau
Dữ liệu cũ vài giây có chấp nhận được không?Chấp nhận thì dùng được cache và replica; không thì phải đọc từ primary
Có cần đúng tuyệt đối không (tiền, tồn kho), hay xấp xỉ là đủ (lượt xem)?Quyết định có dùng được bộ đếm gom lô, số ước tính hay không
Dữ liệu cũ còn được đọc không, giữ bao lâu?Nếu ít đọc thì archive hay partition theo thời gian rẻ hơn nhiều so với thêm máy

Nếu người phỏng vấn không cho con số thì tự giả định và nói rõ ra: "giả sử 50 triệu đơn, mỗi ngày thêm 200 nghìn, đọc 2.000 lượt mỗi giây". Cách này vừa cho thấy mình biết con số nào quan trọng, vừa để người phỏng vấn chỉnh lại nếu giả định sai.

Bước 2: ước lượng nhanh ​

Không cần chính xác. Chỉ cần đúng bậc độ lớn để biết bài toán đang ở vùng "một máy dư sức" hay vùng "phải chia dữ liệu".

Dung lượng. Một dòng trên đĩa tốn nhiều hơn tổng kích thước các cột. InnoDB thêm vào mỗi dòng một header 5 byte, transaction id 6 byte và roll pointer 7 byte. PostgreSQL thêm header 23 byte và con trỏ 4 byte. Page cũng không được lấp đầy hoàn toàn: InnoDB chừa 1/16 mỗi page cho lần ghi sau, và chèn theo thứ tự ngẫu nhiên thì page chỉ đầy từ một nửa tới 15/16. Mỗi index phụ là một cây B+Tree riêng, chứa cột của index cộng khoá chính. Quy tắc nhớ nhanh là tổng dung lượng khoảng gấp đôi dữ liệu thô khi có một index phụ. Đo thật một bảng đơn hàng 1 triệu dòng, dữ liệu thô 79 byte mỗi dòng, ra 157 MB trên MySQL và 172 MB trên PostgreSQL.

So với RAM. Database đọc nhanh khi dữ liệu cần đọc nằm sẵn trong RAM, tức trong buffer pool của InnoDB hay shared buffers và page cache của PostgreSQL (MySQL chương 1). Phần cần nằm trong RAM là working set, tức dữ liệu hay được đọc, chứ không phải cả bảng. Bảng đơn hàng 500 GB mà người dùng chủ yếu xem đơn 3 tháng gần nhất thì working set có khi chỉ vài chục GB.

Đổi "mỗi ngày" sang "mỗi giây". Một ngày có 86.400 giây, nên 1 triệu dòng mỗi ngày chỉ khoảng 12 dòng mỗi giây trung bình. Lúc cao điểm gấp vài lần thì cũng chỉ vài chục dòng mỗi giây. Nhiều bài toán nghe có vẻ lớn nhưng tính ra thì rất nhẹ.

Một node làm được bao nhiêu. Một query đơn giản theo khoá chính chỉ tốn phần nhỏ của một ms, nên một node chạy được hàng chục nghìn query như vậy mỗi giây. Lệnh ghi thì khác: mỗi lần commit, database phải chờ một lần fsync xuống disk (mặc định của cả MySQL lẫn PostgreSQL). Vì vậy insert từng dòng, mỗi dòng một transaction, chậm hơn insert theo lô cả trăm lần. Trên laptop của lab, MySQL chỉ commit được khoảng 2.400 dòng mỗi giây khi insert từng dòng, nhưng insert lô 1.000 dòng thì được khoảng 250.000 dòng mỗi giây (Xử lý dữ liệu lớn theo lô).

Thử ước lượng cho một bài toán cụ thể:

37,0 GBdung lượng hiện tại
172 GBsau 1 năm
442 GBsau 3 năm
11,6 / 57,9dòng ghi mỗi giây: trung bình / cao điểm
174lần cập nhật cây index mỗi giây lúc cao điểm
  • Bảng đã lớn hơn RAM, nên đọc dữ liệu cũ sẽ chạm disk. Hỏi tiếp: query hay đọc phần nào? Nếu chủ yếu đọc dữ liệu gần đây thì archive hay partition theo thời gian rẻ hơn nhiều so với thêm máy.
  • Ghi lúc cao điểm khoảng 57,9 dòng/giây là rất nhẹ với một node.

Mỗi dòng tính 1,4 lần dữ liệu thô cộng 45 byte mỗi index phụ, theo số đo trên MySQL 8.0 và PostgreSQL 17.

Bước 3: đi theo thang lựa chọn từ rẻ đến đắt ​

Mỗi dạng bài toán có một thang lựa chọn. Bậc dưới rẻ, ít rủi ro, làm trong một ngày. Bậc trên đắt, thay đổi kiến trúc, tốn công vận hành lâu dài. Đi từ dưới lên, và chỉ leo lên bậc tiếp theo khi bậc dưới không đủ.

Đọc nhiều ​

  1. Index đúng cho query chạy nhiều nhất (Thiết kế index).
  2. Covering index, tức index chứa đủ mọi cột query cần, để không phải đọc lại bảng chính.
  3. Cache cho dữ liệu đọc nhiều và ít đổi (Cache).
  4. Read replica, nếu chấp nhận được dữ liệu cũ vài trăm ms (Tách đọc / ghi).
  5. Phá chuẩn hoặc bảng tổng hợp cho query tốn kém lặp lại (Chuẩn hoá & phá chuẩn, Bộ đếm & bảng tổng hợp).

Ví dụ trang hồ sơ người dùng chậm. Đáp án vội là "cho vào Redis". Đáp án tốt là xem EXPLAIN trước. Có thể query đang quét cả bảng vì thiếu index trên user_id, và thêm một index là đủ. Cache chỉ nên thêm khi query đã nhanh mà lượng đọc vẫn quá lớn, vì cache kéo theo bài toán giữ cho cache và database khớp nhau (Nhất quán cache).

Ghi nhiều ​

  1. Gom lệnh ghi theo lô, nhiều dòng trong một transaction.
  2. Bớt index không dùng, vì mỗi index là thêm một lần ghi cho mỗi dòng.
  3. Đưa lệnh ghi qua hàng đợi để san phẳng đỉnh, ghi theo tốc độ database chịu được (Hàng đợi).
  4. Partition theo thời gian hay theo khoá, khi một bảng quá lớn làm chậm cả việc ghi.
  5. Sharding, khi một node không ghi kịp dù đã làm hết các bước trên (MySQL chương 10).

Ví dụ ghi log sự kiện 5.000 dòng mỗi giây. Đáp án vội là "sharding". Đáp án tốt là gom lô: app đệm sự kiện trong vài trăm ms rồi insert một lần, hoặc đẩy qua hàng đợi cho worker ghi theo lô. 5.000 dòng mỗi giây chia thành 5 lô 1.000 dòng là rất nhẹ với một node.

Bảng quá lớn ​

  1. Archive dữ liệu cũ sang bảng hay kho khác rẻ hơn, nếu nó ít được đọc (Soft delete & lưu trữ).
  2. Partition theo thời gian, để query dữ liệu gần đây chỉ chạm partition mới và xoá dữ liệu cũ bằng cách bỏ cả partition (Partitioning).
  3. Tách bảng theo cột (cột lớn, ít đọc sang bảng riêng) hoặc theo nghiệp vụ.
  4. Sharding.

Ví dụ bảng đơn hàng 1 tỷ dòng. Hỏi lại trước: người dùng có đọc đơn của 5 năm trước không? Thường là gần như không. Khi đó partition theo tháng cộng với job archive đơn hơn 2 năm giải quyết được phần lớn vấn đề, mà không cần đổi kiến trúc.

Query chậm ​

  1. Đọc EXPLAIN: query đang quét bao nhiêu dòng, có dùng index không (Chẩn đoán query chậm).
  2. Sửa query hoặc index: điều kiện bọc trong hàm không dùng được index, OFFSET lớn, SELECT * đọc cột thừa.
  3. Giảm lượng dữ liệu phải đọc: phân trang bằng keyset (Phân trang), bảng tổng hợp thay cho GROUP BY trên cả bảng.

Tranh chấp cao ​

Nhiều request cùng sửa một dòng, như tồn kho của sản phẩm đang flash sale hay bộ đếm lượt thích.

  1. Ghi có điều kiện trong một câu (UPDATE … SET stock = stock - 1 WHERE id = ? AND stock > 0) thay cho đọc rồi ghi (Chống trùng & race condition).
  2. Giữ lock ngắn nhất có thể: transaction không gọi API bên ngoài, không chờ người dùng.
  3. Chia nhỏ hot row thành nhiều dòng, hoặc gom lệnh ghi qua Redis rồi cộng dồn định kỳ (Bộ đếm & hot row).

Bước 4: nói rõ đánh đổi ​

Mỗi bậc trên thang đều có giá. Nói được cái giá cho thấy mình đã làm thật chứ không chỉ đọc qua:

Lựa chọnTốn gì
Thêm indexMỗi lệnh ghi chậm hơn, tốn thêm dung lượng, tạo index trên bảng lớn mất thời gian
CacheDữ liệu có thể cũ, phải xoá cache đúng lúc, thêm một hệ thống cần vận hành và có thể sập
Read replicaĐọc ngay sau khi ghi có thể không thấy dữ liệu mới, phải chọn query nào được đọc replica
Phá chuẩn, bảng tổng hợpCùng một dữ liệu nằm ở hai chỗ, phải cập nhật cả hai và có lúc lệch nhau
Hàng đợiLệnh ghi không còn tức thì, phải xử lý retry, trùng lặp, thứ tự
PartitionQuery không có cột partition trong điều kiện phải quét mọi partition; khoá chính và UNIQUE phải chứa cột partition
ShardingMất JOIN và transaction giữa các shard, đổi shard key gần như không thể, vận hành phức tạp hơn hẳn

Bước 5: nói cách đo kết quả ​

Một đề xuất chưa trọn vẹn nếu không nói cách biết nó có hiệu quả không:

  • Đo trước khi sửa. Lấy p95, p99 của query hay API đang chậm, số dòng mà query đọc (rows trong EXPLAIN, Rows_examined trong slow log), tải CPU và I/O của database.
  • Đo lại sau khi sửa, trên cùng loại tải. So p99 chứ không chỉ so trung bình (Latency).
  • Theo dõi tác dụng phụ. Thêm index thì xem latency của lệnh ghi. Thêm cache thì xem hit rate và số lần đọc phải dữ liệu cũ. Thêm replica thì xem replication lag (Giám sát database).

Ví dụ trả lời trọn vẹn ​

Câu hỏi: "API lịch sử đơn hàng của người dùng chậm, bảng orders đã 200 triệu dòng. Làm sao?"

  1. Hỏi lại. Mỗi ngày thêm bao nhiêu đơn? API được gọi bao nhiêu lần mỗi giây? Người dùng xem đơn trong bao lâu? Chậm là chậm bao nhiêu? Giả sử 300 nghìn đơn mỗi ngày, 500 lượt gọi mỗi giây, p99 hiện 2 giây, người dùng gần như chỉ xem đơn 6 tháng gần nhất.
  2. Ước lượng. 200 triệu dòng, khoảng 200 byte mỗi dòng, hai index phụ thì khoảng 75 GB, lớn hơn RAM của một máy 32 GB. Nhưng 6 tháng gần nhất chỉ khoảng 55 triệu dòng, khoảng 20 GB, vừa RAM. Ghi khoảng 3,5 dòng mỗi giây trung bình, rất nhẹ.
  3. Thang lựa chọn. Xem EXPLAIN trước. Nếu query là WHERE user_id = ? ORDER BY created_at DESC LIMIT 20 mà chỉ có index trên user_id, database phải đọc mọi đơn của user rồi sắp xếp. Thêm index (user_id, created_at) để đọc thẳng 20 dòng mới nhất. Đổi OFFSET sang keyset nếu có phân trang. Nếu vẫn chưa đủ, partition theo tháng và archive đơn cũ hơn 2 năm. Cache và sharding chưa cần.
  4. Đánh đổi. Index mới tốn thêm vài GB và làm insert chậm đi một chút, chấp nhận được vì ghi rất ít. Tạo index trên bảng 200 triệu dòng cần làm online và vào giờ thấp điểm.
  5. Đo. So p99 trước và sau, xem rows trong EXPLAIN giảm từ hàng nghìn xuống 20.

Lỗi hay gặp khi trả lời ​

  • Nhảy ngay tới sharding hay Redis mà chưa hỏi con số, chưa xem query. Đa số bài toán dừng ở bậc index hoặc gom lô.
  • Không hỏi con số, nên không biết bài toán to cỡ nào, và không phân biệt được 1 triệu với 1 tỷ dòng.
  • Kể tên công nghệ mà không nói đánh đổi. "Dùng read replica" mà không nhắc tới đọc phải dữ liệu cũ thì chưa đủ.
  • Tối ưu cả bảng thay vì tối ưu query quan trọng nhất.
  • Không nói cách đo, nên không ai biết đề xuất có hiệu quả không.

Ghi nhớ

  1. Hỏi lại bằng con số trước khi đề xuất: số dòng, tốc độ tăng, lượt đọc ghi mỗi giây, latency yêu cầu, có cần đúng tuyệt đối không.
  2. Ước lượng bậc độ lớn: dung lượng khoảng gấp đôi dữ liệu thô với một index phụ, đổi "mỗi ngày" sang "mỗi giây", so working set với RAM.
  3. Đi theo thang từ rẻ đến đắt: index và sửa query trước, rồi cache, replica, hàng đợi, partition, cuối cùng mới sharding.
  4. Nói được mỗi lựa chọn tốn gì, và nói cách đo kết quả bằng p99 và số dòng phải đọc.

Nguồn tham khảo ​

Đã đối chiếu (đọc nguồn ngày 2026-10-23):

NguồnDùng cho phần
MySQL 8.0 Reference Manual, InnoDB Row Formats, InnoDB Multi-VersioningHeader 5 byte mỗi bản ghi, transaction id 6 byte và roll pointer 7 byte trong clustered index
MySQL 8.0 Reference Manual, Physical Structure of an InnoDB IndexChừa 1/16 page; chèn tuần tự page đầy khoảng 15/16, chèn ngẫu nhiên từ 1/2 tới 15/16
MySQL 8.0 Reference Manual, sync_binlogMặc định 1: đồng bộ binlog xuống disk trước khi commit
PostgreSQL 17 docs, Database Page LayoutHeader của mỗi tuple 23 byte, mỗi con trỏ 4 byte
PostgreSQL 17 docs, Write Ahead Logsynchronous_commit mặc định on: commit chờ WAL được flush xuống disk

Từ kiến thức chung, chưa đối chiếu lần này: mặc định innodb_flush_log_at_trx_commit = 1 (đọc được trên lab bằng SELECT @@innodb_flush_log_at_trx_commit, nhưng chưa đọc mục đó trong tài liệu); các thang lựa chọn và ví dụ trả lời là kinh nghiệm chung, không có nguồn chính thức.

Số đo chi tiết ​

Đo bằng script lab/phan-tich-bai-toan/ ngày 2026-10-23 trên MacBook M2 (MySQL 8.0 và PostgreSQL 17 trong Docker, buffer pool và shared buffers đều 128 MB). Client Node.js chạy trên cùng máy, đi qua mạng của Docker Desktop, nên các con số chỉ để có cảm giác về bậc độ lớn, không phải năng lực của một server thật.

Dung lượng của 1 triệu dòng

Bảng orders (id, user_id, status, amount, created_at, note) với một index phụ (user_id, created_at). Tổng kích thước các cột trung bình 79 byte mỗi dòng.

MySQL 8.0 (InnoDB)PostgreSQL 17
Dữ liệu (MySQL: nằm trong index khoá chính)107 MB, 107 byte mỗi dòng118 MB (heap)
Index khoá chínhnằm chung với dữ liệu22 MB
Index phụ (user_id, created_at)50 MB32 MB
Tổng157 MB, gấp 2,0 lần dữ liệu thô172 MB, gấp 2,2 lần

MySQL đọc kích thước từ số page trong mysql.innodb_index_stats, PostgreSQL từ pg_relation_size sau VACUUM ANALYZE. Index phụ của MySQL lớn hơn vì nó được chèn theo thứ tự ngẫu nhiên của user_id trong lúc nạp dữ liệu, còn PostgreSQL tạo index sau khi nạp xong nên page đầy hơn.

Một node chạy được bao nhiêu query

Mỗi phép đo 3 giây, hai lượt:

MySQL 8.0PostgreSQL 17
Point select theo khoá chính, 1 connection3.031–3.049 query/s, p50 0,30 ms3.025–3.084 query/s, p50 0,30–0,31 ms
Point select, 8 connection13.109–13.640 query/s12.972–13.261 query/s
Point select, 32 connection25.657–27.070 query/s, p99 3,9–5,4 ms24.063–24.917 query/s, p99 4,5–5,3 ms
Insert từng dòng (autocommit), 8 connection2.308–2.438 dòng/s, p50 2,9–3,0 ms10.276–10.687 dòng/s, p50 0,7 ms
Insert lô 1.000 dòng, 4 connection250.667–252.000 dòng/s545.333–636.000 dòng/s

Với 1 connection, mỗi query gần như chỉ là một vòng mạng (khoảng 0,3 ms), nên con số phản ánh mạng chứ không phải database. Với 32 connection, client Node.js một thread cũng góp phần giới hạn. Insert từng dòng của MySQL chậm hơn PostgreSQL vì mỗi commit phải ghi cả redo log lẫn binlog xuống disk (innodb_flush_log_at_trx_commit = 1, sync_binlog = 1).

Code đã dùng để chạy thử ​

Chạy bằng npm run lab -- phan-tich-bai-toan sau npm run lab:up, khoảng 1 phút. Xem Lab.

lab/phan-tich-bai-toan/size.mjs: dung lượng thật của 1 triệu dòng
js
// Một dòng thật sự tốn bao nhiêu byte trên đĩa, so với tổng kích thước các cột.
// Bảng đơn hàng 1.000.000 dòng, khoá chính và một index phụ (user_id, created_at), trên MySQL 8.0 và PostgreSQL 17.
import mysql from 'mysql2/promise'
import pg from 'pg'
import { MYSQL, PG } from '../common/config.mjs'
import { ensureDb } from '../common/run-sql.mjs'

const N = 1_000_000
const mb = (b) => (b / 1e6).toFixed(0) + ' MB'
const per = (b) => Math.round(b / N) + ' byte'

ensureDb('mysql', 'phan_tich')
ensureDb('pg', 'phan_tich')

// Dữ liệu giống nhau trên hai database: note dài 20–60 ký tự, status một trong bốn giá trị.
const my = await mysql.createConnection({ ...MYSQL, database: 'phan_tich' })
await my.query('DROP TABLE IF EXISTS orders')
await my.query(`CREATE TABLE orders (
  id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL,
  amount DECIMAL(12,2) NOT NULL, created_at DATETIME NOT NULL, note VARCHAR(100) NOT NULL,
  INDEX idx_user_created (user_id, created_at))`)
await my.query('SET SESSION cte_max_recursion_depth = 1000000')
await my.query(`INSERT INTO orders
  WITH RECURSIVE s(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM s WHERE i < ${N})
  SELECT i, i % 50000, ELT(1 + i % 4, 'paid', 'pending', 'shipped', 'refunded'), (i * 37 % 100000) / 100,
         '2025-01-01' + INTERVAL (i % 31536000) SECOND, RPAD('ghi chú đơn hàng', 20 + i % 41, 'x')
  FROM s`)
await my.query('ANALYZE TABLE orders')
const [[raw]] = await my.query(`SELECT AVG(8 + 8 + 1 + LENGTH(status) + 6 + 5 + 1 + LENGTH(note)) AS b FROM orders`)
// Kích thước thật theo số page của từng index (mỗi page 16 KB), chính xác hơn data_length ước lượng.
const [sizes] = await my.query(`SELECT index_name, stat_value * 16384 AS bytes FROM mysql.innodb_index_stats
  WHERE database_name = 'phan_tich' AND table_name = 'orders' AND stat_name = 'size'`)
const myPk = Number(sizes.find((s) => s.index_name === 'PRIMARY').bytes)
const mySec = Number(sizes.find((s) => s.index_name === 'idx_user_created').bytes)
await my.end()

const p = new pg.Client({ ...PG, database: 'phan_tich' })
await p.connect()
await p.query('DROP TABLE IF EXISTS orders')
await p.query(`CREATE TABLE orders (
  id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL,
  amount NUMERIC(12,2) NOT NULL, created_at TIMESTAMP NOT NULL, note VARCHAR(100) NOT NULL)`)
await p.query(`INSERT INTO orders
  SELECT i, i % 50000, (ARRAY['paid','pending','shipped','refunded'])[1 + i % 4], (i * 37 % 100000) / 100.0,
         TIMESTAMP '2025-01-01' + (i % 31536000) * INTERVAL '1 second', RPAD('ghi chú đơn hàng', 20 + i % 41, 'x')
  FROM generate_series(1, ${N}) AS i`)
await p.query('CREATE INDEX idx_user_created ON orders (user_id, created_at)')
await p.query('VACUUM ANALYZE orders')
const { rows: [s] } = await p.query(`SELECT pg_relation_size('orders') AS heap, pg_relation_size('orders_pkey') AS pk,
  pg_relation_size('idx_user_created') AS sec`)
await p.end()

console.log(`\n=== Bảng orders ${N.toLocaleString('vi-VN')} dòng: tổng kích thước các cột trung bình ${Math.round(raw.b)} byte mỗi dòng ===`)
console.log('                                  MySQL 8.0 (InnoDB)        PostgreSQL 17')
console.log(`Dữ liệu (MySQL: index khoá chính) ${(mb(myPk) + ', ' + per(myPk)).padEnd(26)}${mb(Number(s.heap))}, ${per(Number(s.heap))} (heap)`)
console.log(`Index khoá chính                  ${'(nằm chung với dữ liệu)'.padEnd(26)}${mb(Number(s.pk))}, ${per(Number(s.pk))}`)
console.log(`Index phụ (user_id, created_at)   ${(mb(mySec) + ', ' + per(mySec)).padEnd(26)}${mb(Number(s.sec))}, ${per(Number(s.sec))}`)
const myTotal = myPk + mySec, pgTotal = Number(s.heap) + Number(s.pk) + Number(s.sec)
console.log(`Tổng                              ${(mb(myTotal) + ', gấp ' + (myTotal / N / raw.b).toFixed(1).replace('.', ',') + ' lần').padEnd(26)}${mb(pgTotal)}, gấp ${(pgTotal / N / raw.b).toFixed(1).replace('.', ',')} lần dữ liệu thô`)
lab/phan-tich-bai-toan/capacity.mjs: throughput của point select và insert
js
// Một node database trên laptop chịu được bao nhiêu query đơn giản mỗi giây. Chạy sau size.mjs (dùng bảng orders 1 triệu dòng).
//   point select theo khoá chính với 1, 8, 32 connection; insert từng dòng (autocommit) với 8 connection; insert lô 1.000 dòng.
// Client là Node.js trên cùng máy, đi qua mạng Docker Desktop, nên con số chỉ để có cảm giác về bậc độ lớn.
import mysql from 'mysql2/promise'
import pg from 'pg'
import { MYSQL, PG } from '../common/config.mjs'

const SECONDS = 3
const fmt = (n) => Math.round(n).toLocaleString('vi-VN')
const ms = (v) => v.toFixed(2).replace('.', ',')
const pct = (s, p) => s[Math.min(s.length - 1, Math.ceil(p * s.length) - 1)]

// Chạy `conc` vòng lặp song song trong SECONDS giây, mỗi vòng gọi fn() liên tục; trả về số lần/giây và latency.
async function load(conc, fn) {
  const lat = []
  const end = performance.now() + SECONDS * 1000
  await Promise.all(Array.from({ length: conc }, async () => {
    while (performance.now() < end) {
      const t0 = performance.now()
      await fn()
      lat.push(performance.now() - t0)
    }
  }))
  lat.sort((a, b) => a - b)
  return { qps: lat.length / SECONDS, p50: pct(lat, 0.5), p99: pct(lat, 0.99) }
}

const engines = {
  'MySQL 8.0': async () => {
    const pool = mysql.createPool({ ...MYSQL, database: 'phan_tich', connectionLimit: 32 })
    await pool.query('DROP TABLE IF EXISTS events')
    await pool.query('CREATE TABLE events (id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT, payload VARCHAR(100))')
    return {
      select: () => pool.query('SELECT * FROM orders WHERE id = ?', [1 + Math.floor(Math.random() * 1e6)]),
      insert: () => pool.query('INSERT INTO events (user_id, payload) VALUES (?, ?)', [Math.floor(Math.random() * 1e5), 'x'.repeat(60)]),
      batch: () => pool.query('INSERT INTO events (user_id, payload) VALUES ?', [Array.from({ length: 1000 }, (_, i) => [i, 'x'.repeat(60)])]),
      end: () => pool.end()
    }
  },
  'PostgreSQL 17': async () => {
    const pool = new pg.Pool({ ...PG, database: 'phan_tich', max: 32 })
    await pool.query('DROP TABLE IF EXISTS events')
    await pool.query('CREATE TABLE events (id BIGSERIAL PRIMARY KEY, user_id BIGINT, payload VARCHAR(100))')
    const vals = Array.from({ length: 1000 }, (_, i) => `(${i}, '${'x'.repeat(60)}')`).join(',')
    return {
      select: () => pool.query('SELECT * FROM orders WHERE id = $1', [1 + Math.floor(Math.random() * 1e6)]),
      insert: () => pool.query('INSERT INTO events (user_id, payload) VALUES ($1, $2)', [Math.floor(Math.random() * 1e5), 'x'.repeat(60)]),
      batch: () => pool.query(`INSERT INTO events (user_id, payload) VALUES ${vals}`),
      end: () => pool.end()
    }
  }
}

for (const [name, make] of Object.entries(engines)) {
  const db = await make()
  await load(8, db.select) // khởi động, nạp bảng vào buffer
  console.log(`\n=== ${name}, mỗi phép đo ${SECONDS} giây ===`)
  for (const c of [1, 8, 32]) {
    const r = await load(c, db.select)
    console.log(`point select, ${String(c).padStart(2)} connection: ${fmt(r.qps).padStart(7)} query/s   p50 ${ms(r.p50)} ms   p99 ${ms(r.p99)} ms`)
  }
  const ins = await load(8, db.insert)
  console.log(`insert từng dòng,  8 connection: ${fmt(ins.qps).padStart(7)} dòng/s    p50 ${ms(ins.p50)} ms   p99 ${ms(ins.p99)} ms`)
  const b = await load(4, db.batch)
  console.log(`insert lô 1.000 dòng, 4 connection: ${fmt(b.qps * 1000).padStart(7)} dòng/s`)
  await db.end()
}