Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dựng lab PostgreSQL và MySQL dùng chung cho các bài thực hành

Câu hỏi bài này trả lời: làm sao có một PostgreSQL và một MySQL dùng riêng cho thực hành, với dữ liệu giả xác định, reset được, kiểm đúng nơi kết nối và dọn riêng tài nguyên lab?

Cần biết trước: dùng được shell (bash hoặc zsh) và SQL cơ bản. Cách A cần PostgreSQL và MySQL đã cài (chỉ cần các chương trình, không cần chạy dịch vụ nào); cách B cần Docker hoặc Podman kèm Compose. Windows thuần chưa được hỗ trợ; dùng WSL2.

Các bài về hiệu năng database (EXPLAIN, index, khóa, MVCC, VACUUM…) cần một chỗ thực hành chung để kết quả so sánh được với nhau. Thay vì mỗi bài tự dựng một database, bài này dựng một lab duy nhất với sáu yêu cầu:

  1. Tách biệt: không dùng chung tiến trình, thư mục dữ liệu hay cổng với database đang chạy trên máy.
  2. Xác định: cùng một seed cho cùng một dữ liệu, nên số hàng biết trước và có thể đối chiếu.
  3. Đủ lớn để thấy khác biệt: 100.000 đơn hàng và 300.000 dòng hàng, đủ để planner chọn giữa quét tuần tự và dùng index.
  4. Reset nhanh: dựng lại dữ liệu từ đầu trong vài giây.
  5. Tự xác nhận: có dấu hiệu cho biết bạn đang nói chuyện với đúng lab trước khi làm việc phá hủy dữ liệu.
  6. Dọn được: một lệnh xóa sạch mọi thứ lab tạo ra.

Chọn phiên bản

EnginePhiên bản ghimLý do
PostgreSQL18 (18.6)Bảng phiên bản của PostgreSQL ghi 18 là bản mới nhất còn hỗ trợ, phát hành lần đầu 2025-09-25, bản cuối 2030-11-14. Danh sách thẻ image chỉ có 19 ở dạng beta
MySQL26.7 (26.7.0)Đây là bản đã chạy kiểm. Tài liệu image MySQL liệt kê ba nhánh: 26.7 (thẻ innovation), 9.7 (thẻ lts) và 8.4. Lab ghim nhánh Innovation, không phải nhánh hỗ trợ dài hạn

Lệnh chạy luôn dùng thẻ cụ thể (postgres:18.6, mysql:26.7.0), không dùng latest, để lab dựng lại được sau này. Mã SQL của lab chưa được thử trên nhánh MySQL LTS; nếu bạn dùng nhánh đó, hãy coi kết quả của các bài sau là chưa kiểm cho đến khi tự chạy lại.

Lab ghi lại đầy đủ phiên bản thực tế lúc chạy bằng lệnh lab_versions ở bên dưới. Nếu nó in ra phiên bản khác bản ghim thì các con số trong các bài sau có thể lệch.

Hai cách dựng server

Tiêu chíA. Server cục bộ trong thư mục tạmB. Container bằng Compose
Cần cóinitdb, pg_ctl, psql, mysqld, mysqladmin, mysql trong PATHDocker hoặc Podman có Compose
Cổng mạngKhông mở cổng nào: cả hai server chỉ nhận kết nối qua socket UnixKhông công bố cổng: client chạy bên trong container
Dữ liệuThư mục tạm riêng, mặc định chỉ chủ sở hữu truy cập đượcVolume đặt tên riêng của project wikilab
Dọnlab_cleandocker compose down -v
Trạng thái kiểmĐã chạy (phần “Kiểm lab”)Chưa chạy (phần “Cách B”)

Hai cách dùng chung dữ liệu, chung các hàm lab_psql và lab_mysql, nên các bài sau không cần biết bạn chọn cách nào. Chọn A nếu máy đã có sẵn PostgreSQL và MySQL; chọn B nếu bạn quen container hoặc không muốn cài thêm gì.

Dữ liệu dùng chung

Ba bảng của một cửa hàng giả lập, cùng cấu trúc ở cả hai engine:

BảngSố hàngNội dung
customers2.000id, name, country (5 giá trị luân phiên), created_at
orders100.000id, customer_id, status (70% paid, 20% shipped, 7% cancelled, 3% pending), total_cents, created_at
order_items300.000đúng 3 dòng mỗi đơn: order_id, sku (500 giá trị), qty, price_cents

Vài quyết định thiết kế đáng biết:

  • Xác định bằng công thức, không bằng số ngẫu nhiên. Mọi giá trị được tính từ số thứ tự hàng bằng phép nhân và chia dư. Cùng công thức ở hai engine cho cùng dữ liệu, và kết quả không phụ thuộc phiên bản của hàm sinh số ngẫu nhiên.
  • Phân bố lệch có chủ đích. customer_id tính từ bình phương số thứ tự nên chỉ 212 trong 2.000 khách hàng có đơn, và vài khách có rất nhiều đơn. Phân bố đều sẽ làm các bài về index kém thú vị.
  • Không khai báo khóa ngoại. InnoDB tự tạo chỉ mục cho cột khóa ngoại còn PostgreSQL thì không; bỏ khóa ngoại để hai engine bắt đầu với cùng một tập chỉ mục là khóa chính. Tính toàn vẹn được kiểm bằng truy vấn tìm đơn mồ côi.
  • Tiền lưu bằng số nguyên theo cent, để hai engine không khác nhau do cách làm tròn số thập phân.
  • Không có chỉ mục phụ. Các bài sau tự thêm chỉ mục và so sánh trước, sau.

Muốn xem cấu trúc ba bảng dưới dạng sơ đồ ER, dán các câu CREATE TABLE trong seed-pg.sql hoặc seed-mysql.sql (xem phần Cách A) vào sql2erd.dev: công cụ chuyển SQL DDL thành sơ đồ ERD ngay trong trình duyệt, nhận cả PostgreSQL lẫn MySQL. Công cụ này là sản phẩm khác của tác giả; lab không cần đến nó.

Cách A: server cục bộ trong thư mục tạm

Tạo thư mục trống, ví dụ dblab, và các file sau.

lab-common.sh chứa phần không phụ thuộc cách dựng server: nạp dữ liệu, kiểm số hàng, ghi phiên bản. Các hàm này gọi lab_psql và lab_mysql, do file của từng cách định nghĩa:

lab_seed() {
  lab_target || return 1
  lab_psql < seed-pg.sql || return 1
  lab_mysql < seed-mysql.sql > /dev/null
}

lab_check() {
  case "$1" in
    pg) lab_psql -At -F ' ' <<'SQL' ;;
SELECT name, n FROM (
  SELECT 1 AS r, 'customers' AS name, count(*) AS n FROM wiki_lab.customers
  UNION ALL SELECT 2, 'orders', count(*) FROM wiki_lab.orders
  UNION ALL SELECT 3, 'order_items', count(*) FROM wiki_lab.order_items
  UNION ALL SELECT 4, 'orders_paid', count(*) FROM wiki_lab.orders WHERE status = 'paid'
  UNION ALL SELECT 5, 'customers_with_orders', count(DISTINCT customer_id) FROM wiki_lab.orders
  UNION ALL SELECT 6, 'orphan_orders', count(*) FROM wiki_lab.orders o
    LEFT JOIN wiki_lab.customers c ON c.id = o.customer_id WHERE c.id IS NULL
  UNION ALL SELECT 7, 'revenue_cents', sum(total_cents) FROM wiki_lab.orders
) AS t ORDER BY r;
SQL
    mysql) lab_mysql -N <<'SQL' ;;
SELECT name, n FROM (
  SELECT 1 AS r, 'customers' AS name, COUNT(*) AS n FROM wiki_lab.customers
  UNION ALL SELECT 2, 'orders', COUNT(*) FROM wiki_lab.orders
  UNION ALL SELECT 3, 'order_items', COUNT(*) FROM wiki_lab.order_items
  UNION ALL SELECT 4, 'orders_paid', COUNT(*) FROM wiki_lab.orders WHERE status = 'paid'
  UNION ALL SELECT 5, 'customers_with_orders', COUNT(DISTINCT customer_id) FROM wiki_lab.orders
  UNION ALL SELECT 6, 'orphan_orders', COUNT(*) FROM wiki_lab.orders o
    LEFT JOIN wiki_lab.customers c ON c.id = o.customer_id WHERE c.id IS NULL
  UNION ALL SELECT 7, 'revenue_cents', SUM(total_cents) FROM wiki_lab.orders
) AS t ORDER BY r;
SQL
    *) echo "dùng: lab_check pg | mysql" >&2; return 2 ;;
  esac
}

lab_versions() {
  version=$(lab_psql -At -c 'SHOW server_version_num') || return 1
  printf 'postgresql %d.%d\n' "$((version / 10000))" "$((version % 10000))"
  printf 'mysql %s\n' "$(lab_mysql -N -e 'SELECT @@version')"
}

lab-local.sh dựng và điều khiển hai server cục bộ:

. ./lab-common.sh

lab_need() {
  for tool in initdb pg_ctl psql mysqld mysqladmin mysql; do
    command -v "$tool" >/dev/null 2>&1 || { echo "thiếu $tool trong PATH" >&2; return 1; }
  done
}

lab_up() {
  lab_need || return 1
  if [ -f lab.env ]; then
    . ./lab.env
    [ -d "$LAB_DIR" ] && [ ! -L "$LAB_DIR" ] && [ -f "$LAB_DIR/.wiki-lab" ] || return 1
  else
    LAB_DIR=$(mktemp -d /tmp/dblab.XXXXXX) || return 1
    touch "$LAB_DIR/.wiki-lab"
    printf 'export LAB_DIR=%q\n' "$LAB_DIR" > lab.env
    chmod 600 lab.env
  fi
  [ -d "$LAB_DIR/pg" ] || initdb -D "$LAB_DIR/pg" -U lab --auth=trust -E UTF8 --locale=C >/dev/null || return 1
  pg_ctl -D "$LAB_DIR/pg" status >/dev/null 2>&1 ||
    pg_ctl -D "$LAB_DIR/pg" -l "$LAB_DIR/pg.log" -w \
      -o "-c listen_addresses='' -c unix_socket_directories='$LAB_DIR' -c unix_socket_permissions=0700" \
      start >/dev/null || return 1
  [ -d "$LAB_DIR/my" ] ||
    mysqld --no-defaults --initialize-insecure --datadir="$LAB_DIR/my" >"$LAB_DIR/my-init.log" 2>&1 || return 1
  mysqladmin --no-defaults --no-login-paths --socket="$LAB_DIR/mysql.sock" -uroot ping >/dev/null 2>&1 ||
    mysqld --no-defaults --datadir="$LAB_DIR/my" --socket="$LAB_DIR/mysql.sock" \
      --skip-networking --mysqlx=OFF --pid-file="$LAB_DIR/mysql.pid" \
      --log-error="$LAB_DIR/my.log" --daemonize || return 1
  waited=0
  until mysqladmin --no-defaults --no-login-paths --socket="$LAB_DIR/mysql.sock" -uroot ping >/dev/null 2>&1; do
    waited=$((waited + 1))
    [ "$waited" -lt 60 ] || { echo "MySQL không sẵn sàng sau 60 giây" >&2; return 1; }
    sleep 1
  done
}

lab_psql() (
  . ./lab.env
  unset PGSERVICE PGHOSTADDR PGPASSWORD
  PGSERVICEFILE=/dev/null PGPASSFILE="$LAB_DIR/no-pgpass" \
    PGOPTIONS='-c client_min_messages=warning' \
    psql -X -q -w -h "$LAB_DIR" -p 5432 -U lab -d postgres -v ON_ERROR_STOP=1 "$@"
)

lab_mysql() (
  . ./lab.env
  unset MYSQL_PWD
  mysql --no-defaults --no-login-paths --protocol=SOCKET --socket="$LAB_DIR/mysql.sock" \
    -uroot --batch "$@"
)

lab_target() {
  . ./lab.env
  [ "$(lab_psql -At -c 'SHOW unix_socket_directories')" = "$LAB_DIR" ] ||
    { echo "PostgreSQL này không thuộc lab" >&2; return 1; }
  [ -z "$(lab_psql -At -c 'SHOW listen_addresses')" ] || return 1
  [ "$(lab_mysql -N -e 'SELECT @@socket')" = "$LAB_DIR/mysql.sock" ] ||
    { echo "MySQL này không thuộc lab" >&2; return 1; }
  [ "$(lab_mysql -N -e 'SELECT @@skip_networking')" = 1 ] || return 1
}

lab_whoami() {
  lab_target || return 1
  [ "$(lab_psql -At -c "SELECT value FROM wiki_lab.lab_info WHERE key = 'lab'")" = wiki-lab ] ||
    { echo "PostgreSQL thiếu dấu của lab" >&2; return 1; }
  [ "$(lab_mysql -N -e "SELECT v FROM wiki_lab.lab_info WHERE k = 'lab'")" = wiki-lab ] ||
    { echo "MySQL thiếu dấu của lab" >&2; return 1; }
  echo "lab ok"
}

lab_down() {
  [ -f lab.env ] || return 0
  . ./lab.env
  case "$LAB_DIR" in /tmp/dblab.*) ;; *) echo "đường dẫn không thuộc lab" >&2; return 1 ;; esac
  [ -d "$LAB_DIR" ] && [ ! -L "$LAB_DIR" ] && [ -O "$LAB_DIR" ] && [ -f "$LAB_DIR/.wiki-lab" ] || return 1
  if pg_ctl -D "$LAB_DIR/pg" status >/dev/null 2>&1; then
    pg_ctl -D "$LAB_DIR/pg" -m fast -w stop >/dev/null || return 1
  fi
  if mysqladmin --no-defaults --no-login-paths --socket="$LAB_DIR/mysql.sock" -uroot ping >/dev/null 2>&1; then
    mysqladmin --no-defaults --no-login-paths --socket="$LAB_DIR/mysql.sock" -uroot shutdown || return 1
    waited=0
    while [ -e "$LAB_DIR/mysql.pid" ]; do
      waited=$((waited + 1))
      [ "$waited" -lt 60 ] || { echo "MySQL chưa dừng sau 60 giây" >&2; return 1; }
      sleep 1
    done
  fi
}

lab_clean() {
  [ -f lab.env ] || return 0
  . ./lab.env
  lab_down || return 1
  if [ -n "$LAB_DIR" ] && [ -f "$LAB_DIR/.wiki-lab" ]; then
    rm -rf "$LAB_DIR"
  fi
  rm -f lab.env
}

Những lựa chọn trong lab_up và lý do:

  • Chỉ socket Unix, không TCP. Theo tài liệu PostgreSQL, khi listen_addresses rỗng, server không lắng nghe trên giao diện IP nào và chỉ socket Unix dùng được. Với MySQL, --skip-networking tắt kết nối TCP/IP và --mysqlx=OFF tắt giao thức X. Kết quả là lab không chiếm cổng nào, nên không thể va chạm với server đang chạy trên máy.
  • Thư mục riêng của lab, quyền chỉ chủ sở hữu. mktemp -d tạo thư mục mà người khác trên máy không vào được. Quyền mặc định của socket PostgreSQL là 0777 (ai cũng kết nối được), nên lab đặt thêm unix_socket_permissions=0700. Đây là lý do --auth=trust (không hỏi mật khẩu) chấp nhận được chỉ với lab này; đừng dùng nó cho server có cổng TCP.
  • --no-defaults đứng đầu. Tùy chọn này ngăn đọc file cấu hình thông thường. Các client còn có --no-login-paths để không đọc tệp đăng nhập cá nhân; socket và giao thức được chỉ định tường minh.
  • --initialize-insecure tạo tài khoản quản trị cục bộ không mật khẩu và yêu cầu thư mục dữ liệu phải trống; vì vậy lab_up chỉ chạy nó khi thư mục dữ liệu chưa có. Cũng chỉ dùng được ở đây vì server không nghe TCP và thư mục riêng chặn người dùng khác.
  • Dấu .wiki-lab là điều kiện để lab_clean được phép xóa thư mục. Nếu LAB_DIR rỗng hoặc trỏ nhầm chỗ, không có dấu thì không xóa gì.
  • lab.env lưu vị trí lab ngay trước khi khởi tạo server, để cleanup vẫn tìm thấy tài nguyên nếu khởi tạo thất bại. Các client dùng socket tường minh, không lấy địa chỉ database từ môi trường máy bạn.
  • Đường dẫn socket có giới hạn độ dài. Lab chọn thư mục ngắn trong /tmp, không lấy thư mục tạm dài từ cấu hình hệ điều hành.
  • Chạy được đâu thì chạy được đó: lab_up gọi lại nhiều lần vẫn an toàn vì bỏ qua phần đã làm.

Hai file nạp dữ liệu. Cả hai bắt đầu bằng xóa và tạo lại schema wiki_lab, nên chạy lại bao nhiêu lần cũng ra cùng kết quả. PostgreSQL:

DROP SCHEMA IF EXISTS wiki_lab CASCADE;
CREATE SCHEMA wiki_lab;

CREATE TABLE wiki_lab.lab_info (key text PRIMARY KEY, value text NOT NULL);
CREATE TABLE wiki_lab.customers (
  id integer PRIMARY KEY,
  name text NOT NULL,
  country text NOT NULL,
  created_at timestamp NOT NULL
);
CREATE TABLE wiki_lab.orders (
  id integer PRIMARY KEY,
  customer_id integer NOT NULL,
  status text NOT NULL,
  total_cents integer NOT NULL,
  created_at timestamp NOT NULL
);
CREATE TABLE wiki_lab.order_items (
  id integer PRIMARY KEY,
  order_id integer NOT NULL,
  sku text NOT NULL,
  qty integer NOT NULL,
  price_cents integer NOT NULL
);

INSERT INTO wiki_lab.customers
SELECT g, 'customer-' || g, (ARRAY['VN', 'JP', 'US', 'DE', 'SG'])[g % 5 + 1],
       timestamp '2024-01-01' + (g * 53 % 365) * interval '1 day'
FROM generate_series(1, 2000) AS g;

INSERT INTO wiki_lab.orders
SELECT g, ((g::bigint * g) % 2000)::int + 1,
       CASE WHEN g % 100 < 70 THEN 'paid' WHEN g % 100 < 90 THEN 'shipped'
            WHEN g % 100 < 97 THEN 'cancelled' ELSE 'pending' END,
       ((g::bigint * 7919) % 50000)::int + 100,
       timestamp '2025-01-01' + (g::bigint * 37 % 525600) * interval '1 minute'
FROM generate_series(1, 100000) AS g;

INSERT INTO wiki_lab.order_items
SELECT (o.id - 1) * 3 + i, o.id, 'sku-' || ((o.id::bigint * i * 31) % 500), i,
       ((o.id::bigint * i * 17) % 9000)::int + 100
FROM wiki_lab.orders o CROSS JOIN generate_series(1, 3) AS i;

ANALYZE wiki_lab.customers;
ANALYZE wiki_lab.orders;
ANALYZE wiki_lab.order_items;
INSERT INTO wiki_lab.lab_info VALUES ('lab', 'wiki-lab'), ('seed', '1');

MySQL. MySQL không có generate_series, nên dùng CTE đệ quy; mặc định cte_max_recursion_depth là 1000 nên phải nâng lên để sinh 100.000 hàng. Dấu của lab nằm trong bảng lab_info của cả hai engine:

DROP DATABASE IF EXISTS wiki_lab;
CREATE DATABASE wiki_lab;
USE wiki_lab;

CREATE TABLE lab_info (k VARCHAR(40) PRIMARY KEY, v VARCHAR(200) NOT NULL);
CREATE TABLE customers (
  id INT PRIMARY KEY,
  name VARCHAR(40) NOT NULL,
  country CHAR(2) NOT NULL,
  created_at DATETIME NOT NULL
);
CREATE TABLE orders (
  id INT PRIMARY KEY,
  customer_id INT NOT NULL,
  status VARCHAR(12) NOT NULL,
  total_cents INT NOT NULL,
  created_at DATETIME NOT NULL
);
CREATE TABLE order_items (
  id INT PRIMARY KEY,
  order_id INT NOT NULL,
  sku VARCHAR(12) NOT NULL,
  qty INT NOT NULL,
  price_cents INT NOT NULL
);

SET SESSION cte_max_recursion_depth = 1000000;

INSERT INTO customers
WITH RECURSIVE seq (g) AS (SELECT 1 UNION ALL SELECT g + 1 FROM seq WHERE g < 2000)
SELECT g, CONCAT('customer-', g), ELT(g % 5 + 1, 'VN', 'JP', 'US', 'DE', 'SG'),
       TIMESTAMP('2024-01-01') + INTERVAL (g * 53 % 365) DAY
FROM seq;

INSERT INTO orders
WITH RECURSIVE seq (g) AS (SELECT 1 UNION ALL SELECT g + 1 FROM seq WHERE g < 100000)
SELECT g, (g * g) % 2000 + 1,
       CASE WHEN g % 100 < 70 THEN 'paid' WHEN g % 100 < 90 THEN 'shipped'
            WHEN g % 100 < 97 THEN 'cancelled' ELSE 'pending' END,
       (g * 7919) % 50000 + 100,
       TIMESTAMP('2025-01-01') + INTERVAL (g * 37 % 525600) MINUTE
FROM seq;

INSERT INTO order_items
SELECT (o.id - 1) * 3 + i.n, o.id, CONCAT('sku-', (o.id * i.n * 31) % 500), i.n,
       (o.id * i.n * 17) % 9000 + 100
FROM orders o JOIN (SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3) AS i;

ANALYZE TABLE customers, orders, order_items;
INSERT INTO lab_info VALUES ('lab', 'wiki-lab'), ('seed', '1');

Kiểm lab

Mỗi bước dưới đây chạy trong một lần gọi shell riêng, nên chúng đều bắt đầu bằng . ./lab-local.sh. Dựng server từ đầu (lần đầu khởi tạo thư mục dữ liệu của cả hai engine nên chậm hơn các lần sau), rồi nạp dữ liệu và ghi phiên bản:

. ./lab-local.sh
lab_up
test -f lab.env
. ./lab-local.sh
lab_seed
lab_versions
postgresql 18.6
mysql 26.7.0

Số hàng và các số tổng hợp của PostgreSQL:

. ./lab-local.sh
lab_check pg
customers 2000
orders 100000
order_items 300000
orders_paid 70000
customers_with_orders 212
orphan_orders 0
revenue_cents 2509950000

Cùng truy vấn trên MySQL:

. ./lab-local.sh
lab_check mysql
customers 2000
orders 100000
order_items 300000
orders_paid 70000
customers_with_orders 212
orphan_orders 0
revenue_cents 2509950000

Hai engine phải cho cùng một kết quả vì dùng cùng công thức; so sánh trực tiếp thay vì tin bằng mắt (MySQL in cột cách nhau bằng tab nên đổi sang dấu cách):

. ./lab-local.sh
pg_rows=$(lab_check pg)
mysql_rows=$(lab_check mysql)
[ "$pg_rows" = "$(printf '%s\n' "$mysql_rows" | tr '\t' ' ')" ] && echo "hai engine khớp"

Dấu hiệu bạn đang ở đúng lab. lab_whoami kiểm bốn điều: PostgreSQL đang nghe đúng thư mục socket của lab, MySQL đang dùng đúng socket của lab, và cả hai có bảng lab_info mang dấu của lab:

. ./lab-local.sh
lab_whoami

Chạy lệnh này trước mọi thao tác xóa hoặc ghi mạnh. lab_seed còn gọi lab_target để kiểm địa chỉ socket và việc tắt TCP trước khi reset schema. Những kiểm này giúp bắt kết nối sai; đừng sửa địa chỉ của hàm client để trỏ sang database thật.

Restart giữ dữ liệu. Dừng cả hai server rồi dựng lại; dữ liệu nằm trên đĩa nên không đổi:

. ./lab-local.sh
lab_down
lab_up
lab_check pg
lab_check mysql
customers 2000
orders 100000
orders_paid 70000

Reset và nạp lặp. lab_seed xóa và tạo lại schema nên chính là reset. Chạy hai lần liên tiếp, số hàng vẫn như cũ:

. ./lab-local.sh
lab_seed
lab_seed
lab_check pg
lab_check mysql
order_items 300000
orphan_orders 0
revenue_cents 2509950000

Mở hai session

Nhiều bài sau cần hai kết nối cùng lúc, ví dụ một giao dịch giữ khóa trong khi giao dịch khác bị chặn. Mở terminal thứ hai, vào thư mục dblab, chạy . ./lab-local.sh: file lab.env cho terminal mới biết server ở đâu, và lab_psql, lab_mysql dùng được ngay.

Để thấy hai session cùng tồn tại, cho mỗi engine một session ngủ vài giây ở nền, rồi hỏi từ session thứ hai xem nó có thấy session kia không:

. ./lab-local.sh
lab_psql -c "SELECT pg_sleep(4)" >/dev/null &
lab_mysql -e "SELECT SLEEP(4)" >/dev/null &
sleep 1
echo "postgresql $(lab_psql -At -c "SELECT count(*) FROM pg_stat_activity WHERE query LIKE 'SELECT pg_sleep%' AND pid <> pg_backend_pid()")"
echo "mysql $(lab_mysql -N -e "SELECT COUNT(*) FROM information_schema.PROCESSLIST WHERE INFO LIKE 'SELECT SLEEP%'")"
wait
postgresql 1
mysql 1

Dọn sạch

lab_clean dừng cả hai server, xóa thư mục dữ liệu (chỉ khi có dấu của lab) và xóa lab.env. Kiểm rằng thư mục dữ liệu và lab.env thật sự không còn:

. ./lab-local.sh
. ./lab.env
dir=$LAB_DIR
lab_clean
test ! -e "$dir"
test ! -e lab.env
echo "đã dọn"

Nếu một lệnh dừng giữa chừng (mất điện, đóng terminal), lab_clean vẫn dọn được miễn là lab.env còn. Gọi nó an toàn khi lab đã dọn rồi:

. ./lab-local.sh
lab_clean

Cách B: Compose (chưa chạy kiểm)

Phần này chưa được chạy trong môi trường kiểm của bài: máy kiểm chỉ có PostgreSQL và MySQL cài sẵn, không có Docker, và Podman chưa khởi tạo máy ảo. Nội dung dựa trên tài liệu chính thức của hai image đọc ngày 2026-10-03. Chạy thử trên máy của bạn và coi kết quả là của bạn.

Hai điểm theo tài liệu image đáng chú ý. Từ PostgreSQL 18, volume phải gắn ở /var/lib/postgresql (các bản cũ gắn ở /var/lib/postgresql/data); gắn nhầm chỗ sẽ không giữ dữ liệu qua lần tạo lại container. Và /dev/shm mặc định của container chỉ 64 MB, nên shm_size được nâng lên để các truy vấn song song không báo thiếu bộ nhớ chia sẻ. Cả hai image đều bắt buộc đặt mật khẩu superuser; mật khẩu lab ở dưới chỉ dành cho máy cá nhân vì không có cổng nào được công bố ra ngoài container.

name: wikilab

services:
  postgres:
    image: docker.io/library/postgres:18.6
    profiles: [database, postgres]
    shm_size: 256mb
    environment:
      POSTGRES_USER: lab
      POSTGRES_PASSWORD: lab
    volumes:
      - pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U lab -d postgres"]
      interval: 2s
      timeout: 3s
      retries: 30

  mysql:
    image: docker.io/library/mysql:26.7.0
    profiles: [database, mysql]
    environment:
      MYSQL_ROOT_PASSWORD: lab
    volumes:
      - mydata:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", "MYSQL_PWD=lab mysql -h 127.0.0.1 -uroot -N -e 'SELECT 1'"]
      interval: 2s
      timeout: 3s
      retries: 60

volumes:
  pgdata:
  mydata:

Không có mục ports: client chạy bên trong container bằng exec, nên không có gì lắng nghe trên máy chủ. Nếu bạn muốn dùng psql hay công cụ giao diện từ máy chủ, thêm cổng ở dạng dài với host_ip đặt là địa chỉ loopback và để Docker tự chọn cổng; đừng công bố cổng ra mọi giao diện.

lab-compose.sh định nghĩa cùng bộ hàng như lab-local.sh, nên các bài sau không đổi. Biến LAB_COMPOSE cho phép đổi docker compose thành podman compose (chưa thử):

. ./lab-common.sh

LAB_COMPOSE=${LAB_COMPOSE:-docker compose}

lab_up() {
  $LAB_COMPOSE --profile database up -d --wait
}

lab_psql() {
  $LAB_COMPOSE exec -T -e PGOPTIONS='-c client_min_messages=warning' postgres \
    psql -X -q -U lab -d postgres -v ON_ERROR_STOP=1 "$@"
}

lab_mysql() {
  $LAB_COMPOSE exec -T -e MYSQL_PWD=lab mysql mysql -uroot --batch "$@"
}

lab_target() {
  lab_psql -At -c 'SELECT 1' >/dev/null || return 1
  lab_mysql -N -e 'SELECT 1' >/dev/null
}

lab_whoami() {
  [ "$(lab_psql -At -c "SELECT value FROM wiki_lab.lab_info WHERE key = 'lab'")" = wiki-lab ] ||
    { echo "PostgreSQL thiếu dấu của lab" >&2; return 1; }
  [ "$(lab_mysql -N -e "SELECT v FROM wiki_lab.lab_info WHERE k = 'lab'")" = wiki-lab ] ||
    { echo "MySQL thiếu dấu của lab" >&2; return 1; }
  echo "lab ok"
}

lab_down() {
  $LAB_COMPOSE stop
}

lab_clean() {
  $LAB_COMPOSE down -v
}

Chuỗi lệnh tương ứng với cách A: dựng (lần đầu phải tải hai image), nạp dữ liệu, kiểm, restart, dọn:

. ./lab-compose.sh
lab_up
. ./lab-compose.sh
lab_seed
lab_versions
lab_check pg
lab_check mysql
lab_whoami
. ./lab-compose.sh
lab_down
lab_up
lab_check pg
. ./lab-compose.sh
lab_clean

Lần dọn cuối docker compose down -v xóa cả container, mạng và hai volume của project wikilab; -v là phần xóa dữ liệu, nên chỉ dùng khi muốn mất lab.

Giới hạn và lỗi thường gặp

Giới hạn của bài

  • Cách A đã chạy ngày 2026-10-03 trên macOS 27.0.1 (arm64) với PostgreSQL 18.6 và MySQL 26.7.0 cài bằng Homebrew. Linux chưa thử; cách B (Docker/Linux, Podman/macOS) chưa chạy.
  • Dữ liệu sinh bằng công thức, không phản ánh phân bố thật của một hệ thống; 100.000 đơn là quy mô nhỏ. Kết luận về plan ở các bài sau đúng với lab này, không tự động đúng với bảng lớn hơn nhiều lần.
  • Nhánh MySQL ghim là nhánh Innovation; chưa thử trên LTS. Các engine khác phiên bản có thể chọn plan khác.
  • --auth=trust và root không mật khẩu chỉ chấp nhận được vì cả hai server chỉ nhận socket Unix trong thư mục riêng; đổi sang TCP là phải đổi cả cách xác thực.
  • Các bài sau mặc định lab đang chạy từ thư mục này; chúng không dựng lại lab.

Lỗi thường gặp

Triệu chứngNguyên nhân thường gặpCách xử lý
thiếu initdb trong PATHCông cụ cài ở thư mục không nằm trong PATHThêm thư mục chứa initdb, pg_ctl, psql vào PATH; kiểm lại bằng lab_need
Server không khởi động, log báo đường dẫn socket dàiĐã đổi đường dẫn ngắn của labGiữ thư mục ngắn trong /tmp như lab_up
initdb từ chối chạyĐang chạy bằng tài khoản root (thường gặp trong CI)Chạy bằng tài khoản thường
lab_seed báo Recursive query abortedQuên nâng cte_max_recursion_depth khi sửa seed MySQLGiữ lệnh SET SESSION cte_max_recursion_depth trước các câu CTE đệ quy
lab_psql hay lab_mysql báo thiếu lab.envChưa lab_up, hoặc đứng sai thư mụccd vào thư mục lab, . ./lab-local.sh, rồi lab_up
Lo lệnh chạy nhầm vào database thậtQuên mình đang trỏ vào đâuChạy lab_whoami trước; lab không bao giờ dùng cổng TCP
Dữ liệu mất sau khi tạo lại container (cách B)Gắn volume PostgreSQL sai chỗ cho bản 18Gắn tại /var/lib/postgresql, không phải /var/lib/postgresql/data

Học tiếp

  • Các bài thực hành database tiếp theo dùng lab này: đọc plan bằng EXPLAIN, thiết kế index, tái hiện khóa và deadlock, MVCC và VACUUM. Mỗi bài sẽ nối vào mục lục khi có nội dung hoàn chỉnh.
  • Review code do AI sinh ra: cách đặt probe do người review viết bên cạnh test của diff, cùng tinh thần với lab_check và lab_whoami: bằng chứng chạy được thay cho cảm giác.

Nguồn tham khảo