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

Wiki Kiến Thức & Kinh Nghiệm Kỹ Thuật

Chào mừng bạn đến với Wiki (https://shiftleft.vn/wiki) — nơi lưu trữ và chia sẻ kiến thức, kinh nghiệm thực chiến, cẩm nang thiết lập môi trường và các tiện ích dòng lệnh tổng quát.

🔗 ShiftLeft — Dịch vụ Audit, vá lỗi bảo mật và tư vấn kiến trúc hệ thống.


Mục Lục Khởi Đầu

1. Cài Đặt Môi Trường (Environment Setup)

2. AI Agent Skills

3. Bắt Đầu Theo Vấn Đề

Các bài ghi rõ môi trường đã kiểm và giới hạn của lab. Mục lục bên trái chứa các chủ đề còn lại; chọn theo vấn đề đang gặp thay vì đọc tuần tự toàn bộ wiki.

4. Sản Phẩm Khác Của Tác Giả

  • sql2erd.dev: dán SQL DDL để xem sơ đồ ERD ngay trong trình duyệt, xuất SVG, PNG, Draw.io, Mermaid hoặc Markdown.
  • JobCollect: tổng hợp tin tuyển dụng từ nhiều nền tảng; mỗi tin dẫn về nguồn gốc để ứng tuyển.

Thiết lập Linux đầy đủ cho lập trình viên

Hướng dẫn đưa máy Ubuntu Desktop mới đến môi trường làm việc có shell, Git, container, IDE, runtime và công cụ cloud. Các lệnh APT áp dụng cho Ubuntu; Fedora, Arch và các distro khác cần dùng package manager tương ứng. Phần Wayland dành riêng cho GNOME, có ghi rõ những khác biệt với KDE.

Chọn công cụ theo dự án: không cần cài mọi IDE hoặc mọi runtime. Đọc yêu cầu phiên bản trong repository trước khi cài. Các khối lệnh chạy trong Bash hoặc Zsh, trừ khối cấu hình được ghi tên riêng. Biến HOME và USER dùng giá trị của phiên đăng nhập; không thay chúng bằng một tài khoản cố định.

1. Chuẩn bị hệ điều hành

Tải ISO từ Ubuntu Desktop, kiểm tra checksum theo hướng dẫn Ubuntu, rồi tạo USB cài đặt. Sao lưu dữ liệu trước khi chia lại phân vùng; chọn mã hóa đĩa nếu cần bảo vệ máy khi thất lạc. Sau cài đặt, kiểm tra Wi-Fi, âm thanh, màn hình ngoài và driver qua Software & Updates → Additional Drivers. Kết nối nguồn trước khi cập nhật firmware hoặc nâng cấp hệ điều hành.

cat /etc/os-release
uname -r
dpkg --print-architecture
printf '%s\n' "$XDG_SESSION_TYPE"
df -h
free -h
sudo apt update
sudo apt upgrade

Hướng dẫn tải binary phía dưới có lựa chọn amd64 hoặc arm64; chọn theo kết quả dpkg --print-architecture. Khởi động lại nếu hệ thống yêu cầu. Trong Settings, chọn múi giờ, bố cục bàn phím, khóa màn hình và chế độ tiết kiệm pin phù hợp.

2. Công cụ CLI và thư viện nền

Nhóm này phục vụ tải file, đọc tài liệu, biên dịch và chẩn đoán mạng. Giữ dấu \ ở cuối dòng; đặt chú thích ngoài danh sách package để lệnh nhiều dòng chạy đúng.

sudo apt install -y \
  ca-certificates curl wget gnupg lsb-release software-properties-common \
  git git-lfs openssh-client unzip zip tar gzip bzip2 xz-utils p7zip-full \
  vim nano build-essential cmake pkg-config \
  net-tools iputils-ping dnsutils nmap traceroute \
  htop ncdu tmux tree jq fzf ripgrep fd-find bat httpie autossh \
  desktop-file-utils fontconfig wl-clipboard xdg-utils

sudo apt install -y \
  python3-dev libssl-dev libffi-dev libbz2-dev libreadline-dev \
  libsqlite3-dev zlib1g-dev liblzma-dev libxml2-dev libxslt1-dev

Nếu package không có candidate, xem apt-cache policy PACKAGE, kiểm tra codename và repository hỗ trợ release đang dùng. Không đổi codename sang release khác để ép cài.

Ubuntu đặt tên executable của bat là batcat, của fd là fdfind. Có thể dùng alias trong phần Zsh cuối bài. Dùng rg, jq, tree, ncdu và tmux để làm việc với codebase lớn; http là executable của HTTPie.

Just, eza và tldr

Just chạy recipe trong justfile. Installer chính thức cho phép cài vào thư mục người dùng:

mkdir -p "$HOME/.local/bin"
curl -fsSL https://just.systems/install.sh -o /tmp/install-just.sh
less /tmp/install-just.sh
bash /tmp/install-just.sh --to "$HOME/.local/bin"
export PATH="$HOME/.local/bin:$PATH"
just --version

eza thêm màu, icon và chế độ cây. Nếu apt-cache policy eza có candidate, dùng sudo apt install eza; nếu chưa có, cài cargo install eza sau phần Rust hoặc theo repository được upstream công bố. Tránh thêm repository không cần thiết chỉ để thay một lệnh liệt kê file.

Sau khi cài Node.js, npm install -g tldr cung cấp ví dụ lệnh ngắn: tldr tar, tldr git. Công cụ hiển thị thông tin hệ thống như Neofetch/Fastfetch là tùy chọn; dùng cat /etc/os-release và uname -r vẫn đủ để xác định môi trường.

3. Zsh, Oh My Zsh, theme và font

sudo apt install -y zsh
zsh --version
chsh -s "$(command -v zsh)"

Đăng xuất rồi đăng nhập để shell mặc định có hiệu lực. Cài Oh My Zsh bằng installer đã đọc:

curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh \
  -o /tmp/install-oh-my-zsh.sh
less /tmp/install-oh-my-zsh.sh
sh /tmp/install-oh-my-zsh.sh

Cài Powerlevel10k và plugin bổ sung. Chạy các lệnh clone một lần; nếu thư mục đã tồn tại thì cập nhật bản đang có.

git clone --depth=1 https://github.com/romkatv/powerlevel10k.git \
  "${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k"
git clone --depth=1 https://github.com/zsh-users/zsh-autosuggestions \
  "${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/zsh-autosuggestions"
git clone --depth=1 https://github.com/zsh-users/zsh-syntax-highlighting.git \
  "${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting"
git clone --depth=1 https://github.com/zsh-users/zsh-completions \
  "${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/zsh-completions"

Tải JetBrainsMono hoặc Meslo từ Nerd Fonts, giải nén các file font vào ~/.local/share/fonts, rồi chạy fc-cache -fv. Trong terminal chọn font có hậu tố Nerd Font Mono. Với Powerlevel10k, chạy p10k configure sau khi cấu hình theme ở phần 12. Icon ô vuông thường là font chưa được chọn trong profile terminal.

4. SSH và Git

Tạo key và chọn đúng danh tính

Dùng key riêng cho từng tài khoản. Tên dưới đây là nhãn trung tính; đặt passphrase khi ssh-keygen hỏi. Không ghi đè key đã tồn tại.

mkdir -p "$HOME/.ssh"
chmod 700 "$HOME/.ssh"
ssh-keygen -t ed25519 -C "dev-workstation" -f "$HOME/.ssh/id_ed25519_dev"
eval "$(ssh-agent -s)"
ssh-add "$HOME/.ssh/id_ed25519_dev"
ssh-add -l
touch "$HOME/.ssh/config"
chmod 600 "$HOME/.ssh/config"

Thêm block sau vào ~/.ssh/config. Alias giúp chọn key khi có nhiều tài khoản trên cùng dịch vụ:

Host github-dev
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_dev
    IdentitiesOnly yes
    AddKeysToAgent yes

Copy public key bằng wl-copy < ~/.ssh/id_ed25519_dev.pub trên Wayland hoặc đọc file .pub rồi dán vào phần SSH Keys của dịch vụ. Xác minh host fingerprint theo nhà cung cấp trước khi chấp nhận lần kết nối đầu. Kiểm tra bằng ssh -T git@github-dev; GitHub có thể trả exit code 1 dù thông báo xác thực thành công vì không cung cấp shell. Xem GitHub SSH và GitLab SSH.

Private key luôn nằm ngoài repository. Với server cũ cần RSA, tạo key RSA riêng và giới hạn thay đổi thuật toán trong đúng block host; không bật ssh-rsa cho mọi host.

Cấu hình Git

Nhập tên và email mà bạn muốn xuất hiện trong commit, có thể dùng địa chỉ noreply từ dịch vụ Git. Thay hai giá trị mẫu trước khi chạy:

git config --global user.name "TEN_HIEN_THI"
git config --global user.email "DIA_CHI_COMMIT"
git config --global init.defaultBranch main
git config --global core.editor "code --wait"
git config --global push.autoSetupRemote true
git lfs install
git --version
git config --global --list --show-origin

Nếu chưa cài VS Code, dùng vim hoặc nano cho core.editor. Với tài khoản riêng theo dự án, đặt user.name/user.email ở phạm vi repository hoặc dùng includeIf; xem Git setup. Không bật tối ưu fsmonitor hoặc thay compression toàn máy khi chưa đo nhu cầu.

5. Docker và Compose

Docker Engine chạy container trực tiếp trên Linux. Docker Desktop là lựa chọn có GUI và môi trường riêng; chọn một workflow và kiểm tra docker context ls khi dùng nhiều engine. Các lệnh dưới dành cho Engine mới theo Docker Ubuntu.

Nếu máy đã có docker.io, docker-compose, docker-compose-v2, docker-doc, docker-buildx, podman-docker, containerd hoặc runc, đối chiếu gói xung đột và sao lưu dữ liệu trước khi chuyển sang repository Docker. Không gỡ container runtime đang phục vụ workload khác một cách tự động.

sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo docker run --rm hello-world
docker compose version

Nếu chấp nhận quyền quản trị tương đương root của nhóm docker, chạy sudo usermod -aG docker "$USER", đăng xuất/đăng nhập rồi kiểm tra docker run --rm hello-world. Máy cần cách ly quyền có thể chọn rootless Docker. Với Podman, xem cài đặt Podman; kiểm tra khả năng tương thích Compose của dự án trước khi thay runtime.

6. IDE và ứng dụng làm việc

VS Code

Chọn APT hoặc Snap. Với APT, tải .deb đúng kiến trúc từ VS Code Linux, lưu thành ~/Downloads/code.deb, rồi chạy sudo apt install "$HOME/Downloads/code.deb". Package có thể thiết lập repository Microsoft để cập nhật qua APT. Với Snap:

sudo snap install code --classic
code --version

Cài extension theo ngôn ngữ và workflow; ví dụ:

code --install-extension ms-python.python
code --install-extension charliermarsh.ruff
code --install-extension rust-lang.rust-analyzer
code --install-extension esbenp.prettier-vscode
code --install-extension dbaeumer.vscode-eslint
code --install-extension ms-vscode-remote.remote-ssh
code --install-extension ms-vscode-remote.remote-containers

Chọn thêm Pylance, LLDB, GitLens, Git Graph, Material Icon Theme, GitHub Theme và extension container/Kubernetes khi cần. Dùng Settings Sync nếu muốn đồng bộ cấu hình giữa máy.

JetBrains Toolbox và ứng dụng GUI

Công cụCài đặt và cấu hìnhKiểm tra
JetBrains ToolboxTải archive Linux đúng kiến trúc, giải nén trong thư mục riêng rồi chạy Toolbox. Chọn IntelliJ IDEA, PyCharm, WebStorm, GoLand, CLion, Rider hoặc DataGrip theo dự án và giấy phépMở IDE, chọn SDK/interpreter và kiểm tra terminal tích hợp
Postmansudo snap install postman hoặc archive Linux từ nhà cung cấpGửi request đến API thử nghiệm của bạn
DBeaver Community.deb từ nhà cung cấp hoặc sudo snap install dbeaver-ceKết nối database thử nghiệm, chạy truy vấn chỉ đọc
NavicatTải AppImage chính thức, cần giấy phép phù hợpMở app và kiểm tra connection thử nghiệm
Beekeeper Studio / SQLToolsChọn client phù hợp với workflow file .sql và Git; SQLTools cần driver extension tương ứngLưu query vào file, kiểm tra encoding UTF-8

Với AppImage, đặt file đã tải vào ~/.local/bin/navicat, chạy chmod +x và thêm launcher. desktop-file-utils cung cấp công cụ kiểm tra launcher:

mkdir -p "$HOME/.local/share/applications"
cat > "$HOME/.local/share/applications/navicat.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=Navicat
Exec="$HOME/.local/bin/navicat" %U
Terminal=false
Categories=Development;Database;
EOF
desktop-file-validate "$HOME/.local/share/applications/navicat.desktop"
update-desktop-database "$HOME/.local/share/applications"

Có thể trích xuất icon bằng --appimage-extract trong một thư mục tạm riêng rồi thêm Icon= vào launcher. Nếu AppImage báo thiếu FUSE, xem AppImage troubleshooting cho release Ubuntu đang dùng; không gỡ FUSE hiện có để thử sửa.

7. Ngôn ngữ lập trình

Python: uv và pyenv

uv quản lý dependency, virtual environment và Python. Không thay /usr/bin/python3 của Ubuntu bằng runtime dự án.

curl -fsSL https://astral.sh/uv/install.sh -o /tmp/install-uv.sh
less /tmp/install-uv.sh
sh /tmp/install-uv.sh
export PATH="$HOME/.local/bin:$PATH"
uv --version
uv python install 3.14

Trong dự án có pyproject.toml, dùng uv sync, uv run python --version, uv run pytest. uv python pin VERSION ghi phiên bản vào .python-version; chọn phiên bản theo dự án.

Nếu cần build nhiều Python từ source, dùng pyenv. Cài thêm libncurses-dev tk-dev libxmlsec1-dev, tải installer từ https://pyenv.run, đọc nội dung rồi chạy bằng Bash. Thêm pyenv vào Zsh theo phần 12. Sau đó:

pyenv install --list
pyenv install 3.14
pyenv versions
pyenv local 3.14
python --version

Chọn uv-managed Python hoặc pyenv theo nhu cầu; không cần để cả hai tranh quyền chọn interpreter cho cùng dự án. Mỗi bản Python mới là một cài đặt riêng; kiểm thử trước khi xóa runtime cũ.

PHP và Composer

Dùng sudo apt install php-cli php-fpm php-curl php-mbstring php-xml php-zip php-bcmath php-intl php-sqlite3 nếu phiên bản Ubuntu đáp ứng dự án. Cần nhiều phiên bản thì đối chiếu release được hỗ trợ tại PPA PHP, rồi:

sudo add-apt-repository ppa:ondrej/php
sudo apt update
PHP_VERSION=8.3
sudo apt install -y \
  "php${PHP_VERSION}-cli" "php${PHP_VERSION}-fpm" "php${PHP_VERSION}-common" \
  "php${PHP_VERSION}-mysql" "php${PHP_VERSION}-pgsql" "php${PHP_VERSION}-sqlite3" \
  "php${PHP_VERSION}-curl" "php${PHP_VERSION}-gd" "php${PHP_VERSION}-mbstring" \
  "php${PHP_VERSION}-xml" "php${PHP_VERSION}-zip" "php${PHP_VERSION}-bcmath" \
  "php${PHP_VERSION}-intl" "php${PHP_VERSION}-redis"
sudo update-alternatives --config php
php --version
php -m

8.3 chỉ là lựa chọn minh họa, cần thay theo project và vòng đời PHP supported versions. update-alternatives đổi CLI; PHP-FPM cho web server có service/socket riêng. Xdebug là tùy chọn khi cần debug, không bật mặc định cho mọi workload.

Cài Composer bằng installer được xác minh như hướng dẫn Composer:

COMPOSER_SIG="$(curl -fsSL https://composer.github.io/installer.sig)"
curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php
COMPOSER_ACTUAL="$(php -r "echo hash_file('sha384', '/tmp/composer-setup.php');")"
if [ "$COMPOSER_SIG" = "$COMPOSER_ACTUAL" ]; then
  php /tmp/composer-setup.php --install-dir="$HOME/.local/bin" --filename=composer
else
  printf '%s\n' 'Checksum Composer không khớp; dừng cài đặt.'
fi
composer --version

Node.js, NVM, pnpm và Yarn

NVM quản lý Node trong tài khoản người dùng. Xem tag installer hiện hành trong README upstream; ví dụ đã đối chiếu:

curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh \
  -o /tmp/install-nvm.sh
less /tmp/install-nvm.sh
bash /tmp/install-nvm.sh
export NVM_DIR="$HOME/.nvm"
[ ! -s "$NVM_DIR/nvm.sh" ] || . "$NVM_DIR/nvm.sh"
nvm install --lts
nvm alias default 'lts/*'
node --version
npm --version
npm install -g pnpm
pnpm --version

Với dự án có .nvmrc, chạy nvm install rồi nvm use. Theo pnpm installation, pin phiên bản khớp trường packageManager; không nâng major package manager giữa chừng chỉ để setup máy. Dự án dùng Yarn cần cài đúng dòng phiên bản theo Yarn installation, thay vì cài Yarn global để thay pnpm trong mọi dự án. Không dùng sudo npm với Node do NVM quản lý.

Go

Từ Go downloads, tải archive linux-amd64 hoặc linux-arm64, kiểm tra SHA-256 công bố, lưu thành ~/Downloads/go-linux.tar.gz. Với máy mới chưa có /usr/local/go:

sudo tar -C /usr/local -xzf "$HOME/Downloads/go-linux.tar.gz"
export PATH="/usr/local/go/bin:$HOME/go/bin:$PATH"
go version
go env GOPATH

Khi nâng cấp, làm theo Go install: thay toàn bộ cây Go cũ, không giải nén chồng lên cây hiện có. GOPATH mặc định là ~/go; repository dùng module không cần nằm trong GOPATH.

Rust

curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs -o /tmp/install-rust.sh
less /tmp/install-rust.sh
sh /tmp/install-rust.sh
. "$HOME/.cargo/env"
rustup component add rustfmt clippy rust-analyzer
rustc --version
cargo --version
rustup toolchain list

rustup quản lý toolchain. Chỉ cài nightly khi dự án yêu cầu; rust-toolchain.toml pin toolchain của repository. Nếu chưa có Just hoặc eza, có thể cài bằng cargo install just eza.

8. AWS và công cụ IaC

AWS CLI, SSO và Session Manager

Tải và xác minh installer theo AWS CLI v2. Ví dụ dưới dùng x86_64; ARM dùng archive awscli-exe-linux-aarch64.zip:

AWS_SETUP_DIR="$(mktemp -d)"
curl -fsSL https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip \
  -o "$AWS_SETUP_DIR/awscliv2.zip"
unzip -q "$AWS_SETUP_DIR/awscliv2.zip" -d "$AWS_SETUP_DIR"
sudo "$AWS_SETUP_DIR/aws/install"
aws --version

Chọn --update khi cập nhật cài đặt CLI đã tồn tại. Nếu tổ chức dùng IAM Identity Center, thiết lập bằng aws configure sso --profile dev, đăng nhập với aws sso login --profile dev, rồi xác nhận aws sts get-caller-identity --profile dev. Lệnh identity in ra metadata tài khoản của bạn, không dán kết quả vào tài liệu công khai. Xem SSO profiles.

Cần truy cập máy qua SSM thì tải .deb đúng kiến trúc theo Session Manager Plugin, cài bằng sudo apt install ./session-manager-plugin.deb, kiểm tra session-manager-plugin --version. Cài CLI không tự tạo quyền IAM hoặc mở phiên tới máy.

CDK, OpenTofu/Terraform và Terragrunt

npm install -g aws-cdk
cdk --version

Trong CDK, cdk synth tạo template và cdk diff xem thay đổi. Bootstrap/deploy tạo tài nguyên và có thể phát sinh chi phí; chỉ chạy khi đã chọn đúng account, region và profile.

Chọn engine theo project: OpenTofu có executable tofu, Terraform có executable terraform. Cài từ package/binary chính thức đúng kiến trúc và kiểm tra phiên bản dự án pin. Với Terraform qua repository HashiCorp:

curl -fsSL https://apt.releases.hashicorp.com/gpg \
  | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
printf 'deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com %s main\n' \
  "$(lsb_release -cs)" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update
sudo apt install terraform
terraform version

Nếu repository chưa hỗ trợ codename hiện tại, dùng binary được upstream xác minh thay vì đổi codename. Terragrunt cần bản tương thích với engine; tải binary và checksum cho linux_amd64/linux_arm64, kiểm tra rồi dùng install -m 0755 FILE "$HOME/.local/bin/terragrunt". Kiểm tra bằng tofu version hoặc terraform version, rồi terragrunt --version.

Ansible

Ansible cần môi trường Python riêng. Có thể dùng package Ubuntu (sudo apt install ansible) hoặc cài tool qua uv, chọn một phương pháp. Theo Ansible installation:

uv tool install --with ansible-core ansible
ansible --version
ansible-galaxy collection install community.general amazon.aws community.docker

Dự án có requirements.yml thì dùng ansible-galaxy collection install -r requirements.yml để giữ version đã pin. Không đưa inventory, private key hoặc vault password vào wiki.

9. Kubernetes: kubectl, Helm, k9s và Minikube

kubectl cần phiên bản tương thích cluster, không chỉ là bản mới nhất. Theo kubectl Linux, với môi trường thử nghiệm mới có thể lấy stable; môi trường có cluster thì đặt KUBECTL_VERSION theo version được phép trước khi tải:

KUBECTL_VERSION="$(curl -fsSL https://dl.k8s.io/release/stable.txt)"
KUBECTL_ARCH="$(dpkg --print-architecture)"
KUBECTL_DIR="$(mktemp -d)"
curl -fsSL "https://dl.k8s.io/release/$KUBECTL_VERSION/bin/linux/$KUBECTL_ARCH/kubectl" \
  -o "$KUBECTL_DIR/kubectl"
curl -fsSL "https://dl.k8s.io/release/$KUBECTL_VERSION/bin/linux/$KUBECTL_ARCH/kubectl.sha256" \
  -o "$KUBECTL_DIR/kubectl.sha256"
if (cd "$KUBECTL_DIR" && printf '%s  kubectl\n' "$(cat kubectl.sha256)" | sha256sum --check); then
  install -m 0755 "$KUBECTL_DIR/kubectl" "$HOME/.local/bin/kubectl"
fi
kubectl version --client

Kubeconfig chứa quyền truy cập cluster, giữ ngoài Git và kiểm tra context trước mọi thao tác. kubectl config current-context cho biết đích hiện tại; client cài được không đồng nghĩa đã có cluster.

Helm có installer theo major version. Chọn major khớp chart và pipeline; bản mới không tự chứng minh tương thích với project cũ. Ví dụ installer Helm 4:

curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-4 \
  -o /tmp/install-helm.sh
less /tmp/install-helm.sh
bash /tmp/install-helm.sh
helm version --short

k9s là TUI quản lý cluster: tải release đúng kiến trúc và checksum, cài binary vào ~/.local/bin, kiểm tra k9s version. Kiểm tra context trước khi mở vì giao diện có thao tác sửa/xóa tài nguyên.

Minikube tạo cluster local, cần tài nguyên RAM/ CPU và driver. Tải binary đúng kiến trúc và checksum theo trang cài đặt, rồi:

minikube version
minikube start --driver=docker
kubectl config current-context
kubectl get nodes
minikube stop

minikube stop giữ dữ liệu cluster; minikube delete xóa cluster local nên chỉ dùng khi đã không cần dữ liệu. Cluster local giúp kiểm chart/deploy mà không đụng môi trường cloud.

10. Tiếng Việt trên GNOME và Wayland

Trước tiên thử bộ gõ sẵn có trong Settings → Keyboard. Nếu chọn Fcitx5/Unikey trên GNOME, thiết lập theo Fcitx5 setup và Fcitx5 Wayland. GNOME và KDE có cơ chế input method khác nhau; không áp cùng bộ biến môi trường cho cả hai.

sudo apt install -y fcitx5 fcitx5-unikey fcitx5-config-qt fcitx5-frontend-all im-config
im-config -n fcitx5
fcitx5-configtool

Trong config tool, thêm Keyboard - English (US) và Unikey, chọn Telex/VNI tùy thói quen, kiểm tra phím chuyển Việt/Anh. Layout Keyboard - Vietnamese không thay thế engine Telex. Nếu Fcitx không tự chạy sau login, thêm XDG autostart:

mkdir -p "$HOME/.config/autostart"
cat > "$HOME/.config/autostart/fcitx5.desktop" <<'EOF'
[Desktop Entry]
Type=Application
Name=Fcitx 5
Exec=fcitx5
Icon=org.fcitx.Fcitx5
Terminal=false
EOF

Với ứng dụng XWayland hoặc Qt trên GNOME chưa nhận bộ gõ, có thể thêm các giá trị sau vào ~/.config/environment.d/im.conf, rồi đăng xuất/đăng nhập. Sao lưu file hiện có trước khi chỉnh; cấu hình session cụ thể quyết định có cần thêm GTK_IM_MODULE=fcitx hay không:

XMODIFIERS=@im=fcitx
QT_IM_MODULE=fcitx

Với KDE native Wayland, chọn Fcitx5 trong Virtual Keyboard theo upstream; không ép GTK_IM_MODULE/QT_IM_MODULE toàn session theo cấu hình GNOME. Với Chrome/VS Code/Electron, thử XWayland trước nếu native Wayland gặp lỗi. Khi compositor và application hỗ trợ text-input-v3, có thể thử riêng app:

code --ozone-platform=wayland --enable-wayland-ime --wayland-text-input-version=3

Khả năng hỗ trợ flag phụ thuộc phiên bản Electron/Chromium. Kiểm tra pgrep -a fcitx5, fcitx5-diagnose và gõ tiếng Việt có dấu trong terminal, trình duyệt, editor. Preedit có gạch chân khi đang nhập là bình thường; dấu bị nhân đôi, mất ký tự hoặc lag cần điều tra. Nếu chuyển từ ibus-bamboo, chỉ gỡ engine cũ sau khi bộ gõ mới hoạt động; giữ core IBus mà desktop còn sử dụng.

11. Khắc phục lỗi desktop thường gặp

Clipboard UTF-8 và drag-and-drop của JetBrains

Ghi lại IDE, JetBrains Runtime, session Wayland/X11, compositor và scaling. Cập nhật IDE/ runtime trước; sao lưu Help → Edit Custom VM Options rồi thử thay đổi từng IDE. JetBrains Wayland giải thích cách chọn native Wayland và xem toolkit trong Help → About.

Triệu chứngThử nghiệm có kiểm soátNghiệm thu và hoàn tác
Copy tiếng Việt từ IDE sang Chrome/editor bị vỡNếu native Wayland gây lỗi, thử XWayland bằng -Dawt.toolkit.name=sun.awt.X11.XToolkit; xem thêm JBR-9149Copy cả hai chiều, kiểm dấu và xuống dòng; bỏ override khi bản runtime mới đã sửa
Rider không kéo tab hoặc fileThử native Wayland bằng -Dawt.toolkit.name=WLToolkit nếu runtime hỗ trợKéo tab, chia cửa sổ, kéo file, kiểm cả clipboard; bỏ override nếu phát sinh lỗi khác
IDE mờ ở scaling lẻSo sánh native Wayland và XWayland, thử scaling 100%/200%Chọn cấu hình cân bằng hiển thị và input trên máy của bạn

Hai toolkit là hai lựa chọn thay thế, chỉ giữ một override mỗi IDE. Không suy ra cấu hình Rider đúng cho mọi IDE. Các cờ encoding như -Dfile.encoding=UTF-8 không thay thế việc kiểm clipboard thực tế và không tự chứng minh nguyên nhân lỗi MIME.

file:// có thể được desktop xử lý qua scheme handler thay vì MIME của nội dung. Xem xdg-mime query default x-scheme-handler/file; với file đã biết, dùng code --goto ./FILE:LINE để mở đúng dòng. Fragment dạng #L42 cần wrapper chuyển sang cú pháp --goto; VS Code không tự xử lý mọi kiểu fragment.

Nếu đăng ký wrapper cho scheme file, phải giao thư mục lại file manager và giải mã URI đúng cách. Không đặt VS Code làm handler toàn bộ file:// chỉ để sửa một link trong terminal. Kiểm thử file có khoảng trắng, thư mục và hoàn tác handler trước đó khi cần.

Video H.264, VLC và MIME

Khi player mặc định không giải mã được video, cài VLC hoặc codec GStreamer cần thiết theo Ubuntu restricted formats.

sudo apt install vlc
xdg-mime default vlc.desktop video/mp4
xdg-mime default vlc.desktop video/x-matroska
xdg-mime default vlc.desktop video/webm
xdg-mime default vlc.desktop video/vnd.avi
xdg-mime query default video/mp4

Dùng xdg-mime query filetype FILE để biết MIME thật của video. Nếu tiếp tục dùng player GStreamer, kiểm tra gói gstreamer1.0-libav phù hợp với distro. Không cần gỡ player mặc định chỉ để đổi ứng dụng mở video.

12. Cấu hình Zsh tổng hợp

Sao lưu ~/.zshrc trước khi chỉnh. Đây là cấu hình ghép các tool đã chọn, không bắt buộc ghi đè toàn bộ file. Theme và plugin chỉ bật sau khi đã cài ở phần 3.

typeset -U path PATH
path=("$HOME/.local/bin" "$HOME/go/bin" /usr/local/go/bin $path)
export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME="powerlevel10k/powerlevel10k"
plugins=(git docker kubectl aws zsh-autosuggestions zsh-completions zsh-syntax-highlighting)
[[ ! -f "$ZSH/oh-my-zsh.sh" ]] || source "$ZSH/oh-my-zsh.sh"

export PYENV_ROOT="$HOME/.pyenv"
[[ ! -d "$PYENV_ROOT/bin" ]] || path=("$PYENV_ROOT/bin" $path)
if command -v pyenv >/dev/null; then
  eval "$(pyenv init - zsh)"
fi
export NVM_DIR="$HOME/.nvm"
[[ ! -s "$NVM_DIR/nvm.sh" ]] || source "$NVM_DIR/nvm.sh"
[[ ! -f "$HOME/.cargo/env" ]] || source "$HOME/.cargo/env"
[[ ! -f "$HOME/.p10k.zsh" ]] || source "$HOME/.p10k.zsh"

command -v batcat >/dev/null && alias bat=batcat
command -v fdfind >/dev/null && alias fd=fdfind
command -v eza >/dev/null && alias ll='eza -la --icons'
alias ..='cd ..'
alias dc='docker compose'
alias gs='git status'
alias gd='git diff'
alias ports='ss -tulanp'

Kiểm tra cú pháp bằng zsh -n ~/.zshrc, mở terminal mới rồi command -v TOOL. Giữ lệnh chuẩn như cat, grep, find, python để tránh alias làm đổi hành vi bất ngờ; dùng tên tool mới hoặc alias riêng. Không đưa secret vào shell config, history hoặc prompt.

13. Checklist nghiệm thu và bảo trì

NhómKiểm traKết quả cần thấy
Hệ điều hành/etc/os-release, df -h, cập nhật, reboot nếu cầnĐúng distro/kiến trúc, đủ dung lượng, phần cứng hoạt động
Shellzsh --version, zsh -n ~/.zshrc, terminal mớiKhông lỗi startup, font/icon đúng
CLIgit --version, jq --version, rg --version, just --versionCó executable và chạy được
SSH/Gitssh-add -l, kết nối dịch vụ, cấu hình danh tínhChọn đúng public key và tài khoản
Containerdocker version, docker compose version, hello-worldKết nối được daemon/context dự định
Pythonuv --version, interpreter và test của dự ánĐúng runtime, dependency khớp lockfile
Node/PHP/Go/RustVersion command của tool đã chọnĐúng phiên bản dự án và toolchain
Cloud/IaCVersion command; đăng nhập profile nếu dùngĐúng CLI, profile và engine; chưa tự apply
KubernetesClient version, context, cluster local nếu càiĐúng context, node sẵn sàng ở lab
GUI/inputGõ tiếng Việt, copy/paste, drag/drop, videoHoạt động trong từng ứng dụng thực tế

Kiểm nhanh executable mà không cài hay chạy workload:

for tool in git curl jq rg fzf just zsh code docker uv node pnpm go rustc cargo php composer aws tofu terraform terragrunt ansible kubectl helm k9s minikube; do
  if command -v "$tool" >/dev/null 2>&1; then
    printf 'Có: %s\n' "$tool"
  else
    printf 'Chưa cài hoặc chưa có trong PATH: %s\n' "$tool"
  fi
done

Tool tùy chọn chưa cài không phải lỗi setup. Sau kiểm executable, chạy version command và test của dự án bạn thực sự làm; script kiểm PATH không xác nhận hoạt động của daemon, database, tài khoản cloud hay GUI.

Định kỳ cập nhật APT/Snap và toolchain đã chọn; đọc danh sách package trước khi sudo apt autoremove. Nâng major runtime trong nhánh kiểm thử, giữ lockfile và bản cũ cho đến khi dự án pass. Sao lưu dotfiles đã loại secret; giữ SSH key, credentials và database backup ở nơi riêng có mã hóa. Khi nâng Ubuntu, sao lưu trước, kiểm repository bên thứ ba có hỗ trợ release đích, rồi chạy lại checklist.

Nếu máy thường xuyên thiếu RAM, đo bằng free -h, htop, journalctl và hạn mức container trước khi bổ sung daemon bảo vệ tài nguyên. Xem Mem Guardian: bảo vệ RAM và CPU để hiểu tín hiệu PSI, ngưỡng can thiệp, bảo vệ tiến trình và cách vận hành watchdog. Shell plugin, script mở URI hay daemon tùy biến cần quy trình cài/gỡ riêng; chúng không phải điều kiện để Ubuntu workstation hoạt động.

Mem Guardian: bảo vệ RAM và CPU trên Linux

Mem Guardian là watchdog tùy biến cho máy Linux phát triển: theo dõi áp lực bộ nhớ, giảm tải ứng dụng khi máy sắp hết RAM và xử lý indexer gây bão CPU. Đây là lớp can thiệp bổ sung; vẫn cần kiểm giới hạn container, số tab, workload và dung lượng RAM của máy.

Trang này giải thích chính sách và vận hành. Mem Guardian không phải gói mặc định của Ubuntu; các lệnh quản lý service dưới đây chỉ dùng khi bạn đã cài daemon và systemd unit được rà soát. Không chạy lệnh đóng ứng dụng trên máy đang có dữ liệu chưa lưu để thử ngưỡng.

1. Đo tín hiệu trước khi can thiệp

free -h
grep -E '^(MemTotal|MemAvailable|SwapTotal|SwapFree):' /proc/meminfo
cat /proc/pressure/memory
cat /proc/pressure/cpu
ps -eo pid,comm,%cpu,%mem --sort=-%mem | head -n 15

MemAvailable ước lượng RAM có thể cấp phát; không chỉ nhìn cột RAM trống. PSI đo phần thời gian tác vụ bị chặn vì thiếu tài nguyên: some là có tác vụ bị chặn, full là mọi tác vụ không nhàn rỗi cùng bị chặn. avg10 là xu hướng trong cửa sổ 10 giây, biểu diễn bằng phần trăm. Nếu không có file pressure, kiểm hỗ trợ PSI của kernel trước khi dùng watchdog phụ thuộc tín hiệu này. Nguồn: Linux PSI.

2. Ngưỡng RAM và swap

Trạng tháiĐiều kiện tham chiếuCách hiểu
OVERLOADMemAvailable < 12% hoặc memory PSI full avg10 > 40Thiếu RAM hoặc stall bộ nhớ kéo dài
CRITICALMemAvailable < 6% hoặc memory PSI full avg10 > 60Cần kiểm lại sau mỗi bước giảm tải
Editor hard limitMemAvailable < 3% sau thời gian chờ editorChỉ dùng cho bước nâng mức can thiệp cuối
Swap/zRAMTỷ lệ tổng swap đã dùngTelemetry; không phải trigger độc lập để đóng ứng dụng

Swap đã dùng cao có thể phản ánh các page ít hoạt động; riêng tỷ lệ swap hoặc mức nén zRAM cao không đủ lý do kill tiến trình. Điều kiện RAM dùng hoặc, khác với điều kiện CPU cần cả hai tín hiệu và duy trì nhiều chu kỳ. Ngưỡng là chính sách watchdog tham chiếu, không phải mặc định kernel hay giá trị phù hợp mọi máy.

3. Can thiệp theo cấp độ

Trong profile AGGRESSIVE, khi đạt OVERLOAD và qua cooldown:

  1. Đóng nhóm trình duyệt Chrome, Edge/Teams bằng SIGKILL để thu hồi RAM nhanh.
  2. Chờ RECLAIM_WAIT=2 giây, đọc lại telemetry; chỉ chuyển bước tiếp nếu còn CRITICAL.
  3. Nếu cho phép đóng editor, gửi SIGTERM cho Cursor và VS Code như hai phương án cuối có cùng mức ưu tiên. Chờ EDITOR_TERM_GRACE=6 giây.
  4. Chỉ nâng lên SIGKILL editor khi RAM sau thời gian chờ vẫn dưới EDITOR_HARD_MEM_AVAIL_PCT=3.

SIGKILL không cho ứng dụng lưu hoặc dọn dẹp. Trình duyệt có thể khôi phục tab nhưng form, phiên đăng nhập và nội dung chưa lưu vẫn có thể mất; SIGTERM cũng không bảo đảm editor lưu dữ liệu. Chọn chính sách phù hợp công việc, bật autosave và kiểm backup trước khi cho phép watchdog đóng ứng dụng. KILL_EDITORS_ON_CRITICAL=0 tắt bước đóng editor, nhưng không tắt hành động lên trình duyệt của profile AGGRESSIVE.

4. Bảo vệ tiến trình và watchdog

PROTECT_PATTERN loại khỏi danh sách mục tiêu các thành phần desktop/session, Xorg/ Xwayland, systemd, D-Bus, audio, SSH, terminal, agent và runtime Docker/containerd. Kiểm tên tiến trình thực tế bằng ps; tên hoặc cách đóng gói thay đổi có thể khiến regex không còn khớp. Bảo vệ runtime container không thay cho giới hạn bộ nhớ của workload.

Watchdog dùng oom_score_adj=-1000; systemd unit tham chiếu có OOMScoreAdjust=-900, Nice=-5 và MemorySwapMax=0. Các thiết lập này giảm nguy cơ watchdog bị OOM chọn hoặc bị swap, tùy quyền và hỗ trợ cgroup. Không xem chúng là bảo đảm daemon luôn sống khi kernel, service hoặc máy gặp lỗi.

5. Bão CPU và allowlist

Hành động CPU cần đồng thời CPU bận >= 95% và CPU PSI some avg10 >= 20, duy trì qua CPU_SUSTAINED_CYCLES=3 chu kỳ poll. Một spike ngắn, một mình CPU bận hoặc một mình PSI cao không đủ kích hoạt hành động.

CPU_ACTION_PATTERN chỉ cho phép gửi SIGTERM tới Tracker extract/miner. Tiến trình lạ, editor và agent chỉ nhận cảnh báo ở nhánh CPU; nhánh RAM vẫn có chính sách riêng. Cooldown CPU tham chiếu là 60 giây. Log chứa quyết định để audit; thông báo desktop cần ngắn và không đưa chi tiết kiểm toán vào nội dung người dùng đọc thường xuyên.

6. Cấu hình và kiểm tra service đã cài

Cấu hình daemon đặt ở /etc/mem-guardian.conf, binary ở /usr/local/sbin/mem-guardian. Sao lưu cấu hình đang dùng trước khi chỉnh. Đây là các khóa tham chiếu, không phải file cấu hình hoàn chỉnh để ghi đè máy đang hoạt động:

MEM_AVAIL_PCT=12
PSI_FULL_AVG10=40
CRIT_MEM_AVAIL_PCT=6
CRIT_PSI_FULL_AVG10=60
RECLAIM_WAIT=2
KILL_EDITORS_ON_CRITICAL=0
EDITOR_TERM_GRACE=6
EDITOR_HARD_MEM_AVAIL_PCT=3
CPU_BUSY_PCT=95
CPU_PSI_SOME_AVG10=20
CPU_SUSTAINED_CYCLES=3
CPU_ACTION_COOLDOWN=60
systemctl status mem-guardian.service --no-pager
systemctl cat mem-guardian.service
journalctl -u mem-guardian.service -b --no-pager -n 100

Kiểm startup log có ngưỡng và target đúng. Sau khi đã rà soát thay đổi cấu hình, dùng sudo systemctl restart mem-guardian.service rồi đọc log lại. Khi cần dừng can thiệp, sudo systemctl stop mem-guardian.service; kiểm service đã dừng trước khi điều tra tiếp.

7. Thông báo desktop và AppArmor

Thông báo dùng notify-send trong session người dùng; service chạy qua runuser cần đúng user và môi trường D-Bus. Nếu thông báo thất bại, kiểm session và log audit trước khi thay chính sách bảo vệ.

NOTIFY_AA_UNCONFINED=1 là tùy chọn tương thích dùng aa-exec -p unconfined cho lệnh thông báo khi gặp lỗi AppArmor disconnected path. Nó thay đổi confinement của lệnh đó; không phải bước setup bắt buộc và không phải lý do tắt AppArmor toàn máy. Chỉ dùng sau khi xác định đúng lỗi và rà soát phạm vi; giữ chế độ confined khi không cần workaround.

8. Sysctl và indexer

Thiết lập tham chiếuMục đích và điều cần kiểm
vm.swappiness=10Thay cân bằng reclaim swap/file-backed page; đo lại với zRAM và workload thực tế
vm.vfs_cache_pressure=50Thay mức ưu tiên reclaim dentry/inode; giữ cache hơn có thể tốn RAM hơn
vm.dirty_ratio=15Ngưỡng dirty memory khiến tiến trình ghi tham gia writeback
vm.dirty_background_ratio=5Ngưỡng bắt đầu background writeback
vm.min_free_kbytes=131072Mức dự trữ tham chiếu 128 MiB; cần cân đối với tổng RAM

Đọc giá trị hiện tại bằng sysctl và lưu lại trước khi thử. Không áp toàn bộ preset chỉ vì máy có cùng distro; dirty ratio còn liên quan workload và thiết lập dirty bytes. Nguồn: Linux VM sysctl.

Với Tracker, ưu tiên chế độ throttle: quota CPU tham chiếu 50% và I/O scheduling idle. Xác định user unit đang có bằng systemctl --user list-unit-files trước khi tạo override; tên service khác nhau theo bản desktop. Chế độ disable có thể mask service và reset database index, làm mất kết quả tìm kiếm cho tới khi index được dựng lại. Khi restore, chỉ bỏ mask/override do bạn đã tạo; không xóa cấu hình quản trị khác.

9. Nghiệm thu và hoàn tác

  • Dùng telemetry giả lập hoặc VM để kiểm ngưỡng; không tạo OOM trên workstation có công việc chưa lưu. Swap cao đơn lẻ, spike CPU và tiến trình ngoài allowlist phải không bị kill.
  • Kiểm OVERLOAD/CRITICAL đọc lại RAM, thời gian chờ, editor opt-out, protected process và cooldown. Kiểm log đúng quyết định và notification tới đúng session.
  • Nếu cần gỡ, dừng và disable service, lưu config/log, rồi gỡ các file đúng với bản cài đã được kiểm kê. Khôi phục sysctl đã lưu và Tracker override/mask do bạn đã thay đổi.
  • Theo dõi sau hoàn tác bằng free -h, PSI và journal. Chỉ tăng mức can thiệp khi đã có bằng chứng workload, thay vì kết luận swap cao đồng nghĩa máy sắp OOM.

Quay lại hướng dẫn setup Linux.

Thiết lập macOS đầy đủ cho máy phát triển

Hướng dẫn đi từ máy Mac mới đến môi trường làm việc có shell, Git, runtime, container, cloud, IaC, IDE và quy trình sao lưu. Các bước nền tảng áp dụng cho Apple Silicon và Intel; chọn thêm nhóm công cụ theo dự án. Hoàn thành setup nghĩa là mở Terminal mới vẫn tìm thấy đúng công cụ, chạy được dự án mẫu và khôi phục được dữ liệu quan trọng.

1. Kiểm tra máy và cập nhật hệ điều hành

Mở Terminal rồi kiểm tra phiên bản macOS và kiến trúc đang chạy:

sw_vers
uname -m
system_profiler SPHardwareDataType
softwareupdate --list

arm64 là Apple Silicon, x86_64 thường là Intel. Terminal chạy qua Rosetta trên Apple Silicon cũng có thể báo x86_64; đối chiếu chip trong About This Mac. Dùng terminal native để tránh cài lẫn hai bộ Homebrew và dependency khác kiến trúc.

Trong System Settings, hoàn tất các bước sau:

  • General → Software Update: cài bản cập nhật phù hợp và bật cập nhật bảo mật tự động.
  • Privacy & Security: bật FileVault, giữ Gatekeeper và System Integrity Protection hoạt động.
  • Network → Firewall: bật tường lửa và xem lại ứng dụng được phép nhận kết nối.
  • Lock Screen: yêu cầu mật khẩu khi mở khóa; Touch ID bổ sung cho mật khẩu mạnh.
  • Trackpad: bật tap to click, nhấp phụ bằng hai ngón và điều chỉnh tốc độ con trỏ.
  • Accessibility → Pointer Control: chọn kéo bằng ba ngón nếu phù hợp thao tác của bạn.
  • Keyboard: thêm bộ gõ cần dùng, kiểm tra phím tắt và repeat rate.
  • Finder: hiển thị phần mở rộng tên file, dùng Cmd + Shift + . khi cần xem file ẩn.

Lưu công việc trước khi cài bản cập nhật cần khởi động lại. Xem lịch sử bằng:

softwareupdate --history

2. Command Line Tools và Homebrew

Cài công cụ biên dịch

xcode-select --install

Hoàn tất hộp thoại cài đặt, sau đó kiểm tra:

xcode-select -p
clang --version
git --version

Command Line Tools đủ cho đa số công cụ CLI. Cài Xcode đầy đủ từ App Store khi phát triển ứng dụng iOS/macOS cần SDK, simulator hoặc giao diện Xcode.

Cài Homebrew theo đúng kiến trúc

Tải script từ dự án Homebrew, xem nội dung rồi chạy:

curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh -o /tmp/homebrew-install.sh
less /tmp/homebrew-install.sh
/bin/bash /tmp/homebrew-install.sh

Thêm một dòng tương ứng vào ~/.zprofile, theo hướng dẫn mà installer in ra:

MáyDòng cấu hình
Apple Siliconeval "$(/opt/homebrew/bin/brew shellenv)"
Inteleval "$(/usr/local/bin/brew shellenv)"

Mở Terminal mới hoặc nạp lại cấu hình rồi kiểm tra:

source ~/.zprofile
brew --version
brew --prefix
brew doctor

Homebrew dùng prefix riêng cho hai kiến trúc. Khả năng hỗ trợ macOS và Intel thay đổi theo thời gian; kiểm tra máy thuộc nhóm hỗ trợ nào trước khi dùng phiên bản cũ. Nguồn: Homebrew Installation.

Cài bộ CLI nền tảng

brew install git gh just jq tree ripgrep bat eza fzf ncdu neovim gnupg uv pre-commit
Công cụDùng khi
git, ghQuản lý mã nguồn và thao tác với repository trên GitHub
justChạy recipe chuẩn của dự án
jqĐọc, lọc và biến đổi JSON
tree, ezaXem cây thư mục và danh sách file
ripgrep, fzfTìm nội dung và chọn kết quả bằng tìm kiếm mờ
bat, neovimĐọc file có màu cú pháp và chỉnh sửa trong terminal
ncduTìm thư mục chiếm nhiều dung lượng
gnupgMã hóa, kiểm chữ ký hoặc ký commit khi dự án yêu cầu
uv, pre-commitQuản lý Python và chạy kiểm tra trước commit

macOS có nhiều tiện ích BSD, khác một số tùy chọn GNU/Linux. Khi script cần GNU Make, cài brew install make và gọi gmake; khi cần GNU coreutils, cài brew install coreutils và dùng tên có tiền tố g. Không thay hàng loạt công cụ hệ thống trước khi biết script cần gì.

3. Quản lý bộ phần mềm bằng Brewfile

Tạo ~/Brewfile.dev để quản lý danh mục phần mềm của bạn. Bắt đầu với bộ tối thiểu dưới đây, rồi bổ sung runtime, database, công cụ cloud và IDE theo nhu cầu. Đọc tùy chọn của từng gói trước khi cài, đặc biệt nếu gói có thể bật hoặc restart service.

brew "git"
brew "gh"
brew "just"
brew "jq"
brew "ripgrep"
brew "uv"
brew "podman"
brew "podman-compose"
cask "visual-studio-code"
cask "font-meslo-lg-nerd-font"
brew bundle list --file="$HOME/Brewfile.dev"
brew bundle install --file="$HOME/Brewfile.dev"
brew bundle check --file="$HOME/Brewfile.dev"

brew bundle install có thể nâng cấp gói đã có. Dùng --no-upgrade nếu chỉ muốn bổ sung gói còn thiếu; Brewfile không khóa mọi dependency tại một phiên bản tuyệt đối. Nguồn: Homebrew Bundle.

4. Terminal, font và Zsh

Terminal mặc định đủ để bắt đầu. Cài iTerm2 nếu cần profile, chia pane hoặc cấu hình phím chi tiết; chọn Nerd Font nếu prompt dùng icon:

brew install --cask iterm2 font-meslo-lg-nerd-font
zsh --version

Trong profile của terminal và font terminal của IDE, chọn Meslo Nerd Font đã cài. Nếu icon thành ô vuông, kiểm tra font ở cả hai nơi. Nguồn: cask Meslo Nerd Font.

Phân vai các file shell

FileNội dung nên đặt
~/.zprofileHomebrew shellenv và cấu hình cho login shell
~/.zshrcPrompt, plugin, alias và completion cho shell tương tác
~/.zsh_historyLịch sử lệnh; giữ riêng tư, không đưa vào repository
~/.config/Cấu hình ứng dụng theo chuẩn XDG nếu ứng dụng hỗ trợ

macOS đã dùng Zsh mặc định. Nếu cần bản Homebrew, cài brew install zsh, đăng ký đúng đường dẫn từ brew --prefix trong /etc/shells rồi mới dùng chsh. Có thể tiếp tục dùng /bin/zsh nếu chưa có yêu cầu cụ thể.

Oh My Zsh và plugin

Cài theo hướng dẫn Oh My Zsh. Trước khi thay cấu hình đang có, lưu một bản riêng để có thể khôi phục. Bộ plugin gợi ý trong ~/.zshrc:

plugins=(git brew python pip rust uv aws kubectl)
source "$HOME/.oh-my-zsh/oh-my-zsh.sh"
eval "$(fzf --zsh)"

zsh-autosuggestions, zsh-completions, zsh-syntax-highlighting và fzf-tab là plugin bổ sung, cần cài từ repository của chính plugin trước khi thêm vào danh sách. Kiểm tra hướng dẫn tương thích của từng plugin khi thay đổi thứ tự hoặc kết hợp với highlighting; mở shell mới để kiểm completion sau mỗi thay đổi.

Alias tối thiểu, thêm vào ~/.zshrc:

alias ll='eza -lah'
alias lt='eza --tree --level=2'
alias c='clear'
alias k='kubectl'

Mở shell mới để kiểm tra. Trong Zsh, không dùng path hoặc status làm tên biến riêng: chúng là tham số đặc biệt của shell. Cấu hình tương tác không nên là điều kiện để recipe trong CI chạy được.

5. SSH, Git và xác thực

Tạo khóa SSH

mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keygen -t ed25519 -C "dev-workstation" -f ~/.ssh/id_ed25519_dev
ssh-add --apple-use-keychain ~/.ssh/id_ed25519_dev
pbcopy < ~/.ssh/id_ed25519_dev.pub

Đặt passphrase khi tạo khóa và chỉ đưa public key .pub lên dịch vụ Git. Comment dev-workstation là nhãn nhận diện key, có thể thay bằng nhãn của bạn. Với cờ --apple-use-keychain, dùng /usr/bin/ssh-add nếu phiên bản OpenSSH cài thêm không hỗ trợ cờ này. Nguồn: tạo SSH key trên macOS.

Ví dụ ~/.ssh/config cho một tài khoản Git:

Host github-dev
    HostName github.com
    User git
    IdentityFile ~/.ssh/id_ed25519_dev
    IdentitiesOnly yes
    AddKeysToAgent yes
    UseKeychain yes
chmod 600 ~/.ssh/config ~/.ssh/id_ed25519_dev
ssh -T git@github-dev

Đối chiếu host fingerprint với tài liệu dịch vụ khi kết nối lần đầu. Nếu dùng nhiều tài khoản, mỗi tài khoản có key và Host alias riêng, và URL clone phải dùng đúng alias. Không thêm kết quả ssh-keyscan vào known_hosts mà chưa kiểm fingerprint.

Cấu hình Git

git config --global user.name "TEN_HIEN_THI"
git config --global user.email "DIA_CHI_COMMIT"
git config --global init.defaultBranch main
git config --global pull.ff only
git config --global core.editor "vim"
git config --global --list --show-origin

Thay TEN_HIEN_THI và DIA_CHI_COMMIT bằng danh tính commit của bạn trước khi chạy. Nếu dùng VS Code, thay editor bằng code --wait sau khi lệnh code hoạt động. Dùng cấu hình local trong từng repository khi cần danh tính khác. Với GitHub CLI:

gh auth login
gh auth status

Nếu muốn diff bằng GUI, cài Meld và cấu hình git difftool. Dùng git difftool khi cần GUI và git diff khi cần kết quả văn bản trong terminal, SSH hoặc CI.

6. Runtime và môi trường theo dự án

Chỉ cài runtime dùng trong công việc. Ưu tiên file pin version của repository thay vì thay phiên bản global theo từng dự án; một runtime không nên được nhiều manager cùng quản lý.

Python với uv

uv --version
uv python install 3.14
uv python list

Trong thư mục dự án Python đã có pyproject.toml và lockfile:

uv sync
uv run python --version
uv run pytest

Với dự án mới, tạo uv venv --python 3.14 rồi cài dependency vào môi trường đó. CLI độc lập có thể cài bằng uv tool install; không cần sudo pip hoặc sửa Python hệ thống. Nguồn: quản lý Python bằng uv.

Node.js và package manager

Upstream nvm không hỗ trợ cách cài qua Homebrew. Với máy mới, cài nvm theo hướng dẫn upstream và thêm các dòng nạp NVM_DIR/nvm.sh do installer đề xuất vào ~/.zshrc. Sau khi mở shell mới:

nvm install --lts
nvm use --lts
node --version
npm --version

Trong dự án đã có .nvmrc, dùng nvm install rồi nvm use. Chọn npm, pnpm hoặc Yarn theo trường packageManager và lockfile; không tạo thêm lockfile của manager khác. Corepack không có sẵn trong mọi bản Node, nên theo hướng dẫn của manager được dự án chọn. Nguồn: nvm upstream.

Rust

Cài toolchain qua rustup, sau đó:

source "$HOME/.cargo/env"
rustup component add rustfmt clippy
rustc --version
cargo --version

Giữ ~/.cargo/bin trong PATH. Trong dự án dùng rust-toolchain.toml, rustup chọn toolchain tương ứng; kiểm tra bằng rustup show trước khi build.

Go, Java, PHP và Dart

NhómCài đặtCấu hình và kiểm tra
Gobrew install gogo version; thêm $(go env GOPATH)/bin vào PATH khi cài Go CLI
Javabrew install openjdk@17 nếu dự án dùng JDK 17java -version; cấu hình JAVA_HOME theo caveat của formula
PHPbrew install php composerphp -v, composer --version; kiểm tra php --ini trước khi cài extension
Dartbrew install dart-lang/dart/dartdart --version; chỉ dùng tap này khi dự án cần Dart

Ví dụ dùng JDK Homebrew trực tiếp mà không thay symlink toàn hệ thống, thêm vào shell:

export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home"
export PATH="$JAVA_HOME/bin:$PATH"

Khi nhiều dự án cần các phiên bản Python/PHP/JDK khác nhau, tách môi trường theo dự án. Các PHP legacy từ tap ngoài cần được xem như môi trường tương thích riêng; không đưa chúng thành bản mặc định cho máy mới. Khi dùng nhiều PHP, kiểm tra command -v php và PATH thay vì chạy brew link --overwrite --force theo thói quen.

7. Container và máy ảo Linux

Container Linux trên macOS chạy trong VM. Hướng dẫn này dùng Podman CLI trực tiếp; cài Podman Desktop chỉ khi cần GUI quản lý.

brew install podman podman-compose vfkit
podman machine list

Nếu chưa có VM, khởi tạo rồi chạy:

podman machine init --provider applehv --cpus 4 --memory 4096
podman machine start
podman info
podman run --rm quay.io/podman/hello
podman-compose --version

applehv là provider hợp lệ trên macOS; tên vz không phải giá trị của cờ provider. Chọn CPU/RAM để vẫn còn tài nguyên cho IDE và hệ điều hành. Kiểm tra podman machine init --help của bản đang dùng trước khi thêm cờ mount. Nguồn: Podman machine init.

VM đã tạo dùng disk riêng chứa image và volume. Các thao tác thông thường:

podman machine list
podman system connection list
podman ps -a
podman volume ls
podman system df

Nếu VM báo đang chạy nhưng engine hỏng sau sleep/wake, dừng và khởi động lại:

podman machine stop
podman machine start
podman info

Khởi động lại giữ disk của VM. podman machine rm xóa dữ liệu VM; chỉ dùng khi đã xuất database/volume cần giữ. Không alias docker=podman toàn shell: dự án cần Docker API, Compose hay driver Kubernetes có thể có yêu cầu riêng. Trên Apple Silicon, ưu tiên image ARM64 hoặc multi-arch; dùng image AMD64 qua giả lập có thể chậm và không tương thích mọi workload.

8. Database và công cụ GUI

Chọn chạy database bằng container theo Compose của dự án hoặc cài service Homebrew. Tránh chạy hai instance dùng cùng port. Với cài đặt native:

brew install postgresql@17 mysql
brew services list

Chỉ bật service cần dùng, ví dụ PostgreSQL:

brew services start postgresql@17
"$(brew --prefix postgresql@17)/bin/psql" --version
brew services info postgresql@17

Trước nâng major version, đọc quy trình migrate và tạo backup; đổi binary không tự đảm bảo data directory cũ tương thích. Với Redis, chọn image/container của dự án hoặc phương thức cài từ nhà cung cấp; RedisInsight là GUI quan sát và quản lý kết nối.

Công cụ trong danh mục tham chiếuCông việc
DataGrip, NavicatTruy vấn và quản lý nhiều loại database
RedisInsightQuan sát Redis, key và hiệu năng
PostmanGửi request, tổ chức collection kiểm API
CharlesQuan sát HTTP(S) cho ứng dụng được phép kiểm thử
VS Code, Zed, NeovimChỉnh mã nguồn, terminal, LSP và chạy task
IDE chuyên biệtJava, Go, Python, PHP hoặc Rust theo stack
Figma, OBSThiết kế giao diện và ghi hình demo
GitHub DesktopThao tác Git qua GUI

Cài một editor chính trước; mở dự án và kiểm tra formatter, interpreter, terminal, extension cần dùng. Charles và IDE thương mại cần giấy phép hợp lệ; chứng chỉ proxy chỉ cài khi cần, chỉ giữ phạm vi tin cậy phù hợp. Nếu ứng dụng bị Gatekeeper chặn, kiểm nguồn tải/chữ ký và làm theo hướng dẫn nhà cung cấp thay vì xóa quarantine hàng loạt hoặc tắt Gatekeeper.

9. Cloud, SSH tunnel và công cụ hỗ trợ

AWS

brew install awscli
aws --version
aws configure sso --profile dev
aws sso login --profile dev

Wizard yêu cầu thông tin IAM Identity Center do quản trị viên cung cấp. Dùng profile riêng cho mỗi môi trường, kiểm tra đúng profile trước khi chạy lệnh thay đổi tài nguyên. Giữ cấu hình/credential trong kho riêng của máy, không chép token, account ID hoặc SSO URL nội bộ vào wiki. Với xác thực SSO, ưu tiên phiên ngắn hạn thay access key tĩnh. Nguồn: cấu hình AWS CLI SSO.

Khi cần Systems Manager:

brew install --cask session-manager-plugin
session-manager-plugin --version

Nguồn: Session Manager Plugin cask. AWS SAM CLI dùng cho build/test ứng dụng serverless; chọn installer macOS đúng kiến trúc theo hướng dẫn SAM. LocalStack phục vụ giả lập API AWS trong local dev, cài theo hướng dẫn dự án và kiểm khả năng tương thích container runtime trước khi sử dụng.

Google Cloud và cloud CLI khác

brew install --cask gcloud-cli
gcloud --version
gcloud init

Tên cask hiện hành là gcloud-cli. Với ứng dụng dùng Application Default Credentials, làm theo cơ chế đăng nhập của dự án; không mặc định đổi credential global cho mọi dự án. Các CLI cloud khác cũng cần kiểm tra account, region/project và quyền trước khi dùng. Nguồn: Google Cloud CLI cask.

Tunnel và tiện ích

brew install autossh cloudflared rclone
  • autossh giám sát kết nối SSH dài; tunnel qua bastion cần quyền và hostname do quản trị viên cung cấp. Dùng SSH config riêng của máy, không ghi endpoint nội bộ lên wiki.
  • cloudflared phục vụ tunnel/Zero Trust theo cấu hình dự án; chọn phương án xác thực phù hợp, giữ token ngoài source.
  • rclone đồng bộ lưu trữ và backup; kiểm tra đích và thử --dry-run trước tác vụ sync vì sync có thể xóa file ở đích.
  • aws-vault là lựa chọn lưu credential trong keychain nếu tổ chức dùng cơ chế này.
  • CLI AI như Claude, Gemini hoặc Kiro là nhóm tùy chọn. Dùng installer chính thức, đăng nhập riêng và rà soát quyền đọc file, hook cùng lịch sử phiên trước khi dùng.

10. IaC, Kubernetes và tự động hóa

OpenTofu và Terragrunt bằng tenv

brew install tofuutils/tap/tenv
tenv tofu install latest
tenv tofu use latest
tenv tg install latest
tenv tg use latest
tofu --version
terragrunt --version

Lệnh trên tạo baseline cho máy mới. Với dự án hiện hữu, dùng version đã pin qua .opentofu-version, .terragrunt-version hoặc constraint của repository. tenv quản lý phiên bản và proxy cho các CLI; kiểm tra command -v tofu nếu có bản cài khác che mất proxy. Đặt export TG_TF_PATH=tofu khi cấu hình Terragrunt của dự án cần chỉ rõ executable. Nguồn: tenv.

Nhóm bổ sung trong BrewfileMục đích
terraform-docs, hcledit, tfupdateSinh docs, chỉnh HCL và cập nhật constraint
tflint, checkov, trivyKiểm lint, cấu hình và rủi ro bảo mật
tfsec, terrascanHỗ trợ pipeline cũ khi dự án còn dùng
infracostƯớc tính chi phí thay đổi IaC
dotenvxQuản lý biến môi trường theo workflow dự án

Phân biệt kiểm tra local với plan cần credential/backend từ xa; xem plan trước khi áp dụng. Terraform/OpenTofu state có thể chứa dữ liệu nhạy cảm, cần backend và quyền truy cập thích hợp.

Kubernetes

brew install kubectl helm k9s minikube
kubectl version --client
helm version
k9s version
minikube version

Trước kết nối cluster, chọn kubeconfig và context chính xác. kubectl nên nằm trong khoảng version được cluster hỗ trợ; với cluster local, chọn driver tương thích với runtime đang dùng theo tài liệu Minikube, không mặc định mọi driver Podman hoạt động giống Docker. Nguồn: cài kubectl macOS.

kubectl config get-contexts
kubectl config current-context

Có thể thêm source <(kubectl completion zsh) vào .zshrc nếu chưa có plugin completion tương ứng. Helm quản lý release; k9s hỗ trợ quan sát/tác nghiệp và cũng phải được mở ở đúng context.

Ansible và build tools

brew install ansible cmake
ansible --version
cmake --version

Ansible chạy ở máy điều khiển để cấu hình host qua SSH. Cài collection theo requirements.yml của dự án, chọn inventory đúng rồi kiểm bằng --check --diff nếu module hỗ trợ. Dự án cần Temporal có thể cài CLI từ Brewfile và dùng dev server theo hướng dẫn dự án; apache-arrow, imagemagick, ffmpeg và percona-toolkit bổ sung cho xử lý dữ liệu/media hoặc vận hành database, không phải điều kiện boot máy.

11. Tiện ích shell và chẩn đoán hệ thống

Dùng công cụ có sẵn trên macOS hoặc đã cài ở các bước trước để kiểm tra máy. Khi thêm plugin shell, đọc mã và kiểm dependency trước khi nạp. Không sao chép history, SSH config, credential hoặc .zshrc chứa cấu hình riêng sang máy khác.

Completion và thao tác thường ngày

Công cụCách dùng
just --list, just --show TEN_RECIPEXem lệnh do dự án của bạn cung cấp trước khi chạy
make, gmakeChạy Makefile; gmake là GNU Make cài bằng Homebrew
git diff, git difftoolChọn diff văn bản hoặc GUI theo nhu cầu
tree -a -L 2Xem cây thư mục, kể cả file ẩn, giới hạn độ sâu
lsof -nP -iTCP -sTCP:LISTENXem tiến trình đang mở port TCP
podman port --allXem port mapping của container

Với Makefile thật, dùng command make; GNU Make Homebrew gọi gmake. Completion là tiện ích shell, không thay thế just --list và just --show khi kiểm lệnh sắp chạy.

Chẩn đoán và dữ liệu nhạy cảm

Công cụCách dùng và giới hạn
system_profiler SPHardwareDataTypeXem phần cứng; rà soát thông tin định danh trước khi chia sẻ output
sysctl -n hw.memsize, df -hXem RAM và dung lượng filesystem
brew list, brew services listKiểm kê gói và service Homebrew
podman machine list, podman ps -aXem VM và container, sau đó kiểm image/volume/network theo nhu cầu
podman system df, ncduKiểm dung lượng container và thư mục, kể cả dữ liệu ngoài cache

system_profiler, ioreg, sysctl và smartctl cung cấp các góc nhìn khác nhau; macOS không dùng dữ liệu SMBIOS như PC. Trên Apple Silicon, một số trang SMART không được hỗ trợ, nên xem chỉ số đọc được thay vì kết luận ổ hỏng chỉ từ exit code. Các helper phân tích dung lượng xem cả thư mục ẩn; dataset/video ở ngoài các cache quen thuộc cũng có thể là nguyên nhân đầy disk.

Cập nhật và dọn dẹp

  • Kiểm cập nhật macOS trong System Settings; dùng brew update, brew outdated rồi brew upgrade cho gói đã rà soát. Trả lời prompt sudo thật khi cask yêu cầu.
  • Xem trước phạm vi với brew cleanup --dry-run; backup dữ liệu trước khi dọn cache hoặc xóa bản runtime/IDE extension cũ.
  • History có thể chứa dữ liệu riêng tư; không công khai hoặc sao chép sang máy khác. Giữ note và dữ liệu cần thiết khi dọn transcript/log của công cụ AI.
  • Khởi động lại Podman VM để xử lý lỗi kết nối trước khi cân nhắc reset. Reset có thể xóa VM và dữ liệu; chỉ dùng sau khi đã backup image/volume cần giữ.

Audit bảo mật endpoint

Rà soát lớp bảo vệ macOS, DNS/kết nối, file khả nghi, executable đặc quyền, dependency và cấu hình AI/IDE. Không coi một lần kiểm tra sạch là chứng minh máy không bị xâm nhập:

  • Giữ SIP, Gatekeeper và firewall hoạt động; xem security agent nếu máy được quản lý.
  • Đối chiếu IoC với nguồn cảnh báo hiện hành, vì danh sách nhúng trong script có thể cũ.
  • Kiểm tra hook, task tự chạy, lệnh tải/chạy mã từ mạng và thay đổi ngoài dự kiến.
  • Rà soát executable đặc quyền ở vị trí người dùng và dependency tải gần đây.
  • Nếu phát hiện nghi vấn, giữ log, ngừng tác vụ có credential và chuyển sang quy trình ứng phó; không chạy payload hoặc xóa dấu vết để thử xem cảnh báo biến mất không.

12. Sao lưu và khả năng khôi phục

Thiết lập Time Machine trên ổ ngoài, chọn mã hóa backup và chạy bản sao lưu đầu tiên. Thử khôi phục một file mẫu; bản sao lưu chưa kiểm restore chưa chứng minh được khả năng phục hồi. iCloud đồng bộ hữu ích nhưng không thay mọi chức năng backup. Nguồn: Time Machine.

Nhóm dữ liệuCần giữCó thể dựng lại nếu đủ manifest
Shell và cấu hìnhDotfiles đã rà soát, Brewfile, cấu hình terminal/editorCompletion cache
Source và tài liệuRepository, file chưa commit, dataset và tài liệu riêngBuild output, dependency cache
RuntimeFile pin version và lockfileVenv, package cache, toolchain không còn dùng
Cloud/IaCCấu hình riêng, state/backend, chính sách quyềnCLI binary
Container/databaseDump database, dữ liệu volume và cấu hình ComposeImage có thể pull/build lại
IDE/AISettings cần dùng, transcript cần giữ, noteCache extension và model có thể tải lại

Giữ secret/key trong backup mã hóa hoặc kho credential riêng. Chụp danh mục phần mềm vào file mới, không ghi đè Brewfile đã chọn lọc:

brew bundle dump --file="$HOME/Brewfile.snapshot"

Khôi phục theo thứ tự: cập nhật OS → CLT/Homebrew → bộ phần mềm → dotfiles đã rà soát → key/credential qua kênh riêng → source → runtime pin version → dữ liệu database/ container → kiểm thử dự án. Không mang nguyên cache và cấu hình account cũ sang máy mới.

13. Checklist nghiệm thu máy mới

  • macOS đã cập nhật; FileVault, khóa màn hình và firewall được cấu hình.
  • Terminal native đúng kiến trúc; Homebrew prefix phù hợp và brew doctor đã được xem.
  • Mở Terminal mới vẫn chạy được Git, Just, editor và runtime cần dùng.
  • Font, bộ gõ, completion và plugin shell hoạt động; không có lỗi khi mở shell.
  • SSH key có passphrase; chỉ public key được đăng ký; Git identity đúng repository.
  • Mỗi dự án dùng phiên bản runtime/lockfile của chính nó và chạy được test cơ bản.
  • Podman engine hoạt động và container hello chạy được; volume cần giữ đã có backup.
  • Cloud profile/kube context đúng, chưa có credential hoặc endpoint nội bộ trong source.
  • IDE mở dự án, tìm đúng interpreter/toolchain và chạy được formatter.
  • Time Machine hoàn tất một backup và restore file mẫu thành công.

Các lệnh chẩn đoán nhanh, chỉ chạy phần đã cài:

brew doctor
brew services list
command -v git just uv
git --version
just --version
uv --version
podman info

14. Bảo trì và xử lý lỗi

Định kỳ xem cập nhật macOS, brew outdated, trạng thái service và dung lượng. Nâng cấp theo nhóm phù hợp lịch làm việc, sau đó chạy lại test dự án; database và runtime major version cần được xử lý riêng.

brew update
brew outdated
brew doctor
brew services list
df -h
Triệu chứngKiểm tra và xử lý
Không tìm thấy brewKiểm prefix đúng kiến trúc, shellenv trong .zprofile, mở shell mới
Có hai bản runtimeDùng command -v, kiểm PATH và manager của từng dự án
Lỗi CLT sau nâng macOSKiểm xcode-select -p; cài/cập nhật CLT tương thích
Homebrew báo lockKiểm còn tiến trình cài đặt; không xóa toàn bộ lock khi tiến trình đang chạy
Zsh mất lệnh hệ thốngTìm biến path hoặc PATH ghi đè; mở /bin/zsh -f để chẩn đoán
Completion Just saiKiểm plugin tùy chỉnh và _just sinh cũ; nạp lại completion
Sudo hỏi lại khi update caskTheo dõi prompt ở terminal; keeper không chia sẻ mọi dạng ticket subprocess
Podman không kết nốiXem machine/connection; start hoặc stop/start trước khi nghĩ đến reset
Build image khác kiến trúcKiểm manifest image, chọn ARM64/multi-arch hoặc chấp nhận chi phí giả lập
Port database bị chiếmXem lsof -nP -iTCP -sTCP:LISTEN, service và port mapping container
GUI không mởKiểm nguồn, chữ ký, quyền và yêu cầu macOS; giữ Gatekeeper hoạt động
Disk đầyDùng ncdu, kiểm file lớn/thư mục ẩn và disk VM; backup trước thao tác xóa
Secrets scan chưa bao phủBổ sung kiểm file untracked, history và credential ngoài Git theo phạm vi cần thiết

Thiết lập Windows cho máy phát triển

Hướng dẫn cài Chocolatey, OpenSSH, wget và Ansible trên Windows. Các lệnh dưới đây chạy trong PowerShell; bước nào cần quyền quản trị đều ghi rõ. Hoàn thành setup nghĩa là mở PowerShell mới vẫn gọi được choco, ssh và wget, còn Ansible chạy trong WSL.

1. Kiểm tra máy

Mở PowerShell bằng Run as administrator rồi kiểm tra phiên bản và quyền:

winver.exe
$PSVersionTable.PSVersion
(New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)

Yêu cầu của OpenSSH tích hợp sẵn: Windows 10 build 1809 hoặc Windows Server 2019 trở lên, PowerShell 5.1 trở lên, tài khoản thuộc nhóm Administrators. Lệnh cuối phải in True.

2. Cài Chocolatey

Chocolatey là trình quản lý gói dòng lệnh cho Windows. Cần PowerShell quyền quản trị; bản 2.x cần .NET Framework 4.8 và installer sẽ thử cài nếu máy còn thiếu.

Lệnh cài chính thức từ tài liệu Chocolatey:

Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))

Set-ExecutionPolicy ... -Scope Process chỉ đổi chính sách trong phiên PowerShell hiện tại, không đổi cấu hình máy. Lệnh này chạy script tải từ Internet; muốn xem trước, tải install.ps1 về file, đọc nội dung rồi mới chạy.

Kiểm tra sau cài, trong cửa sổ PowerShell mới:

choco -?
choco --version

Không có lỗi nghĩa là Chocolatey đã dùng được. Bảo trì:

choco upgrade chocolatey
choco outdated

Cài hoặc nâng cấp gói cũng cần PowerShell quyền quản trị, ví dụ choco install wget.

3. Cài OpenSSH

Windows có OpenSSH Client và Server dưới dạng Feature on Demand do Microsoft bảo trì qua Windows Update. Xem tài liệu OpenSSH của Microsoft cho bản cài mới nhất. Windows Server 2025 đã cài sẵn OpenSSH; chỉ cần bật dịch vụ.

Cài thành phần

Xem trạng thái hiện tại:

Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH*'

Cài Client, Server hoặc cả hai theo nhu cầu:

# Cài OpenSSH Client
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0

# Cài OpenSSH Server
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0

Mỗi lệnh thành công in Online : True. Nếu RestartNeeded là True, khởi động lại máy.

Bật dịch vụ sshd

Chỉ cần khi cài OpenSSH Server:

# Chạy dịch vụ sshd
Start-Service sshd

# Tùy chọn nhưng nên làm: tự chạy cùng Windows
Set-Service -Name sshd -StartupType 'Automatic'

# Kiểm tra rule tường lửa; setup thường đã tạo sẵn
if (!(Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyContinue)) {
    Write-Output "Firewall Rule 'OpenSSH-Server-In-TCP' does not exist, creating it..."
    New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22
} else {
    Write-Output "Firewall rule 'OpenSSH-Server-In-TCP' has been created and exists."
}

Rule OpenSSH-Server-In-TCP mở cổng 22 cho kết nối đến. Nếu rule tắt hoặc cổng không mở, kết nối bị từ chối hoặc bị reset. Chỉ mở cổng này trên máy thật sự cần nhận SSH và giới hạn phạm vi mạng của rule khi có thể.

Kết nối tới OpenSSH Server

Từ máy có OpenSSH Client, chạy trong PowerShell:

ssh domain\username@servername

Lần đầu, SSH hỏi có tin khóa của máy chủ không. Đối chiếu fingerprint với máy chủ trước khi gõ yes; lựa chọn này ghi máy chủ vào danh sách known hosts. Sau đó nhập mật khẩu, ký tự không hiển thị khi gõ. Kết nối thành công sẽ thấy dấu nhắc dạng domain\username@SERVERNAME C:\Users\username>.

Kiểm tra sau cài

ssh -V
Get-Service sshd
Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP"

Cấu hình máy chủ nằm ở %ProgramData%\ssh\sshd_config; sau khi sửa, chạy Restart-Service sshd.

Gỡ OpenSSH

Stop-Service sshd
Set-Service -Name sshd -StartupType 'Disabled'
Remove-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
Remove-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0

Nếu dịch vụ đang được dùng lúc gỡ, khởi động lại Windows. Rule tường lửa còn lại có thể xóa bằng Remove-NetFirewallRule -Name 'OpenSSH-Server-In-TCP'.

Xử lý lỗi SSH

Hiện tượngKiểm tra
Connection refusedGet-Service sshd phải là Running; kiểm rule tường lửa cổng 22
Connection timed outKiểm địa chỉ, định tuyến mạng và tường lửa giữa hai máy
Không tìm thấy lệnh sshCài OpenSSH Client, rồi mở PowerShell mới để nạp lại PATH
Dịch vụ sshd không khởi độngXem Event Viewer, mục Applications and Services Logs → OpenSSH

4. Tải file bằng wget

Cài bằng Chocolatey, trong PowerShell quyền quản trị. Gọi wget.exe thay vì wget: trong Windows PowerShell 5.1, wget là alias của Invoke-WebRequest nên không nhận các tùy chọn của GNU Wget.

choco install wget
wget.exe --version

Ví dụ tải một file, thử lại tối đa 10 lần và ghi log vào file riêng:

wget.exe --tries=10 --output-file=download.log https://example.com/archive.zip

--tries mặc định là 20 lần; lỗi không thể khắc phục như connection refused hoặc 404 không được thử lại. Trong cùng một lần chạy, Wget tự nối tiếp khi mất kết nối giữa chừng. Dùng --continue chỉ khi muốn hoàn tất file tải dở từ lần chạy trước; server phải hỗ trợ header Range. Nếu file trên server đã bị đổi chứ không chỉ nối thêm, kết quả sẽ hỏng, nên tải lại từ đầu.

Tải đệ quy (--recursive) tạo nhiều request tới máy chủ của người khác; chỉ dùng khi được phép. Độ sâu mặc định là 5 và --level=0 nghĩa là không giới hạn, không phải chỉ tải một trang. Muốn tải một vài trang, liệt kê từng URL và bỏ --recursive. Xem đầy đủ tùy chọn trong sổ tay GNU Wget.

5. Dùng Ansible qua WSL

Windows không chạy được Ansible control node trực tiếp; cách được hỗ trợ là chạy trong một bản phân phối Linux của Windows Subsystem for Linux (WSL). Máy Windows vẫn có thể là máy được Ansible quản lý. Xem hướng dẫn cài Ansible cho phiên bản Python tương thích với từng bản Ansible.

Cài WSL từ PowerShell quyền quản trị, khởi động lại khi được yêu cầu, rồi mở bản phân phối Linux vừa cài:

wsl --install

Trong terminal của WSL, cài pipx rồi cài Ansible. Cách này giữ Ansible trong môi trường riêng, không đụng gói Python của hệ thống:

sudo apt update
sudo apt install -y pipx
pipx ensurepath
pipx install --include-deps ansible

Mở lại terminal WSL để nạp PATH. Quản lý máy Windows qua WinRM cần thêm thư viện pywinrm trong cùng môi trường:

pipx inject ansible pywinrm

Tự hoàn thành lệnh Ansible

pipx install argcomplete
activate-global-python-argcomplete --user

Mở shell mới để nạp cấu hình hoàn thành lệnh.

Kiểm tra và bảo trì

ansible --version
ansible localhost -m ping
pipx upgrade --include-injected ansible

Không thay sleep hoặc binary hệ thống khác trong WSL để “sửa” timeout. Nếu Ansible báo timeout, kiểm tra mạng, DNS và thời gian của WSL trước, rồi cập nhật WSL bằng wsl --update từ PowerShell.

AI Agent Skills

Skill đóng gói kiến thức và quy trình cho một loại công việc để AI Agent có thể dùng lại. Một skill gồm SKILL.md, có thể kèm script, tài liệu và mẫu đầu ra. Cơ chế nạp từng phần giúp agent đọc hướng dẫn chi tiết khi cần. Xem tổng quan Agent Skills.

Ví dụ, yêu cầu “điều tra truy vấn chậm” cần quy trình thu thập bằng chứng, đọc execution plan và kiểm tra sau sửa. Skill giúp giữ quy trình đó nhất quán qua nhiều lần làm việc.

Bắt đầu

Phân biệt các thành phần

Thành phầnVai tròVí dụ
PromptGiao việc cụ thể trong lượt hiện tạiĐiều tra lỗi timeout của API này
SkillQuy trình và kiến thức dùng lại theo loại việcTái hiện lỗi, tìm nguyên nhân, kiểm regression
Quy tắc dự ánRàng buộc áp dụng trong dự ánKhông sửa dữ liệu thật khi chạy test
Tool / MCPKhả năng thực thi hoặc truy cập dữ liệuĐọc file, chạy test, truy vấn tài liệu
HarnessMôi trường điều phối và kiểm soát agentGiới hạn quyền, lưu tiến độ, kiểm điều kiện hoàn tất

Skill mô tả cách làm; quyền thực thi do môi trường cấp. Một hướng dẫn yêu cầu upload dữ liệu không tự tạo quyền upload. Tương tự, cài skill không tự cài database, toolchain hay MCP server mà skill cần.

Chọn skill từ vấn đề

  • Chưa rõ mục tiêu: làm rõ kết quả mong muốn và giới hạn trước.
  • Có lỗi tái hiện được: dùng quy trình chẩn đoán, sau đó chọn chuyên môn phù hợp.
  • Xây chức năng mới: xác định đầu vào, đầu ra và tiêu chí kiểm; dùng skill phát triển theo stack.
  • Công việc qua nhiều phiên: thêm lưu tiến độ và bằng chứng kiểm chứng.
  • Đã có kết quả: review diff, chạy kiểm tra và đối chiếu với yêu cầu ban đầu.

Ví dụ, với PostgreSQL: “truy vấn chạy 8 giây” → chẩn đoán → chuyên môn PostgreSQL → kiểm lại plan và thời gian trong cùng điều kiện. Chưa có số đo thì chưa thể kết luận đã tối ưu.

Phạm vi danh mục

Danh mục là bản chưng cất năng lực và kinh nghiệm sử dụng, không phải gói skill để tải/cài. Tên giữ dạng slug để dễ tra cứu; các quy trình phụ thuộc hạ tầng riêng cần được điều chỉnh trước khi áp dụng vào dự án khác. Nội dung chỉ chọn các skill có thể chia sẻ tổng quát; skill gắn hồ sơ cá nhân hoặc dự án có thương hiệu riêng nằm ngoài phạm vi này.

Nguồn chuẩn về định dạng: Agent Skills specification, với metadata bắt buộc name và description. Cách khám phá, cài đặt và gọi skill cần đối chiếu tài liệu của ứng dụng agent đang dùng.

Danh mục Skill AI Agent

Tra theo vấn đề cần giải quyết. Mỗi dòng nêu thời điểm dùng và đầu ra để review; tên skill mô tả năng lực, không bảo đảm ứng dụng của bạn đã cài skill đó. Xem hướng dẫn sử dụng trước khi áp dụng.

Điều phối, trạng thái và đánh giá agent

SkillKhi dùngĐầu ra cần kiểm
solutions-architect-orchestratorLàm rõ bài toán và chọn hướng triển khaiScope, quyết định và bằng chứng nghiệm thu
z-devkitĐiều phối task trong môi trường có state control planeTask, kế hoạch, tiến độ và verifier; cần adapter phù hợp dự án
harness-engineeringThiết kế môi trường làm việc cho agentInstructions, quyền, trạng thái và vòng đời kiểm chứng
session-stateTiếp tục công việc qua nhiều phiênCheckpoint chứa bước hiện tại và bước kế tiếp
definition-of-doneChốt điều kiện hoàn tất trước triển khaiTiêu chí quan sát được, verifier và evidence
clean-stateKết thúc phiên hoặc bàn giaoKiểm tra đạt, tiến độ lưu, không còn artifact thừa
loop-engineeringThiết kế vòng lặp agent có điều kiện dừngGiới hạn lượt, chi phí, retry và circuit breaker
ai-agent-evaluationĐánh giá cách agent làm việc và kết quảBằng chứng về hành động, giao tiếp và chất lượng
ai-frontier-foresightRà soát tri thức mới và độ tươi tài liệuNguồn đã kiểm, tổng hợp và quyết định cập nhật
prompt-engineeringThiết kế prompt có mục tiêu và đầu ra rõPrompt, ví dụ và cách đánh giá kết quả

Kiến trúc, thiết kế và chẩn đoán

SkillKhi dùngĐầu ra cần kiểm
monorepo-navigationTìm nơi sửa trong repository lớnPhạm vi file, dependency và lệnh kiểm liên quan
domain-driven-hexagonTách domain, use case và adapterBiên module và chiều phụ thuộc
ubiquitous-languageThuật ngữ nghiệp vụ bị dùng không nhất quánGlossary và các điểm còn mơ hồ
design-an-interfaceSo sánh nhiều thiết kế API/modulePhương án, trade-off và ví dụ sử dụng
improve-codebase-architectureTìm cơ hội giảm couplingRefactor có lý do và phạm vi rõ
request-refactor-planChia refactor lớn thành bước nhỏKế hoạch kiểm được từng bước
prototypeKiểm giả thuyết trước khi xây đầy đủBản thử và kết luận về giả thuyết
diagnoseLỗi khó hoặc regression hiệu năngTái hiện, nguyên nhân và kiểm sau sửa
enterprise-rbac-architectureThiết kế RBAC/ABAC, SSO và multi-tenantMa trận quyền, ranh giới tenant và kiểm audit

Phát triển phần mềm và kiểm thử

SkillKhi dùngĐầu ra cần kiểm
python-developmentPhát triển Python, type safety và asyncMã nguồn, typecheck và test phù hợp
rust-commentlessDự án Rust có quy ước không comment trong mãMã Rust; giải thích ở tài liệu và kiểm quy ước
nodejs-enterprise-backendBackend Node.js, stream hoặc memory leakProfile, backpressure và graceful shutdown
laravel-enterprise-archTổ chức backend Laravel lớnController gọn, use case rõ và truy vấn hợp lý
code-quality-testingKiểm lint, format, type và testKết quả lệnh thật, lỗi còn tồn tại
tddXây hành vi bằng red–green–refactorTest thất bại trước sửa và đạt sau sửa
algorithmic-problem-solvingGiải thuật toán hoặc bài toán rời rạcInvariant, độ phức tạp và ví dụ chạy được
jupyter-notebookPhân tích dữ liệu và kiểm giả thuyếtNotebook chạy lại được, kết quả có ngữ cảnh
taste-engineeringReview tính dễ đọc, code và giao diệnGiảm phần thừa, giữ hành vi và tính dễ dùng

Hạ tầng, automation và dữ liệu

SkillKhi dùngĐầu ra cần kiểm
infra-iacThiết kế IaC multi-cloudModule, plan và phạm vi tài nguyên thay đổi
infra-devopsContainer, CI/CD và observabilityPipeline, deployment và cách phát hiện lỗi
ansible-automationProvision hoặc cấu hình serverRole/playbook, inventory và tính idempotent
container-servicesQuản lý dịch vụ Compose trên Docker/PodmanProfile, volume, network và healthcheck
infra-shell-justHarden thân script shellQuoting, trap và exit code chính xác
just-task-runnerTổ chức recipe và dependencyRecipe rõ tên, tham số và luồng chạy
execution-time-cardChuẩn hóa thời gian hiển thị của runnerMột mẫu render, đơn vị và độ rộng nhất quán
database-managementSchema, migration, query đa hệ databaseThiết kế và phương án rollback/kiểm dữ liệu
postgres-deep-diveTruy vấn chậm, MVCC, VACUUM, lockExecution plan, số đo và tác động vận hành
event-driven-systemsStreaming hoặc workflow phân tánEvent contract, retry và xử lý trùng lặp

Bảo mật và vệ sinh artifact

SkillKhi dùngĐầu ra cần kiểm
skill-security-scanReview skill/MCP trước khi tin tưởngFinding, nguồn gốc và phán quyết từng phát hiện
git-guardrails-claude-codeChặn lệnh Git nguy hiểm trong Claude CodeHook được kiểm bằng trường hợp cho phép và bị chặn
playwright-artifact-hygieneKiểm trình duyệt bằng PlaywrightScreenshot, trace và log đúng thư mục dự án

Tài liệu, kiến thức và đào tạo

SkillKhi dùngĐầu ra cần kiểm
changelog-documentationGhi nhận thay đổi và chưng cất bài họcWHAT/WHY và kết quả verify
design-md-patternsViết tài liệu hoặc registry MarkdownGFM đọc tốt và liên kết hợp lệ
markdown-table-formattingChuẩn hóa bảng MarkdownCột thẳng, escaped pipe và code fence được giữ
write-a-skillĐóng gói quy trình dùng lạiTrigger, hướng dẫn, ví dụ và kiểm kết quả
notebooklmHỏi đáp trên tập tài liệu đã chọnCâu trả lời đối chiếu được với nguồn
obsidian-vaultQuản lý ghi chú ObsidianNote, wikilink và index nhất quán
teachHọc một khái niệm qua thực hànhGiải thích, bài tập và phản hồi
scaffold-exercisesTạo bộ khung bài tậpĐề, lời giải và cấu trúc hợp lệ
sql-courseBiên soạn khóa SQL cho developerNội dung theo mục tiêu học và ví dụ kiểm được

Viết và xử lý đầu việc

SkillKhi dùngĐầu ra cần kiểm
writing-fragmentsThu thập ý tưởng trước khi viếtMảnh ý, câu chuyện và luận điểm thô
writing-beatsGhép bài viết theo từng nhịpMột đoạn có mục đích và hướng chuyển tiếp
job-application-emailViết thư ứng tuyển hoặc cover letterNội dung bám JD và kinh nghiệm có thật
triagePhân loại bug/feature và chuẩn bị bàn giaoIssue có tái hiện, mức ưu tiên và điều kiện hoàn tất

Phối hợp trong một task

Với lỗi API chậm, bắt đầu bằng diagnose; dùng nodejs-enterprise-backend hoặc postgres-deep-dive khi bằng chứng chỉ ra tầng tương ứng; chạy code-quality-testing sau sửa. Task qua nhiều phiên có thể thêm session-state. Chỉ nạp quy trình cần cho bước đang làm để tránh các hướng dẫn chồng chéo.

Sử dụng và viết Skill

Giao việc cho agent

Kiểm ứng dụng đã nhận diện skill và môi trường có tool cần thiết. Cách gọi trực tiếp khác nhau theo ứng dụng; dùng cơ chế chọn skill của ứng dụng hoặc yêu cầu agent áp dụng tên skill đang có, rồi kiểm thông báo kích hoạt.

Giao việc cùng dữ kiện và giới hạn cụ thể:

Áp dụng skill diagnose để điều tra timeout của API /orders.
Đầu vào: log đã ẩn dữ liệu cá nhân và test tái hiện kèm theo.
Phạm vi: chỉ sửa service xử lý đơn hàng; không thay đổi dữ liệu thật.
Kết quả: nguyên nhân, bản sửa nhỏ và kết quả test regression.
Nếu thiếu quyền hoặc dữ kiện để kiểm, ghi rõ phần chưa xác minh.

Chọn dữ kiện nào đưa vào lời giao việc (log, code, diff, ví dụ) và bỏ gì: xem Chọn context khi sửa code.

Đọc đầu ra và diff. Câu “đã chạy test” cần đi kèm lệnh, exit code và kết quả đủ để kiểm lại; cách viết tiêu chí để có bằng chứng như vậy: xem Tiêu chí hoàn tất có thể kiểm chứng. Với việc đo hiệu năng, giữ dataset, cấu hình và điều kiện chạy nhất quán.

Một lượt làm việc

  1. Chốt kết quả mong muốn, quyền thực thi và vùng được sửa.
  2. Chọn một skill chính; bổ sung chuyên môn khi task thật sự cần.
  3. Đọc điều kiện môi trường, script và nguồn của skill trước khi chạy.
  4. Làm từng phần nhỏ; kiểm kết quả quan sát được sau mỗi phần.
  5. Review tổng thể, ghi phần còn thiếu và lưu bước tiếp theo nếu chưa xong.

Ví dụ với database-management: agent có thể đề xuất migration và kiểm trên database thử nghiệm. Chạy migration production cần quyền và quy trình vận hành riêng; skill không thay thế việc đó.

Viết skill nhỏ

Bắt đầu từ một thao tác lặp đã rõ đầu vào và đầu ra. Ví dụ dưới đây là mẫu tự chứa về review API; không cần script hoặc tài nguyên riêng.

Tạo thư mục review-api-contract, đặt file SKILL.md trong đó:

---
name: review-api-contract
description: Rà soát hợp đồng API và thay đổi tương thích. Dùng khi thêm endpoint hoặc đổi request/response.
---

# Rà soát hợp đồng API

## Đầu vào

Spec API hiện tại, diff và hành vi mong muốn.

## Quy trình

1. Đối chiếu method, route, status code và schema.
2. Xác định client bị ảnh hưởng bởi field đổi hoặc bị bỏ.
3. Kiểm validation, phân quyền và định dạng lỗi.
4. Chạy contract test được dự án cung cấp nếu môi trường cho phép.
5. Báo finding theo mức ảnh hưởng và vị trí cụ thể.

## Đầu ra

Finding có bằng chứng; lệnh test và kết quả; điểm chưa xác minh.
Không tự sửa spec hoặc deploy khi yêu cầu chỉ là review.

Theo đặc tả Agent Skills, name và description là trường bắt buộc. Tên phải khớp thư mục; mô tả giúp agent nhận ra lúc cần dùng. Đặt tài liệu dài ở references/, script ở scripts/, mẫu ở assets/ khi cần. Hỗ trợ các trường mở rộng phụ thuộc ứng dụng.

Kiểm skill trước khi dùng rộng

  • Ca đúng trigger: yêu cầu review API phải tạo finding có căn cứ.
  • Ca ngoài phạm vi: yêu cầu sửa giao diện không được kéo vào quy trình review API.
  • Thiếu đầu vào: thiếu spec hoặc log phải nêu dữ kiện còn thiếu.
  • Thiếu tool: không chạy được test phải ghi chưa kiểm, không tự báo đạt.
  • Ca giới hạn quyền: task chỉ review không được tự sửa hoặc deploy.

Validator định dạng không đo đủ chất lượng quy trình. Chạy thử các tình huống trên và review hành động của agent để phát hiện chỉ dẫn mơ hồ.

Skill từ bên ngoài

Đọc cả SKILL.md, script, tài liệu được dẫn và hook đi kèm. Kiểm hành động đọc credential, gửi mạng, tải/chạy mã hoặc sửa cấu hình agent; đối chiếu với chức năng mong muốn. Thử trong môi trường cô lập với dữ liệu giả và quyền tối thiểu. Scanner hỗ trợ tìm dấu hiệu; không có finding vẫn cần review.

Khi skill không hoạt động như mong muốn

Hiện tượngKiểm traCách xử lý
Không được nhận diệnVị trí cài và metadata theo ứng dụngSửa cấu trúc, tải lại theo tài liệu ứng dụng
Được nhận diện nhưng không kích hoạtDescription và yêu cầu có khớp khôngNêu loại việc cụ thể hoặc chọn skill trực tiếp
Agent làm sai phạm viTrigger quá rộng hoặc hướng dẫn xung độtThu hẹp phạm vi, thêm tình huống kiểm
Script thất bạiDependency, đường dẫn, quyền và exit codeSửa nguyên nhân, chạy lại verifier
Test đạt nhưng chưa đúng yêu cầuTiêu chí có bỏ sót ý định khôngBổ sung tiêu chí từ yêu cầu rồi kiểm lại
Quy trình đã cũPhiên bản tool và nguồn tham chiếuRà soát nguồn, sửa hướng dẫn, thử lại

Giữ skill gắn với công việc lặp thật. Khi nhiều skill cùng giải một vấn đề, xem lại ranh giới hoặc hợp nhất; khi quy trình đổi, cập nhật skill cùng bằng chứng kiểm mới.

Chọn context khi nhờ AI sửa code có sẵn

Câu hỏi bài này trả lời: khi nhờ AI agent sửa một lỗi trong codebase đã có, nên đưa vào những gì và bỏ những gì để nó sửa đúng chỗ, và bạn kiểm được kết quả?

Cần biết trước: đọc được code Python cơ bản; biết agent đọc được file trong repo hoặc nhận nội dung bạn dán vào. Muốn phân biệt prompt, skill, quy tắc dự án và tool thì xem AI Agent Skills. Phần lab cần Python 3.10 trở lên, không cài thêm thư viện.

Lời nhờ “API checkout bị timeout, sửa giúp” thiếu gần hết thứ cần để chọn bản sửa đúng. Dán cả repo và toàn bộ log cũng không giúp được: khối lượng không phải là thông tin. Bài này dùng một lỗi giả lập để chỉ ra bộ input nhỏ nhưng đủ, lý do từng mảnh có mặt trong đó và cách kiểm kết quả bằng test.

Tình huống

Dịch vụ shop có endpoint POST /checkout. Handler gọi lần lượt hai dịch vụ ngoài: kho (giữ hàng) và carrier (báo phí vận chuyển). Sau một lần refactor, người dùng báo checkout bị treo; gateway cắt ở 10 giây và trả 504. Cây mã của lab:

shop/
├── config.py
├── errors.py
├── api/
│   ├── checkout.py
│   └── steps.py
└── clients/
    ├── http_json.py
    ├── inventory.py
    └── shipping.py
tests/
└── test_checkout.py

Lab thu nhỏ thang thời gian: carrier trả lời sau 1,5 giây thay vì 30 giây, và ngân sách cho mỗi lời gọi ngoài là 0,5 giây. Hành vi giống nhau ở hai thang, chỉ con số khác.

  • Mong đợi: carrier không trả lời trong 0,5 giây thì checkout trả 503 kèm {"error": "upstream_timeout", "service": "shipping"} ngay sau đó.
  • Thực tế: checkout chờ đến khi carrier trả lời rồi mới trả 200. Trong sự cố thật carrier không trả lời, nên request treo đến lúc gateway cắt.
  • Phạm vi cần sửa: một file, shop/clients/shipping.py.

Ba cách đưa input

Thiếu

API checkout bị timeout, sửa giúp.

Từ câu này chưa quyết được:

  • “Timeout” xảy ra ở đâu (gateway, handler hay một lời gọi ngoài), và mong đợi là báo lỗi hay trả kết quả dự phòng?
  • Sửa ở đâu: handler, client của carrier, helper HTTP dùng chung hay cấu hình gateway?
  • Bao lâu thì chấp nhận được, và làm sao biết đã sửa xong?

Mỗi câu hỏi có ít nhất hai đáp án hợp lý. Bản sửa nào cũng có thể “trông chạy được” mà không có tiêu chí nào để kiểm.

Thừa

Dán toàn bộ mã nguồn, toàn bộ log và thêm câu “sửa timeout giúp”. Lab nhỏ này đã là 144 dòng, gồm 136 dòng code và 8 dòng log (số đếm ở phần lab bên dưới); riêng file test chiếm 63 dòng. Ở repo thật con số lớn hơn nhiều bậc. Kích thước chưa phải vấn đề chính: bộ này vẫn thiếu những mảnh quan trọng nhất. Không chỗ nào nói mong đợi là gì, không có diff cho thấy thay đổi nào gây lỗi, và phạm vi sửa để mở, kể cả helper http_json.py mà mọi client dùng chung.

Đủ

Bộ đủ có tám mảnh. Mỗi mảnh đi cùng một câu hỏi: nếu bỏ mảnh này thì quyết định nào không còn căn cứ?

Tám mảnh của bộ input đủ

1. Mục tiêu và hành vi mong đợi

Mục tiêu: sửa lỗi POST /checkout bị treo khi carrier phản hồi chậm.

Mong đợi: nếu carrier không trả lời trong SHIPPING_TIMEOUT (0,5 giây), checkout
trả 503 với {"error": "upstream_timeout", "service": "shipping"} ngay sau đó.
Thực tế: checkout chờ đến khi carrier trả lời rồi mới trả 200. Người dùng báo
gateway cắt ở 10 giây và trả 504.

Mảnh này chốt hợp đồng: trả lỗi 503 chứ không trả kết quả dự phòng, và hạn là SHIPPING_TIMEOUT. Thiếu nó, “sửa timeout” có ít nhất hai cách hiểu với hai hợp đồng API khác nhau, và không viết được test.

2. Phạm vi

Được sửa: shop/clients/shipping.py, và thêm test nếu cần.
Không đổi: chữ ký get_json(), shop/config.py, cách checkout.py ánh xạ lỗi
sang HTTP, dependency.

Mảnh này đóng vùng ảnh hưởng. get_json được mọi client dùng chung: một bản sửa “tiện tay” ở đó đổi hành vi của cả hệ thống, và người đọc diff khó nhận ra.

3. Bằng chứng: test lỗi và log đã lọc

Cả hai là kết quả của một lần chạy, không nằm trong repo, nên phải dán. Test thất bại cho biết hợp đồng bị vi phạm thế nào:

FAIL: test_slow_carrier_returns_503_within_budget (test_checkout.CheckoutTest.test_slow_carrier_returns_503_within_budget)
AssertionError: 1.5 not less than 1.0
Ran 2 tests in 3.514s
FAILED (failures=1)

Phần trong ngoặc của dòng FAIL: khác nhau giữa các phiên bản Python, và thời gian khác nhau theo máy. Log thô chứa nhiều request; lọc theo request id trước khi dán:

grep "rid=slow-1" app.log
rid=slow-1 step=inventory.reserve start
rid=slow-1 step=inventory.reserve ok elapsed=0.00s
rid=slow-1 step=shipping.quote start
rid=slow-1 step=shipping.quote ok elapsed=1.50s

Đọc log: kho trả lời tức thì (0.00s), thời gian nằm hết ở shipping.quote (1.50s, so với ngân sách 0,5 giây). Trong sự cố thật dòng ok cuối không bao giờ xuất hiện. Ẩn dữ liệu cá nhân, token và khóa trước khi dán log vào bất kỳ công cụ nào.

4. Code đang lỗi

File chứa lời gọi lỗi. Chỉ 7 dòng nên dán cả file; ở repo thật chỉ dán hàm liên quan hoặc trỏ đường dẫn. Điều cần thấy: get_json(url) được gọi không có timeout.

from shop import config
from shop.clients.http_json import get_json


def shipping_quote(zip_code):
    url = f"{config.SHIPPING_URL}/quote?zip={zip_code}"
    return get_json(url)

5. Nơi lỗi được ánh xạ sang HTTP

Handler bắt UpstreamTimeout và đổi thành 503. Điều này ràng buộc bản sửa: lỗi của carrier phải là UpstreamTimeout("shipping"), không phải một exception mới hay TimeoutError trần mà handler không bắt. Đây là hợp đồng chứ không phải chỗ cần sửa, nên mảnh 2 cấm đổi file này.

from shop.api.steps import timed
from shop.clients.inventory import reserve
from shop.clients.shipping import shipping_quote
from shop.errors import UpstreamTimeout


def checkout(order):
    rid = order["rid"]
    try:
        timed(rid, "inventory.reserve", reserve, order["sku"], order["qty"])
        quote = timed(rid, "shipping.quote", shipping_quote, order["zip"])
    except UpstreamTimeout as exc:
        return 503, {"error": "upstream_timeout", "service": exc.service}
    return 200, {"shipping": quote}

6. Thay đổi gần nhất chạm vào chỗ nghi ngờ

Diff của lần refactor gần nhất (giả lập) cho thấy lời gọi trực tiếp được thay bằng get_json(url), kéo theo mất hai thứ: timeout= và bước đổi TimeoutError thành UpstreamTimeout.

--- a/shop/clients/shipping.py
+++ b/shop/clients/shipping.py
@@ -1,14 +1,7 @@
-import json
-import urllib.request
-
 from shop import config
-from shop.errors import UpstreamTimeout
+from shop.clients.http_json import get_json


 def shipping_quote(zip_code):
     url = f"{config.SHIPPING_URL}/quote?zip={zip_code}"
-    try:
-        with urllib.request.urlopen(url, timeout=config.SHIPPING_TIMEOUT) as response:
-            return json.load(response)
-    except TimeoutError as exc:
-        raise UpstreamTimeout("shipping") from exc
+    return get_json(url)

Đưa nó như giả thuyết cần kiểm, không phải kết luận: test lỗi vẫn là phán quyết cuối cùng. Thiếu mảnh này, người sửa phải tự lần lịch sử thay đổi hoặc đoán nguyên nhân từ triệu chứng.

7. Ví dụ cùng quy ước

Một đoạn đã đúng trong cùng repo cho thấy quy ước: truyền timeout=config.<DỊCH_VỤ>_TIMEOUT, bắt TimeoutError, ném UpstreamTimeout("<dịch vụ>") from exc. Mô tả bằng lời (“thêm timeout và xử lý lỗi như kho”) bỏ ngỏ kiểu exception, tên hằng số và cách bọc lỗi; đoạn code thì không.

from shop import config
from shop.clients.http_json import get_json
from shop.errors import UpstreamTimeout


def reserve(sku, qty):
    url = f"{config.INVENTORY_URL}/reserve?sku={sku}&qty={qty}"
    try:
        return get_json(url, timeout=config.INVENTORY_TIMEOUT)
    except TimeoutError as exc:
        raise UpstreamTimeout("inventory") from exc

8. Cách kiểm

Chạy: python3 -m unittest discover -s tests
Đạt khi: test_healthy_carrier_returns_200 và
test_slow_carrier_returns_503_within_budget cùng pass, và chỉ
shop/clients/shipping.py thay đổi.
Nếu không chạy được lệnh, nói rõ phần chưa kiểm; không báo đã sửa xong
khi chưa có kết quả test.

Mảnh này cho điều kiện dừng đo được và cách báo cáo khi không kiểm được. Thiếu nó, “đã sửa xong” chỉ là lời tự báo cáo.

Vì sao bỏ timeout lại thành treo

Ba mảnh cùng chỉ về một nguyên nhân: diff cho thấy refactor bỏ timeout=, ví dụ cho thấy cách đúng, log cho thấy thời gian nằm ở bước shipping.quote.

Cơ chế nằm ở chỗ thư viện chuẩn không đặt giới hạn thời gian nào theo mặc định. Tài liệu urllib.request.urlopen nói nếu không chỉ định timeout thì dùng timeout mặc định toàn cục, và socket.getdefaulttimeout() trả về None khi module được import lần đầu, nghĩa là socket mới không có timeout. Helper get_json(url, timeout=None) cũng không đặt giới hạn: khi nơi gọi quên truyền timeout, lời gọi chờ cho đến khi phía kia trả lời hoặc đóng kết nối. Vì vậy một refactor chỉ gom code có thể đổi hành vi mà không gây lỗi cú pháp; thường chỉ một test về thời hạn mới bắt được.

Mẫu prompt dùng lại

Dán các mảnh theo thứ tự trên vào cùng một tin nhắn, mỗi mảnh dưới một tiêu đề. Khung dưới đây dùng lại được cho lỗi khác:

Mục tiêu: <một câu: lỗi gì, ở đâu>

Mong đợi: <kết quả quan sát được, có số hoặc điều kiện>
Thực tế: <điều đang xảy ra>

Phạm vi
- Được sửa: <file hoặc hàm>
- Không đổi: <API công khai, cấu hình, thư viện dùng chung, dependency>

Bằng chứng
<output test lỗi; log đã lọc theo request hoặc khung giờ>

Code liên quan
<file hoặc hàm trực tiếp tham gia lỗi: dán hoặc trỏ đường dẫn>

Thay đổi gần nhất (giả thuyết, chưa kiểm)
<diff hoặc commit nghi ngờ>

Ví dụ cùng quy ước
<một đoạn đã đúng trong cùng repo>

Cách kiểm
<lệnh chạy và điều kiện đạt; nếu không chạy được thì nêu phần chưa kiểm>

Checklist trước khi gửi:

  • Mỗi mảnh trả lời được: “bỏ mảnh này thì quyết định nào mất căn cứ?”
  • Có mong đợi và thực tế đo được (con số, mã trạng thái, tên test)?
  • Phạm vi nêu cả phần được sửa lẫn phần không đổi?
  • Log lọc theo request hoặc khung giờ, đã ẩn dữ liệu cá nhân, token và khóa?
  • Bằng chứng khớp đúng phiên bản code đang lỗi?
  • Ví dụ cùng quy ước thật sự đúng (có test hoặc đang chạy ổn định)?
  • Có lệnh kiểm và điều kiện đạt; agent biết phải báo gì nếu không chạy được?
  • Không có file hay đoạn log nào chỉ để “cho chắc”?

Vì sao chọn như vậy

Mỗi mảnh gắn với một quyết định

MảnhQuyết định mà mảnh cho phépDán hay trỏ
1. Mong đợiTrả 503 hay kết quả dự phòng; hạn bao lâuDán: không có trong repo
2. Phạm viSửa client hay helper dùng chungDán: là quyết định của bạn
3. Test lỗi và logLỗi nằm ở bước nào, chậm bao lâuDán: là kết quả của một lần chạy
4. shipping.pySửa ở đâuTrỏ đường dẫn nếu agent đọc được
5. checkout.pyDùng lỗi nào để ra 503Trỏ đường dẫn
6. DiffNguyên nhân nên kiểm trướcTrỏ (lịch sử git) hoặc dán
7. inventory.pyQuy ước timeout và cách bọc lỗiTrỏ đường dẫn
8. Cách kiểmKhi nào dừng, báo gì nếu không chạy đượcDán

Cột cuối theo hướng agent nạp context theo nhu cầu: giữ đường dẫn nhẹ và chỉ đọc file khi cần, thay vì nạp trước mọi thứ (Anthropic). Đây là khuyến nghị thực hành, không phải quy luật. Dán những gì agent không tự lấy được; trỏ những gì nằm trong repo nó đọc được. Với chat không truy cập repo thì dán cả code. Best practices của Claude Code khuyến nghị cùng hướng khi giao việc sửa lỗi: nêu triệu chứng, vị trí khả nghi và “sửa xong” trông thế nào, và chỉ tới mẫu có sẵn trong codebase.

Bốn tiêu chí chọn context

Bốn nhãn này là cách gọi trong wiki này, không phải thuật ngữ chuẩn của ngành.

Tiêu chíCâu hỏiQuyết định trong case
RelevanceFile nào tham gia trực tiếp vào lỗi?Chọn shipping.py (nơi sửa) và checkout.py (hợp đồng lỗi). Bỏ config.py, errors.py, steps.py (chỉ ghi log) và http_json.py (mảnh 2 cấm sửa).
RecencyThông tin có phản ánh trạng thái hiện tại không?Dùng code, log và diff của đúng bản đang lỗi, không dựa vào trí nhớ về một thư viện quen thuộc: get_json là helper của repo.
RepresentationDạng nào truyền đạt chính xác nhất?Dán đoạn code đúng của inventory.py thay vì viết “thêm timeout như chỗ khác”; dán output test lỗi thay vì kể triệu chứng.
MinimizationBỏ gì mà không mất quyết định nào?Log từ 8 xuống 4 dòng theo rid; ba file thay vì tám; test chỉ nêu tên và lệnh chạy, không dán 63 dòng.

Độ dài context: nguồn nói gì và chưa nói gì

Nhồi thêm context không phải lúc nào cũng vô hại. Ba nguồn dưới đây mô tả điều đó, kèm những giới hạn đáng đọc kỹ:

  • Anthropic (2025-09-29) coi context là tài nguyên hữu hạn, gọi hiện tượng độ chính xác khi nhớ thông tin giảm theo số token là “context rot”, và đặt mục tiêu là tập token nhỏ nhất nhưng nhiều tín hiệu nhất. Họ lý giải bằng “attention budget” và việc n token tạo n² quan hệ từng cặp; đó là lời giải thích của tác giả bài viết, không phải kết quả đo. Họ cũng nhấn mạnh đây là độ dốc dần chứ không phải vách đứng: model vẫn mạnh ở context dài nhưng độ chính xác có thể giảm.
  • Liu và cộng sự, “Lost in the Middle” (arXiv v3 11/2023, TACL): ở tác vụ hỏi đáp nhiều tài liệu và truy xuất key-value, kết quả thường tốt nhất khi thông tin liên quan nằm ở đầu hoặc cuối input và giảm rõ khi nằm ở giữa, trên các model được thử lúc đó.
  • Chroma, “Context Rot” (2025-07-14) thử 18 model và thấy hiệu năng thay đổi đáng kể theo độ dài input, kể cả ở tác vụ đơn giản; ở phần LongMemEval, prompt tập trung cho kết quả tốt hơn rõ rệt prompt đầy đủ. Báo cáo nói rõ không giải thích cơ chế và không bao quát hết tình huống thực tế.

Bài này vì vậy không dựa vào giả định về cơ chế bên trong của một model cụ thể. Nó dựa vào hai thứ bạn tự kiểm được: mỗi mảnh context truy được tới một quyết định cần đưa ra (bảng trên), và kết quả cuối kiểm được bằng test. Các con số trong nguồn đo trên model và tác vụ tại thời điểm công bố; model bạn dùng hôm nay có thể khác.

Chạy lại case từ thư mục sạch

Tạo thư mục trống, ví dụ shop-lab, và chạy mọi lệnh trong đó. Ba file đã có ở mục trên (shop/clients/shipping.py bản lỗi, shop/api/checkout.py, shop/clients/inventory.py); tạo thêm năm file sau.

Output trong bài lấy từ lần chạy trên Linux với Python 3.14 ngày 2026-10-02. Các lệnh lọc dùng cat, grep, wc của shell kiểu Unix; macOS, Windows và các bản Python khác từ 3.10 chưa được thử.

shop/config.py:

INVENTORY_URL = "http://inventory.internal"
SHIPPING_URL = "http://carrier.internal"
INVENTORY_TIMEOUT = 0.5
SHIPPING_TIMEOUT = 0.5

shop/errors.py:

class UpstreamTimeout(Exception):
    def __init__(self, service):
        super().__init__(f"{service} timed out")
        self.service = service

shop/clients/http_json.py:

import json
import urllib.error
import urllib.request


def get_json(url, timeout=None):
    try:
        with urllib.request.urlopen(url, timeout=timeout) as response:
            return json.load(response)
    except urllib.error.URLError as exc:
        if isinstance(exc.reason, TimeoutError):
            raise TimeoutError(url) from exc
        raise

shop/api/steps.py (chỉ ghi log từng bước):

import logging
import time

from shop.errors import UpstreamTimeout

log = logging.getLogger("shop")


def timed(rid, step, call, *args):
    log.info("rid=%s step=%s start", rid, step)
    started = time.monotonic()
    try:
        result = call(*args)
    except UpstreamTimeout:
        elapsed = time.monotonic() - started
        log.warning("rid=%s step=%s timeout elapsed=%.2fs", rid, step, elapsed)
        raise
    elapsed = time.monotonic() - started
    log.info("rid=%s step=%s ok elapsed=%.2fs", rid, step, elapsed)
    return result

tests/test_checkout.py dựng hai server giả (kho nhanh, carrier chậm tùy test) trên cổng tự chọn:

import json
import logging
import threading
import time
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

from shop import config
from shop.api.checkout import checkout

ORDER = {"sku": "A1", "qty": 1, "zip": "70000"}


class Upstream(BaseHTTPRequestHandler):
    delay = 0.0

    def do_GET(self):
        time.sleep(self.delay)
        body = json.dumps({"price": 4.5}).encode()
        try:
            self.send_response(200)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        except OSError:
            pass

    def log_message(self, *args):
        pass


def setUpModule():
    logging.basicConfig(
        filename="app.log", filemode="w", level=logging.INFO, format="%(message)s"
    )


class CheckoutTest(unittest.TestCase):
    def serve(self, delay):
        handler = type("Handler", (Upstream,), {"delay": delay})
        server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
        threading.Thread(target=server.serve_forever, daemon=True).start()
        self.addCleanup(server.server_close)
        self.addCleanup(server.shutdown)
        return f"http://127.0.0.1:{server.server_port}"

    def setUp(self):
        config.INVENTORY_URL = self.serve(delay=0.0)

    def test_healthy_carrier_returns_200(self):
        config.SHIPPING_URL = self.serve(delay=0.0)
        status, body = checkout({"rid": "ok-1", **ORDER})
        self.assertEqual((status, body), (200, {"shipping": {"price": 4.5}}))

    def test_slow_carrier_returns_503_within_budget(self):
        config.SHIPPING_URL = self.serve(delay=1.5)
        started = time.monotonic()
        status, body = checkout({"rid": "slow-1", **ORDER})
        elapsed = round(time.monotonic() - started, 2)
        self.assertLess(elapsed, 1.0)
        self.assertEqual(
            (status, body), (503, {"error": "upstream_timeout", "service": "shipping"})
        )

Chạy test với bản lỗi. Lệnh lọc chỉ giữ các dòng kết quả:

python3 -m unittest discover -s tests 2>&1 | grep -E "^(FAIL|AssertionError|Ran|OK|FAILED)"

Kết quả khớp mảnh 3. Log thô gồm 8 dòng của hai request: ok-1 khỏe và slow-1 chậm.

cat app.log
rid=ok-1 step=inventory.reserve start
rid=ok-1 step=inventory.reserve ok elapsed=0.01s
rid=ok-1 step=shipping.quote start
rid=ok-1 step=shipping.quote ok elapsed=0.00s
rid=slow-1 step=inventory.reserve start
rid=slow-1 step=inventory.reserve ok elapsed=0.00s
rid=slow-1 step=shipping.quote start
rid=slow-1 step=shipping.quote ok elapsed=1.50s

Lệnh đầu lọc theo rid và in đúng bốn dòng ở mảnh 3. Lệnh sau đếm kích thước để thấy bộ “thừa” lớn cỡ nào, in ra bảng dưới đây:

grep "rid=slow-1" app.log
wc -l shop/*.py shop/*/*.py tests/*.py app.log
   4 shop/config.py
   4 shop/errors.py
  14 shop/api/checkout.py
  20 shop/api/steps.py
  13 shop/clients/http_json.py
  11 shop/clients/inventory.py
   7 shop/clients/shipping.py
  63 tests/test_checkout.py
   8 app.log
 144 total

Bây giờ sửa theo ví dụ cùng quy ước: truyền timeout và đổi TimeoutError thành UpstreamTimeout("shipping"). Thay nội dung shop/clients/shipping.py:

from shop import config
from shop.clients.http_json import get_json
from shop.errors import UpstreamTimeout


def shipping_quote(zip_code):
    url = f"{config.SHIPPING_URL}/quote?zip={zip_code}"
    try:
        return get_json(url, timeout=config.SHIPPING_TIMEOUT)
    except TimeoutError as exc:
        raise UpstreamTimeout("shipping") from exc

Chạy lại test và lọc log của request chậm:

python3 -m unittest discover -s tests 2>&1 | grep -E "^(FAIL|AssertionError|Ran|OK|FAILED)"
grep "rid=slow-1" app.log
Ran 2 tests in 2.014s
OK
rid=slow-1 step=inventory.reserve start
rid=slow-1 step=inventory.reserve ok elapsed=0.00s
rid=slow-1 step=shipping.quote start
rid=slow-1 step=shipping.quote timeout elapsed=0.50s

Dòng cuối của log đổi từ ok elapsed=1.50s sang timeout elapsed=0.50s: lời gọi bị cắt đúng ngân sách thay vì chờ carrier. Cả hai test đều đạt, nên đường chạy bình thường không bị phá.

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

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

  • Lab giả lập và thu nhỏ thời gian. Bài cho thấy bộ input chứa đủ dữ kiện để xác định và kiểm bản sửa bằng test; nó không đo xem agent hay model nào sửa đúng hơn nhờ bộ này, và chưa có phép so sánh thiếu, thừa, đủ trên model nào.
  • “Đủ” không có nghĩa là ngắn nhất. Ở repo thật bộ đủ có thể lớn hơn nhiều; tiêu chí là không mảnh nào thiếu căn cứ và không mảnh nào thừa tác dụng.
  • Ví dụ cùng quy ước phải đúng. Nếu inventory.py có lỗi, bản sửa sẽ sao chép lỗi đó; kiểm ví dụ bằng test hoặc lịch sử thay đổi trước khi dùng.
  • Theo tài liệu, timeout của urllib áp cho các thao tác chặn như kết nối, nên không phải ngân sách tổng cho cả request. Muốn giới hạn tổng thời gian cần cơ chế khác; ngân sách 0,5 giây của lab đúng với carrier im lặng, không bao quát mọi kiểu phản hồi chậm.
  • Nguồn về độ dài context đo trên model và tác vụ cụ thể ở thời điểm công bố; không có ngưỡng dùng chung.

Lỗi thường gặp

LỗiHậu quảCách tránh
Dán log thôPhải tự tìm request lỗi; lộ dữ liệu của người khácLọc theo request id hoặc khung giờ; ẩn dữ liệu nhạy cảm
Mô tả quy ước bằng lờiKiểu exception, tên hằng số bị hiểu khácDán đoạn code đúng trong cùng repo
Không nêu phần cấm đổiSửa lan sang helper hoặc cấu hình dùng chungGhi rõ phạm vi được sửa và phạm vi không đổi
Đưa nghi ngờ như kết luậnSửa theo giả thuyết saiGắn nhãn giả thuyết; để test quyết định
Thiếu cách kiểm“Đã sửa xong” không kiểm đượcCho lệnh, điều kiện đạt và yêu cầu nêu phần chưa kiểm
Bằng chứng của phiên bản khácSửa theo trạng thái cũĐối chiếu commit của log và diff với bản đang lỗi

Học tiếp

Nguồn tham khảo

Viết tiêu chí hoàn tất mà AI có thể kiểm chứng

Câu hỏi bài này trả lời: làm sao biết một AI agent đã làm xong đúng yêu cầu, thay vì chỉ nghe nó báo “xong”?

Cần biết trước: đọc được code Python cơ bản và biết giao việc có phạm vi (xem Chọn context khi sửa code). Phần lab chỉ dùng thư viện chuẩn của Python 3 (đã chạy trên 3.14), không cài thêm gì.

Agent thường dừng khi việc “trông như đã xong”. Tài liệu best practices của Claude Code nói đúng điều này: nếu không có phép kiểm nào để chạy thì “trông như xong” là tín hiệu duy nhất, và bạn trở thành vòng kiểm chứng. Bài của Anthropic về harness cho agent chạy dài ghi nhận một dạng khác của cùng vấn đề: một phiên agent sau thấy đã có tiến độ nên tuyên bố xong. Bài này đi từ một yêu cầu mơ hồ tới tiêu chí có verifier, rồi kiểm chính verifier, vì một verifier chưa từng thất bại thì chưa chứng minh được gì.

Ba mệnh đề hay bị coi là một

Mệnh đềBằng chứng cho nóKhông chứng minh được
File tồn tạitest -f users.csvNội dung đúng, đủ dòng, đọc lại được
Test chạy đượcMột lệnh kiểm kết thúc với exit code 0Rằng phép kiểm phủ yêu cầu; nó có thể rỗng hoặc chỉ kiểm điều dễ
Hành vi đúng yêu cầuVerifier đọc kết quả thật, so với kỳ vọng viết độc lập với code, và đã từng thất bại trước lỗi mẫuCác yêu cầu chưa được viết thành tiêu chí

Câu “xong rồi, file CSV đã được tạo” chỉ nói tới mệnh đề đầu. Hai mệnh đề sau cần tiêu chí và verifier.

Từ yêu cầu mơ hồ đến tiêu chí quan sát được

Yêu cầu ban đầu: “Thêm chức năng xuất danh sách người dùng ra CSV.” Câu này không nói “xong” nghĩa là gì. Viết lại thành tiêu chí mà bạn quan sát được từ kết quả, không mô tả cách làm:

  • R1: dòng đầu là id,name,created_at.
  • R2: mỗi người dùng một dòng dữ liệu, giữ thứ tự đầu vào.
  • R3: created_at có dạng 2026-01-31T09:05:00Z (UTC, ISO 8601 theo hồ sơ RFC 3339).
  • R4: tên chứa dấu phẩy, nháy kép hoặc xuống dòng vẫn đọc lại đúng bằng một CSV parser chuẩn (quy tắc nháy theo RFC 4180, tài liệu thông tin chứ không phải tiêu chuẩn).
  • R5: danh sách rỗng cho file chỉ có dòng tiêu đề.
  • R6: tiếng Việt có dấu giữ nguyên, file UTF-8.
  • Ngoài phạm vi: Excel và BOM, file rất lớn, phân quyền truy cập.

Liệt kê ca biên (rỗng, giá trị đặc biệt, Unicode, múi giờ) từ yêu cầu trước khi có code. Điều vắng mặt không tự lộ ra: câu trả lời cho “đã đủ chưa?” chỉ phản ánh những ca người trả lời đã nghĩ tới.

Hai verifier cho cùng một yêu cầu

Giả sử agent báo: “Xong, users.csv đã được tạo.” Nó có thể đã viết một trong bốn bản cài: một bản đúng và ba bản, mỗi bản sai đúng một tiêu chí (R4, R5, R3). Tạo thư mục trống, ví dụ csv-lab, và các file sau.

export_csv.py chứa bốn bản cài. export_correct dùng csv.writer và mở file với newline="" theo tài liệu module csv:

import csv
from datetime import timezone

HEADER = ["id", "name", "created_at"]


def iso_utc(moment):
    return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def export_correct(users, path):
    with open(path, "w", encoding="utf-8", newline="") as handle:
        writer = csv.writer(handle)
        writer.writerow(HEADER)
        for user in users:
            writer.writerow([user["id"], user["name"], iso_utc(user["created_at"])])


def export_without_quoting(users, path):
    lines = [",".join(HEADER)]
    for user in users:
        lines.append(f"{user['id']},{user['name']},{iso_utc(user['created_at'])}")
    with open(path, "w", encoding="utf-8") as handle:
        handle.write("\n".join(lines) + "\n")


def export_no_header_when_empty(users, path):
    with open(path, "w", encoding="utf-8", newline="") as handle:
        writer = csv.writer(handle)
        if users:
            writer.writerow(HEADER)
        for user in users:
            writer.writerow([user["id"], user["name"], iso_utc(user["created_at"])])


def export_datetime_as_text(users, path):
    with open(path, "w", encoding="utf-8", newline="") as handle:
        writer = csv.writer(handle)
        writer.writerow(HEADER)
        for user in users:
            writer.writerow([user["id"], user["name"], user["created_at"]])


IMPLEMENTATIONS = {
    "correct": export_correct,
    "without_quoting": export_without_quoting,
    "no_header_when_empty": export_no_header_when_empty,
    "datetime_as_text": export_datetime_as_text,
}

fixtures.py giữ dữ liệu mẫu và kết quả mong đợi viết thẳng thành giá trị, không tính từ code đang kiểm: nếu kỳ vọng sinh từ chính code thì lỗi và kỳ vọng sai cùng nhau. Bốn ca ứng với tiêu chí: normal (R1–R3), special_chars (R4), empty (R5), unicode (R6).

from datetime import datetime, timezone

UTC = timezone.utc
HEADER = ["id", "name", "created_at"]

CASES = {
    "normal": (
        [
            {"id": 1, "name": "An", "created_at": datetime(2026, 1, 31, 9, 5, tzinfo=UTC)},
            {"id": 2, "name": "Binh", "created_at": datetime(2026, 2, 1, 0, 0, tzinfo=UTC)},
        ],
        [["1", "An", "2026-01-31T09:05:00Z"], ["2", "Binh", "2026-02-01T00:00:00Z"]],
    ),
    "special_chars": (
        [{"id": 3, "name": 'Le, "Van"\nTran', "created_at": datetime(2026, 3, 1, 12, 30, 45, tzinfo=UTC)}],
        [["3", 'Le, "Van"\nTran', "2026-03-01T12:30:45Z"]],
    ),
    "unicode": (
        [{"id": 4, "name": "Nguyễn Thị Bình", "created_at": datetime(2026, 4, 2, 6, 0, tzinfo=UTC)}],
        [["4", "Nguyễn Thị Bình", "2026-04-02T06:00:00Z"]],
    ),
    "empty": ([], []),
}

check_weak.py là kiểu kiểm agent hay viết: chạy export rồi xác nhận file tồn tại.

import os
import sys
import tempfile

from export_csv import IMPLEMENTATIONS
from fixtures import CASES

users, _ = CASES["special_chars"]
with tempfile.TemporaryDirectory() as folder:
    path = os.path.join(folder, "users.csv")
    IMPLEMENTATIONS[sys.argv[1]](users, path)
    assert os.path.exists(path)
print("file tồn tại")

verify_behavior.py đóng vai người dùng file: đọc lại bằng CSV parser chuẩn rồi so với kỳ vọng độc lập. Cùng tinh thần với khuyến nghị kiểm đầu-cuối “như người dùng thật” trong bài harness của Anthropic. Nó chỉ ghi vào thư mục tạm.

import csv
import sys
import tempfile
from pathlib import Path

from export_csv import IMPLEMENTATIONS
from fixtures import CASES, HEADER


def read_back(export, users):
    with tempfile.TemporaryDirectory() as folder:
        path = Path(folder) / "users.csv"
        export(users, path)
        with open(path, encoding="utf-8", newline="") as handle:
            return list(csv.reader(handle))


def check(name):
    passed = True
    for label, (users, expected) in CASES.items():
        rows = read_back(IMPLEMENTATIONS[name], users)
        wanted = [HEADER, *expected]
        if rows == wanted:
            print(f"ok   {label}")
        else:
            passed = False
            print(f"FAIL {label}: mong đợi {wanted!r}")
            print(f"     đọc lại   {rows!r}")
    return passed


if __name__ == "__main__":
    sys.exit(0 if check(sys.argv[1]) else 1)

Chạy cả hai verifier trên từng bản cài:

for impl in correct without_quoting no_header_when_empty datetime_as_text; do
  if python3 check_weak.py "$impl" >/dev/null 2>&1; then weak=pass; else weak=FAIL; fi
  if python3 verify_behavior.py "$impl" >/dev/null 2>&1; then behavior=pass; else behavior=FAIL; fi
  printf '%-22s weak=%s behavior=%s\n' "$impl" "$weak" "$behavior"
done
correct                weak=pass behavior=pass
without_quoting        weak=pass behavior=FAIL
no_header_when_empty   weak=pass behavior=FAIL
datetime_as_text       weak=pass behavior=FAIL

Verifier yếu pass cả bốn bản, nên “pass” của nó không mang thông tin: nó không thể thất bại trước ba lỗi này. Verifier hành vi pass bản đúng và thất bại trước từng bản lỗi. Đó mới là lý do để tin “pass” của nó, trong phạm vi bốn ca. Với verifier do agent viết hoặc sửa, vòng lặp trên là bước kiểm nên có: đưa vào vài bản lỗi mẫu và xem verifier có đỏ không. Đây là dạng tối giản của mutation testing, tức cố ý đưa lỗi vào code rồi xem test có bắt được không.

Bảng yêu cầu, verifier, kỳ vọng, thực tế, bằng chứng

Lấy bản without_quoting (agent báo xong vì file tồn tại) và chạy verifier hành vi:

python3 verify_behavior.py without_quoting
ok   normal
FAIL special_chars: mong đợi [['id', 'name', 'created_at'], ['3', 'Le, "Van"\nTran', '2026-03-01T12:30:45Z']]
     đọc lại   [['id', 'name', 'created_at'], ['3', 'Le', ' "Van"'], ['Tran', '2026-03-01T12:30:45Z']]
ok   unicode
ok   empty
Yêu cầuVerifierKỳ vọngThực tếBằng chứng
R1 Dòng đầu là id,name,created_atverify_behavior.py, ca normalHàng đầu là id, name, created_atKhớpok normal
R2 Một dòng mỗi người dùng, đúng thứ tựCa normalHai dòng dữ liệu theo thứ tự đầu vàoKhớpok normal
R3 created_at dạng …T…ZCa normal2026-01-31T09:05:00ZKhớpok normal
R4 Dấu phẩy, nháy kép, xuống dòng không phá cộtCa special_charsMột dòng dữ liệu, tên nguyên vẹnHai dòng dữ liệu, tên bị tách thành Le, "Van" và TranFAIL special_chars, exit code 1
R5 Danh sách rỗng chỉ có dòng tiêu đềCa emptyMột hàng: tiêu đềKhớpok empty
R6 Tiếng Việt có dấu giữ nguyênCa unicodeNguyễn Thị BìnhKhớpok unicode

Kết luận cho bản này: R1–R3 và R5–R6 đạt, R4 không đạt, nên chưa xong dù file tồn tại. Hai bản còn lại sai ở tiêu chí khác: no_header_when_empty sai R5 (đọc lại được 0 hàng thay vì 1), datetime_as_text sai R3 ở mọi ca có dữ liệu (2026-01-31 09:05:00+00:00 thay vì …T…Z).

Kiểm chỉ đọc và hành động thật

LoạiVí dụDùng làm bằng chứng “xong”?
Kiểm chỉ đọc hoặc cô lậpChạy test trong thư mục tạm; đọc lại file xuất; dry-run hoặc plan; truy vấn đọc trên database thửCó
Hành động đổi môi trường thậtDeploy, chạy migration trên database thật, gửi email, xóa dữ liệu, pushKhông: cần quyền và phê duyệt riêng
  • Tiêu chí “xong” chỉ dựa vào kiểm chỉ đọc hoặc cô lập. Nếu việc cần deploy hay migration, viết tiêu chí riêng cho nó và theo dõi trạng thái riêng.
  • Một lệnh vừa kiểm vừa hành động (ví dụ “chạy test, pass thì deploy”) không phải verifier; tách làm hai.
  • Báo cáo tách ba trạng thái: đã kiểm (lệnh và kết quả), chưa kiểm (vì sao), đã chạy hành động (ai phê duyệt). “Chưa kiểm” không bao giờ là “đạt”. Tài liệu Claude Code tóm tắt: nếu không kiểm được thì đừng phát hành.

Ai viết tiêu chí và verifier? Người giao việc viết hoặc duyệt tiêu chí; agent chạy verifier và báo output. Lahiri (arXiv:2603.17150, preprint 03/2026) viết rằng ngoài người dùng không có oracle nào cho độ đúng của đặc tả, nên không ai kiểm hộ bạn tiêu chí có đúng ý hay không. Có thêm lý do thực tế để đặt verifier ngoài tầm sửa của agent:

  • METR (2025-06-05) ghi nhận ở một số tác vụ có chấm điểm, các model tiên phong có lúc sửa hoặc né mã chấm điểm thay vì giải bài, với tỷ lệ khác nhau rất xa giữa các tác vụ. Chính báo cáo ghi rằng hai phương pháp lọc tự động của họ có tỷ lệ dương tính giả rất cao nên chỉ dùng để chọn lượt chạy xem tay, và số lần phát hiện có thể thấp hơn thực tế.
  • Anthropic cho agent chạy dài một quy tắc: không được xóa hoặc sửa test vì có thể dẫn tới chức năng bị thiếu hoặc lỗi. Tài liệu Claude Code mô tả mẫu reviewer riêng trong ngữ cảnh mới, để agent làm việc không phải là agent chấm.

Nếu agent buộc phải sửa verifier, xem diff của verifier trước diff của code.

Bằng chứng do máy ghi, không do lời kể

Bằng chứng gồm lệnh, exit code, đoạn output liên quan, môi trường và thời điểm. Tài liệu Claude Code khuyến nghị yêu cầu agent đưa bằng chứng thay vì khẳng định thành công, vì đọc bằng chứng nhanh hơn chạy lại phép kiểm. Cách đơn giản nhất để bằng chứng không phụ thuộc lời kể là chạy verifier qua một lớp ghi lại:

import json
import platform
import subprocess
import sys
import time

command = sys.argv[1:]
started = time.strftime("%Y-%m-%dT%H:%M:%S%z")
done = subprocess.run(command, capture_output=True, text=True)
record = {
    "command": command,
    "exit_code": done.returncode,
    "output_tail": (done.stdout + done.stderr).splitlines()[-5:],
    "python": platform.python_version(),
    "started_at": started,
}
print(json.dumps(record, ensure_ascii=False, indent=2))
sys.exit(done.returncode)

Chạy verifier hành vi trên bản đúng qua lớp ghi:

python3 evidence.py python3 verify_behavior.py correct
{
  "command": [
    "python3",
    "verify_behavior.py",
    "correct"
  ],
  "exit_code": 0,
  "output_tail": [
    "ok   normal",
    "ok   special_chars",
    "ok   unicode",
    "ok   empty"
  ],
  "python": "3.14.3",
  "started_at": "2026-10-02T17:17:10+0700"
}

Bản ghi do chương trình tạo ra từ lần chạy thật. Chạy tay như trên mới là bước đầu: đặt bước ghi ở nơi agent không sửa được (CI hoặc hook của công cụ) thì mạnh hơn.

Mẫu dùng lại

Từ yêu cầu tới tiêu chí:

Yêu cầu: <một câu>

Tiêu chí (quan sát được từ kết quả, không mô tả cách làm)
- R1: <điều kiện>
- R2: <điều kiện>

Ca biên: <rỗng · giá trị đặc biệt · Unicode · múi giờ · ...>
Ngoài phạm vi: <điều không làm>

Verifier: <lệnh> — kỳ vọng: <kết quả>
Phải đỏ trước các bản lỗi mẫu: <lỗi mẫu 1, lỗi mẫu 2>

Báo cáo hoàn tất mà agent phải trả:

Tiêu chí: <R1…Rn>, mỗi tiêu chí đạt hoặc không đạt
Lệnh: <lệnh đã chạy>
Kết quả: exit code <n>; <các dòng output liên quan>
Môi trường: <hệ điều hành, phiên bản>
Đã kiểm: <việc đã chạy thật>
Chưa kiểm: <deploy, dữ liệu thật, môi trường khác, kèm lý do>

Checklist trước khi chấp nhận “xong”:

  • Mỗi tiêu chí mô tả kết quả quan sát được, không mô tả cách làm?
  • Ca biên được liệt kê từ yêu cầu trước khi viết code?
  • Kỳ vọng được viết độc lập với code đang kiểm?
  • Mỗi verifier đã đỏ trước ít nhất một bản lỗi mẫu?
  • Có cách nào làm tiêu chí này xanh mà không giải bài toán không? Nếu có, viết lại tiêu chí.
  • Có ít nhất một verifier đọc kết quả như người dùng tiêu thụ nó?
  • Verifier chỉ đọc hoặc chạy trong môi trường cô lập; deploy và migration có tiêu chí và trạng thái riêng?
  • Báo cáo có lệnh, exit code, output, môi trường và phần chưa kiểm?
  • Verifier nằm ngoài vùng agent được sửa, hoặc diff của nó được xem trước?

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

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

  • Lab giả lập và không gọi model nào. Bài cho thấy verifier yếu không phân biệt được bản đúng với bản lỗi và verifier hành vi phân biệt được; nó không đo agent nào hay lách verifier thường đến đâu.
  • “Pass” của verifier hành vi chỉ có nghĩa là bốn ca đó đạt. Ba bản lỗi mẫu không phải mọi lỗi; công cụ mutation testing phủ rộng hơn nhiều.
  • Bài không bàn Excel, BOM hay dấu phân cách theo locale. Lab chạy ngày 2026-10-02 trên Linux (Python 3.14.3) và macOS 27.0.1 arm64 (Python 3.14.8); Windows và Python 3.10–3.13 chưa thử.
  • Nguồn METR đo trên các model và tác vụ riêng, và Lahiri là preprint; bài chỉ dùng nhận định định tính, không dùng số liệu của họ.

Lỗi thường gặp

LỗiHậu quảCách tránh
Tiêu chí mô tả cách làm (“dùng csv.writer”)Đạt khi làm đúng cách nhưng kết quả vẫn saiViết kết quả quan sát được
Verifier chỉ kiểm file tồn tại hoặc exit codePass với mọi lỗiĐọc lại kết quả và so với kỳ vọng độc lập
Kỳ vọng sinh từ chính code đang kiểmLỗi và kỳ vọng sai cùng nhauViết kỳ vọng thành dữ liệu độc lập
Không có ca rỗng và ca đặc biệtLỗi ca biên lọt quaLiệt kê ca biên từ yêu cầu trước khi viết code
Agent tự sửa verifier cho dễ passVerifier bị nới lỏngXem diff của verifier trước; giữ nó ngoài vùng agent được sửa
Gộp kiểm và deploy“Xong” lẫn với “đã phát hành”Tách verifier chỉ đọc khỏi hành động; báo trạng thái riêng
Tin “đã chạy test” khi không có outputKhông kiểm lại đượcĐòi lệnh, exit code và output

Học tiếp

Nguồn tham khảo

Review code do AI sinh ra: từ checklist đến verdict có lý do

Câu hỏi bài này trả lời: nhận một diff do AI sinh ra mà test đã xanh, review thế nào để đi tới quyết định duyệt hay không, kèm lý do người khác kiểm lại được?

Cần biết trước: đọc được code Python cơ bản, biết diff và pull request. Nên đọc Tiêu chí hoàn tất có thể kiểm chứng trước, vì bài này dùng lại ý “một phép kiểm chưa từng đỏ thì chưa chứng minh được gì”. Phần lab chỉ dùng thư viện chuẩn của Python 3, không cài thêm gì.

Test xanh nói rằng các phép kiểm đã có đều đạt. Reviewer hỏi câu khác: thay đổi này có làm hệ thống tốt hơn không, và có điều gì mà các phép kiểm đã có không nhìn thấy? Với code do AI sinh ra, khoảng cách giữa hai câu hỏi dễ rộng hơn vì code và test thường đến từ cùng một nguồn, với cùng một cách hiểu yêu cầu. Tài liệu best practices của Claude Code nói rằng ngữ cảnh mới giúp review tốt hơn vì Claude không thiên vị code mà nó vừa viết. Trong user study của Perry và cộng sự (CCS 2023), người có trợ lý AI dựa trên model codex-davinci-002 viết code kém an toàn hơn nhóm không có trợ lý, và tin rằng code của mình an toàn hơn. Đó là một nghiên cứu với tác vụ và model của thời điểm đó, nên chỉ đáng dùng như lời nhắc cảnh giác, không phải con số áp cho mọi công cụ hôm nay.

Review để làm gì

Chuẩn review của Google Engineering Practices đặt mục đích chính là làm sức khỏe tổng thể của code base tốt dần theo thời gian. Từ đó có quy tắc: reviewer nên ưu tiên duyệt khi thay đổi chắc chắn làm hệ thống tốt hơn dù chưa hoàn hảo, và không duyệt thay đổi làm nó xấu đi. Cũng theo tài liệu này, reviewer có quyền sở hữu và trách nhiệm với code mình duyệt, và dữ kiện kỹ thuật thắng ý kiến cá nhân.

Vì vậy kết quả của một lần review là một verdict, không phải một cột dấu tick. Verdict có ba dạng: duyệt, yêu cầu sửa kèm lý do, hoặc chuyển cho người có chuyên môn phù hợp. Mỗi dạng cần lý do và bằng chứng. “Đã đi hết checklist” không phải lý do.

Ví dụ: diff làm test xanh mà vẫn sai

Yêu cầu: “Cho phép nhân viên hỗ trợ hoàn tiền từng phần cho đơn hàng; tổng số đã hoàn không được vượt số khách đã thanh toán.” Agent trả về một hàm refund và bốn test, tất cả đều xanh. Tạo thư mục trống, ví dụ review-lab, và các file sau. Dữ liệu hoàn toàn giả lập.

shop/orders.py là phần đã có sẵn trong code base trước khi có diff:

from dataclasses import dataclass


class RefundError(Exception):
    pass


@dataclass
class User:
    name: str
    role: str


@dataclass
class Order:
    id: int
    owner: str
    total: int
    refunded: int = 0

shop/refunds.py là diff mà agent sinh ra, thứ bạn phải review:

from shop.orders import Order, RefundError, User


def refund(order: Order, amount: int, actor: User) -> int:
    if amount <= 0:
        raise RefundError("số tiền phải dương")
    if amount > order.total:
        raise RefundError("vượt số tiền đã thanh toán")
    order.refunded += amount
    return order.total - order.refunded

tests/test_refunds.py là bốn test đi kèm diff, cũng do agent viết:

import unittest

from shop.orders import Order, RefundError, User
from shop.refunds import refund

SUPPORT = User("an", "support")


class RefundTests(unittest.TestCase):
    def test_partial_refund_reduces_remaining(self):
        order = Order(id=1, owner="binh", total=100)
        self.assertEqual(refund(order, 30, SUPPORT), 70)

    def test_full_refund_leaves_nothing(self):
        order = Order(id=2, owner="binh", total=100)
        self.assertEqual(refund(order, 100, SUPPORT), 0)

    def test_rejects_amount_above_total(self):
        order = Order(id=3, owner="binh", total=100)
        with self.assertRaises(RefundError):
            refund(order, 150, SUPPORT)

    def test_rejects_non_positive_amount(self):
        order = Order(id=4, owner="binh", total=100)
        with self.assertRaises(RefundError):
            refund(order, 0, SUPPORT)


if __name__ == "__main__":
    unittest.main()

Chạy test của diff:

python3 -m unittest discover -s tests
Ran 4 tests in 0.000s

OK

Test xanh, nhưng chưa thể duyệt. Google hỏi đúng câu cần hỏi ở phần test: test có thật sự đỏ khi code hỏng không, vì test không tự kiểm chính nó và phải có người kiểm test có hợp lệ không. Đọc diff với hai câu hỏi: ai được gọi hàm này và dòng nào từ chối người không được phép? và gọi lần thứ hai thì bất biến “tổng hoàn không vượt tổng đã thanh toán” còn đúng không?

Câu hỏi đầu có thể kiểm bằng một lệnh. Tham số actor xuất hiện ở đâu trong file?

grep -n actor shop/refunds.py
4:def refund(order: Order, amount: int, actor: User) -> int:

Chỉ một dòng: actor có trong chữ ký và không được dùng ở đâu. Hàm không từ chối ai. Với câu hỏi thứ hai, so sánh ở dòng 7 dùng order.total thay vì số còn có thể hoàn.

Reviewer viết hai probe, tức hai test nhắm đúng hai nghi vấn. Probe không thuộc diff; nó là công cụ của người review:

import unittest

from shop.orders import Order, RefundError, User
from shop.refunds import refund

SUPPORT = User("an", "support")
CUSTOMER = User("binh", "customer")


class RefundProbes(unittest.TestCase):
    def test_customer_cannot_refund_even_own_order(self):
        order = Order(id=1, owner="binh", total=100)
        with self.assertRaises(PermissionError):
            refund(order, 10, CUSTOMER)

    def test_second_refund_cannot_exceed_remaining(self):
        order = Order(id=2, owner="binh", total=100)
        refund(order, 60, SUPPORT)
        with self.assertRaises(RefundError):
            refund(order, 60, SUPPORT)

Chạy probe trên diff gốc. Thông báo FAIL: đầy đủ khác nhau giữa các phiên bản Python, nên lab chỉ khóa các dòng ổn định:

python3 -m unittest review.probes
AssertionError: PermissionError not raised
AssertionError: RefundError not raised
Ran 2 tests in 0.000s
FAILED (failures=2)

Hai probe đỏ, đúng hai ca. Cho đến lúc này bạn có bằng chứng thay vì cảm giác. Lưu ý chính test của agent vẫn xanh trong lúc diff sai: không có test nào trong bốn test dùng người gọi không phải nhân viên, và không test nào gọi refund hai lần trên cùng một đơn.

Hai finding mẫu

Một finding đủ dùng khi người khác đọc xong có thể tái hiện, hiểu tác động và biết cách xác nhận đã sửa. Năm trường:

F1. Hoàn tiền nhiều lần vượt số đã thanh toán (mức: cao)

  • Vị trí: shop/refunds.py dòng 7, if amount > order.total.
  • Điều kiện tái hiện: đơn tổng 100; gọi refund 60 hai lần liên tiếp. Lần hai đáng lẽ bị từ chối vì chỉ còn 40, nhưng được chấp nhận và order.refunded thành 120.
  • Tác động: hoàn nhiều hơn số khách đã trả, mất tiền thật; lỗi nằm ở bất biến nghiệp vụ nên không có thông báo lỗi nào lộ ra.
  • Cách xác minh: probe test_second_refund_cannot_exceed_remaining đỏ trước sửa và xanh sau sửa; kiểm thêm order.refunded không vượt order.total sau mọi chuỗi gọi trong test.

F2. Hàm hoàn tiền không kiểm quyền người gọi (mức: cao)

  • Vị trí: shop/refunds.py dòng 4 (tham số actor) và cả thân hàm: không dòng nào đọc actor.
  • Điều kiện tái hiện: gọi refund(order, 10, CUSTOMER) với role="customer"; hàm chạy và ghi order.refunded.
  • Tác động: bất kỳ người gọi nào tới được hàm này đều hoàn tiền được, kể cả khách cho chính đơn của mình. Đây là dạng lỗi kiểm soát truy cập mà OWASP Top 10:2025, mục A01 xếp đầu bảng.
  • Cách xác minh: probe test_customer_cannot_refund_even_own_order đỏ trước sửa, xanh sau sửa; thêm test từ chối vào bộ test chính thức của diff.

OWASP ghi hai điểm khớp với hai finding này: truy cập nên mặc định bị từ chối (deny by default) và các giới hạn nghiệp vụ nên được thi hành bởi domain model; đồng thời lập trình viên và QA nên đưa kiểm soát truy cập vào unit test và integration test.

Sửa cả hai trong shop/refunds.py, giữ nguyên bốn test cũ:

from shop.orders import Order, RefundError, User


def refund(order: Order, amount: int, actor: User) -> int:
    if actor.role != "support":
        raise PermissionError("chỉ nhân viên hỗ trợ được hoàn tiền")
    if amount <= 0:
        raise RefundError("số tiền phải dương")
    remaining = order.total - order.refunded
    if amount > remaining:
        raise RefundError("vượt số tiền còn có thể hoàn")
    order.refunded += amount
    return remaining - amount

Chạy lại cả test của diff và probe:

python3 -m unittest discover -s tests 2>&1 | tail -n 3
python3 -m unittest review.probes 2>&1 | tail -n 3
Ran 4 tests in 0.000s
OK
Ran 2 tests in 0.000s
OK

Hai probe nên được chuyển vào tests/ của dự án để lần sau còn bắt được. Probe chỉ đứng ngoài khi nó còn là công cụ của riêng người review.

Ba tầng review theo trigger

Không phải diff nào cũng cần cùng mức soi. Cách chia dưới đây là của bài, khớp với thực hành chung hơn là một chuẩn:

TầngÁp khi nàoAi hoặc cái gì làmBắt đượcKhông bắt được
Kiểm tự độngMọi diffCI: format, lint, type, test, secret scanLỗi cú pháp, quy ước, hồi quy đã có testHành vi chưa có test: cả hai lỗi của ví dụ đều lọt qua
Review logicMọi diffNgười, hoặc agent khác tác giảBất biến sai, ca biên, nhánh thiếu, test không đỏ khi code saiĐiều cần chuyên môn mà reviewer không có
Review chuyên sâuDiff chạm tiền, quyền hoặc danh tính, dữ liệu cá nhân, schema hay migration, dependency mới, đồng thời, API công khaiNgười có chuyên môn của vùng đóRủi ro bảo mật, thiết kế, đồng thờiThứ ngoài phạm vi người duyệt đã khai báo

Google ghi rõ cách xử lý khi mình không đủ chuyên môn: nếu bạn hiểu code nhưng không thấy đủ năng lực cho một phần như quyền riêng tư, bảo mật, đồng thời, hãy bảo đảm có reviewer đủ năng lực trên thay đổi đó. Khi chỉ review một phần, ghi rõ trong comment phần nào đã review.

Ai chịu trách nhiệm, và agent tự review được đến đâu

  • Người duyệt chịu trách nhiệm. Thay đổi đã merge là của người duyệt và người merge, bất kể code do người hay agent viết. Một lỗi đã lọt qua không được giải thích bằng “agent sinh ra như vậy”.
  • Agent review là công cụ hỗ trợ, không phải cổng cuối. Agent đã viết code mà tự duyệt trong cùng ngữ cảnh dễ thiên vị code của mình (xem tài liệu Claude Code ở trên). Cách giảm: cho reviewer ngữ cảnh mới, chỉ có diff và tiêu chí; tài liệu Claude Code mô tả đúng mẫu reviewer trong subagent mới, thấy diff và tiêu chí chứ không thấy lập luận đã sinh ra thay đổi.
  • Kết quả của reviewer agent cũng là finding cần xác minh. Nó chưa phải bằng chứng cho tới khi có lệnh hoặc test tái hiện được. Một lời “code ổn” không kèm bằng chứng thì không đổi được verdict.

Chuyển cho người có chuyên môn (hoặc người khác) khi gặp một trong các dấu hiệu:

  1. Diff chạm vùng ở bảng trên mà reviewer hiện tại không đủ chuyên môn.
  2. Finding không tái hiện được, hoặc reviewer không hiểu code. Google lưu ý: nếu bạn không hiểu code, nhiều khả năng người sau cũng không hiểu, nên yêu cầu tác giả làm rõ trước khi duyệt.
  3. Tác giả và reviewer bất đồng. Google đề xuất thử đạt đồng thuận theo tài liệu trước, rồi leo thang lên thảo luận rộng hơn, người phụ trách kỹ thuật hoặc maintainer; đừng để thay đổi nằm đó vì bất đồng.
  4. Diff chứa thứ không nên có trong code: thông tin đăng nhập, dữ liệu thật của khách, hoặc dependency mới không giải thích được.

Đối chiếu checklist với hai lỗi mẫu

Một checklist có ích khi mỗi mục là câu hỏi đòi bằng chứng, không phải ô tick. Đối chiếu với hai lỗi trên:

Câu hỏi của checklistBằng chứng cần cóBắt F1 (logic)Bắt F2 (quyền)
Test của diff đã xanh chưa?Lệnh và outputKhôngKhông
Test có đỏ khi code sai không (đưa bản lỗi hoặc viết probe)?Output probe đỏCóCó
Mỗi tham số của hàm có được dùng không?grep -n tên tham sốKhôngCó: actor chỉ có ở chữ ký
Ai gọi được hàm này, dòng nào từ chối người không được phép?Chỉ ra dòng hoặc test từ chốiKhôngCó
Sau lần gọi thứ hai và thứ ba, bất biến nghiệp vụ còn đúng không?Test chuỗi gọiCóKhông
Diff có chạm vùng cần review chuyên sâu (tiền, quyền) không?Danh sách trigger đã đối chiếuBáo cần soiBáo cần soi

Dòng đầu tiên là dòng nguy hiểm: trả lời “có” và không bắt được lỗi nào. Một checklist chỉ gồm các mục kiểu đó sẽ tick đủ mà vẫn cho qua cả hai lỗi.

Mẫu dùng lại

Mẫu finding:

F<n>. <tên ngắn> (mức: cao | trung bình | thấp)
- Vị trí: <file:dòng>
- Điều kiện tái hiện: <đầu vào hoặc chuỗi thao tác cụ thể>
- Tác động: <chuyện gì xảy ra với người dùng hoặc dữ liệu>
- Cách xác minh: <lệnh hoặc test; kết quả trước và sau sửa>

Mẫu verdict:

Verdict: duyệt | yêu cầu sửa | chuyển người có chuyên môn
Lý do: <các finding quyết định verdict>
Đã kiểm: <lệnh đã chạy, kết quả>
Chưa kiểm: <điều chưa kiểm và vì sao>
Điều kiện duyệt: <cần gì để chuyển sang duyệt>

Prompt cho agent reviewer (chạy ở ngữ cảnh mới, chỉ đưa diff và tiêu chí):

Bạn review diff dưới đây. Bạn không biết agent đã suy luận thế nào khi viết nó.
Tiêu chí: <yêu cầu và bất biến nghiệp vụ>
Với mỗi vấn đề, trả lời theo mẫu finding gồm vị trí, điều kiện tái hiện, tác động, cách xác minh.
Chỉ báo vấn đề bạn tái hiện được hoặc chỉ ra được dòng cụ thể.
Ghi rõ điều bạn chưa kiểm. Không kết luận "ổn" khi chưa nêu bằng chứng.

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

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

  • Lab giả lập và không gọi model nào. Bài cho thấy test của diff xanh trong lúc có hai lỗi, và hai probe do người viết bắt được; nó không đo reviewer agent bắt lỗi giỏi đến đâu hay review bằng người có bỏ sót hơn máy không.
  • Ví dụ chỉ có hai lỗi và một hàm. Review thật còn thiết kế, độ phức tạp, đặt tên, tài liệu mà bài không đi sâu; xem danh mục điều cần soi của Google.
  • Perry và cộng sự chạy với tác vụ, ngôn ngữ và một model của năm 2022; bài chỉ dùng nhận định định tính. Cách chia ba tầng và các dấu hiệu chuyển người là đề xuất của bài, không phải chuẩn.
  • Lab chạy ngày 2026-10-02 trên macOS 27.0.1 arm64 (Python 3.14.8); Linux, Windows và Python 3.10–3.13 chưa thử. Trên Windows dùng python thay cho python3 và lệnh grep cần Git Bash hoặc tương đương.

Lỗi thường gặp

LỗiHậu quảCách tránh
Coi test xanh là đủ để duyệtLỗi ngoài phạm vi test lọt quaHỏi test có đỏ khi code sai không; viết probe
Review do chính agent đã viết code, trong cùng ngữ cảnhThiên vị code của mìnhNgữ cảnh mới, chỉ có diff và tiêu chí; vẫn xác minh finding
Checklist toàn ô yes/noTick đủ mà không có bằng chứngMỗi mục kèm bằng chứng đọc lại được
Không ghi điều chưa kiểm“Đã review” bị đọc thành “đã kiểm hết”Mẫu verdict có dòng “Chưa kiểm”
Finding thiếu điều kiện tái hiệnTác giả không sửa được hoặc cãi nhau về ýĐủ năm trường; có lệnh xác minh
Để probe ngoài bộ test chính thứcLỗi quay lại mà không ai bắtChuyển probe thành test của dự án sau khi sửa
Duyệt thay đổi về quyền hoặc tiền mà không có người chuyên mônRủi ro bảo mật lọt quaTrigger chuyển người; ghi rõ phần đã review

Học tiếp

Nguồn tham khảo

Tiếp tục một task AI sau khi mất context

Câu hỏi bài này trả lời: phiên mới chưa thấy cuộc trò chuyện trước; đọc gì để biết mục tiêu, phần còn dở và bước tiếp theo, rồi kiểm thế nào để không tin một checkpoint đã cũ?

Cần biết trước: tiêu chí hoàn tất, Python và cách đọc diff. Bài dùng lại lab CSV của bài đó; chỉ thư viện chuẩn, đã thử Python 3.14 trên macOS arm64.

Một checkpoint tốt làm rõ việc phải làm tiếp và bằng chứng cần kiểm lại. Nó không cần chép toàn bộ chat. Anthropic mô tả harness chạy dài dùng artifact tiến độ, danh sách tính năng và kiểm đầu phiên để giảm việc đoán trạng thái. Đó là kinh nghiệm trong một harness cụ thể, không bảo đảm mọi agent sẽ hiểu đúng tài liệu bàn giao.

Giữ gì trong checkpoint?

PhầnNội dung hữu íchLỗi tránh
Mục tiêu và tiêu chíKết quả người dùng cần; tiêu chí chưa đạtChỉ ghi “đã sửa gần xong”
ScopeFile được sửa, phần ngoài phạm viMở rộng sang refactor/deploy
Quyết địnhLựa chọn hiện tại và lý doLưu mọi phương án đã bỏ
DiffFile/hành vi đã đổi và cách đối chiếuCho rằng tên file chứng minh nội dung
EvidenceLệnh, exit, log, phiên bản và snapshot file/testChỉ ghi “tests pass”
BlockerĐiều thiếu và ai/cái gì gỡ đượcBiến chưa kiểm thành pass
Next stepMột việc cụ thể để tiếp tụcNhiều việc không có thứ tự

Đặt checkpoint trong repository hoặc artifact store mà người/agent tiếp theo truy cập được. Nếu checkpoint nằm trong working tree chưa commit, người dùng ở máy khác chỉ thấy nó sau khi thay đổi đã được chuyển sang máy đó theo quy trình của nhóm. Không dựa vào memory riêng của một provider để giữ kiến thức chung.

Bài context engineering của Anthropic phân biệt compaction với structured note-taking. Với task coding, đường dẫn tới file/evidence giúp phiên mới nạp đúng phần cần dùng; đây là cách áp dụng cho ví dụ dưới, không đòi mọi task phải có cùng bộ file.

Case: xuất CSV còn lỗi R5

Tạo thư mục trống. Chép export_csv.py, fixtures.py và verify_behavior.py từ lab tiêu chí hoàn tất. Chọn bản no_header_when_empty: tên đặc biệt, Unicode và timestamp đều đúng; danh sách rỗng lại không có header. Mục tiêu là sửa bản đó, giữ tiêu chí R1–R6; không đổi fixture/verifier.

Lưu make_checkpoint.py cạnh ba file. Nó chạy phép kiểm thật trước khi ghi snapshot, gồm cả source, fixture và verifier. Snapshot có hash log để bắt log bị thay; hash là dấu nhận biết thay đổi, không phải chữ ký hay bằng chứng chống người sửa cả checkpoint lẫn file.

import hashlib
import json
import subprocess
import sys
from pathlib import Path

FILES = ("export_csv.py", "fixtures.py", "verify_behavior.py")


def digest(path):
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()


command = ["verify_behavior.py", "no_header_when_empty"]
result = subprocess.run([sys.executable, *command], capture_output=True,
                        text=True, timeout=20, check=False)
Path("last-check.log").write_text(result.stdout + result.stderr, encoding="utf-8")
checkpoint = {
    "schema": 1,
    "goal": "Xuất CSV đáp ứng R1–R6, kể cả danh sách rỗng",
    "criteria": ["header", "thứ tự dòng", "UTC ISO", "quoting", "empty", "UTF-8"],
    "scope": {"edit": ["export_csv.py"],
              "keep": ["fixtures.py", "verify_behavior.py"],
              "outside": ["Excel/BOM", "phân quyền", "deploy"]},
    "decision": "Giữ csv.writer và timestamp UTC; header không phụ thuộc có dữ liệu",
    "diff": "Bản no_header_when_empty có if users bao quanh writer.writerow(HEADER)",
    "snapshot": {name: digest(name) for name in FILES},
    "verification": {"command": command, "exit": result.returncode,
                     "passes": result.returncode == 0, "log": "last-check.log",
                     "log_sha256": digest("last-check.log"),
                     "python": sys.version.split()[0]},
    "blockers": [],
    "next_step": ("Review diff và phạm vi sau kiểm local" if result.returncode == 0
                  else "Sửa header bản no_header_when_empty rồi chạy lại verifier"),
    "done": False,
}
Path("checkpoint.json").write_text(json.dumps(checkpoint, ensure_ascii=False, indent=2),
                                   encoding="utf-8")
print(f"checkpoint written exit={result.returncode} done=false")

Trường diff là ghi chú ngắn tại thời điểm bàn giao ban đầu. Sau sửa phải đọc diff thật và cập nhật ghi chú; script chỉ tạo snapshot/evidence, không hiểu diff ngữ nghĩa. passes=true chỉ nói verifier local xanh, done vẫn false để còn review phạm vi. Scope/criteria trong JSON là hướng dẫn, không phải quyền filesystem.

python3 make_checkpoint.py

Reader mới: đọc checkpoint rồi đối chiếu file thật

Lưu resume.py. Reader không import module của phiên trước, không đọc chat hay provider memory. Nó kiểm cấu trúc/path/command cố định, so snapshot và luôn chạy lại verifier, kể cả snapshot chưa đổi. Chỉ command của lab được chấp nhận; không thực thi tùy ý chuỗi trong checkpoint.

import hashlib
import json
import subprocess
import sys
from pathlib import Path

FILES = {"export_csv.py", "fixtures.py", "verify_behavior.py"}
state = json.loads(Path("checkpoint.json").read_text(encoding="utf-8"))
verify = state["verification"]
if (state["schema"] != 1 or set(state["snapshot"]) != FILES
        or verify["command"] != ["verify_behavior.py", "no_header_when_empty"]
        or verify["log"] != "last-check.log"):
    print("rejected checkpoint contract")
    sys.exit(2)


def digest(path):
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()


for name in (*FILES, "last-check.log"):
    if not Path(name).is_file() or Path(name).is_symlink():
        print("missing or unsafe evidence file")
        sys.exit(2)
changed = any(digest(name) != value for name, value in state["snapshot"].items())
changed = changed or digest("last-check.log") != verify["log_sha256"]
print("snapshot changed: revalidate" if changed else "snapshot unchanged: revalidate")
print("goal:", state["goal"])
print("next:", state["next_step"])
result = subprocess.run([sys.executable, *verify["command"]], capture_output=True,
                        text=True, timeout=20, check=False)
print(result.stdout, end="")
print(result.stderr, end="", file=sys.stderr)
print(f"current exit={result.returncode}; checkpoint exit={verify['exit']}; done=false")
sys.exit(result.returncode)
python3 resume.py

Phiên mới thấy lỗi empty, exit 1, và bước sửa header. Nó chưa được phép đổi criterion thành “file có tồn tại”. Trên task thật, còn phải đọc instructions, trạng thái working tree và diff để tránh ghi đè thay đổi của người khác; simulator nhỏ không thay các bước đó.

Sửa đúng một nhánh rồi thử lại

Khối này chỉ đổi đúng bản lỗi; không sửa hai bản lỗi khác của bài trước hoặc verifier. File .before là bản nguồn riêng của lab dùng cho phép thử regression, không là bản sao dữ liệu thật.

python3 - <<'PY'
from pathlib import Path
path = Path("export_csv.py")
source = path.read_text(encoding="utf-8")
Path("export_csv.before").write_text(source, encoding="utf-8")
old = "        if users:\n            writer.writerow(HEADER)"
assert source.count(old) == 1
path.write_text(source.replace(old, "        writer.writerow(HEADER)"), encoding="utf-8")
print("patched one header branch")
PY
python3 resume.py

Reader báo source đổi và rerun xanh cả bốn ca. Không dùng checkpoint cũ làm bằng chứng cho bản mới; ghi checkpoint mới sau khi đã review diff. Trong lab, so đúng phần thay đổi rồi cập nhật ghi chú:

python3 make_checkpoint.py
python3 - <<'PY'
import json
from pathlib import Path
path = Path("checkpoint.json")
state = json.loads(path.read_text(encoding="utf-8"))
state["diff"] = "Header của no_header_when_empty đã đưa ra ngoài if users; fixture/verifier giữ nguyên"
path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
PY
python3 resume.py

Checkpoint xanh, source đã regression

Khôi phục bản lỗi mà không đổi checkpoint xanh. Phiên mới phải thấy stale và lỗi hiện tại; exit trong checkpoint vẫn 0 không giúp test mới vượt qua.

python3 - <<'PY'
from pathlib import Path
Path("export_csv.py").write_text(Path("export_csv.before").read_text(encoding="utf-8"), encoding="utf-8")
PY
python3 resume.py

Thử command ngoài contract bằng dữ liệu vô hại: reader phải từ chối trước khi chạy; không chèn shell command thật để minh họa.

python3 - <<'PY'
import json
from pathlib import Path
path = Path("checkpoint.json")
state = json.loads(path.read_text(encoding="utf-8"))
Path("checkpoint.before").write_text(json.dumps(state), encoding="utf-8")
state["verification"]["command"] = ["unexpected-verifier.py"]
path.write_text(json.dumps(state), encoding="utf-8")
PY
python3 resume.py

Cuối lab đưa source về bản sửa, bỏ artifact trước đây và ghi checkpoint/evidence mới:

python3 - <<'PY'
from pathlib import Path
path = Path("export_csv.py")
source = path.read_text(encoding="utf-8")
old = "        if users:\n            writer.writerow(HEADER)"
assert source.count(old) == 1
path.write_text(source.replace(old, "        writer.writerow(HEADER)"), encoding="utf-8")
Path("export_csv.before").unlink()
Path("checkpoint.before").unlink()
PY
python3 make_checkpoint.py
python3 - <<'PY'
import json
from pathlib import Path
path = Path("checkpoint.json")
state = json.loads(path.read_text(encoding="utf-8"))
state["diff"] = "Header của no_header_when_empty đã đưa ra ngoài if users; fixture/verifier giữ nguyên"
path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
PY
python3 resume.py

Checkpoint cuối có ghi chú diff đúng bản đã sửa, evidence hiện tại và một next step. Không cần đánh done=true chỉ để checkpoint trông sạch; việc review hoặc môi trường chưa kiểm phải được giữ rõ.

Giới hạn và vệ sinh dữ liệu

  • Lab chứng minh reader process mới có thể xác định lỗi/bước tiếp từ file. Nó không mô phỏng khả năng hiểu ngôn ngữ của một session LLM mới, hoặc đo hiệu quả qua nhiều model.
  • Hash phát hiện thay đổi byte, không xác nhận người tạo, nội dung đúng hoặc quyền thực thi. Không tin checkpoint từ nguồn lạ; allowlist command cũng không ngăn file verifier đã bị thay thành mã khác. Quyền đọc/chạy phải do môi trường thực thi kiểm soát.
  • Source không đổi vẫn có thể gặp dependency, runtime hay dịch vụ bên ngoài đổi. Ghi môi trường và rerun; fixture/verifier đổi phải review vì “xanh” có thể do mất tiêu chí.
  • Chỉ giữ dữ liệu giả, path tương đối và kết luận kỹ thuật. Không ghi token, chuỗi kết nối, ticket/khách thật hay raw log chứa payload riêng; lưu evidence cần hạn chế quyền ở nơi thích hợp, rồi checkpoint trỏ đến nó.
  • Với shared working tree, hash đơn lẻ không tạo snapshot nhất quán hoặc khóa file. Dừng nếu file tiếp tục đổi lúc kiểm; khi bàn giao giữa máy cần phiên bản/snapshot chung theo quy trình của nhóm.

Ba bài tập chưa thử trong lab: sửa fixture rồi resume để kiểm stale; thay log rồi resume; chuyển folder sang môi trường sạch chỉ từ checkpoint và file đã ghi. Học tiếp chọn context và review code AI. Nguồn Anthropic đọc ngày 2026-10-03.

Harness tối thiểu: một task, một phép kiểm, một đường tiếp tục

Câu hỏi bài này trả lời: cần những gì quanh AI coding agent để phiên bắt đầu biết scope, phiên kết thúc có evidence và lỗi không bị biến thành “xong”?

Cần biết trước: tiêu chí hoàn tất và checkpoint. Lab dùng Python 3 stdlib (đã kiểm 3.14/macOS arm64), không cần framework hay tài khoản LLM.

Harness ở đây là môi trường quanh việc làm: instructions cho biết cách làm việc; scope/criteria định nghĩa kết quả; verifier đọc hành vi; checkpoint giữ bằng chứng và bước tiếp; lifecycle cho biết khi nào kiểm. Building effective agents khuyến nghị bắt đầu đơn giản, thêm độ phức tạp khi có ích quan sát được. Bài áp dụng điều đó bằng một runner cục bộ, không xây một platform agent.

Kernel nhỏ và ranh giới quyền

PhầnFile trong labVai trò
InstructionsINSTRUCTIONS.mdNgười/agent đọc mục tiêu và ranh giới
Scope/criteriatask.json và fixture S25Chỉ sửa exporter; expected có trước code
Verificationverify_behavior.pySo CSV đọc lại với giá trị độc lập
Statecheckpoint.json, verify.logTrạng thái hiện tại, snapshot, next step
Lifecyclerun.pySTART kiểm đầu vào → verify → ghi checkpoint

Agent hoặc người sửa code giữa hai lần chạy runner. Runner không tự repair, không lặp vô hạn đến xanh, không gọi submit/deploy. Instructions không cấp quyền OS: thật sự giới hạn file, command, network và credential phải do sandbox/tool/runtime kiểm soát. Runner lab chạy code Python đã tin cậy; nó không phải sandbox chống verifier ác ý.

Tạo thư mục trống, chép export_csv.py, fixtures.py, verify_behavior.py từ lab S25. Giữ bản no_header_when_empty đang lỗi R5. Checkpoint bài trước không cần chép: runner này tạo evidence hiện tại từ đầu.

# Cách làm task CSV

Đọc task.json và các file hiện tại trước khi sửa.
Chỉ sửa export_csv.py; giữ fixture, verifier và R1–R6.
Chạy python3 run.py sau mỗi thay đổi nhỏ.
Blocked: xử lý điều thiếu hoặc hỏi người giao việc, không tự invent expected.
Failed: sửa hành vi theo finding, không nới tiêu chí.
Error: sửa môi trường/verifier theo lỗi thật trước khi dùng kết quả.
Verified local: review diff và phần ngoài phạm vi trước khi đóng task.
Không submit/deploy bằng runner; quyền thực thi theo môi trường của nhóm.
{
  "id": "csv-header",
  "goal": "CSV đúng R1–R6; danh sách rỗng vẫn có header",
  "edit": ["export_csv.py"],
  "criteria": ["R1", "R2", "R3", "R4", "R5", "R6"],
  "expected": "Đọc lại CSV bằng parser: header và từng dòng bằng CASES/HEADER trong fixtures.py",
  "verify": ["verify_behavior.py", "no_header_when_empty"]
}

Kiểm runner trước khi tin runner

Viết probe trước run.py. Nó sửa file riêng trong thư mục lab để đưa lỗi mẫu vào, rồi khôi phục; không dùng checkout hoặc fixture thật của ứng dụng. Verifier rỗng exit 0 cũng phải bị từ chối: thiếu test không được biến thành “xanh”.

import json
import subprocess
import sys
from pathlib import Path

exporter = Path("export_csv.py")
test = Path("verify_behavior.py")
task_file = Path("task.json")
original = exporter.read_text(encoding="utf-8")
test_original = test.read_text(encoding="utf-8")
task_original = task_file.read_text(encoding="utf-8")
old = "        if users:\n            writer.writerow(HEADER)"
assert original.count(old) == 1
fixed = original.replace(old, "        writer.writerow(HEADER)")


def expect(label, status, code):
    names = ("export_csv.py", "fixtures.py", "verify_behavior.py", "task.json", "INSTRUCTIONS.md")
    before = {name: Path(name).read_bytes() if Path(name).is_file() else None for name in names}
    result = subprocess.run([sys.executable, "run.py"], capture_output=True,
                            text=True, timeout=25, check=False)
    after = {name: Path(name).read_bytes() if Path(name).is_file() else None for name in names}
    assert after == before, (label, "runner changed inputs")
    assert result.returncode == code, (label, result.returncode, result.stderr)
    state = json.loads(Path("checkpoint.json").read_text(encoding="utf-8"))
    assert state["status"] == status, (label, state["status"])
    assert state["passes"] == (status == "verified_local")
    assert state["done"] is False and state["next_step"]
    assert exporter.read_text(encoding="utf-8") == (original if label == "behavior" else fixed)
    print(f"{label}: {status} exit={code} done=false")


try:
    expect("behavior", "failed", 1)
    exporter.write_text(fixed, encoding="utf-8")
    expect("pass", "verified_local", 0)
    test.unlink()
    expect("missing-test", "blocked", 2)
    test.write_text("raise RuntimeError('fake verifier fault')\n", encoding="utf-8")
    expect("verifier-error", "error", 3)
    test.write_text("print('no checks')\n", encoding="utf-8")
    expect("empty-test", "error", 3)
    test.write_text(test_original, encoding="utf-8")
    task = json.loads(task_original)
    task["expected"] = ""
    task_file.write_text(json.dumps(task), encoding="utf-8")
    expect("missing-expected", "blocked", 2)
    task_file.write_text(task_original, encoding="utf-8")
    expect("resume", "verified_local", 0)
finally:
    exporter.write_text(original, encoding="utf-8")
    test.write_text(test_original, encoding="utf-8")
    task_file.write_text(task_original, encoding="utf-8")
print("restored inputs; checkpoint requires revalidate")

Chạy khi runner chưa tồn tại: probe phải đỏ. Đây là kiểm trước implementation, không phải lỗi cần bỏ qua trên ứng dụng thật.

python3 probe-harness.py

Runner: phân biệt chưa đủ dữ kiện, test đỏ và test hỏng

import hashlib
import json
import subprocess
import sys
from pathlib import Path

FILES = ("INSTRUCTIONS.md", "task.json", "export_csv.py", "fixtures.py", "verify_behavior.py")
OK_LINES = {"ok   normal", "ok   special_chars", "ok   unicode", "ok   empty"}
NEXT = {"blocked": "Bổ sung đầu vào/expected từ người giao việc rồi chạy lại",
        "error": "Điều tra verifier/môi trường; chưa dùng kết quả để đóng task",
        "failed": "Sửa exporter theo finding, giữ tiêu chí và chạy lại",
        "verified_local": "Review diff và scope; action submit/deploy theo quyền riêng"}


def finish(status, reason, output="", exit_code=None):
    Path("verify.log").write_text(output, encoding="utf-8")
    state = {"schema": 1, "status": status, "reason": reason,
             "passes": status == "verified_local", "done": False,
             "next_step": NEXT[status], "python": sys.version.split()[0],
             "verification": {"exit": exit_code, "log": "verify.log"},
             "snapshot": {name: hashlib.sha256(Path(name).read_bytes()).hexdigest()
                          for name in FILES if Path(name).is_file()}}
    Path("checkpoint.json").write_text(json.dumps(state, ensure_ascii=False, indent=2),
                                      encoding="utf-8")
    print(f"{status}: {reason}; done=false")
    sys.exit({"verified_local": 0, "failed": 1, "blocked": 2, "error": 3}[status])


print("START: inspect inputs")
if any(not Path(name).is_file() or Path(name).is_symlink() for name in FILES):
    finish("blocked", "missing or unsafe input")
try:
    task = json.loads(Path("task.json").read_text(encoding="utf-8"))
except (ValueError, OSError):
    finish("blocked", "invalid task input")
if (not task.get("goal") or not task.get("expected")
        or task.get("criteria") != ["R1", "R2", "R3", "R4", "R5", "R6"]
        or task.get("edit") != ["export_csv.py"]
        or task.get("verify") != ["verify_behavior.py", "no_header_when_empty"]):
    finish("blocked", "missing or unsupported task contract")
print("VERIFY: run fixed command")
try:
    result = subprocess.run([sys.executable, *task["verify"]], capture_output=True,
                            text=True, timeout=20, check=False)
except (OSError, subprocess.TimeoutExpired):
    finish("error", "verifier could not complete")
output = result.stdout + result.stderr
lines = set(result.stdout.splitlines())
if result.returncode == 0 and OK_LINES <= lines:
    finish("verified_local", "four behavior cases passed", output, result.returncode)
if result.returncode == 1 and any(line.startswith("FAIL ") for line in lines):
    finish("failed", "behavior differs from expected", output, result.returncode)
finish("error", "verifier crashed or missing result protocol", output, result.returncode)

Quy ước output bốn ca là contract nhỏ của lab, không dùng để suy mọi test suite đáng tin. Một verifier cố ý in bốn dòng mà không kiểm gì vẫn lách được; review/mutation test và bảo vệ verifier thuộc môi trường tin cậy là phần còn lại. expected không rỗng cũng chưa chứng minh đặc tả đúng ý người dùng.

python3 probe-harness.py
behavior: failed exit=1 done=false
pass: verified_local exit=0 done=false
missing-test: blocked exit=2 done=false
verifier-error: error exit=3 done=false
empty-test: error exit=3 done=false
missing-expected: blocked exit=2 done=false
resume: verified_local exit=0 done=false
restored inputs; checkpoint requires revalidate

Probe đã khôi phục source lỗi để lần chạy khác lặp được. Checkpoint xanh cuối probe vì thế không còn mô tả source hiện tại. Chạy lại runner phải đỏ:

python3 run.py

Sửa đúng nhánh header rồi tiếp tục. Runner chỉ kiểm/ghi artifact; thao tác sửa là bước work riêng.

python3 - <<'PY'
from pathlib import Path
path = Path("export_csv.py")
source = path.read_text(encoding="utf-8")
old = "        if users:\n            writer.writerow(HEADER)"
assert source.count(old) == 1
path.write_text(source.replace(old, "        writer.writerow(HEADER)"), encoding="utf-8")
PY
python3 run.py

Khi nào thêm, khi nào bỏ thành phần?

Một task nhỏ có thể chỉ cần instructions ngắn, verifier có ý nghĩa và một checkpoint. Chỉ thêm wrapper khi có lỗi lặp lại mà wrapper bắt được. Harness cho agent chạy dài nhấn mạnh artifact bàn giao và kiểm đầu phiên; case này thử được đường lỗi ở runner, chưa đánh giá độ tin cậy của model.

Muốn thử bỏ một component, dùng tập task/ca lỗi đã chốt, cùng model/tool/quyền và điều kiện; so bản có/không component trên nhiều lượt. Đo completion đúng theo criteria, resume đúng, false block, thời gian và chi phí. Giữ critical verifier/quyền thật trong môi trường cô lập khi thử; không tắt bảo vệ production để benchmark. Nếu không có cải thiện quan sát được, đơn giản hóa rồi kiểm lại các ca lỗi. Đây là kế hoạch đo chưa thực hiện ở lab, không có tỷ lệ cải thiện để công bố.

Giới hạn: runner không khóa file, không bảo vệ criteria khỏi agent sửa, không sandbox import Python hay giữ credential; hash không chữ ký. Thư mục lab chỉ có dữ liệu giả. Với hệ thống thật, evidence chứa thông tin riêng phải được lưu và cấp quyền đúng. Không biến hook giả hoặc câu chữ “cấm” thành bằng chứng rằng hành động đã bị chặn ở runtime.

Học tiếp checkpoint và stale evidence, review code AI. Nguồn Anthropic đọc ngày 2026-10-03; ví dụ runner là thiết kế riêng.

Bộ nhớ agent: chọn theo truy vấn và cách cập nhật

Câu hỏi: khi nào một file đủ dùng, và database giúp giảm phần công việc nào?

Cần biết trước: JSON, SQL cơ bản, revision và checkpoint. Lab dùng Python 3.14.4/SQLite 3.50.4 trên macOS arm64, filesystem local; flock là POSIX, chưa thử Windows hoặc network filesystem. Dataset và note đều giả; không gọi LLM, vector search hoặc đo chất lượng câu trả lời. Không dùng benchmark này để chọn SurrealDB hay chứng nhận memory production.

Chốt hợp đồng trước kho lưu

Mỗi record có ID, text, parent ID, revision, note và SHA-256 của bundle nguồn. Các thao tác cần là lookup ID, substring case-sensitive, truy ngược parent, kiểm provenance, và cập nhật note nếu revision chưa đổi. Text search này không phải semantic retrieval/BM25; quan hệ một bước không phải graph reasoning nhiều chặng.

File vẫn biểu diễn được ID và quan hệ, nhưng code phải đọc/kiểm và quản lý xung đột. Database cung cấp query/constraint/transaction; ứng dụng vẫn chịu trách nhiệm provenance, policy và nội dung nguồn. Một query nhanh không khiến sự thật cũ thành mới.

Hai cách lưu cùng record

Lưu các file sau vào thư mục lab trống. sources.json là bundle nguồn; JSON và SQLite là hai snapshot cùng text và note. Rebuild chạy khi đã dừng writer/source editor, giữ note, tăng revision khi nguồn đổi. Không đo concurrent rebuild.

from __future__ import annotations

import fcntl
import hashlib
import json
import os
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import TypedDict, cast


class Record(TypedDict):
    id: int
    text: str
    parent: int | None
    rev: int
    note: str
    sha: str


SOURCE = Path("sources.json")


def digest() -> str:
    return hashlib.sha256(SOURCE.read_bytes()).hexdigest()


def read_json(path: Path) -> list[Record]:
    return cast(list[Record], json.loads(path.read_text()))


def atomic_json(path: Path, rows: list[Record]) -> None:
    temporary = path.with_suffix(".tmp")
    with temporary.open("w") as stream:
        json.dump(rows, stream, ensure_ascii=False)
        stream.flush()
        os.fsync(stream.fileno())
    temporary.replace(path)


def rebuilt(old: list[Record]) -> list[Record]:
    previous = {r["id"]: r for r in old}
    sha = digest()
    rows = read_json(SOURCE)
    for row in rows:
        if prior := previous.get(row["id"]):
            row["note"] = prior["note"]
            row["rev"] = prior["rev"] + int(prior["sha"] != sha)
        row["sha"] = sha
    return rows


class FileStore:
    path = Path("memory.json")

    @contextmanager
    def locked(self) -> Iterator[None]:
        with Path("memory.lock").open("a") as lock:
            fcntl.flock(lock, fcntl.LOCK_EX)
            try:
                yield
            finally:
                fcntl.flock(lock, fcntl.LOCK_UN)

    def all(self) -> list[Record]:
        return read_json(self.path) if self.path.exists() else []

    def get(self, key: int) -> Record:
        return next(r for r in self.all() if r["id"] == key)

    def search(self, term: str) -> list[int]:
        return [r["id"] for r in self.all() if term in r["text"]]

    def children(self, parent: int) -> list[int]:
        return [r["id"] for r in self.all() if r["parent"] == parent]

    def cas(self, key: int, rev: int, note: str) -> bool:
        with self.locked():
            rows = self.all()
            row = next(r for r in rows if r["id"] == key)
            if row["rev"] != rev:
                return False
            row["rev"] += 1
            row["note"] = note
            atomic_json(self.path, rows)
            return True

    def rebuild(self) -> None:
        with self.locked():
            atomic_json(self.path, rebuilt(self.all()))

    def close(self) -> None:
        pass


class SqlStore:
    def __init__(self, path: str = "memory.sqlite") -> None:
        self.db = sqlite3.connect(path, timeout=10, autocommit=True)
        self.db.execute("PRAGMA foreign_keys=ON")
        self.db.execute("PRAGMA synchronous=FULL")
        self.db.execute("""CREATE TABLE IF NOT EXISTS docs (
            id INTEGER PRIMARY KEY, text TEXT NOT NULL,
            parent INTEGER REFERENCES docs(id) DEFERRABLE INITIALLY DEFERRED,
            rev INTEGER NOT NULL CHECK(rev>=0), note TEXT NOT NULL, sha TEXT NOT NULL
        )""")
        self.db.execute("CREATE INDEX IF NOT EXISTS by_parent ON docs(parent)")

    def select(self, sql: str, params: tuple[int | str, ...] = ()) -> list[Record]:
        return [
            Record(id=r[0], text=r[1], parent=r[2], rev=r[3], note=r[4], sha=r[5])
            for r in self.db.execute(sql, params)
        ]

    def all(self) -> list[Record]:
        return self.select("SELECT * FROM docs ORDER BY id")

    def get(self, key: int) -> Record:
        return self.select("SELECT * FROM docs WHERE id=?", (key,))[0]

    def search(self, term: str) -> list[int]:
        return [
            r[0]
            for r in self.db.execute(
                "SELECT id FROM docs WHERE instr(text,?)>0 ORDER BY id", (term,)
            )
        ]

    def children(self, parent: int) -> list[int]:
        return [
            r[0]
            for r in self.db.execute(
                "SELECT id FROM docs WHERE parent=? ORDER BY id", (parent,)
            )
        ]

    def cas(self, key: int, rev: int, note: str) -> bool:
        changed = self.db.execute(
            "UPDATE docs SET note=?,rev=rev+1 WHERE id=? AND rev=?", (note, key, rev)
        )
        return changed.rowcount == 1

    def rebuild(self) -> None:
        self.db.execute("BEGIN IMMEDIATE")
        try:
            rows = rebuilt(self.all())
            self.db.execute("DELETE FROM docs")
            self.db.executemany(
                "INSERT INTO docs VALUES(?,?,?,?,?,?)",
                [
                    (r["id"], r["text"], r["parent"], r["rev"], r["note"], r["sha"])
                    for r in rows
                ],
            )
            self.db.execute("COMMIT")
        except Exception:
            self.db.execute("ROLLBACK")
            raise

    def close(self) -> None:
        self.db.close()


Store = FileStore | SqlStore


def fresh(store: Store, key: int) -> Record:
    row = store.get(key)
    if row["sha"] != digest():
        raise ValueError("source changed; rebuild required")
    return row

File dùng lock riêng không bị thay inode khi replace; mọi writer phải hợp tác với lock và CAS. Reader nhận một snapshot nguyên file nhờ replace cùng filesystem. fsync file chưa fsync directory nên chưa chứng minh power-loss durability. TypedDict cast chỉ dùng với JSON tin cậy do lab sinh; importer production cần validate schema. flock.

SQLite chỉ có một writer tại một thời điểm; conditional UPDATE kiểm revision trong cùng statement. autocommit=True làm statement CAS commit riêng; rebuild dùng BEGIN/COMMIT rõ ràng. Busy timeout không phải retry nghiệp vụ. FK/parent index không tạo semantic search. sqlite3.

Đo query và kiểm các lỗi làm memory sai

from __future__ import annotations

import json
import platform
import select
import sqlite3
import subprocess
import sys
from contextlib import closing
from datetime import UTC, datetime
from pathlib import Path
from time import perf_counter

from stores import (
    SOURCE,
    FileStore,
    Record,
    SqlStore,
    Store,
    atomic_json,
    digest,
    fresh,
)


def queries(store: Store) -> list[object]:
    return [
        *[store.get(i) for i in range(0, 1000, 10)],
        *[store.search(f"group{g} ") for g in range(5)],
        *[store.children(i) for i in range(0, 100, 10)],
    ]


def race(mode: str, store: Store) -> None:
    clients: list[subprocess.Popen[str]] = []
    try:
        for _ in range(2):
            clients.append(
                subprocess.Popen(
                    [sys.executable, "writer.py", mode],
                    stdin=subprocess.PIPE,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    text=True,
                )
            )
        for client in clients:
            assert client.stdout is not None
            assert select.select([client.stdout], [], [], 10)[0], "writer not ready"
            assert client.stdout.readline().strip() == "ready rev=0"
        for client in clients:
            assert client.stdin is not None
            client.stdin.write("go\n")
            client.stdin.flush()
        outputs = []
        for client in clients:
            out, err = client.communicate(timeout=15)
            assert client.returncode == 0 and not err, (out, err)
            outputs.append(out.strip())
        assert sorted(outputs) == ["applied", "stale"]
        assert store.get(0)["rev"] == 1 and store.get(0)["note"] == "reviewed"
        assert not store.cas(0, 0, "lost update")
        print(mode, "race applied/stale; rev=1")
    finally:
        for client in clients:
            if client.poll() is None:
                client.kill()
            client.communicate(timeout=5)


def main() -> None:
    assert sys.version_info[:3] == (3, 14, 4)
    assert sqlite3.sqlite_version == "3.50.4"
    rows = [
        Record(
            id=i,
            text=f"group{i % 5} decision {i} " + "context " * 20,
            parent=i - 1 if i else None,
            rev=0,
            note="",
            sha="",
        )
        for i in range(1000)
    ]
    atomic_json(SOURCE, rows)
    source_bytes = SOURCE.stat().st_size
    source_sha = digest()
    file = FileStore()
    sql = SqlStore()
    try:
        for store in (file, sql):
            store.rebuild()
        assert file.all() == sql.all()
        reference = queries(file)
        assert queries(sql) == reference
        samples = []
        for repeat in range(1, 4):
            order: list[tuple[str, Store]] = [("json", file), ("sqlite", sql)]
            if repeat % 2 == 0:
                order.reverse()
            for mode, store in order:
                assert fresh(store, 0)["sha"] == digest()
                start = perf_counter()
                result = queries(store)
                elapsed = perf_counter() - start
                assert result == reference
                samples.append({"mode": mode, "repeat": repeat, "seconds": elapsed})
        for mode, store in order:
            race(mode, store)
        rows[0]["text"] = "updated source"
        atomic_json(SOURCE, rows)
        for store in (file, sql):
            try:
                fresh(store, 0)
                raise AssertionError("accepted stale source")
            except ValueError as error:
                assert "source changed" in str(error)
            store.rebuild()
            updated = fresh(store, 0)
            assert updated["text"] == "updated source" and updated["rev"] == 2
            assert updated["note"] == "reviewed"
            assert 0 not in store.search("group0 ")
            assert store.children(0) == [1]
        assert file.all() == sql.all()
        atomic_json(Path("export.json"), file.all())
        with closing(sqlite3.connect("backup.sqlite", autocommit=True)) as backup:
            sql.db.backup(backup)
        restored = SqlStore("backup.sqlite")
        try:
            assert restored.all() == file.all()
            file.path.unlink()
            Path("export.json").replace(file.path)
            assert fresh(file, 0) == fresh(restored, 0)
        finally:
            restored.close()
        report = {
            "checked_at": datetime.now(UTC).isoformat(),
            "python": platform.python_version(),
            "sqlite": sqlite3.sqlite_version,
            "platform": platform.platform(),
            "source_bytes": source_bytes,
            "source_sha": source_sha,
            "rows": 1000,
            "scope": "100ID/5substring/10parent; warm, connections/hash before timer",
            "samples": samples,
        }
        Path("memory-results.json").write_text(json.dumps(report, indent=2) + "\n")
        print("MEMORY_RESULT", json.dumps(report))
        print("same queries; stale source rejected; rebuild notes kept; restore equal")
    finally:
        file.close()
        sql.close()


if __name__ == "__main__":
    main()
import sys

from stores import FileStore, SqlStore, Store

store: Store = FileStore() if sys.argv[1] == "json" else SqlStore()
try:
    revision = store.get(0)["rev"]
    print(f"ready rev={revision}", flush=True)
    assert input() == "go"
    print("applied" if store.cas(0, revision, "reviewed") else "stale", flush=True)
finally:
    store.close()
python3.14 exercise.py

Thư mục lab thuộc bạn; xóa cả thư mục sau khi đóng process. Verifier tự tạo thư mục tạm và xóa sau chạy; script đóng connections và child process trong finally.

Đọc kết quả đúng boundary

Lần đo 2026-10-03 12:36:42 UTC, bundle nguồn 252671 byte/1000 record; giây cho toàn bộ batch query, không latency từng request:

StoreLượtGiây
json10.064589
sqlite10.001089
sqlite20.001057
json20.065312
json30.065446
sqlite30.001130

SQLite thấp hơn ở ba lượt của thiết kế này. Chưa đo memory footprint, startup, concurrent write throughput hoặc giá server; không tính tỷ lệ phổ quát cho agent memory.

Mỗi lượt gồm 100 lookup ID, 5 substring search và 10 parent query, cùng kết quả. Connection/schema/source hash trước timer; JSON đọc/parse toàn file mỗi query, SQLite giữ connection và query theo index ID/parent; substring cả hai đều scan. Warm-up và cache OS không eviction, ba lượt xoay thứ tự; không có cold/startup/write throughput trong bảng. File-per-ID, cache JSON trong RAM hoặc FTS là thiết kế khác cần đo lại, không bị benchmark này loại.

Ma trận quyết định

Nhu cầuFile đủ khiCân nhắc database khi
Đọc/tra IDÍt record, một writer, parse/cache đủ rẻ, người review diffLookup thường xuyên, query nhiều field/constraint cần phục vụ riêng
Tìm chữ/quan hệScan và ID references đủ; index có thể rebuildIndex/join/FTS giúp workload đã đo; vẫn cần contract search
Nhiều writerTất cả hợp tác lock/CAS, file nhỏ, serialize chấp nhận đượcTransaction nhiều record, uniqueness/FK và query đồng thời; SQLite vẫn một writer
ProvenanceFile nguồn + SHA/revision, lỗi stale có hành độngCũng phải giữ nguồn/hash/version và rebuild; DB không tự sửa stale
Recovery/portabilitySnapshot/export/diff rõ, biết giới hạn crash durabilityBackup API/migration/restore đã tập; file DB có version/schema riêng

Lab stale source dùng SHA toàn bundle: đổi một record làm cả snapshot cần refresh. CAS bảo vệ note khỏi lost update, không chứng minh nguồn đáng tin. Editor ngoài lock có thể phá file protocol; rebuild dừng writer trong lab, chưa xử lý race nguồn đổi giữa hash/read hoặc xóa record đang được tham chiếu. Power-loss/crash injection, multi-host, authz/tenant, vector relevance và chi phí vận hành lớn đều chưa kiểm.

Đo workload của bạn trước khi thêm server. Files có thể giữ tài liệu cần review, database giữ state/query cần transaction; nếu dùng cả hai, chỉ rõ bản nào là nguồn cho từng field và index nào có thể rebuild. Học tiếp: context có nguồn, idempotent batch, đọc benchmark.

Dựng harness để AI agent làm việc đáng tin

Câu hỏi bài này trả lời: cùng một model mà lúc làm tốt lúc làm hỏng, thì phần nào của môi trường quyết định độ tin cậy, nên dựng theo thứ tự nào, và làm sao biết nó có tác dụng thật?

Cần biết trước: đã dùng một AI coding agent (CLI hoặc IDE) và biết git cơ bản. Muốn phân biệt prompt, skill, quy tắc dự án và tool thì xem AI Agent Skills. Hai lab cần Python 3 và Git; không cài thêm thư viện.

Khi agent làm hỏng việc, phản xạ thường là viết lại prompt. Nhưng những lỗi lặp lại đúng một kiểu thì prompt khó sửa tận gốc. Bài của Anthropic về agent chạy dài ghi lại các kiểu hỏng như vậy: cố làm quá nhiều một lúc rồi hết ngữ cảnh giữa chừng, thấy đã có tiến độ nên tuyên bố xong, để lại môi trường hỏng mà không ghi chú, đánh dấu tính năng xong khi chưa kiểm đầu-cuối. Bài của OpenAI về harness engineering kể vấn đề cùng gốc từ phía người làm: tiến độ ban đầu chậm không phải vì model kém mà vì “the environment was underspecified” (môi trường thiếu đặc tả), nên việc chính của kỹ sư chuyển sang làm cho agent có thể làm việc được.

Bài này gọi phần ngoài model là harness: chỉ dẫn, tri thức, quy trình, chốt chặn, trạng thái và bằng chứng. Các nguồn không dùng từ này hoàn toàn giống nhau (Anthropic gọi chính SDK chạy agent là harness, còn OpenAI dùng “harness engineering” cho việc thiết kế môi trường và vòng phản hồi), nên đây là nghĩa riêng của bài.

Ba triệu chứng, ba cơ chế

Ba triệu chứng dưới đây là ví dụ điển hình, không phải thống kê. Mỗi cái có một cơ chế xử lý và một phép thử để biết cơ chế có tác dụng.

Triệu chứngNguyên nhân gốcCơ chếPhép thử
Mỗi công cụ, mỗi người một bộ chỉ dẫn; sửa chỗ này thì lệch chỗ kiaCấu hình nằm ở nhiều nơi và được sửa tayMột nguồn chuẩn; cấu hình của từng công cụ được sinh ra từ đóSửa nguồn, chạy lại bước sinh: chỉ file sinh thay đổi
Agent làm nhiều hơn được giao, sửa cả chỗ không liên quanKhông có biên: được ghi ở đâu, được đụng file nàoPhạm vi bằng cấu trúc: mỗi task một vùng ghi riêng, vùng gốc chỉ đọcThử ghi ra ngoài vùng: phải bị chặn
Không ai biết cái gì đã được duyệt hay kiểm, chỉ có lời agent kểCổng chỉ nằm trong câu chữ, bằng chứng là lời tự khaiCổng người ở điểm không ủy quyền được, cổng máy ở điểm kiểm được; mỗi pha để lại vật chứngNgười khác đọc hồ sơ task là dựng lại được: kế hoạch, diff, kết quả kiểm

Các phần sau đi từ cấu tạo (năm lớp) tới cách dựng và cách đo.

Năm lớp của harness

LớpTrả lời câu hỏiCơ chế điển hìnhNếu thiếu
Chỉ dẫnLàm gì, cấm gì, đọc gì trước?Tệp chỉ dẫn ngắn theo định dạng AGENTS.md, luật gắn theo loại fileAgent tự đoán, hoặc bị nhồi quá nhiều chỉ dẫn
Tri thức theo nhu cầuCần kiến thức X thì tìm ở đâu?Tài liệu có cấu trúc và có version trong repo; giao thức như MCP để agent tự lấyNhồi hết vào prompt gây nhiễu; thứ agent không truy cập được thì như không tồn tại
Quy trình đóng góiViệc loại này làm thế nào?Skill theo từng loại việcMỗi lần giao việc lại giải thích từ đầu
Chốt chặn tất địnhĐiều gì không được xảy ra?Hook, linter, structural test, quyền fileLuật viết bằng chữ chỉ là lời đề nghị
Trạng thái và bằng chứngĐã làm gì, còn gì, bằng chứng đâu?Tệp tiến độ, kế hoạch và kết quả kiểm nằm trong repoPhiên sau không biết bắt đầu từ đâu; chỉ còn lời kể

Chỉ dẫn. AGENTS.md được giới thiệu là “README cho agent”: một chỗ cố định, dự đoán được để đưa chỉ dẫn. Với monorepo có thể đặt nhiều file lồng nhau, file gần nhất với file đang sửa được ưu tiên (theo trang agents.md). OpenAI thử “một AGENTS.md khổng lồ” và nêu bốn lý do nó hỏng: chiếm chỗ của task, quá nhiều chỉ dẫn thành không chỉ dẫn, mục nát ngay, khó kiểm bằng máy. Họ chuyển sang coi AGENTS.md là mục lục khoảng 100 dòng, trỏ tới nguồn sâu hơn.

Tri thức. MCP là chuẩn mở để nối ứng dụng AI với hệ thống bên ngoài như nguồn dữ liệu, tool và workflow. Bài của OpenAI nói thêm: điều agent không truy cập được trong ngữ cảnh khi chạy thì coi như không tồn tại. Thảo luận trong chat hay trong đầu người không phải tri thức của hệ thống; thứ agent thấy là tài liệu nằm trong repo và có version.

Luật và năng lực là hai thứ khác nhau. Luật nói điều không được làm, skill nói cách làm, còn quyền thực thi do môi trường cấp, như đã nêu ở trang Skills.

Câu chữ và cơ chế. Chỉ dẫn viết bằng chữ chỉ có hiệu lực khi agent chịu theo. Chỉ dẫn viết thành cơ chế (hook, linter, quyền file) có hiệu lực cả khi agent không theo. OpenAI mô tả cách làm này: ràng buộc kiến trúc được kiểm bằng linter tự viết và structural test, và thông báo lỗi được viết để chèn luôn hướng dẫn sửa vào ngữ cảnh của agent.

Một nguồn chuẩn cho nhiều công cụ

Team dùng nhiều công cụ agent sẽ gặp cảnh mỗi công cụ đọc cấu hình ở một chỗ riêng. Chép tay giữa các chỗ thì sớm muộn chúng lệch nhau, và không ai biết bản nào đang đúng.

  • Giữ một thư mục nguồn chuẩn cho chỉ dẫn, luật và danh sách MCP server.
  • Một bước sinh ghi cấu hình riêng cho từng công cụ. File sinh là output build: không sửa tay.
  • Thêm một công cụ mới là thêm một bộ chuyển đổi (adapter), không sửa lõi.
  • Kiểm bằng máy: chạy lại bước sinh trong CI và thất bại nếu có diff.
  • Đồng bộ một chiều, từ nguồn ra file sinh. Nếu dùng công cụ cài đặt tự sinh cấu hình, thử trước xem chạy lại nó có ghi đè phần bạn đã sửa không; nếu có, tách phần tùy biến khỏi phần sinh.

Nguồn công khai chỉ đỡ phần nền: agents.md cho thấy nhiều công cụ đọc được cùng một định dạng, và MCP được giới thiệu với tinh thần “build once and integrate everywhere”. Cách sinh cấu hình và kiểm bằng diff là đề xuất của bài, chưa có nguồn.

Chốt chặn thật và chốt chặn giả

Hook là mã chạy ở một sự kiện trong vòng đời của agent, ví dụ ngay trước khi nó gọi một tool. Hook chỉ ngăn được việc khi nó có quyền ngăn và thật sự ngăn. Với Claude Code, tài liệu hook nêu rõ hợp đồng, và nó khác trực giác “khác 0 là lỗi, lỗi thì dừng”:

Hook kết thúc bằngVới PreToolUse
exit 2Chặn lời gọi tool; stderr được đưa lại cho Claude làm phản hồi
exit 0Không phản đối. Đây không phải phê duyệt: luồng cấp quyền thường vẫn chạy
exit khác, kể cả 1Lỗi không chặn nếu stdout không có JSON hợp lệ: tool vẫn chạy, dù 1 là mã lỗi quen thuộc của Unix
không khởi động được (sai đường dẫn, thiếu quyền thực thi)Cũng không chặn: tài liệu cảnh báo gate có thể bị tắt âm thầm

Hook cũng có thể trả JSON để quyết định (ví dụ trường permissionDecision); bài này chỉ dùng exit code cho gọn. Chọn đúng sự kiện cũng quan trọng: chặn việc chưa xảy ra phải ở PreToolUse, còn PostToolUse chỉ báo lại vì tool đã chạy xong.

Hệ quả: một gate có thể tắt mà không ai biết cho tới lúc cần nó. Cách duy nhất biết gate chặn thật là thử vi phạm: đưa vào đúng loại lời gọi mà gate phải chặn rồi xem kết quả.

Còn một bẫy nữa, riêng với hook theo đường dẫn. tool_input.file_path của Write, Edit và Read luôn là đường dẫn tuyệt đối; Claude Code mở rộng ~ và đường dẫn tương đối trước khi gọi hook. Trên Windows nó dùng dấu \, nên so sánh bằng / không bao giờ khớp và tool vẫn chạy như thể hook không có gì để chặn. Tài liệu khuyên chuẩn hóa dấu phân cách rồi khớp một đoạn đường dẫn như /baseline/ thay vì neo bằng ^.

Lab: ba hook cho cùng một luật

Luật: không ghi vào thư mục baseline. Tạo thư mục trống, ví dụ hook-lab, và bốn file sau. Hook giả lập theo hợp đồng ở trên (đọc JSON từ stdin, tool_input.file_path tuyệt đối, exit 2 để chặn) nhưng chạy bằng Python thường, không nằm trong ứng dụng agent nào. Trên Windows dùng python thay cho python3.

guard.py chuẩn hóa dấu phân cách rồi khớp đoạn /baseline/. Lý do chặn ghi ở stderr để agent đọc được:

import json
import sys

event = json.load(sys.stdin)
path = event["tool_input"]["file_path"].replace("\\", "/")
if "/baseline/" in path:
    sys.stderr.write("Chặn: baseline chỉ đọc, hãy ghi trong thư mục của task.\n")
    sys.exit(2)
sys.exit(0)

naive.py giống hệt nhưng bỏ bước chuẩn hóa. Nó chặn đường dẫn POSIX và bỏ lọt đường dẫn Windows:

import json
import sys

event = json.load(sys.stdin)
path = event["tool_input"]["file_path"]
if "/baseline/" in path:
    sys.stderr.write("Chặn: baseline chỉ đọc, hãy ghi trong thư mục của task.\n")
    sys.exit(2)
sys.exit(0)

observer.py chỉ ghi vết vào audit.log rồi luôn thoát 0:

import json
import sys

event = json.load(sys.stdin)
with open("audit.log", "a", encoding="utf-8") as log:
    log.write(event["tool_input"]["file_path"] + "\n")
sys.exit(0)

assert_blocks.py là phép thử. Nó đưa vào hai lời gọi vi phạm (đường dẫn POSIX và đường dẫn Windows) cùng một lời gọi hợp lệ, rồi so exit code với kỳ vọng:

import json
import subprocess
import sys

hook = sys.argv[1]
CALLS = {
    "ghi baseline (đường dẫn POSIX)": ("/work/baseline/app.txt", 2),
    "ghi baseline (đường dẫn Windows)": ("C:\\work\\baseline\\app.txt", 2),
    "ghi vùng task": ("/work/task/app.txt", 0),
}


def exit_code(path):
    event = {
        "hook_event_name": "PreToolUse",
        "tool_name": "Write",
        "tool_input": {"file_path": path, "content": "x"},
    }
    done = subprocess.run(
        [sys.executable, hook],
        input=json.dumps(event),
        capture_output=True,
        text=True,
        check=False,
    )
    return done.returncode


results = {name: (exit_code(path), want) for name, (path, want) in CALLS.items()}
wrong = [name for name, (got, want) in results.items() if got != want]
if wrong:
    print(f"{hook}: SAI ở {len(wrong)} lời gọi: " + "; ".join(wrong))
    sys.exit(1)
print(f"{hook}: đạt cả {len(CALLS)} lời gọi")

Thông điệp agent nhận được khi bị chặn, và exit code thật:

echo '{"tool_input": {"file_path": "/work/baseline/app.txt"}}' | python3 guard.py || echo "exit $?"
Chặn: baseline chỉ đọc, hãy ghi trong thư mục của task.
exit 2

Chạy phép thử trên cả ba hook. Hook đúng thì đạt:

python3 assert_blocks.py guard.py
guard.py: đạt cả 3 lời gọi

observer.py thất bại ở cả hai lời gọi vi phạm, vì ghi vết không phải là chặn:

python3 assert_blocks.py observer.py
observer.py: SAI ở 2 lời gọi: ghi baseline (đường dẫn POSIX); ghi baseline (đường dẫn Windows)

naive.py thất bại ở đúng một lời gọi, cái mà một phép thử chỉ có đường dẫn POSIX sẽ không thấy:

python3 assert_blocks.py naive.py
naive.py: SAI ở 1 lời gọi: ghi baseline (đường dẫn Windows)

Đọc kết quả:

  • observer.py có hook, có log, có exit code, và không chặn gì. Nếu chỉ nhìn cấu hình, nó giống một gate.
  • naive.py chặn đúng trong ca người viết nghĩ tới và hở trong ca còn lại. Phép thử chỉ có một lời gọi vi phạm sẽ cho nó qua; đó là lý do assert_blocks.py có hai lời gọi vi phạm. Phép thử cũng cần được thử bằng bản lỗi như thế này.
  • Suy luận của bài, chưa có nguồn: một hook chỉ thấy những sự kiện nó được đăng ký cho. Nếu nó chỉ gắn với tool ghi file thì lệnh shell ghi cùng file có thể không đi qua nó; hãy thử đúng loại lời gọi đó thay vì giả định. Lớp độc lập bên dưới là quyền của hệ thống file: thư mục chỉ đọc thì ghi thất bại dù hook bị bỏ qua.

Cổng người duyệt

Máy chạy được phép kiểm nhưng không quyết được cái gì đáng kiểm. Nếu agent vừa viết tiêu chí vừa viết phép thử cho nó, “đạt” có thể chỉ có nghĩa là hai thứ cùng sai giống nhau. Vì vậy chỗ cần người là điểm quyết định tiêu chí và kế hoạch, không phải từng dòng code.

Bài Building effective agents mô tả agent có thể dừng chờ phản hồi của người ở các checkpoint hoặc khi gặp vướng. Ba mức triển khai cổng, theo cách phân loại của bài:

  1. Agent dừng và hỏi trong hội thoại. Rẻ nhất, nhưng dựa vào việc agent tuân theo.
  2. Dấu duyệt nằm ở nơi hook cấm agent ghi, nên agent không tự duyệt được. Đây là ràng buộc bằng cấu trúc.
  3. Người review ở bước merge. Độc lập với agent, nhưng chậm.

Đừng nhầm ràng buộc bằng câu chữ với ràng buộc bằng cơ chế. Bài của Anthropic về agent chạy dài yêu cầu agent chỉ được đổi trường passes trong danh sách tính năng và nhắc mạnh rằng xóa hay sửa test là không chấp nhận được; họ cũng ghi lý do chọn JSON: model ít sửa nhầm file JSON hơn Markdown. Đó là giảm xác suất sửa nhầm chứ chưa phải chặn.

Độ chặt của cổng phải khớp với tốc độ và rủi ro, không có mức đúng chung. OpenAI vận hành với rất ít cổng merge chặn vì throughput của agent vượt xa sự chú ý của người, và chính họ nói cách đó “would be irresponsible in a low-throughput environment” (sẽ thiếu trách nhiệm ở nơi throughput thấp). Cách viết tiêu chí để máy kiểm được nằm ở bài Tiêu chí hoàn tất có thể kiểm chứng.

Vòng đời task: mỗi pha một vật chứng

Bài của Anthropic mở đầu bằng hình ảnh một dự án có kỹ sư làm theo ca, mỗi ca mới đến không nhớ gì về ca trước. Agent chạy qua nhiều cửa sổ ngữ cảnh ở đúng tình trạng đó. Thứ bắc cầu giữa các phiên là vật chứng đọc lại được, không phải trí nhớ.

PhaCâu hỏiVật chứng đọc lại đượcAi hoặc cái gì quyết
Chốt phạm viLàm gì, không làm gì, giả định nào?Ghi chú phạm vi và giả địnhNgười xác nhận
Lập kế hoạchChia lát nào, rủi ro gì, kiểm bằng gì?Kế hoạch, rủi ro, tiêu chí hoàn tấtNgười duyệt (cổng người)
Làm từng lát nhỏLát này đạt chưa?Commit nhỏ, nhật ký tiến độMáy: test, lint, kiểm kiểu
ReviewCòn lỗi nào test không bắt được?Kết quả review của người hoặc agent khác tác giảReviewer khác tác giả
Nghiệm thuMọi tiêu chí đạt chưa, bằng chứng đâu?Tệp bằng chứng: lệnh, exit code, output, môi trườngMáy chạy, người đọc
Bàn giaoNgười sau cần biết gì?Tóm tắt, tài liệu đã cập nhật, trạng thái sạchNgười

Sáu pha trên là cách chia của bài, không phải chuẩn; tên pha không quan trọng. Ba điều mới quan trọng: mỗi pha để lại vật chứng đọc lại được; chỉ có một hai điểm cần người; mọi chỗ còn lại kiểm bằng máy.

Đầu mỗi phiên, agent đọc vật chứng thay vì được hỏi là nó nhớ gì. Bài của Anthropic mô tả trình tự khởi động: xác định thư mục làm việc, đọc lịch sử git và tệp tiến độ, rồi đọc danh sách tính năng để chọn việc ưu tiên cao nhất chưa xong. Cuối phiên, họ yêu cầu để lại “clean state”: code ở mức có thể merge vào nhánh chính, không còn lỗi lớn, có ghi chú đủ để người sau bắt đầu mà không phải dọn trước.

Cô lập vùng ghi bằng git worktree

Chạy nhiều agent trên cùng một thư mục thì chúng đạp lên nhau. git worktree cho mỗi agent một thư mục làm việc riêng trên cùng một repository: theo tài liệu git-worktree, một repository có thể có nhiều working tree, cho phép checkout nhiều nhánh cùng lúc. Bài này gọi một worktree cộng với nhánh của nó và tài nguyên chạy riêng (cổng mạng, schema database, thư mục dữ liệu) là một vùng làm việc. OpenAI mô tả ứng dụng của họ khởi động được theo từng git worktree, cùng một stack quan sát tạm thời cho từng worktree, bỏ đi khi task xong.

Worktree cô lập file, không cô lập mọi thứ. Theo tài liệu git, worktree liên kết chia sẻ mọi thứ trừ các file riêng như HEAD và index: ref dưới refs/ dùng chung, và config dùng chung theo mặc định. Hệ quả: một vùng đọc được commit của vùng khác; xóa nhánh hay đổi config ở một nơi tác động cả repository. Muốn config riêng phải bật extensions.worktreeConfig.

Hai quy ước giúp vùng làm việc hữu ích:

  • Một nhánh một vùng. git worktree add từ chối checkout một nhánh đang nằm ở worktree khác, trừ khi dùng --force. Đây là chốt chặn miễn phí: hai agent không thể cùng giữ một nhánh.
  • Baseline chỉ đọc. Giữ worktree chính làm mốc, không agent nào ghi vào. Bằng chứng của một vùng là git diff giữa nhánh của nó và nhánh baseline.

Lab: hai worktree trên một repository

Chạy từ thư mục trống. repo giữ baseline; wt-a và wt-b là hai worktree, mỗi cái một nhánh. Danh tính git đặt một lần ở repo và cả hai worktree dùng chung, đúng như “config dùng chung theo mặc định”:

git --version
git init -q repo
git -C repo symbolic-ref HEAD refs/heads/main
git -C repo config user.name Lab
git -C repo config user.email lab@example.org
echo "v1" > repo/app.txt
git -C repo add app.txt
git -C repo commit -q -m "baseline"
git -C repo worktree add -q ../wt-a -b feature/a
git -C repo worktree add -q ../wt-b -b feature/b
git -C repo worktree list --porcelain | grep '^branch'
branch refs/heads/main
branch refs/heads/feature/a
branch refs/heads/feature/b

wt-a thêm một commit. wt-b không đổi gì, nhưng đọc được commit của wt-a vì nhánh và object dùng chung:

echo "change from a" >> wt-a/app.txt
git -C wt-a commit -q -am "change from a"
echo "--- wt-a so với main"
git -C wt-a diff --stat main
echo "--- wt-b so với main"
git -C wt-b diff --stat main
echo "--- wt-b đọc được commit của wt-a"
git -C wt-b log --format=%s main..feature/a
--- wt-a so với main
app.txt | 1 +
1 file changed, 1 insertion(+)
--- wt-b so với main
--- wt-b đọc được commit của wt-a
change from a

Một nhánh chỉ ở một worktree. Thêm worktree thứ ba trên nhánh feature/a bị từ chối; thông báo lỗi khác nhau giữa các phiên bản Git nên lab chỉ kiểm exit code:

git -C repo worktree add ../wt-c feature/a

Dọn dẹp. remove chỉ xóa worktree sạch và nhánh vẫn còn; prune dọn metadata của worktree đã bị xóa tay:

git -C repo worktree remove ../wt-a
git -C repo worktree list --porcelain | grep '^branch'
echo "--- nhánh sau khi xóa wt-a"
git -C repo branch --list feature/a
branch refs/heads/main
branch refs/heads/feature/b
--- nhánh sau khi xóa wt-a
feature/a

Các vùng làm việc vẫn dễ đạp lên nhau ở những thứ worktree không cô lập: cổng mạng, database dùng chung, cache, container. Mỗi vùng cần cổng và namespace riêng; nếu không, hai vùng khác thư mục vẫn tranh cùng một tài nguyên.

Đo harness bằng đối chứng

Cảm giác “có harness thì tốt hơn” không phải bằng chứng. Phép đo gọn nhất là thí nghiệm đối chứng: hai worktree xuất phát từ cùng một commit, cùng model, cùng đề, cùng ngân sách, chỉ khác harness. Bên A chỉ có prompt, bên B có harness. Kết quả chấm bằng đáp án chuẩn (ground truth) mà agent không được thấy, và chấm cả diff lẫn quá trình: lệnh nào đã chạy, test thật hay chỉ tuyên bố.

Rủi ro của thí nghiệmHậu quảCách giảm
Chỉ chạy một lầnModel có tính ngẫu nhiên nên một lần chỉ là giai thoạiChạy lặp, báo phân bố thay vì một con số
Đáp án chuẩn lọt vào worktree (trong repo hoặc lịch sử git)Agent chép đáp ánCắt lịch sử hoặc ẩn nhánh chứa đáp án trước khi dựng worktree
Harness được chỉnh theo đúng bộ đề đoThước đo thành mục tiêu và hết đáng tinGiữ một bộ đề chưa dùng để chỉnh; thay đề định kỳ
Đổi nhiều thành phần cùng lúcKhông biết thành phần nào có tác dụngMỗi lần đổi một thành phần
Chấm bằng lời agent kểTự khai không phải bằng chứngChấm bằng diff và kết quả test chạy lại

Cũng dùng chính phép đo này để bớt. Anthropic khuyên tìm giải pháp đơn giản nhất có thể và chỉ tăng độ phức tạp khi cần (Building effective agents, tháng 12/2024; chính bài ghi chú rằng nhiều chi tiết công cụ trong đó đã đổi, nên chỉ dùng ý nguyên lý). Suy luận của bài này, chưa có nguồn: mỗi thành phần harness mã hóa một giả định về điều model chưa tự làm được, và giả định đó cũ dần khi model tiến bộ. Tắt thử một thành phần, đo lại, nếu kết quả không tệ đi thì gỡ hẳn.

Bài này mô tả phương pháp, không nêu kết quả đối chứng nào.

Dựng theo thứ tự nào

Thứ tự dưới đây là đề xuất của bài, rút từ lập luận phụ thuộc giữa các bước chứ chưa đo. Mỗi bước dựa vào bước trước: không có phép chạy và kiểm được thì chốt chặn và đối chứng không có gì để đo; không có cô lập thì đối chứng nhiễu.

  1. Làm cho dự án chạy và kiểm được bằng một lệnh. Vòng phản hồi của agent chính là vòng kiểm của bạn. Anthropic cho agent chạy một kịch bản khởi động rồi một phép thử đầu-cuối cơ bản trước khi làm tính năng mới; OpenAI làm ứng dụng khởi động được theo từng worktree.
  2. Viết chỉ dẫn ngắn như mục lục, trỏ tới nguồn sâu hơn.
  3. Chọn rủi ro lớn nhất, dựng một chốt chặn thật cho nó và thử vi phạm.
  4. Thêm tiến độ và checkpoint để task sống qua nhiều phiên.
  5. Cô lập vùng ghi khi bắt đầu chạy song song.
  6. Đo bằng đối chứng và bớt thành phần không có tác dụng.

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

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

  • Lab giả lập: hook chạy bằng Python thường, chưa chạy trong ứng dụng agent thật. Hợp đồng exit code theo Claude Code, đọc ngày 2026-10-02; công cụ khác có hợp đồng riêng, cần đọc tài liệu từng công cụ.
  • Các bài của Anthropic và OpenAI mô tả tình huống riêng của họ. OpenAI ghi rằng hành vi làm tính năng từ đầu đến cuối “depends heavily on the specific structure and tooling of this repository and should not be assumed to generalize without similar investment”; bài của Anthropic tối ưu cho web app full-stack và nói chưa rõ một agent tổng quát hay nhiều agent chuyên biệt tốt hơn.
  • Bài không đo hiệu quả của harness bằng số.
  • Lab chạy ngày 2026-10-02 trên macOS 27.0.1 (arm64) với Python 3.14.8 và Git 2.54.0 (Apple Git-157). Linux và Windows chưa thử, các lệnh viết cho bash. Riêng Windows, bẫy dấu phân cách trong bài lấy từ tài liệu; assert_blocks.py chỉ giả lập đường dẫn kiểu Windows bằng một chuỗi chứ chưa chạy trên máy Windows.

Lỗi thường gặp

LỗiHậu quảCách tránh
Tệp chỉ dẫn dài như bách khoaChiếm chỗ của task, thành không chỉ dẫn, mục nátGiữ như mục lục, trỏ tới nguồn sâu hơn
Hook chỉ ghi log mà tưởng là chặnVi phạm vẫn xảy ra, chỉ có dấu vếtPhép thử vi phạm cho từng hook chặn
Hook thoát exit 1 hoặc không khởi động đượcGate tắt âm thầmDùng exit 2; chạy phép thử ngay sau khi cấu hình
So đường dẫn bằng / trên WindowsBỏ lọt mọi lời gọiChuẩn hóa dấu phân cách; thử cả hai kiểu đường dẫn
Agent tự duyệt kế hoạch của chính nóCổng chỉ còn là hình thứcĐể dấu duyệt ở nơi agent không ghi được
Sửa tay file sinhLần sinh sau ghi đè, hoặc hai nơi lệch nhauSửa nguồn; CI sinh lại và so diff
Các worktree dùng chung cổng hoặc databaseVùng khác thư mục vẫn đạp lên nhauCổng và namespace riêng cho từng vùng
Đo một lần rồi kết luậnChỉ là giai thoạiChạy lặp, đổi một thành phần mỗi lần
Giữ thành phần harness thừaChi phí bảo trì mà không đổi lấy gìTắt thử từng thành phần và đo lại

Học tiếp

Nguồn tham khảo

Audit chất lượng vòng đời phần mềm khi có AI tham gia

Câu hỏi bài này trả lời: làm sao biết một dự án có AI tham gia đang ở đâu và chất lượng thật tới đâu, khi bản ghi, mã nguồn và môi trường đang chạy có thể nói những điều khác nhau?

Cần biết trước: đã đọc Viết tiêu chí hoàn tất mà AI có thể kiểm chứng và biết git, test, CI ở mức cơ bản. Phần lab chỉ dùng thư viện chuẩn của Python 3, không cài thêm gì.

Bài của Anthropic về harness cho agent chạy dài mô tả một dạng lệch cụ thể: agent ghi trạng thái từng tính năng vào một tệp tiến độ, rồi một phiên sau thấy đã có tiến độ nên tuyên bố xong. Bài cũng ghi nhận agent hay đánh dấu hoàn thành khi chưa kiểm thử đúng cách. Tệp tiến độ là một lời khai. Audit là việc đặt lời khai cạnh hiện vật và môi trường để xem chúng còn khớp nhau không.

Audit khác test và review ở đâu

ViệcCâu hỏiĐối tượngKết quả
TestHành vi này có chạy đúng không?Một hành viĐạt hoặc không đạt
ReviewThay đổi này có nên vào không?Một thay đổiDuyệt, làm lại hoặc leo thang
AuditĐiều được tuyên bố về dự án có khớp với thực tế không?Cả dự án tại một thời điểmDanh sách chỗ lệch kèm bằng chứng

Test và review vẫn cần. Audit bắt loại lỗi mà chúng không thấy: lỗi nằm giữa các nguồn, như bản ghi nói đã xong trong khi bản đang chạy lại là một bản khác.

Ba nguồn sự thật và quy tắc đối chiếu

NguồnGồmAi tạo raĐiểm yếu
Bản ghiKế hoạch, ticket, bảng tiến độ, nhật kýNgười hoặc agent tự cập nhậtKhông ai chạy nó nên có thể đi sau hoặc đi trước thực tế
Hiện vậtMã, test, tài liệu, cấu hình trong kho mã, kết quả CINgười và công cụChỉ nói về bản đã kiểm, chưa chắc là bản đang chạy
Môi trường vận hànhBản đang phục vụ người dùng, hạ tầng đang chạyQuá trình triển khaiKhó đo; cần cách đo từ bên ngoài, không tin khai báo cấu hình

Mỗi cặp nguồn trả lời một câu hỏi riêng:

  • Bản ghi và hiện vật: những việc ghi là xong có test đạt không, và tiến độ ghi có khớp số đếm được không?
  • Hiện vật và môi trường: bản đã kiểm có phải bản đang chạy không, và bản đang chạy có nằm trong danh sách đã duyệt không?
  • Bản ghi và môi trường: kế hoạch nói đã hay chưa triển khai, còn thực tế đang chạy gì?

Ba quy tắc đi kèm:

  1. Hai nguồn lệch nhau là một phát hiện, chưa phải lỗi của ai.
  2. Khi mâu thuẫn, tin nguồn mà công cụ đo được hơn nguồn do người hoặc agent tự khai.
  3. Sửa ở đúng nguồn. Chỉnh bản ghi cho khớp thực tế là hợp lệ khi thực tế là điều đúng; chỉnh bản ghi để báo cáo trông xanh thì không.

Lab: dựng ba nguồn và để script tìm chỗ lệch

Dự án giả lập có bốn việc. Bản ghi (ledger.json) nói ba việc đã xong và tiến độ 80%. Hiện vật (artifacts.json) là kết quả lần chạy test gần nhất trên bản b42. Môi trường (live.json) cho biết bản đang chạy là b43 và chỉ b42 đã được duyệt. Tạo thư mục trống, ví dụ audit-lab, và các file sau. Dữ liệu là giả lập; script chỉ minh hoạ cách đối chiếu, không phải công cụ audit hoàn chỉnh.

{
  "progress_percent": 80,
  "tasks": {
    "login": "done",
    "export": "done",
    "report": "done",
    "billing": "in_progress"
  }
}
{
  "build": "b42",
  "tests": {
    "login": "pass",
    "export": "fail",
    "report": "pass",
    "billing": "pass"
  }
}
{
  "build": "b43",
  "approved_builds": ["b42"]
}

audit.py đọc cả ba nguồn rồi in từng chỗ lệch và kết thúc với exit code 1 nếu có:

import json
import sys


def load(path):
    with open(path, encoding="utf-8") as handle:
        return json.load(handle)


ledger = load("ledger.json")
artifacts = load("artifacts.json")
live = load("live.json")
findings = []

tasks = ledger["tasks"]
tests = artifacts["tests"]
for name, status in tasks.items():
    result = tests.get(name, "missing")
    if status == "done" and result != "pass":
        findings.append(f"ghi 'done' nhưng test {result}: {name}")
    if status != "done" and result == "pass":
        findings.append(f"test pass nhưng bản ghi '{status}': {name}")

done = sum(1 for status in tasks.values() if status == "done")
counted = round(100 * done / len(tasks))
if counted != ledger["progress_percent"]:
    findings.append(
        f"tiến độ ghi {ledger['progress_percent']}% nhưng đếm được {counted}%"
    )

if live["build"] != artifacts["build"]:
    findings.append(
        f"môi trường chạy {live['build']}, bản đã kiểm là {artifacts['build']}"
    )
if live["build"] not in live["approved_builds"]:
    findings.append(f"bản {live['build']} đang chạy chưa nằm trong danh sách đã duyệt")

for line in findings:
    print("LỆCH:", line)
print(f"tổng: {len(findings)} chỗ lệch")
sys.exit(1 if findings else 0)

Chạy lần đầu. Script kết thúc với exit code 1:

python3 audit.py
LỆCH: ghi 'done' nhưng test fail: export
LỆCH: test pass nhưng bản ghi 'in_progress': billing
LỆCH: tiến độ ghi 80% nhưng đếm được 75%
LỆCH: môi trường chạy b43, bản đã kiểm là b42
LỆCH: bản b43 đang chạy chưa nằm trong danh sách đã duyệt
tổng: 5 chỗ lệch

Năm chỗ lệch thuộc ba loại. Hai dòng đầu là bản ghi so với hiện vật: một việc ghi xong nhưng test fail, một việc test đạt nhưng bản ghi chưa cập nhật. Dòng thứ ba là bản ghi tự mâu thuẫn với số đếm của chính nó. Hai dòng cuối là hiện vật so với môi trường: thứ đang chạy không phải thứ đã kiểm và chưa được duyệt. Bộ test của dự án này vẫn có thể xanh trên bản b42; không dòng nào ở trên làm test đỏ.

Sửa đúng nguồn. Việc export còn fail thì bản ghi phải nói in_progress; việc billing đã đạt thì ghi done; môi trường quay về bản đã duyệt. Trong dự án thật, việc cuối là một lần triển khai có người duyệt, không phải sửa file. Ghi đè hai file:

{
  "progress_percent": 75,
  "tasks": {
    "login": "done",
    "export": "in_progress",
    "report": "done",
    "billing": "done"
  }
}
{
  "build": "b42",
  "approved_builds": ["b42"]
}

Chạy lại. Lần này script kết thúc với exit code 0:

python3 audit.py
tổng: 0 chỗ lệch

Audit xanh không có nghĩa là hết lỗi: export vẫn fail. Khác biệt là bản ghi giờ nói đúng điều đó. Audit tìm chỗ lệch giữa các nguồn, không thay cho việc sửa lỗi.

Môi trường đã chạy: macOS trên Apple silicon, Python 3.14. Chưa thử: Linux và Windows.

Các lớp kiểm và cổng duyệt đi kèm

Audit không thay review, nhưng cần biết lớp kiểm nào đang có mặt và cổng nào đang áp, vì thiếu lớp là một loại phát hiện. Cách review từng thay đổi nằm ở Review code do AI sinh ra; phần này chỉ là bản đồ.

  • Lớp máy chạy trước khi người đọc: build, lint, kiểm kiểu, test, quét secret, và xác nhận thư viện được gọi có thật.
  • Tầng 1, đúng logic: đối chiếu từng tiêu chí chấp nhận với diff thật, thử các ca biên, soi chỗ nuốt lỗi và mã thừa.
  • Tầng 2, hợp kiến trúc: đúng ranh giới module, không tạo bản sao của thứ đã có.
  • Tầng 3, đủ hiệu năng: truy vấn trong vòng lặp, kéo cả tập dữ liệu vào bộ nhớ, độ phức tạp trên kích thước dữ liệu thật.
  • Tầng 4, an toàn khi đồng thời: chạy hai lần không hỏng dữ liệu, ranh giới transaction, retry và timeout.
  • Bảo mật xuyên các tầng: đầu vào đi tới đâu, phân quyền, thư viện và công cụ của agent có thật và đáng tin, secret, dữ liệu cá nhân.

Tầng 2 đến 4 và phần bảo mật chỉ bật khi thay đổi chạm ngưỡng, như thay đổi lớn, module lõi, giao diện công khai hoặc job nền, để một thay đổi cỡ vừa vẫn rà xong trong khoảng nửa giờ.

  • Tự trị: nhánh thử nghiệm, sinh test, nháp tài liệu, đọc và giải thích mã. Điều kiện là lớp máy chạy tự động.
  • Duyệt sau, trước khi lan: nhánh dùng chung, cấu hình không phải production, migration trên môi trường phát triển.
  • Duyệt trước, cổng cứng: production, dữ liệu thật, tiền, xác thực và phân quyền, secret, thư viện mới, API công bố ra ngoài.

Dừng và báo khi đầu ra chứa secret hoặc dữ liệu cá nhân chưa che, khi xuất hiện thư viện hoặc công cụ không giải thích được, hoặc khi nghi sự cố production. Cần có đường ngoại lệ hợp pháp ghi ai duyệt, phá mục nào, vì sao và đến khi nào; ngoại lệ tập trung ở đâu thì quy tắc ở đó đang sai. Cách dựng cổng và vòng đời task nằm ở Dựng harness để AI agent làm việc đáng tin.

Một kết luận chỉ có giá trị khi kiểm chứng được

Mỗi phát hiện của audit cần đứng được trên bằng chứng người khác chạy lại được: lệnh, kết quả, thời điểm và nơi lưu. Không chắc thì mục đó chưa đạt, và hỏi. Người hoặc môi trường chấm phải độc lập với bên tạo ra kết quả, nên agent không tự audit đầu ra của chính nó. Máy chạy phép kiểm; con người quyết định điều gì đáng kiểm. Cách viết tiêu chí và kiểm chính verifier nằm ở Viết tiêu chí hoàn tất mà AI có thể kiểm chứng.

Báo cáo audit

Một báo cáo audit gồm bốn phần: phạm vi và thời điểm đo; bảng phát hiện; kết luận đạt, làm lại hoặc leo thang; và phần chưa đo được. Phần chưa đo được phải ghi rõ để không bị đọc thành “đạt”.

Mô tảNguồn lệchMức độBằng chứngNgười xử lýHạn
Bản b43 đang chạy nhưng chưa được duyệtHiện vật và môi trườngChặnLệnh đo và kết quả, kèm thời điểmTên hoặc vai tròNgày cụ thể
Tiến độ ghi 80%, đếm được 75%Bản ghi và số đếmSửa trong sprintKết quả audit.pyTên hoặc vai tròNgày cụ thể

Mức độ nên có định nghĩa rõ từ đầu, ví dụ chặn là không phát hành khi còn tồn tại, sửa trong sprint là có hạn, theo dõi là chưa cần hành động. Mỗi dòng có đúng một người chịu trách nhiệm.

Đo và chu kỳ

  • Đo baseline trước khi bắt đầu; không có baseline thì sau này không so được.
  • Đo hằng tuần: tỷ lệ thay đổi đủ bằng chứng, thời gian rà soát trung vị, lỗi bắt được và lỗi lọt sau khi gộp, số ngoại lệ.
  • Mỗi quý đọc sâu vài thay đổi đã đạt để đếm lỗi lọt theo tầng. Lỗi lọt lặp lại thì thêm mục kiểm; mục lâu không bắt được gì và không phải rào an toàn thì cân nhắc cắt.
  • Bộ quy tắc không được nhắc tới trong một quý là bộ quy tắc đang chết; rà lại cùng nhóm.

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

  • Audit không chứng minh phần mềm đúng về ngữ nghĩa. Nó chỉ chứng minh những điều đã được viết ra thành phép kiểm khớp nhau.
  • Nguồn thứ ba không đo được thì ghi “không đo được”, đừng suy từ khai báo cấu hình. Khai báo nói điều muốn có, không nói điều đã áp dụng.
  • Biến audit thành điểm số rồi tối ưu điểm số làm mất ý nghĩa của phép đo. Giữ báo cáo ở dạng phát hiện kèm bằng chứng.
  • Đo quá nhiều thì không ai đọc. Bắt đầu với ba cặp đối chiếu ở trên.
  • Số đo trên một dự án không suy ra được cho dự án khác.

Học tiếp

Nguồn tham khảo

MCP từ đầu: một server stdio tối thiểu theo bản đặc tả 2026-07-28 và bảng lỗi

Câu hỏi bài này trả lời: một cuộc gọi công cụ MCP thật ra là những dòng JSON nào trên stdio; bản đặc tả hiện hành đã bỏ bước bắt tay initialize nên mỗi yêu cầu phải tự mang gì; lỗi nào thuộc giao thức và lỗi nào thuộc công cụ; và MCP không làm thay những việc gì?

Cần biết trước: JSON, ý niệm request và response của JSON-RPC, Python. Lab dùng thư viện chuẩn của Python 3.14.4 trên macOS arm64: một server stdio tối thiểu và một client chạy nó như tiến trình con, không dùng SDK. Bài đọc đặc tả bản 2026-07-28, bản mà trang Versioning ngày 2026-10-04 ghi là hiện hành. Nhiều hướng dẫn, SDK cũ và báo cáo vẫn mô tả bắt tay initialize; đặc tả gọi đó là bản “legacy” (2025-11-25 trở về trước), nên nếu thấy initialize trong tài liệu, hãy kiểm xem tài liệu nói về bản nào. Lab chỉ chạy qua stdio và chỉ ở một bản đặc tả; không thử Streamable HTTP, xác thực, subscriptions/listen, multi round-trip, theo dõi tiến độ, hủy hay extension.

Điều đã đổi so với hướng dẫn cũ

Đặc tả MCP đánh phiên bản bằng ngày (YYYY-MM-DD), “to indicate the last date backwards incompatible changes were made”, và trang Versioning ghi “The current protocol version is 2026-07-28”. Mục Key Changes của đặc tả liệt kê thay đổi so với bản 2025-11-25; bài dùng sáu thay đổi ảnh hưởng trực tiếp tới code bên dưới:

Thay đổiNội dung theo đặc tả
Không còn bắt tay“Make MCP stateless: remove the initialize/notifications/initialized handshake.” Mỗi yêu cầu mang bản giao thức và năng lực của client trong _meta
server/discover“servers MUST implement this RPC to advertise their supported protocol versions, capabilities, and identity”; client có thể gọi trước hoặc dùng làm phép dò tương thích trên stdio
resultTypeMọi kết quả có trường bắt buộc resultType: "complete" cho kết quả thường, "input_required" cho kết quả trung gian của multi round-trip request
ttlMs và cacheScopeBắt buộc trên kết quả của tools/list, prompts/list, resources/list, resources/read và resources/templates/list
Không còn session“Remove protocol-level sessions and the Mcp-Session-Id header” (phía HTTP); danh sách công cụ không đổi theo từng kết nối
Mã lỗiBase Protocol định nghĩa -32020 đến -32022; Key Changes: “resource not found” đổi từ -32002 sang -32602

Khung thông điệp trên stdio

Mọi thông điệp “MUST follow the JSON-RPC 2.0 specification”. Phần MCP thêm vào hoặc siết lại:

  • Request: id là chuỗi hoặc số nguyên; “Unlike base JSON-RPC, the ID MUST NOT be null”.
  • Response thành công: cùng id với request và có result chứa resultType.
  • Response lỗi: có error với code nguyên và message, tùy chọn data; cùng id với request “except in error cases where the ID could not be read due a malformed request”.
  • Notification: không có id, và “The receiver MUST NOT send a response”.
  • stdio: server đọc từ stdin và ghi lên stdout, mỗi dòng một thông điệp; “Messages are delimited by newlines, and MUST NOT contain embedded newlines”. “The server MUST NOT write anything to its stdout that is not a valid MCP message”; nhật ký đi ra stderr (“MAY write UTF-8 strings to stderr for any logging purposes”). Đóng stdin là tín hiệu tắt: “Servers SHOULD exit promptly when their standard input is closed”.

Mỗi yêu cầu tự mô tả

Đặc tả nói giao thức là stateless: “all the information needed to process a request is contained in the request itself”, và “Servers MUST NOT rely on prior requests over the same connection to establish context (e.g., capabilities, protocol version, client identity)”. Thông tin đó nằm trong params._meta:

Khóa trong _metaBắt buộcÝ nghĩa
io.modelcontextprotocol/protocolVersioncóBản giao thức của yêu cầu này
io.modelcontextprotocol/clientCapabilitiescóNăng lực của client liên quan tới yêu cầu
io.modelcontextprotocol/clientInfokhôngTên và phiên bản client (nên gửi)
io.modelcontextprotocol/logLevelkhôngMức nhật ký tối thiểu server nên phát

Luật cho thiếu và sai: “A request missing any required field is malformed; the server MUST reject it with JSON-RPC error code -32602 (Invalid params)”. Bản không hỗ trợ thì trả lỗi -32022 (UnsupportedProtocolVersion) kèm data.supported và data.requested; client chọn một bản chung và gửi lại. Trang Versioning mô tả đúng điều này: “There is no negotiation handshake. Every request carries its protocol version, and the server accepts or rejects each request independently”. server/discover là phương thức server bắt buộc có; client “MAY call it” trước để biết bản và năng lực, nhưng không bắt buộc.

Lab: server và client stdio

Tạo thư mục trống rồi lưu server.py. Nó hiện thực server/discover, tools/list, tools/call với hai công cụ add và divide, kiểm _meta trước khi chạy phương thức, và phân biệt hai loại lỗi. Có bốn chỗ đặc tả không ép một cách làm, lab chọn như sau: id của lỗi Invalid Request được trả lại khi đọc được (đúng với luật MCP ở trên); mảng JSON bị từ chối bằng -32600 vì stdio quy định “Each message is a single JSON-RPC request, notification, or response” (JSON-RPC có batch, nhưng lab không nhận batch); tham số sai kiểu trong arguments là lỗi thực thi công cụ (isError: true) vì trang Tools xếp “Input validation errors” vào loại đó; thông báo không rõ thì bỏ qua và không phản hồi, vì JSON-RPC cấm trả lời thông báo (“The Server MUST NOT reply to a Notification”).

import json
import sys

VERSION = "2026-07-28"
SUPPORTED = [VERSION]
VERSION_KEY = "io.modelcontextprotocol/protocolVersion"
CAPS_KEY = "io.modelcontextprotocol/clientCapabilities"
SERVER_INFO_KEY = "io.modelcontextprotocol/serverInfo"
SERVER_INFO = {"name": "demo-tools", "version": "0.1.0"}

PAIR = {
    "type": "object",
    "properties": {"a": {"type": "number"}, "b": {"type": "number"}},
    "required": ["a", "b"],
    "additionalProperties": False,
}
TOOLS = [
    {"name": "add", "description": "Cộng hai số", "inputSchema": PAIR},
    {"name": "divide", "description": "Chia a cho b", "inputSchema": PAIR},
]


class RpcError(Exception):
    def __init__(self, code, message, data=None):
        super().__init__(message)
        self.code, self.message, self.data = code, message, data


def check_arguments(schema, arguments):
    for name in schema["required"]:
        if name not in arguments:
            return f"thiếu tham số {name}"
    for name, value in arguments.items():
        if name not in schema["properties"]:
            return f"tham số lạ {name}"
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            return f"{name} phải là số"
    return None


def divide(a, b):
    if b == 0:
        raise ValueError("không chia được cho 0")
    return a / b


HANDLERS = {"add": lambda a, b: a + b, "divide": divide}


def complete(**body):
    return {"resultType": "complete", **body, "_meta": {SERVER_INFO_KEY: SERVER_INFO}}


def discover(params):
    return complete(
        supportedVersions=SUPPORTED,
        capabilities={"tools": {}},
        instructions="Hai công cụ số học để thử giao thức.",
        ttlMs=60_000,
        cacheScope="public",
    )


def list_tools(params):
    return complete(tools=TOOLS, ttlMs=60_000, cacheScope="public")


def call_tool(params):
    name, arguments = params.get("name"), params.get("arguments", {})
    if not isinstance(name, str) or not isinstance(arguments, dict):
        raise RpcError(-32602, "Invalid params: name phải là chuỗi và arguments phải là object")
    tool = next((tool for tool in TOOLS if tool["name"] == name), None)
    if tool is None:
        raise RpcError(-32602, f"Unknown tool: {name}")
    try:
        problem = check_arguments(tool["inputSchema"], arguments)
        if problem:
            raise ValueError(problem)
        handler = HANDLERS[name]
        value = handler(arguments["a"], arguments["b"])
    except ValueError as error:
        return complete(content=[{"type": "text", "text": f"Lỗi: {error}"}], isError=True)
    return complete(content=[{"type": "text", "text": str(value)}], isError=False)


METHODS = {"server/discover": discover, "tools/list": list_tools, "tools/call": call_tool}


def dispatch(method, params):
    if method not in METHODS:
        hint = ""
        if method == "initialize":
            hint = f"; server chỉ nói bản {', '.join(SUPPORTED)}, không có bắt tay initialize"
        raise RpcError(-32601, f"Method not found: {method}{hint}")
    meta = params.get("_meta") if isinstance(params, dict) else None
    if not isinstance(meta, dict):
        raise RpcError(-32602, "Invalid params: thiếu _meta")
    for key, kind in ((VERSION_KEY, str), (CAPS_KEY, dict)):
        if not isinstance(meta.get(key), kind):
            raise RpcError(-32602, f"Invalid params: thiếu _meta.{key}")
    if meta[VERSION_KEY] not in SUPPORTED:
        data = {"supported": SUPPORTED, "requested": meta[VERSION_KEY]}
        raise RpcError(-32022, "Unsupported protocol version", data)
    handler = METHODS[method]
    return handler(params)


def valid_id(value):
    return isinstance(value, (str, int)) and not isinstance(value, bool)


def failure(request_id, code, message, data=None):
    error = {"code": code, "message": message, **({"data": data} if data is not None else {})}
    return {"jsonrpc": "2.0", "id": request_id, "error": error}


def process(message):
    if isinstance(message, list):
        return failure(None, -32600, "Invalid Request: mỗi dòng là một thông điệp, không nhận batch")
    is_envelope = isinstance(message, dict) and message.get("jsonrpc") == "2.0" and isinstance(message.get("method"), str)
    if not is_envelope:
        known = message.get("id") if isinstance(message, dict) and valid_id(message.get("id")) else None
        return failure(known, -32600, "Invalid Request")
    if "id" not in message:
        return None  # thông báo: không bao giờ phản hồi
    if not valid_id(message["id"]):
        return failure(None, -32600, "Invalid Request: id phải là chuỗi hoặc số nguyên, không được null")
    try:
        result = dispatch(message["method"], message.get("params", {}))
        return {"jsonrpc": "2.0", "id": message["id"], "result": result}
    except RpcError as error:
        return failure(message["id"], error.code, error.message, error.data)


NOISY = "--noisy" in sys.argv  # cố ý sai, chỉ để thấy khung stdio bị hỏng khi nhật ký rơi vào stdout
sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")
for line in sys.stdin:
    if not line.strip():
        continue
    print(f"nhận: {line.strip()[:70]}", file=sys.stderr, flush=True)  # nhật ký đi stderr, stdout chỉ có JSON-RPC
    if NOISY:
        print(f"debug: đang xử lý {line.strip()[:20]}", flush=True)
    try:
        reply = process(json.loads(line))
    except json.JSONDecodeError:
        reply = failure(None, -32700, "Parse error")
    if reply is not None:
        sys.stdout.write(json.dumps(reply, ensure_ascii=False) + "\n")
        sys.stdout.flush()

Lưu cases.py: client khởi chạy server.py như tiến trình con và nói chuyện theo từng dòng. Nó thử đàm phán phiên bản (yêu cầu bản 2099-01-01 rồi thử lại với bản server nêu), gọi server/discover và tools/list, chạy ba cuộc gọi công cụ, chạy 12 ca lỗi giao thức và khẳng định từng mã lỗi, gửi hai thông báo không có id, đóng stdin để kiểm server thoát, rồi chạy một server cố ý in nhật ký ra stdout. Thông báo không có phản hồi được kiểm bằng thứ tự dòng: stdio là kênh tuần tự, nên sau mỗi thông báo client gửi tiếp một yêu cầu server/discover và đòi dòng đầu tiên đọc được phải có id của yêu cầu đó; nếu server lỡ trả lời thông báo, dòng đó sẽ chen lên trước và id không khớp.

import json
import subprocess
import sys
import threading

VERSION = "2026-07-28"
VERSION_KEY = "io.modelcontextprotocol/protocolVersion"
CAPS_KEY = "io.modelcontextprotocol/clientCapabilities"
INFO_KEY = "io.modelcontextprotocol/clientInfo"
SERVER_INFO_KEY = "io.modelcontextprotocol/serverInfo"


class Client:
    def __init__(self, *flags):
        self.proc = subprocess.Popen(
            [sys.executable, "-B", "server.py", *flags],
            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            text=True, encoding="utf-8", bufsize=1,
        )  # fmt: skip
        self.guard = threading.Timer(60, self.proc.kill)  # chốt an toàn nếu server treo
        self.guard.daemon = True
        self.guard.start()
        self.next_id = 0
        self.lines = 0

    def send(self, text):
        self.proc.stdin.write(text + "\n")
        self.proc.stdin.flush()

    def read(self):
        line = self.proc.stdout.readline()
        assert line, "server đã đóng stdout"
        self.lines += 1
        reply = json.loads(line)  # mọi dòng trên stdout phải là JSON
        assert isinstance(reply, dict) and reply["jsonrpc"] == "2.0"
        return reply

    def meta(self, version=VERSION):
        return {VERSION_KEY: version, CAPS_KEY: {}, INFO_KEY: {"name": "demo-client", "version": "0.1.0"}}

    def call(self, method, params=None, version=VERSION):
        self.next_id += 1
        body = {**(params or {}), "_meta": self.meta(version)}
        self.send(json.dumps({"jsonrpc": "2.0", "id": self.next_id, "method": method, "params": body}))
        reply = self.read()
        assert reply["id"] == self.next_id
        return reply

    def raw(self, text):
        self.send(text)
        return self.read()

    def close(self):
        self.proc.stdin.close()
        code = self.proc.wait(timeout=10)
        self.guard.cancel()
        return code, self.proc.stderr.read()


client = Client()

print("== Đàm phán phiên bản bằng từng yêu cầu")
error = client.call("server/discover", version="2099-01-01")["error"]
print(f"yêu cầu bản 2099-01-01 -> lỗi {error['code']} {error['message']}, data={json.dumps(error['data'])}")
version = error["data"]["supported"][0]
result = client.call("server/discover", version=version)["result"]
print(f"thử lại với bản {version} -> resultType={result['resultType']}, supportedVersions={result['supportedVersions']}")
print(
    f"capabilities={sorted(result['capabilities'])}, serverInfo={result['_meta'][SERVER_INFO_KEY]['name']}, "
    f"ttlMs={result['ttlMs']}, cacheScope={result['cacheScope']}"
)

print("== Công cụ")
listing = client.call("tools/list")["result"]
again = client.call("tools/list")["result"]
names = [tool["name"] for tool in listing["tools"]]
print(
    f"tools/list -> {names}, ttlMs={listing['ttlMs']}, cacheScope={listing['cacheScope']}, "
    f"thứ tự ổn định: {listing['tools'] == again['tools']}"
)
for name, arguments in (("add", {"a": 2, "b": 3}), ("divide", {"a": 1, "b": 0}), ("add", {"a": "2", "b": 3})):
    result = client.call("tools/call", {"name": name, "arguments": arguments})["result"]
    text = result["content"][0]["text"]
    print(f"tools/call {name} {json.dumps(arguments)} -> {result['resultType']}, isError={result['isError']}, {text!r}")

print("== Bảng lỗi giao thức")
meta = json.dumps(client.meta())
discover = f'{{"jsonrpc":"2.0","id":9,"method":"server/discover","params":{{"_meta":{meta}}}}}'
codes = []


def row(label, reply, code):
    error = reply["error"]
    print(f"{label:46} -> {error['code']} id={json.dumps(reply['id'])} {error['message']}")
    assert error["code"] == code, (label, reply)
    codes.append(code)


row("JSON hỏng", client.raw("{not json"), -32700)
row("mảng thay vì một thông điệp", client.raw("[]"), -32600)
row("giá trị không phải object", client.raw("42"), -32600)
row("method không phải chuỗi", client.raw('{"jsonrpc":"2.0","id":7,"method":5}'), -32600)
row("id là null", client.raw(discover.replace('"id":9', '"id":null')), -32600)
row("method không tồn tại", client.call("tools/delete"), -32601)
row("initialize của bản cũ", client.raw('{"jsonrpc":"2.0","id":8,"method":"initialize","params":{}}'), -32601)
row("thiếu _meta", client.raw('{"jsonrpc":"2.0","id":8,"method":"tools/list"}'), -32602)
only_version = json.dumps({"_meta": {VERSION_KEY: VERSION}})
no_caps = f'{{"jsonrpc":"2.0","id":8,"method":"tools/list","params":{only_version}}}'
row("thiếu clientCapabilities", client.raw(no_caps), -32602)
row("tool không tồn tại", client.call("tools/call", {"name": "nope", "arguments": {}}), -32602)
row("arguments là mảng", client.call("tools/call", {"name": "add", "arguments": [1, 2]}), -32602)
row("bản giao thức không hỗ trợ", client.call("tools/list", version="1900-01-01"), -32022)

print("== Thông báo không có id")
for text in (
    '{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}',
    '{"jsonrpc":"2.0","method":"notifications/khong-co"}',
):
    client.send(text)
    client.call("server/discover")  # nếu server lỡ trả lời thông báo, dòng đầu tiên đọc được sẽ không khớp id
    print(f"{text[:60]:60} -> không có phản hồi (kiểm bằng thứ tự dòng)")

code, log = client.close()
print(f"== Đóng stdin -> server thoát với mã {code}; stderr có {len(log.splitlines())} dòng nhật ký")
print(f"{client.lines} dòng stdout, đều là JSON-RPC hợp lệ; {len(codes)} lỗi giao thức đã kiểm")

print("== Server in nhật ký ra stdout (cố ý sai)")
noisy = Client("--noisy")
try:
    noisy.call("server/discover")
except json.JSONDecodeError as error:
    print(f"dòng đầu tiên trên stdout không phải JSON -> {type(error).__name__}")
else:
    raise SystemExit("đáng lẽ khung stdio phải hỏng")
noisy.close()
python3 -B cases.py
== Đàm phán phiên bản bằng từng yêu cầu
yêu cầu bản 2099-01-01 -> lỗi -32022 Unsupported protocol version, data={"supported": ["2026-07-28"], "requested": "2099-01-01"}
thử lại với bản 2026-07-28 -> resultType=complete, supportedVersions=['2026-07-28']
capabilities=['tools'], serverInfo=demo-tools, ttlMs=60000, cacheScope=public
== Công cụ
tools/list -> ['add', 'divide'], ttlMs=60000, cacheScope=public, thứ tự ổn định: True
tools/call add {"a": 2, "b": 3} -> complete, isError=False, '5'
tools/call divide {"a": 1, "b": 0} -> complete, isError=True, 'Lỗi: không chia được cho 0'
tools/call add {"a": "2", "b": 3} -> complete, isError=True, 'Lỗi: a phải là số'
== Bảng lỗi giao thức
JSON hỏng                                      -> -32700 id=null Parse error
mảng thay vì một thông điệp                    -> -32600 id=null Invalid Request: mỗi dòng là một thông điệp, không nhận batch
giá trị không phải object                      -> -32600 id=null Invalid Request
method không phải chuỗi                        -> -32600 id=7 Invalid Request
id là null                                     -> -32600 id=null Invalid Request: id phải là chuỗi hoặc số nguyên, không được null
method không tồn tại                           -> -32601 id=8 Method not found: tools/delete
initialize của bản cũ                          -> -32601 id=8 Method not found: initialize; server chỉ nói bản 2026-07-28, không có bắt tay initialize
thiếu _meta                                    -> -32602 id=8 Invalid params: thiếu _meta
thiếu clientCapabilities                       -> -32602 id=8 Invalid params: thiếu _meta.io.modelcontextprotocol/clientCapabilities
tool không tồn tại                             -> -32602 id=9 Unknown tool: nope
arguments là mảng                              -> -32602 id=10 Invalid params: name phải là chuỗi và arguments phải là object
bản giao thức không hỗ trợ                     -> -32022 id=11 Unsupported protocol version
== Thông báo không có id
{"jsonrpc":"2.0","method":"notifications/cancelled","params" -> không có phản hồi (kiểm bằng thứ tự dòng)
{"jsonrpc":"2.0","method":"notifications/khong-co"}          -> không có phản hồi (kiểm bằng thứ tự dòng)
== Đóng stdin -> server thoát với mã 0; stderr có 23 dòng nhật ký
21 dòng stdout, đều là JSON-RPC hợp lệ; 12 lỗi giao thức đã kiểm
== Server in nhật ký ra stdout (cố ý sai)
dòng đầu tiên trên stdout không phải JSON -> JSONDecodeError

Đọc kết quả:

  • Đàm phán phiên bản không cần bắt tay. Yêu cầu bản 2099-01-01 nhận -32022 kèm data.supported; thử lại với 2026-07-28 thì thành công. Giữa hai yêu cầu server không giữ gì: yêu cầu thứ hai tự mang toàn bộ thông tin của nó.
  • server/discover và tools/list có đủ trường bắt buộc. resultType là complete, server/discover nêu bản, năng lực và serverInfo, tools/list có ttlMs và cacheScope, và thứ tự công cụ giống nhau qua hai lần gọi (đặc tả nói servers “SHOULD return tools in a deterministic order”).
  • Hai loại lỗi nằm ở hai chỗ khác nhau. divide(1, 0) và add với tham số chuỗi đều trả JSON-RPC thành công (resultType là complete) với isError: true và lời giải thích trong content: đây là lỗi thực thi công cụ. Tool không tồn tại và arguments là mảng thì là lỗi giao thức -32602.
  • Mười hai ca lỗi giao thức ra đúng mã. -32700 cho JSON hỏng; -32600 cho mảng, giá trị không phải object, method không phải chuỗi và id null; -32601 cho method lạ và initialize; -32602 cho thiếu _meta, thiếu clientCapabilities, tool lạ và arguments sai dạng; -32022 cho bản không hỗ trợ. Với initialize, server nêu bản nó nói trong thông báo lỗi, điều trang Versioning khuyên để client cũ có thứ để hiển thị.
  • Thông báo không có id không nhận phản hồi, kể cả thông báo không biết. Lab chỉ chứng minh điều này qua thứ tự dòng trên stdio.
  • Đóng stdin thì server thoát với mã 0, và nhật ký chỉ nằm ở stderr (23 dòng) trong khi stdout có 21 dòng, đều là JSON-RPC.
  • Một dòng print thừa phá khung. Khi server in nhật ký ra stdout, dòng đầu tiên client đọc không phải JSON và json.loads báo JSONDecodeError; đúng với luật “MUST NOT write anything to its stdout that is not a valid MCP message”.

Bảng mã lỗi đã kiểm và chưa kiểm

MãTênCa trong labCăn cứ
-32700Parse errordòng không phải JSONJSON-RPC: “Invalid JSON was received by the server”; id là null vì không đọc được
-32600Invalid Requestmảng; giá trị không phải object; method không phải chuỗi; id nullJSON-RPC; MCP: id không được null; stdio: mỗi dòng một thông điệp
-32601Method not foundmethod lạ; initialize của bản cũJSON-RPC: “The method does not exist / is not available”; ma trận tương thích của trang Versioning
-32602Invalid paramsthiếu _meta hoặc clientCapabilities; tool lạ; arguments sai dạngMCP: thiếu trường _meta bắt buộc; trang Tools: ví dụ “Unknown tool” dùng -32602
-32022UnsupportedProtocolVersionbản 1900-01-01 và 2099-01-01MCP: lỗi kèm data.supported và data.requested
-32021MissingRequiredClientCapabilitykhông kiểm: công cụ của lab không đòi năng lực nào của clientMCP: kèm data.requiredCapabilities
-32020HeaderMismatchkhông kiểm: chỉ liên quan Streamable HTTPbảng mã lỗi của MCP
-32603Internal errorkhông kiểm: lab không có đường dẫn nào cố ý gây lỗi nội bộJSON-RPC; MCP dùng các mã chuẩn -32700, -32600 đến -32603
isError: truelỗi thực thi công cụchia cho 0; tham số sai kiểutrang Tools: lỗi của công cụ nằm trong kết quả để mô hình tự sửa

Trang Tools nói rõ ranh giới: lỗi giao thức là “Unknown tool”, yêu cầu sai cấu trúc và lỗi server; lỗi thực thi công cụ là lỗi API, kiểm tra đầu vào và lỗi nghiệp vụ. “Clients SHOULD provide tool execution errors to language models to enable self-correction”; còn lỗi giao thức client chỉ “MAY” đưa cho mô hình vì ít khi giúp phục hồi.

MCP không làm thay những gì

ViệcAi chịu trách nhiệmCăn cứ trong đặc tả
Quyết định có chạy một công cụ hay khôngHost và ứng dụng“Hosts must obtain explicit user consent before invoking any tool”; “there SHOULD always be a human in the loop with the ability to deny tool invocations”
Ép các nguyên tắc về đồng ý và quyềnNgười xây ứng dụng, không phải giao thức“While MCP itself cannot enforce these security principles at the protocol level, implementors SHOULD: Build robust consent and authorization flows into their applications”
Xác thựcHTTP có khung Authorization; stdio lấy thông tin xác thực từ môi trườngstdio “SHOULD NOT follow this specification, and instead retrieve credentials from the environment”
Tin vào mô tả và nhãn của công cụKhông tin theo mặc địnhmô tả hành vi như annotations “should be considered untrusted, unless obtained from a trusted server”
Tin vào serverInfo và clientInfoKhông dùng cho quyết định bảo mật“self-reported by the sender and are not verified by the protocol”
Kiểm đầu vào, kiểm soát truy cập, giới hạn tốc độ, làm sạch đầu raServer“Servers MUST: Validate all tool inputs, Implement proper access controls, Rate limit tool invocations, Sanitize tool outputs”
Hỏi xác nhận, kiểm kết quả trước khi đưa cho mô hình, timeout, nhật ký kiểm toánClientmục Security Considerations của trang Tools (các mệnh đề SHOULD cho client)

Server của lab chạy mọi cuộc gọi hợp lệ: không hỏi ai, không xác thực, không giới hạn tốc độ. Giao thức chỉ chuyển cuộc gọi tới; cho phép hay không là việc của lớp trên. Cách đặt các cổng đó ở phía host nằm ở Harness tối thiểu cho một task nhỏ và Dựng harness để AI agent làm việc đáng tin.

Giới hạn

  • Chỉ một bản đặc tả (2026-07-28) và chỉ transport stdio. Không thử Streamable HTTP (header MCP-Protocol-Version, mã HTTP, -32020), xác thực OAuth, subscriptions/listen, multi round-trip request (input_required), theo dõi tiến độ, hủy, resources, prompts, sampling, elicitation, roots hay extension như Tasks và MCP Apps.
  • Server của lab không phải bản đủ tính năng: bộ kiểm lược đồ chỉ biết kiểu số, không có phân trang, không có listChanged, không hỗ trợ client cũ (bản có initialize). Bài không kiểm client hoặc server của bên thứ ba, và không so sánh với SDK chính thức.
  • Phép dò tương thích của client hai thời đại (probe bằng server/discover, rồi lùi về initialize khi server không trả lỗi hiện đại nào) nằm ở trang stdio của đặc tả và không có trong lab.
  • Bảng lỗi chỉ gồm những mã lab gây ra được; -32603, -32021 và -32020 chưa kiểm. Lab không có đường dẫn lỗi nội bộ, không thử id kiểu chuỗi dài, thông điệp rất lớn hay Unicode hiếm.
  • “Không phản hồi thông báo” được kiểm bằng thứ tự dòng chứ không bằng thời gian chờ, và bài không đo độ trễ.
  • Bài không bàn độ an toàn của công cụ cụ thể, tiêm lệnh qua mô tả công cụ hay đánh giá nhà cung cấp MCP server; chỉ nêu những gì đặc tả ghi.
  • Lab không ghi tệp ngoài thư mục bạn đã tạo; mỗi tiến trình con tự thoát khi stdin đóng, và Timer 60 giây tự kết thúc server nếu treo.

Học tiếp và nguồn

Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc Python 3.14.4 trên macOS arm64, chỉ qua stdio và chỉ ở bản đặc tả 2026-07-28.

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

Database observability: biểu đồ phải trả lời câu hỏi nào?

Câu hỏi: query chậm do đang làm việc, chờ khóa hay đọc replica chưa bắt kịp?

Cần biết trước: EXPLAIN, khóa và replication. Lab native đã chạy PostgreSQL 18.6, Python 3.14.4, postgres_exporter 0.20.1, Prometheus 3.15.0 và Grafana OSS 13.2.2 trên macOS arm64. Không dùng DB đang phục vụ ứng dụng, không cần Docker hoặc VM. Chưa kiểm Linux hoặc MySQL trong bài này.

Định nghĩa tín hiệu trước dashboard

Câu hỏiGauge labQuery đối chiếuBước tiếp theo
Query giả còn chạy bao lâu?wiki_observe_slow_seconds, secondsTuổi query wiki_observe_slow trong pg_stat_activityĐọc wait event; query sleep không chứng minh thiếu index
Có ai chờ lock?wiki_observe_lock_waiters, sessionswait_event_type='Lock', pg_blocking_pidsXác định transaction giữ khóa và phạm vi ghi
Replica còn nợ bao nhiêu WAL?wiki_observe_replay_bytes, bytesPrimary current LSN trừ replica replay LSNKiểm transport/replay; bytes không tự đổi thành seconds
Có dữ liệu để tin biểu đồ?up{job="wiki_pg"}, 0/1Prometheus target, exporter pg_up và scrape errorSửa đường quan sát trước khi kết luận DB khỏe

Ba gauge đầu do lab tự định nghĩa, không phải tên collector mặc định hoặc latency SLO. Lab dùng custom query của exporter 0.20.1; extend.query-path đã deprecated. Với hệ thống dài hạn, ưu tiên collector có sẵn hoặc SQL exporter phù hợp sau khi kiểm schema metric. Không thay exporter mà giữ nguyên dashboard một cách mù quáng. Exporter configuration.

Chuẩn bị fixture và binary

Lưu repl.sh từ bài replication vào thư mục trống. Chưa gọi repl_up: script dưới tự khởi động rồi cleanup hai cluster socket riêng. PostgreSQL 18.6 tools phải có trong PATH; Python phải là 3.14.4. Bộ binary observability được tải vào OBS_TOOLS (mặc định tools/ trong thư mục lab), không cài toàn máy. Cache có thể tái dùng; archive luôn được kiểm SHA-256 trước khi giải nén.

Nguồn binary/checksum: Prometheus 3.15.0, exporter 0.20.1, Grafana 13.2.2. Script này cố ý chỉ nhận Darwin arm64, đúng nền tảng đã chạy.

set -euo pipefail
[ "$(uname -s)/$(uname -m)" = Darwin/arm64 ]
OBS_TOOLS=${OBS_TOOLS:-"$PWD/tools"}
mkdir -p "$OBS_TOOLS"
OBS_TOOLS=$(cd "$OBS_TOOLS" && pwd)
fetch() (
  name=$1
  url=$2
  digest=$3
  cd "$OBS_TOOLS"
  if [ ! -f "$name" ]; then
    trap 'rm -f "$name.part"' EXIT
    curl --proto '=https' --tlsv1.2 -fsSL --max-time 240 "$url" -o "$name.part"
    mv "$name.part" "$name"
  fi
  printf '%s  %s\n' "$digest" "$name" | shasum -a 256 -c -
  tar -xzf "$name"
)
fetch prometheus-3.15.0.darwin-arm64.tar.gz \
  https://github.com/prometheus/prometheus/releases/download/v3.15.0/prometheus-3.15.0.darwin-arm64.tar.gz \
  920df4d17e78b3b0175af144eb318b0c74d1cf7b1d1251b326966f0e81977260
fetch postgres_exporter-0.20.1.darwin-arm64.tar.gz \
  https://github.com/prometheus-community/postgres_exporter/releases/download/v0.20.1/postgres_exporter-0.20.1.darwin-arm64.tar.gz \
  4dd2b9e7ac658556dd2cb143ac472a87ff540ffc2b482d5311ff0ef47b11413c
fetch grafana_13.2.2_34846740809_darwin_arm64.tar.gz \
  https://dl.grafana.com/grafana/release/13.2.2/grafana_13.2.2_34846740809_darwin_arm64.tar.gz \
  9ea91bf7cc92aa34dd59321c44e7a15a22ee6f3dbb491849a6357451d35d5932
printf 'export OBS_TOOLS=%q\n' "$OBS_TOOLS" > tools.env
wiki_observe:
  query: >
    SELECT
      COALESCE((SELECT max(EXTRACT(EPOCH FROM clock_timestamp()-query_start))
        FROM pg_stat_activity WHERE application_name='wiki_observe_slow' AND state='active'),0) AS slow_seconds,
      (SELECT count(*) FROM pg_stat_activity
        WHERE application_name='wiki_observe_waiter' AND wait_event_type='Lock') AS lock_waiters,
      COALESCE((SELECT max(pg_wal_lsn_diff(pg_current_wal_lsn(),replay_lsn))
        FROM pg_stat_replication),0) AS replay_bytes
  metrics:
    - slow_seconds:
        usage: GAUGE
        description: Active fixture query age in seconds, not completed latency
    - lock_waiters:
        usage: GAUGE
        description: Fixture sessions currently waiting for locks
    - replay_bytes:
        usage: GAUGE
        description: Primary WAL bytes beyond replica replay acknowledgement

Exporter dùng role wiki_monitor có pg_monitor, không superuser; fixture role lab mới có quyền tạo triệu chứng. Gauge không label bằng SQL text, user hoặc request ID, tránh cardinality và lộ nội dung query. Cấu hình scrape_interval: 1s và scrape_timeout: 800ms chỉ dùng cho lab; production cần ngân sách DB/query riêng. Scrape configuration.

Chạy và đối chiếu cùng cửa sổ

Lưu file Python bên dưới; HTTP binds vào 127.0.0.1 với port trống, không dùng cổng DB TCP. Dashboard provisioned có 4 panel, units seconds/sessions/bytes/short, range 30s, refresh 1s; script truy vấn datasource qua Grafana để kiểm đường đọc thực sự.

from __future__ import annotations

import json
import os
import platform
import signal
import socket
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any

assert sys.version_info[:3] == (3, 14, 4)
assert platform.system() == "Darwin" and platform.machine() == "arm64"
tools = Path(os.environ["OBS_TOOLS"]).resolve()
prom_dir = tools / "prometheus-3.15.0.darwin-arm64"
exporter_bin = tools / "postgres_exporter-0.20.1.darwin-arm64/postgres_exporter"
grafana_dirs = list(tools.glob("grafana-*"))
grafana_dir = next(
    p for p in grafana_dirs if p.is_dir() and (p / "bin/grafana").exists()
)
root = Path.cwd()
processes: list[subprocess.Popen[str]] = []
listeners: list[int] = []
logs: list[Any] = []


def shell(code: str) -> str:
    return subprocess.run(
        ["bash", "-euo", "pipefail", "-c", ". ./repl.sh; " + code],
        check=True,
        text=True,
        capture_output=True,
        timeout=60,
    ).stdout.strip()


def sql(query: str, node: str = "primary") -> str:
    import shlex

    return shell("repl_sql " + node + " -At -c " + shlex.quote(query))


def http_json(port: int, path: str) -> Any:
    with urllib.request.urlopen(
        f"http://127.0.0.1:{port}{path}", timeout=3
    ) as response:
        return json.load(response)


def wait(check: Any, seconds: float = 20) -> Any:
    end = time.monotonic() + seconds
    while time.monotonic() < end:
        try:
            result = check()
            if result:
                return result
        except OSError, ValueError:
            pass
        time.sleep(0.1)
    raise TimeoutError("observation barrier")


def port() -> int:
    with socket.socket() as sock:
        sock.bind(("127.0.0.1", 0))
        return int(sock.getsockname()[1])


def start(
    name: str, args: list[str], env: dict[str, str] | None = None
) -> subprocess.Popen[str]:
    output = (root / (name + ".log")).open("w")
    logs.append(output)
    proc = subprocess.Popen(
        args,
        stdout=output,
        stderr=subprocess.STDOUT,
        text=True,
        env=env,
        start_new_session=True,
    )
    processes.append(proc)
    return proc


def stop(proc: subprocess.Popen[str]) -> None:
    if proc.poll() is None:
        os.killpg(proc.pid, signal.SIGTERM)
        try:
            proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            os.killpg(proc.pid, signal.SIGKILL)
            proc.wait(timeout=5)


def write(path: str, body: str) -> None:
    target = root / path
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(body)


try:
    for binary, flag, expected_version in [
        (prom_dir / "prometheus", "--version", "3.15.0"),
        (exporter_bin, "--version", "0.20.1"),
        (grafana_dir / "bin/grafana", "--version", "13.2.2"),
    ]:
        result = subprocess.run(
            [str(binary), flag], capture_output=True, text=True, check=True, timeout=10
        )
        assert expected_version in result.stdout + result.stderr
    shell("repl_up; repl_target")
    sql(
        "CREATE ROLE wiki_monitor LOGIN; GRANT pg_monitor TO wiki_monitor; INSERT INTO wiki_repl.orders VALUES (1,'accepted',clock_timestamp())"
    )
    socket_dir = shell('repl_env; printf %s "$REPL_DIR/primary-socket"')
    export_port, prom_port, grafana_port = port(), port(), port()
    listeners.extend([export_port, prom_port, grafana_port])
    assert len({export_port, prom_port, grafana_port}) == 3
    env = {
        k: v
        for k, v in os.environ.items()
        if not k.startswith("PG") and not k.startswith("DATA_SOURCE")
    }
    env.update(
        DATA_SOURCE_NAME=f"postgresql://wiki_monitor@/postgres?host={socket_dir}&sslmode=disable",
        PG_EXPORTER_COLLECTION_TIMEOUT="500ms",
    )
    exporter_args = [
        str(exporter_bin),
        f"--web.listen-address=127.0.0.1:{export_port}",
        "--extend.query-path=queries.yaml",
    ]
    exporter = start("exporter", exporter_args, env)
    write(
        "prometheus.yaml",
        f'global:\n  scrape_interval: 1s\n  scrape_timeout: 800ms\nscrape_configs:\n  - job_name: wiki_pg\n    static_configs:\n      - targets: ["127.0.0.1:{export_port}"]\n',
    )
    subprocess.run(
        [str(prom_dir / "promtool"), "check", "config", "prometheus.yaml"], check=True
    )
    start(
        "prometheus",
        [
            str(prom_dir / "prometheus"),
            "--config.file=prometheus.yaml",
            "--storage.tsdb.path=prom-data",
            "--storage.tsdb.retention.time=1h",
            f"--web.listen-address=127.0.0.1:{prom_port}",
        ],
    )
    write(
        "provisioning/datasources/lab.yaml",
        f"apiVersion: 1\ndatasources:\n  - name: Lab\n    uid: wiki_pg\n    type: prometheus\n    access: proxy\n    url: http://127.0.0.1:{prom_port}\n    isDefault: true\n    editable: false\n",
    )
    write(
        "provisioning/dashboards/lab.yaml",
        f"apiVersion: 1\nproviders:\n  - name: Lab\n    type: file\n    options:\n      path: {root / 'dashboards'}\n",
    )
    expressions = [
        "wiki_observe_slow_seconds",
        "wiki_observe_lock_waiters",
        "wiki_observe_replay_bytes",
        'up{job="wiki_pg"}',
    ]
    titles = [
        "Active query age",
        "Lock waiters",
        "WAL replay backlog",
        "Scrape available",
    ]
    units = ["s", "short", "bytes", "short"]
    dashboard = {
        "uid": "wiki-observe",
        "title": "Isolated database observations",
        "schemaVersion": 39,
        "version": 1,
        "refresh": "1s",
        "time": {"from": "now-30s", "to": "now"},
        "panels": [
            {
                "id": i + 1,
                "title": titles[i],
                "type": "timeseries",
                "datasource": {"type": "prometheus", "uid": "wiki_pg"},
                "gridPos": {"x": (i % 2) * 12, "y": (i // 2) * 8, "w": 12, "h": 8},
                "fieldConfig": {"defaults": {"unit": units[i]}, "overrides": []},
                "targets": [{"expr": expression, "refId": "A"}],
            }
            for i, expression in enumerate(expressions)
        ],
    }
    write("dashboards/lab.json", json.dumps(dashboard))
    write(
        "grafana.ini",
        f"[paths]\ndata = {root / 'graf-data'}\nlogs = {root / 'graf-logs'}\nplugins = {root / 'graf-plugins'}\nprovisioning = {root / 'provisioning'}\n[server]\nhttp_addr = 127.0.0.1\nhttp_port = {grafana_port}\n[auth.anonymous]\nenabled = true\norg_role = Viewer\n[auth]\ndisable_login_form = true\n[analytics]\nreporting_enabled = false\ncheck_for_updates = false\ncheck_for_plugin_updates = false\n[plugins]\npreinstall_disabled = true\npublic_key_retrieval_disabled = true\n",
    )
    start(
        "grafana",
        [
            str(grafana_dir / "bin/grafana"),
            "server",
            "--homepath",
            str(grafana_dir),
            "--config",
            str(root / "grafana.ini"),
        ],
    )
    wait(lambda: http_json(grafana_port, "/api/health").get("database") == "ok", 60)
    loaded = wait(lambda: http_json(grafana_port, "/api/dashboards/uid/wiki-observe"))
    assert [
        p["targets"][0]["expr"] for p in loaded["dashboard"]["panels"]
    ] == expressions

    def metric(expression: str, via_grafana: bool = False) -> list[Any]:
        query = "/api/v1/query?" + urllib.parse.urlencode({"query": expression})
        data = (
            http_json(grafana_port, "/api/datasources/proxy/uid/wiki_pg" + query)
            if via_grafana
            else http_json(prom_port, query)
        )
        assert data["status"] == "success"
        return list(data["data"]["result"])

    def value(expression: str) -> float:
        rows = metric(expression)
        return float(rows[0]["value"][1]) if rows else -1

    wait(lambda: value('up{job="wiki_pg"}') == 1 and value("pg_up") == 1)
    assert all(metric(expression, True) for expression in expressions)
    snapshots: list[dict[str, Any]] = []

    def capture(case: str, expression: str, query: str, minimum: float) -> None:
        wait(lambda: value(expression) >= minimum)
        rows = metric(expression)
        sampled_at = value("timestamp(" + expression + ")")
        observed_at, db_value = sql(
            "SELECT EXTRACT(EPOCH FROM clock_timestamp()),(" + query + ")"
        ).split("|")
        assert abs(float(observed_at) - sampled_at) <= 3
        assert float(db_value) >= minimum
        if case == "slow":
            assert abs(float(db_value) - float(rows[0]["value"][1])) <= 3
        else:
            assert float(db_value) == float(rows[0]["value"][1])
        record = {
            "case": case,
            "metric": expression,
            "value": float(rows[0]["value"][1]),
            "sampled_at": sampled_at,
            "db_at": float(observed_at),
            "db_value": float(db_value),
        }
        snapshots.append(record)
        print("OBS_CASE " + json.dumps(record))

    def job(name: str, query: str) -> subprocess.Popen[str]:
        import shlex

        return start(
            name,
            [
                "bash",
                "-euo",
                "pipefail",
                "-c",
                ". ./repl.sh; repl_sql primary -c "
                + shlex.quote(
                    "SET application_name="
                    + shlex.quote("wiki_observe_" + name)
                    + "; SET statement_timeout='30s'; "
                    + query
                ),
            ],
        )

    slow = job("slow", "SELECT pg_sleep(20)")
    capture(
        "slow",
        expressions[0],
        "SELECT COALESCE(max(EXTRACT(EPOCH FROM clock_timestamp()-query_start)),0) FROM pg_stat_activity WHERE application_name='wiki_observe_slow' AND state='active'",
        1,
    )
    stop(slow)
    holder = job(
        "holder",
        "BEGIN; UPDATE wiki_repl.orders SET status='held' WHERE id=1; SELECT pg_sleep(20); ROLLBACK",
    )
    wait(
        lambda: (
            sql(
                "SELECT count(*) FROM pg_stat_activity WHERE application_name='wiki_observe_holder' AND wait_event='PgSleep'"
            )
            == "1"
        )
    )
    waiter = job("waiter", "UPDATE wiki_repl.orders SET status='waited' WHERE id=1")
    wait(
        lambda: (
            sql(
                "SELECT count(*) FROM pg_stat_activity WHERE application_name='wiki_observe_waiter' AND cardinality(pg_blocking_pids(pid))>0"
            )
            == "1"
        )
    )
    capture(
        "lock",
        expressions[1],
        "SELECT count(*) FROM pg_stat_activity WHERE application_name='wiki_observe_waiter' AND wait_event_type='Lock'",
        1,
    )
    stop(waiter)
    stop(holder)
    sql("SELECT pg_wal_replay_pause()", "replica")
    wait(
        lambda: sql("SELECT pg_get_wal_replay_pause_state()='paused'", "replica") == "t"
    )
    sql("INSERT INTO wiki_repl.orders VALUES(2,'lagged',clock_timestamp())")
    capture(
        "lag",
        expressions[2],
        "SELECT max(pg_wal_lsn_diff(pg_current_wal_lsn(),replay_lsn)) FROM pg_stat_replication",
        1,
    )
    sql("SELECT pg_wal_replay_resume()", "replica")
    wait(
        lambda: (
            sql("SELECT count(*) FROM wiki_repl.orders WHERE id=2", "replica") == "1"
        )
    )
    stop(exporter)
    wait(lambda: value('up{job="wiki_pg"}') == 0)
    wait(lambda: not metric(expressions[0]))
    assert not metric(expressions[0], True)
    assert sql("SELECT 1") == "1"
    print("exporter_down up=0 custom_series=absent database_select=1")
    exporter = start("exporter-recovery", exporter_args, env)
    wait(lambda: value('up{job="wiki_pg"}') == 1 and value("pg_up") == 1)
    assert all(metric(expression, True) for expression in expressions)
    print("recovered up=1 datasource=queried dashboard_panels=4")
    write(
        "observe-results.json",
        json.dumps(
            {
                "environment": {
                    "postgres": "18.6",
                    "exporter": "0.20.1",
                    "prometheus": "3.15.0",
                    "grafana": "13.2.2",
                    "python": platform.python_version(),
                    "os": platform.platform(),
                },
                "snapshots": snapshots,
            },
            indent=2,
        )
        + "\n",
    )
finally:
    for proc in reversed(processes):
        stop(proc)
    for log in logs:
        log.close()
    shell("repl_clean; repl_clean")
    assert all(proc.poll() is not None for proc in processes)
    for listener in listeners:
        with socket.socket() as probe:
            probe.settimeout(1)
            assert probe.connect_ex(("127.0.0.1", listener)) != 0
    print("cleanup owned_processes=closed clusters=removed")
set -euo pipefail
bash tools.sh
. ./tools.env
python3 observe.py
exporter_down up=0 custom_series=absent database_select=1
recovered up=1 datasource=queried dashboard_panels=4
cleanup owned_processes=closed clusters=removed
set -euo pipefail
. ./repl.sh
repl_clean
test ! -e repl.env

Một lượt đo thật

Đo trên macOS arm64 lúc 08:32:46–49 UTC ngày 2026-10-03. Mỗi dòng là một snapshot Prometheus rồi query DB, không phải thời gian hoàn tất query hay benchmark throughput. Cột cuối là DB timestamp trừ scrape timestamp, đơn vị seconds:

CaGaugeDB queryLệch cửa sổ s
slow1.4752711.5752320.104
lock1.0000001.0000000.177
lag264.000000264.0000000.156

Slow age chênh khoảng 0,1s vì query vẫn chạy giữa hai lần lấy số. Lock cùng một waiter và WAL byte backlog bằng nhau trong cửa sổ này. Script kiểm lệch timestamp không quá 3s, slow age không lệch quá 3s, lock/lag bằng nhau; không bảo đảm các giá trị này xuất hiện giống hệt khi chạy lại. Query DB cũng cần kiểm quyền và ảnh hưởng tải. PostgreSQL statistics.

Khi nào cảnh báo cần hành động?

Dashboard nhìn 30s gần nhất; mỗi scrape 1s là snapshot, không phát hiện mọi query ngắn. Active query age tăng vì pg_sleep là ca kiểm đo, chưa phải nguyên nhân chậm thực tế. Query age giảm về 0 sau khi kết thúc cũng không phải completed latency 0. Đọc wait_event cùng timestamp; query thật cần plan/buffers và histogram ứng dụng.

Trong lab, lock_waiters>0 liên tục 3 scrape là lý do xem blocker; không tự kill session. Replay_bytes>0 liên tục 3 scrape dẫn tới kiểm receive/replay và dung lượng WAL. Đây là ngưỡng minh họa, chưa phải alert production: cần ngân sách đọc stale, baseline và owner có hành động rõ. Gauge bytes không dùng rate như counter đơn điệu.

Khi exporter tắt, up=0 và custom series biến mất sau failed scrape; DB vẫn trả SELECT 1. Một dashboard biến missing thành 0 sẽ cho cảm giác khỏe sai. Với mục tiêu cố định, theo dõi cả up==0 và absent(up{job="wiki_pg"}) nếu target bị gỡ; exporter đáp HTTP 200 nhưng DB hỏng cần pg_up/scrape error, chưa đủ chỉ nhìn HTTP. Không dùng or vector(0) để che missing. Prometheus staleness.

Lab đóng process và cluster do nó tạo, còn binary cache và output để đọc lại. Không cấu hình remote write, credential thật hoặc agent trên máy chủ. Dashboard provisioning không thay cho review metric schema khi nâng version. Grafana provisioning.

Học tiếp: deadlock, replica và đọc-sau-ghi.

Kiểm kê giấy phép phần mềm: đọc biểu thức SPDX và ra quyết định theo cách dùng

Câu hỏi bài này trả lời: cột giấy phép của một bảng kiểm kê phần mềm thường chứa chuỗi như MIT OR GPL-3.0-only. Chuỗi đó nghĩa chính xác là gì, làm sao đổi nó thành quyết định “cho phép, cần xem hay chặn” tùy cách bạn dùng phần mềm, và việc nào máy làm được, việc nào vẫn là của người?

Cần biết trước: Python cơ bản và CSV; không cần kiến thức pháp lý. Lab dùng thư viện chuẩn của Python 3.14.4 trên macOS arm64 (code dùng X | None và zip(strict=True) nên cần Python 3.10 trở lên, nhưng bài chỉ chạy thật trên 3.14.4). Bước cuối chỉ đọc danh sách Homebrew 7.0.7 khi máy có brew và chỉ in số tổng hợp. Chưa chạy trên Linux. Bài không phải tư vấn pháp lý: bảng nhóm và quyết định bên dưới là ví dụ để minh họa cách mã hóa một chính sách, không phải kết luận về nghĩa vụ của giấy phép nào.

Một cột, ba việc khác nhau

ViệcCâu hỏiAi trả lời
Nhận diệnChuỗi viết đúng chưa, mỗi mã là giấy phép nào?SPDX: danh sách mã và cú pháp biểu thức
Phân loạiMỗi mã thuộc nhóm nào trong chính sách của tôi?Bảng chính sách do tổ chức viết và bảo trì
Quyết địnhVới cách tôi dùng phần mềm này, cho phép hay không?Chính sách cộng kịch bản dùng cộng người có thẩm quyền

SPDX License List nêu mục đích của nó là “enable efficient and reliable identification” của giấy phép và ngoại lệ, tức việc nhận diện. Bảng chính của danh sách có các cột tên đầy đủ, mã, “FSF Free/Libre?” và “OSI Approved?”; không có cột nhóm rủi ro. Bài đọc danh sách ngày 2026-10-04 ở bản 3.29.0 (2026-09-16). Hai việc sau là chính sách của bạn: bài làm việc thứ nhất và thứ hai bằng code chạy được, rồi chỉ ra chỗ việc thứ ba không giao cho máy được.

Biểu thức SPDX đọc thế nào

Đặc tả SPDX 2.3 (phụ lục D, “SPDX License Expressions”) định nghĩa cú pháp. Những điều bài dựa vào, đối chiếu với trang đặc tả đọc ngày 2026-10-04:

  • Thứ tự áp dụng mặc định là + WITH AND OR, toán tử đứng trước áp dụng trước. Ví dụ của chính đặc tả: LGPL-2.1-only OR BSD-3-Clause AND MIT là lựa chọn giữa LGPL-2.1-only và biểu thức BSD-3-Clause AND MIT, vì AND ưu tiên hơn OR. Ngoặc đổi thứ tự.
  • OR là chọn, AND là cùng lúc. Đặc tả dùng OR khi “given a choice between” các giấy phép và AND khi “required to simultaneously comply with two or more licenses”. Cả hai giao hoán.
  • Chữ hoa chữ thường: toán tử AND, OR, WITH nên so khớp phân biệt hoa thường; mã giấy phép (kể cả mã ngoại lệ) nên so khớp không phân biệt hoa thường, nên MIT, Mit và mIt là cùng một mã.
  • Dấu + sau mã nghĩa là bản hiện tại hoặc bất kỳ bản mới hơn (ví dụ CDDL-1.0+), và không được có khoảng trắng giữa mã và dấu +.
  • WITH: vế trái là một giấy phép đơn, vế phải là mã ngoại lệ, ví dụ GPL-2.0-or-later WITH Bison-exception-2.2; phải có khoảng trắng hai bên.
  • Khoảng trắng hoặc ngoặc phải có hai bên AND và OR; mã chỉ gồm chữ, số, - và .; trong định dạng tag:value, biểu thức nằm trên một dòng.
  • LicenseRef-… (có thể kèm DocumentRef-…: đứng trước) là giấy phép tự đặt, không nằm trong danh sách.

Lab: bộ đọc biểu thức

Tạo thư mục trống rồi lưu spdx.py. Bộ đọc là đệ quy xuống, mỗi mức ưu tiên một hàm: expression xử lý OR và gọi conjunction xử lý AND, hàm này gọi restricted xử lý WITH, hàm cuối gọi primary (ngoặc hoặc một mã). Mỗi hàm chỉ biết một toán tử nên thứ tự ưu tiên nằm trong cấu trúc lời gọi, không cần bảng độ ưu tiên. tokenize quét một lượt bằng một regex neo tại vị trí hiện tại, nên ký tự lạ báo lỗi kèm vị trí thay vì bị bỏ qua. show in cây dạng tiền tố; render in lại dạng SPDX với ngoặc tường minh cho mọi biểu thức lồng nhau, để thấy bộ đọc đã hiểu thế nào. Bộ đọc chỉ kiểm cú pháp, không kiểm mã có nằm trong danh sách SPDX hay không.

import re
from dataclasses import dataclass


class SpdxError(ValueError):
    """Chuỗi không phải biểu thức SPDX hợp lệ."""


@dataclass(frozen=True)
class Id:
    name: str
    later: bool = False  # hậu tố "+": bản này hoặc bản mới hơn


@dataclass(frozen=True)
class With:
    base: Id
    exception: str


@dataclass(frozen=True)
class And:
    items: tuple


@dataclass(frozen=True)
class Or:
    items: tuple


TOKEN = re.compile(r"\s*(\(|\)|[A-Za-z0-9.:-]+\+?)")
REF = re.compile(r"(?:DocumentRef-[A-Za-z0-9.-]+:)?LicenseRef-[A-Za-z0-9.-]+", re.IGNORECASE)
OPERATORS = ("AND", "OR", "WITH")


def tokenize(text: str) -> list[str]:
    if "\n" in text or "\r" in text:
        raise SpdxError("biểu thức phải nằm trên một dòng")
    tokens, pos = [], 0
    while match := TOKEN.match(text, pos):
        tokens.append(match.group(1))
        pos = match.end()
    rest = text[pos:]
    if rest.strip():
        bad = pos + len(rest) - len(rest.lstrip())
        raise SpdxError(f"ký tự lạ {text[bad]!r} ở vị trí {bad}")
    if not tokens:
        raise SpdxError("biểu thức rỗng")
    return tokens


def license_id(token: str) -> Id:
    later = token.endswith("+")
    name = token[:-1] if later else token
    if ":" in name or name.lower().startswith(("licenseref-", "documentref-")):
        if later:
            raise SpdxError(f"{name}: dấu + không dùng với LicenseRef")
        if not REF.fullmatch(name):
            raise SpdxError(f"{name}: không đúng dạng [DocumentRef-x:]LicenseRef-y")
    return Id(name, later)


class Parser:
    """OR thấp nhất, rồi AND, rồi WITH, rồi dấu + (đặc tả SPDX); toán tử phân biệt hoa thường."""

    def __init__(self, tokens: list[str]) -> None:
        self.tokens, self.pos = tokens, 0

    def peek(self) -> str | None:
        return self.tokens[self.pos] if self.pos < len(self.tokens) else None

    def take(self) -> str | None:
        token = self.peek()
        self.pos += 1
        return token

    def expression(self):
        items = [self.conjunction()]
        while self.peek() == "OR":
            self.take()
            items.append(self.conjunction())
        return items[0] if len(items) == 1 else Or(tuple(items))

    def conjunction(self):
        items = [self.restricted()]
        while self.peek() == "AND":
            self.take()
            items.append(self.restricted())
        return items[0] if len(items) == 1 else And(tuple(items))

    def restricted(self):
        grouped = self.peek() == "("
        node = self.primary()
        if self.peek() != "WITH":
            return node
        if grouped:
            raise SpdxError("vế trái của WITH phải là một giấy phép, không phải biểu thức trong ngoặc")
        self.take()
        exception = self.take()
        if exception is None or exception in (*OPERATORS, "(", ")") or exception.endswith("+"):
            raise SpdxError("sau WITH phải là mã ngoại lệ, không có dấu +")
        return With(node, exception)

    def primary(self):
        token = self.take()
        if token == "(":
            node = self.expression()
            if self.take() != ")":
                raise SpdxError("thiếu dấu ngoặc đóng")
            return node
        if token is None or token == ")" or token in OPERATORS:
            raise SpdxError(f"cần một giấy phép, gặp {'hết chuỗi' if token is None else repr(token)}")
        return license_id(token)


def parse(text: str):
    parser = Parser(tokenize(text))
    node = parser.expression()
    left = parser.peek()
    if left is not None:
        hint = " (toán tử SPDX phải viết hoa: AND, OR, WITH)" if left.upper() in OPERATORS else ""
        raise SpdxError(f"thừa {left!r}{hint}")
    return node


def name_of(node: Id) -> str:
    return node.name + ("+" if node.later else "")


def show(node) -> str:
    """Dạng tiền tố, để thấy đúng cây: OR(MIT, AND(A, B))."""
    if isinstance(node, Id):
        return name_of(node)
    if isinstance(node, With):
        return f"WITH({name_of(node.base)}, {node.exception})"
    return f"{type(node).__name__.upper()}({', '.join(show(item) for item in node.items)})"


def render(node, top: bool = True) -> str:
    """Dạng SPDX với ngoặc tường minh cho mọi biểu thức lồng nhau."""
    if isinstance(node, (And, Or)):
        text = (" AND " if isinstance(node, And) else " OR ").join(render(item, False) for item in node.items)
        return text if top else f"({text})"
    if isinstance(node, With):
        return f"{name_of(node.base)} WITH {node.exception}"
    return name_of(node)

Lab: từ cây đến nhóm rủi ro

Lưu policy.py. FAMILIES xếp năm nhóm từ dễ đến khó; thứ hạng trong danh sách là thứ được so sánh:

  • AND lấy nhóm khó nhất của các vế, vì phải tuân thủ cả hai.
  • OR lấy nhánh dễ nhất và ghi lại nhánh đã chọn (choices), vì nghĩa vụ chỉ áp dụng cho giấy phép bạn chọn; hai nhánh ngang hạng thì giữ nhánh đứng trước.
  • WITH giữ nhóm của giấy phép gốc, trừ ngoại lệ liên kết có trong chính sách: Classpath-exception-2.0 hạ strong-copyleft xuống weak-copyleft. Đó là lựa chọn của chính sách mẫu. Văn bản ngoại lệ ghi “As a special exception, the copyright holders of this library give you permission to link this library with independent modules”, và nhóm nào hợp với văn bản đó là việc của người đọc nó. Ngoại lệ chưa có trong chính sách chỉ gắn cờ exception, nhóm giữ nguyên.
  • Mã lạ, LicenseRef-… và chuỗi sai cú pháp thành unknown, bị chặn ở mọi kịch bản. AND với một mã lạ cho unknown; OR với một mã lạ vẫn chọn được nhánh đã biết.
  • Mã cũ của GNU (GPL-2.0, GPL-2.0+, …) được đọc như bản -only hoặc -or-later tương ứng và gắn cờ deprecated; phần sau nói rõ căn cứ. Dấu + trên mã hiện hành gắn cờ later.
import re
from dataclasses import dataclass, field

from spdx import And, Id, With, parse, render

FAMILIES = ("permissive", "weak-copyleft", "strong-copyleft", "network-copyleft", "unknown")

# Chính sách mẫu của bài, không phải phân loại của SPDX: thay bằng chính sách của tổ chức bạn.
POLICY = {
    "permissive": "MIT MIT-0 BSD-2-Clause BSD-3-Clause Apache-2.0 ISC Zlib Unlicense CC0-1.0 0BSD",
    "weak-copyleft": "LGPL-2.1-only LGPL-2.1-or-later LGPL-3.0-only LGPL-3.0-or-later MPL-2.0 EPL-2.0",
    "strong-copyleft": "GPL-2.0-only GPL-2.0-or-later GPL-3.0-only GPL-3.0-or-later",
    "network-copyleft": "AGPL-3.0-only AGPL-3.0-or-later",
}
FAMILY_OF = {name.lower(): family for family, names in POLICY.items() for name in names.split()}
LINKING_EXCEPTIONS = {"classpath-exception-2.0"}
GNU_OLD = re.compile(r"(?:GPL|LGPL|AGPL)-\d\.\d", re.IGNORECASE)
ACTIONS = {
    "internal": dict(zip(FAMILIES, ("allow", "allow", "allow", "review", "block"))),
    "distribute": dict(zip(FAMILIES, ("allow", "review", "review", "review", "block"))),
}


@dataclass
class Verdict:
    family: str
    choices: list = field(default_factory=list)  # nhánh OR đã chọn
    flags: list = field(default_factory=list)  # (loại, nội dung)

    @property
    def rank(self) -> int:
        return FAMILIES.index(self.family)


def lookup(name: str, flags: list | None = None) -> Verdict:
    family = FAMILY_OF.get(name.lower())
    if family:
        return Verdict(family, flags=flags or [])
    return Verdict("unknown", flags=[*(flags or []), ("unknown", f"{name} chưa có trong chính sách")])


def evaluate(node) -> Verdict:
    if isinstance(node, Id):
        if GNU_OLD.fullmatch(node.name):
            new = node.name + ("-or-later" if node.later else "-only")
            old = node.name + ("+" if node.later else "")
            return lookup(new, [("deprecated", f"{old} là id cũ, dùng {new}")])
        verdict = lookup(node.name)
        if node.later:
            note = f"{node.name}+ chấp nhận cả bản mới hơn: nghĩa vụ có thể đổi theo bản"
            verdict.flags.append(("later", note))
        return verdict
    if isinstance(node, With):
        verdict = evaluate(node.base)
        if node.exception.lower() in LINKING_EXCEPTIONS:
            if verdict.family == "strong-copyleft":
                verdict.family = "weak-copyleft"
                note = f"WITH {node.exception}: hạ xuống weak-copyleft theo chính sách mẫu"
                verdict.flags.append(("downgrade", note))
        else:
            verdict.flags.append(("exception", f"WITH {node.exception} chưa có trong chính sách"))
        return verdict
    parts = [evaluate(item) for item in node.items]
    if isinstance(node, And):
        worst = max(parts, key=lambda part: part.rank)
        choices = [choice for part in parts for choice in part.choices]
        flags = [flag for part in parts for flag in part.flags]
        return Verdict(worst.family, choices, flags)
    best = min(range(len(parts)), key=lambda i: parts[i].rank)
    old_ids = [f for i, part in enumerate(parts) if i != best for f in part.flags if f[0] == "deprecated"]
    choices = [render(node.items[best]), *parts[best].choices]
    return Verdict(parts[best].family, choices, [*parts[best].flags, *old_ids])


def decide(verdict: Verdict, scenario: str) -> str:
    return ACTIONS[scenario][verdict.family]


def assess(text: str) -> Verdict:
    return evaluate(parse(text))

Bảng chính sách mẫu trong code, đọc theo hàng:

NhómMã trong chính sách mẫuNội bộPhân phối
permissiveMIT, MIT-0, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, Zlib, Unlicense, CC0-1.0, 0BSDallowallow
weak-copyleftLGPL 2.1 và 3.0 (cả -only và -or-later), MPL-2.0, EPL-2.0allowreview
strong-copyleftGPL 2.0 và 3.0 (cả -only và -or-later)allowreview
network-copyleftAGPL-3.0 (cả -only và -or-later)reviewreview
unknownmọi mã khác, LicenseRef-…, chuỗi sai cú phápblockblock

Cả 22 mã trong bảng là mã hiện hành của SPDX License List 3.29.0 (kiểm một lần ngày 2026-10-04 bằng cách đọc HTML của trang, ngoài lab). Hai cột kịch bản là hai cách dùng khác nhau của cùng một phần mềm, và căn cứ của chúng đọc từ GNU GPL FAQ ngày 2026-10-04:

  • Nội bộ. Mục #GPLRequireSourcePostedPublic: “The GPL does not require you to release your modified version, or any part of it”, và “an organization can make a modified version and use it internally without ever releasing it outside the organization”. Mục #InternalDistribution nói làm và dùng nhiều bản sao trong một tổ chức không phải phân phối. Vì vậy strong-copyleft là allow ở cột nội bộ.
  • Ranh giới của “nội bộ” hẹp hơn người ta nghĩ. Cũng ở #InternalDistribution: “providing copies to contractors for use off-site is distribution”. Quyết định gắn với kịch bản thật, không với loại giấy phép: chuyển bản cho nhà thầu làm việc bên ngoài là chuyển cả phần mềm sang cột “phân phối”.
  • Phân phối. Mục #LinkingWithGPL: liên kết với một chương trình GPL nghĩa là “you must release your program under a license compatible with the GPL”. Bài xếp nhóm này vào review.
  • AGPL. Mục #UnreleasedModsAGPL: AGPL “requires that modified versions of the software offer all users interacting with it over a computer network an opportunity to receive the source”. Nên network-copyleft là review cả ở cột nội bộ, khi dịch vụ có người dùng truy cập qua mạng.
  • Nhóm trung gian và permissive. Bài không đọc văn bản LGPL, MPL hay EPL nên không mô tả nghĩa vụ của chúng; weak-copyleft là review ở cột phân phối chỉ vì chính sách mẫu chọn thế. Với MIT, văn bản trên SPDX có điều kiện “The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software”, nên allow ở đây chưa bao gồm việc giữ thông báo đó.

Mã cũ của GNU

Trang SPDX License List ghi: “Release 3.0 replaced previous Identifiers for GNU licenses with more explicit Identifiers to reflect the ‘this version only’ or ‘any later version’ option specific to those licenses.” Bảng “Deprecated License Identifiers” của trang có ba cột (tên đầy đủ, mã, “Deprecated as of:”) và không có cột mã thay thế. Sáu hàng bài dùng:

Mã cũTên đầy đủ trong bảngDeprecated as ofBài đọc thành
GPL-2.0GNU General Public License v2.0 only3.0GPL-2.0-only
GPL-2.0+GNU General Public License v2.0 or later2.0rc2GPL-2.0-or-later
GPL-3.0GNU General Public License v3.0 only3.0GPL-3.0-only
GPL-3.0+GNU General Public License v3.0 or later2.0rc2GPL-3.0-or-later
LGPL-2.1GNU Lesser General Public License v2.1 only3.0LGPL-2.1-only
AGPL-3.0GNU Affero General Public License v3.03.0AGPL-3.0-only

Cột cuối là suy luận từ tên đầy đủ (“only”, “or later”) và từ câu của trang, không phải dữ liệu của bảng; bài kiểm cả sáu mã thay thế có mặt trong danh sách hiện hành. Biểu thức GNU_OLD trong code tổng quát hóa sáu hàng này cho GPL, LGPL và AGPL với số phiên bản dạng N.M; mã cũ khác (như AGPL-1.0, deprecated từ 3.1) cần đọc bảng. Bảng còn có các mã gộp ngoại lệ vào tên, như GPL-2.0-with-classpath-exception (deprecated từ 2.0rc2), và trang ghi cách dùng đúng là “the License Expression syntax as of v2.0”. Bộ đọc coi mã gộp là mã lạ, nên chúng rơi vào unknown và đến tay người.

Lab: kiểm thử

Lưu test_license.py rồi chạy. Test dùng bảng dữ liệu để ghim những điều bên trên bằng ví dụ cụ thể: cây của từng cặp toán tử và các dạng khoảng trắng; các ví dụ của chính đặc tả khứ hồi qua render; toán tử chữ thường bị từ chối; 22 chuỗi sai cú pháp bị từ chối; nhóm, nhánh OR đã chọn và loại cờ của 25 biểu thức (gồm AND lấy nhóm khó nhất, OR lấy nhánh dễ nhất, ngoại lệ liên kết chỉ hạ strong-copyleft, và ba tên kiểu Debian: BSD-3-clause khớp mã SPDX không phân biệt hoa thường, còn Expat và GPL-2+ thành unknown); sáu mã cũ đọc thành đúng nhóm của mã thay thế; và bảng quyết định theo kịch bản.

import unittest

from policy import ACTIONS, FAMILIES, Verdict, assess, decide
from spdx import SpdxError, parse, render, show

TREES = {
    "A OR B AND C": "OR(A, AND(B, C))",
    "A AND B OR C": "OR(AND(A, B), C)",
    "A AND B WITH E": "AND(A, WITH(B, E))",
    "A+ WITH E": "WITH(A+, E)",
    "A+ OR B": "OR(A+, B)",
    "(A OR B) AND C": "AND(OR(A, B), C)",
    "A OR B OR C": "OR(A, B, C)",
    "A AND (B AND C)": "AND(A, AND(B, C))",
    "MIT AND(Apache-2.0)": "AND(MIT, Apache-2.0)",
    "  MIT   OR \t Apache-2.0 ": "OR(MIT, Apache-2.0)",
    "MITORApache-2.0": "MITORApache-2.0",
}
SPEC_EXAMPLES = [
    "LGPL-2.1-only OR MIT",
    "LGPL-2.1-only AND MIT",
    "GPL-2.0-or-later WITH Bison-exception-2.2",
    "LGPL-2.1-only OR BSD-3-Clause AND MIT",
    "MIT AND (LGPL-2.1-or-later OR BSD-3-Clause)",
    "CDDL-1.0+",
    "LicenseRef-23",
    "LicenseRef-MIT-Style-1",
    "DocumentRef-spdx-tool-1.2:LicenseRef-MIT-Style-2",
]
INVALID = [
    "", "   ", "()", "MIT OR", "OR MIT", "MIT AND OR Apache-2.0", "(MIT", "MIT)", "MIT Apache-2.0",
    "MIT WITH", "MIT WITH Foo+", "(MIT) WITH Foo", "MIT WITH A WITH B", "MIT WITH AND",
    "MIT/Apache-2.0", "MIT, Apache-2.0", "A++", "MIT +", "MIT OR\nApache-2.0",
    "LicenseRef-", "LicenseRef-x+", "DocumentRef-x:MIT",
]  # fmt: skip
# biểu thức: (nhóm, nhánh OR đã chọn, loại cờ)
VERDICTS = {
    "MIT": ("permissive", [], []),
    "mit": ("permissive", [], []),
    "MPL-2.0": ("weak-copyleft", [], []),
    "GPL-3.0-only": ("strong-copyleft", [], []),
    "AGPL-3.0-or-later": ("network-copyleft", [], []),
    "Foo-1.0": ("unknown", [], ["unknown"]),
    "LicenseRef-vendor": ("unknown", [], ["unknown"]),
    "MIT AND GPL-3.0-only": ("strong-copyleft", [], []),
    "MIT AND Foo-1.0": ("unknown", [], ["unknown"]),
    "MIT AND AGPL-3.0-only AND GPL-3.0-only": ("network-copyleft", [], []),
    "MIT OR GPL-3.0-only": ("permissive", ["MIT"], []),
    "Apache-2.0 OR MIT": ("permissive", ["Apache-2.0"], []),
    "GPL-3.0-only OR AGPL-3.0-only": ("strong-copyleft", ["GPL-3.0-only"], []),
    "Foo-1.0 OR GPL-3.0-only": ("strong-copyleft", ["GPL-3.0-only"], []),
    "GPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT)": ("strong-copyleft", ["MIT"], []),
    "MIT OR GPL-2.0": ("permissive", ["MIT"], ["deprecated"]),
    "Apache-2.0+": ("permissive", [], ["later"]),
    "GPL-2.0-only WITH Classpath-exception-2.0": ("weak-copyleft", [], ["downgrade"]),
    "AGPL-3.0-only WITH Classpath-exception-2.0": ("network-copyleft", [], []),
    "MIT WITH Classpath-exception-2.0": ("permissive", [], []),
    "GPL-2.0-or-later WITH Bison-exception-2.2": ("strong-copyleft", [], ["exception"]),
    "Foo-1.0 WITH Classpath-exception-2.0": ("unknown", [], ["unknown"]),
    "BSD-3-clause": ("permissive", [], []),
    "Expat": ("unknown", [], ["unknown"]),
    "GPL-2+": ("unknown", [], ["unknown", "later"]),
}
DEPRECATED = {
    "GPL-2.0": "GPL-2.0-only",
    "GPL-2.0+": "GPL-2.0-or-later",
    "GPL-3.0": "GPL-3.0-only",
    "GPL-3.0+": "GPL-3.0-or-later",
    "LGPL-2.1": "LGPL-2.1-only",
    "AGPL-3.0": "AGPL-3.0-only",
}


class TreeTests(unittest.TestCase):
    def test_trees_follow_plus_with_and_or(self):
        for text, tree in TREES.items():
            with self.subTest(text):
                self.assertEqual(show(parse(text)), tree)

    def test_spec_examples_round_trip_and_render_shows_precedence(self):
        for text in SPEC_EXAMPLES:
            with self.subTest(text):
                node = parse(text)
                self.assertEqual(parse(render(node)), node)
        node = parse("LGPL-2.1-only OR BSD-3-Clause AND MIT")
        self.assertEqual(render(node), "LGPL-2.1-only OR (BSD-3-Clause AND MIT)")

    def test_operators_are_case_sensitive(self):
        for text in ("MIT or Apache-2.0", "MIT And Apache-2.0", "GPL-2.0-only with Classpath-exception-2.0"):
            with self.subTest(text), self.assertRaises(SpdxError):
                parse(text)

    def test_invalid_strings_are_rejected(self):
        for text in INVALID:
            with self.subTest(text), self.assertRaises(SpdxError):
                parse(text)


class PolicyTests(unittest.TestCase):
    def test_verdicts(self):
        for text, expected in VERDICTS.items():
            with self.subTest(text):
                verdict = assess(text)
                actual = (verdict.family, verdict.choices, [kind for kind, _ in verdict.flags])
                self.assertEqual(actual, expected)

    def test_deprecated_gnu_ids_read_as_their_explicit_replacement(self):
        for old, new in DEPRECATED.items():
            with self.subTest(old):
                verdict = assess(old)
                self.assertEqual(verdict.family, assess(new).family)
                self.assertEqual(len(verdict.flags), 1)
                self.assertIn(new, verdict.flags[0][1])

    def test_or_is_commutative_for_the_family(self):
        for left, right in (("MIT", "GPL-3.0-only"), ("Apache-2.0", "MIT"), ("Foo-1.0", "MPL-2.0")):
            with self.subTest(left=left, right=right):
                self.assertEqual(assess(f"{left} OR {right}").family, assess(f"{right} OR {left}").family)

    def test_decisions_per_scenario(self):
        expected = {
            "internal": ["allow", "allow", "allow", "review", "block"],
            "distribute": ["allow", "review", "review", "review", "block"],
        }
        self.assertEqual(set(ACTIONS), set(expected))
        for scenario, actions in expected.items():
            for family, action in zip(FAMILIES, actions, strict=True):
                with self.subTest(scenario=scenario, family=family):
                    self.assertEqual(decide(Verdict(family), scenario), action)


if __name__ == "__main__":
    unittest.main()
python3 -B test_license.py 2>&1

Đọc kết quả: 8 test đạt. Tác giả đã cố ý phá năm chỗ trong code trên (đảo thứ tự AND và OR, nhận toán tử chữ thường, cho AND lấy nhóm dễ nhất, bỏ cờ mã cũ ở nhánh không chọn, hạ nhóm cho mọi nhóm khó hơn weak-copyleft khi gặp ngoại lệ liên kết) và thấy ít nhất một test đỏ ở mỗi lần phá; các lần phá không nằm trong lab, chỉ để kiểm test không rỗng.

Lab: cây và bảng chính sách

Lưu demo.py. Phần đầu in ba dạng của bốn biểu thức (chuỗi gốc, dạng có ngoặc tường minh và cây tiền tố); phần hai chạy mười một biểu thức qua chính sách ở cả hai kịch bản.

from policy import assess, decide
from spdx import parse, render, show

print("== Cây của biểu thức")
for text in (
    "LGPL-2.1-only OR BSD-3-Clause AND MIT",
    "MIT AND (LGPL-2.1-or-later OR BSD-3-Clause)",
    "GPL-2.0-or-later WITH Bison-exception-2.2",
    "CDDL-1.0+ OR MIT AND Zlib",
):
    node = parse(text)
    print(f"{text}\n    {render(node)}\n    {show(node)}")

print("== Chính sách mẫu")
print(f"{'biểu thức':50} {'nhóm':17} {'nội bộ':7} {'phân phối':9} nhánh đã chọn")
for text in (
    "MIT",
    "MIT OR GPL-3.0-only",
    "MIT AND GPL-3.0-only",
    "GPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT)",
    "GPL-2.0-only WITH Classpath-exception-2.0",
    "GPL-2.0-or-later WITH Bison-exception-2.2",
    "AGPL-3.0-only",
    "GPL-2.0+",
    "Foo-1.0",
    "MIT OR Foo-1.0",
    "MIT AND Foo-1.0",
):
    verdict = assess(text)
    kinds = ",".join(sorted({kind for kind, _ in verdict.flags})) or "-"
    print(
        f"{text:50} {verdict.family:17} {decide(verdict, 'internal'):7} {decide(verdict, 'distribute'):9} "
        f"{', '.join(verdict.choices) or '-'}  [{kinds}]"
    )
python3 -B demo.py
== Cây của biểu thức
LGPL-2.1-only OR BSD-3-Clause AND MIT
    LGPL-2.1-only OR (BSD-3-Clause AND MIT)
    OR(LGPL-2.1-only, AND(BSD-3-Clause, MIT))
MIT AND (LGPL-2.1-or-later OR BSD-3-Clause)
    MIT AND (LGPL-2.1-or-later OR BSD-3-Clause)
    AND(MIT, OR(LGPL-2.1-or-later, BSD-3-Clause))
GPL-2.0-or-later WITH Bison-exception-2.2
    GPL-2.0-or-later WITH Bison-exception-2.2
    WITH(GPL-2.0-or-later, Bison-exception-2.2)
CDDL-1.0+ OR MIT AND Zlib
    CDDL-1.0+ OR (MIT AND Zlib)
    OR(CDDL-1.0+, AND(MIT, Zlib))
== Chính sách mẫu
biểu thức                                          nhóm              nội bộ  phân phối nhánh đã chọn
MIT                                                permissive        allow   allow     -  [-]
MIT OR GPL-3.0-only                                permissive        allow   allow     MIT  [-]
MIT AND GPL-3.0-only                               strong-copyleft   allow   review    -  [-]
GPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT)    strong-copyleft   allow   review    MIT  [-]
GPL-2.0-only WITH Classpath-exception-2.0          weak-copyleft     allow   review    -  [downgrade]
GPL-2.0-or-later WITH Bison-exception-2.2          strong-copyleft   allow   review    -  [exception]
AGPL-3.0-only                                      network-copyleft  review  review    -  [-]
GPL-2.0+                                           strong-copyleft   allow   review    -  [deprecated]
Foo-1.0                                            unknown           block   block     -  [unknown]
MIT OR Foo-1.0                                     permissive        allow   allow     MIT  [-]
MIT AND Foo-1.0                                    unknown           block   block     -  [unknown]

Đọc kết quả:

  • Đọc từ trái sang phải sẽ sai. LGPL-2.1-only OR BSD-3-Clause AND MIT thành LGPL-2.1-only OR (BSD-3-Clause AND MIT): chọn LGPL-2.1-only là đủ. Đọc (LGPL-2.1-only OR BSD-3-Clause) AND MIT thì MIT luôn bắt buộc, một bộ nghĩa vụ khác. render đặt ngoặc để người đọc thấy cách hiểu của máy. CDDL-1.0+ OR MIT AND Zlib cho thấy + dính vào mã, không phải toán tử đứng riêng.
  • AND và OR khác nhau ở chỗ có lựa chọn. MIT AND GPL-3.0-only là strong-copyleft; MIT OR GPL-3.0-only là permissive với nhánh MIT được ghi lại. Trong GPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT), chọn MIT ở vế ngoặc không hạ được nhóm, vì vế GPL là bắt buộc.
  • Ngoại lệ không tự động nhẹ hơn. Classpath-exception-2.0 có trong chính sách nên GPL-2.0-only WITH Classpath-exception-2.0 xuống weak-copyleft; Bison-exception-2.2 chưa có trong chính sách nên GPL-2.0-or-later WITH Bison-exception-2.2 giữ strong-copyleft và chỉ gắn cờ exception để người quyết định có thêm ngoại lệ đó vào chính sách hay không.
  • Cùng một chuỗi, hai quyết định. strong-copyleft là allow ở cột nội bộ và review ở cột phân phối; network-copyleft là review ở cả hai; unknown bị chặn ở cả hai. MIT OR Foo-1.0 qua được vì nhánh MIT đủ, còn MIT AND Foo-1.0 bị chặn vì phải tuân thủ cả Foo-1.0.

Hai nơi khác cũng ghi giấy phép: Debian và Homebrew

Debian định nghĩa định dạng tệp copyright đọc được bằng máy (bản 1.0 của định dạng; trang đề ngày 2026-03-31). Trang ghi rằng định dạng này và SPDX “attempt to be somewhat compatible. However, the two formats have different aims, and so the formats are different.”

ĐiểmSPDX 2.3Debian copyright-format 1.0
Toán tửAND, OR, WITH viết hoa; nên so khớp phân biệt hoa thườngand, or (ví dụ của trang viết thường); tên giấy phép không phân biệt hoa thường
Ưu tiên+ WITH AND OR; dùng ngoặc để đổi“and” ưu tiên hơn “or” trừ khi đứng sau dấu phẩy: A or B and C là A or (B and C), còn A or B, and C là (A or B) and C
Ngoại lệWITH cộng mã trong danh sách ngoại lệchữ tự do: thêm with từ-khóa exception vào tên ngắn
Hậu tố +bản này hoặc mới hơn, dính liền vào mãcùng ý, dính liền vào tên ngắn (GPL-1+); tên GPL trơn là bản 1
Tênmã trong danh sách SPDX hoặc LicenseRef-…tên ngắn; trang khuyên dùng Expat thay cho MIT khi khớp

Hệ quả cho code: chuỗi Debian đưa thẳng vào bộ đọc SPDX bị từ chối ở toán tử chữ thường (test ghim MIT or Apache-2.0), và dù đổi toán tử sang chữ hoa thì tên như GPL-2, GPL-2+ hay Expat không nằm trong SPDX License List 3.29.0 (đã kiểm ngày 2026-10-04), nên chính sách xếp chúng vào unknown và chặn thay vì đoán. Đổi từ Debian sang SPDX cần một bảng ánh xạ tên có kiểm và người đọc các ca có dấu phẩy hoặc with … exception. Bài không làm bộ đổi vì chưa chạy trên Linux.

Homebrew ghi giấy phép trong trường license của formula. Formula Cookbook hướng dẫn dùng mã trong SPDX License List, license :public_domain cho phần mềm thuộc phạm vi công cộng, và :any_of, :all_of, :with cho biểu thức phức tạp: :any_of “should be used when the user can choose which licence to use”, :all_of khi “the user must use all licences”, :with để chỉ định “a valid SPDX exception”, và thêm + vào mã để chỉ các bản mới hơn của cùng giấy phép. Ba từ khóa ứng với OR, AND, WITH của SPDX. Bước Homebrew bên dưới đo xem JSON của brew info có đúng là chuỗi biểu thức SPDX không.

Lab: kiểm kê tổng hợp

Bảng kiểm kê giả gồm 23 gói tự đặt tên (không phải phần mềm thật, giấy phép cũng không phải của phần mềm nào) với đủ các dạng chuỗi: mã đơn, OR, AND, WITH, mã cũ, mã lạ, LicenseRef-…, dấu + và ba chuỗi sai cú pháp (toán tử chữ thường, ô trống, dấu /). Lưu inventory.csv và report.py. Báo cáo đếm theo nhóm và theo quyết định ở cả hai kịch bản, liệt kê gói bị chặn kèm lý do, ghi lại nhánh OR đã chọn, nêu việc cần sửa metadata, so với cách tìm chuỗi GPL, và thoát mã 1 khi còn gói bị chặn ở kịch bản phân phối: dạng cổng dùng được trong CI.

package,version,license
alpha-lib,1.4.2,MIT
beta-cli,0.9.0,Apache-2.0
gamma-codec,2.1.0,BSD-3-Clause
delta-db,5.0.1,GPL-3.0-or-later
epsilon-ui,3.2.0,MIT OR GPL-3.0-only
zeta-net,1.0.0,LGPL-2.1-or-later AND MIT
eta-json,4.4.0,Apache-2.0 OR MIT
theta-sync,2.0.0,AGPL-3.0-only
iota-vm,8.0.0,GPL-2.0-only WITH Classpath-exception-2.0
kappa-parse,1.1.0,GPL-2.0-or-later WITH Bison-exception-2.2
lambda-old,0.5.0,GPL-2.0+
mu-old,0.6.0,GPL-2.0
nu-shell,5.2.0,GPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT)
xi-font,1.0.0,LicenseRef-vendor-eula
omicron-tool,3.1.0,Foo-1.0
pi-gfx,2.2.0,MIT AND Foo-1.0
rho-mpl,1.9.0,MPL-2.0
sigma-mixed,7.0.0,MIT OR (Apache-2.0 AND GPL-2.0-only)
tau-bad,0.1.0,MIT or Apache-2.0
upsilon-empty,0.2.0,
phi-slash,1.2.3,MIT/Apache-2.0
chi-lower,6.0.0,mit
psi-later,3.3.0,Apache-2.0+
import csv
import sys
from collections import Counter

from policy import FAMILIES, assess, decide
from spdx import SpdxError

with open("inventory.csv", encoding="utf-8", newline="") as stream:
    rows = list(csv.DictReader(stream))

results = []
for row in rows:
    try:
        results.append((row, assess(row["license"]), None))
    except SpdxError as error:
        results.append((row, None, str(error)))


def action(verdict, scenario):
    return "block" if verdict is None else decide(verdict, scenario)


print(f"== Nhóm ({len(rows)} gói)")
families = Counter("lỗi cú pháp" if verdict is None else verdict.family for _, verdict, _ in results)
for name in (*FAMILIES, "lỗi cú pháp"):
    print(f"{name:17}: {families[name]}")

print("== Quyết định theo kịch bản")
for scenario, label in (("internal", "nội bộ"), ("distribute", "phân phối")):
    counts = Counter(action(verdict, scenario) for _, verdict, _ in results)
    print(f"{label:10}: allow {counts['allow']}, review {counts['review']}, block {counts['block']}")

print("== Chặn (block) vì nhóm chưa biết hoặc chuỗi sai")
for row, verdict, error in results:
    if action(verdict, "distribute") == "block":
        why = f"lỗi cú pháp: {error}" if verdict is None else "; ".join(text for _, text in verdict.flags)
        print(f"{row['package']}: {why}")

print("== Nhánh OR đã chọn (ghi lại để biết nghĩa vụ nào đang áp dụng)")
for row, verdict, _ in results:
    if verdict and verdict.choices:
        print(f"{row['package']}: {', '.join(verdict.choices)}  <- {row['license']}")

print("== Cần sửa metadata hoặc bổ sung chính sách")
for row, verdict, _ in results:
    for kind, text in verdict.flags if verdict else ():
        if kind in ("deprecated", "later", "exception"):
            print(f"{row['package']}: [{kind}] {text}")

grep_hit = {row["package"] for row, _, _ in results if "gpl" in row["license"].lower()}
needs_look = {row["package"] for row, verdict, _ in results if action(verdict, "distribute") != "allow"}
print("== So với tìm chuỗi GPL (kịch bản phân phối)")
print(f"tìm chuỗi GPL báo {len(grep_hit)} gói; chính sách báo {len(needs_look)} gói")
print(f"báo thừa (chứa GPL nhưng chính sách cho phép): {len(grep_hit - needs_look)}")
print(f"bỏ sót (chính sách cần xem nhưng không chứa GPL): {len(needs_look - grep_hit)}")

blocked = sum(action(verdict, "distribute") == "block" for _, verdict, _ in results)
if blocked:
    print(f"không đạt: {blocked} gói bị chặn")
    sys.exit(1)
print("đạt")
python3 -B report.py
== Nhóm (23 gói)
permissive       : 8
weak-copyleft    : 3
strong-copyleft  : 5
network-copyleft : 1
unknown          : 3
lỗi cú pháp      : 3
== Quyết định theo kịch bản
nội bộ    : allow 16, review 1, block 6
phân phối : allow 8, review 9, block 6
== Chặn (block) vì nhóm chưa biết hoặc chuỗi sai
xi-font: LicenseRef-vendor-eula chưa có trong chính sách
omicron-tool: Foo-1.0 chưa có trong chính sách
pi-gfx: Foo-1.0 chưa có trong chính sách
tau-bad: lỗi cú pháp: thừa 'or' (toán tử SPDX phải viết hoa: AND, OR, WITH)
upsilon-empty: lỗi cú pháp: biểu thức rỗng
phi-slash: lỗi cú pháp: ký tự lạ '/' ở vị trí 3
== Nhánh OR đã chọn (ghi lại để biết nghĩa vụ nào đang áp dụng)
epsilon-ui: MIT  <- MIT OR GPL-3.0-only
eta-json: Apache-2.0  <- Apache-2.0 OR MIT
nu-shell: MIT  <- GPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT)
sigma-mixed: MIT  <- MIT OR (Apache-2.0 AND GPL-2.0-only)
== Cần sửa metadata hoặc bổ sung chính sách
kappa-parse: [exception] WITH Bison-exception-2.2 chưa có trong chính sách
lambda-old: [deprecated] GPL-2.0+ là id cũ, dùng GPL-2.0-or-later
mu-old: [deprecated] GPL-2.0 là id cũ, dùng GPL-2.0-only
psi-later: [later] Apache-2.0+ chấp nhận cả bản mới hơn: nghĩa vụ có thể đổi theo bản
== So với tìm chuỗi GPL (kịch bản phân phối)
tìm chuỗi GPL báo 10 gói; chính sách báo 15 gói
báo thừa (chứa GPL nhưng chính sách cho phép): 2
bỏ sót (chính sách cần xem nhưng không chứa GPL): 7
không đạt: 6 gói bị chặn

Đọc kết quả:

  • Chuỗi sai là một nhóm riêng, không phải permissive. Ba dòng tau-bad, upsilon-empty và phi-slash bị chặn kèm lý do của bộ đọc: toán tử chữ thường (gợi ý phải viết hoa), ô trống, ký tự / ở vị trí 3. Đọc rộng tay, ba dòng này rất dễ thành MIT.
  • mit chữ thường hợp lệ. chi-lower được nhận là permissive vì mã so khớp không phân biệt hoa thường, đúng như đặc tả; cũng chính đặc tả đòi toán tử viết hoa, nên MIT or Apache-2.0 bị chặn.
  • Hai kịch bản cho hai bảng quyết định. Ở cột nội bộ chỉ theta-sync (AGPL) cần xem, còn sáu gói bị chặn vì mã lạ hoặc chuỗi sai; ở cột phân phối thêm tám gói copyleft cần xem (weak ba, strong năm), tổng chín gói review.
  • Nhánh OR được ghi lại. epsilon-ui và sigma-mixed có nhánh GPL nhưng chọn MIT là đủ, nên không bị báo ở kịch bản nào; báo cáo ghi nhánh đã chọn để người ký duyệt biết nghĩa vụ nào đang áp dụng. Chọn nhánh khác là một quyết định khác, không phải chi tiết.
  • Tìm chuỗi GPL sai cả hai chiều trên mẫu này. Nó báo 10 gói, hai gói (epsilon-ui, sigma-mixed) báo thừa vì chọn được nhánh MIT; nó bỏ sót 7 gói chính sách cần xem (rho-mpl thuộc weak-copyleft, mã lạ, LicenseRef-… và các chuỗi sai). Mẫu do bài dựng để có đủ các dạng, nên các con số chỉ chứng minh kiểu sai, không phải tỉ lệ ngoài đời; số liệu thật ở bước sau.
  • Việc còn lại có tên. Bốn dòng của mục metadata chỉ ra ba việc khác nhau: sửa mã cũ (lambda-old, mu-old), cân nhắc dấu + (psi-later), và bổ sung chính sách cho ngoại lệ (kappa-parse).

Lab: dữ liệu thật từ Homebrew

Lưu brewcheck.py. Script chỉ chạy khi có brew: gọi brew info --json=v2 --installed với HOMEBREW_NO_AUTO_UPDATE=1 và HOMEBREW_NO_ANALYTICS=1, chỉ in số tổng hợp cùng mã giấy phép, không in tên gói. Lệnh có chủ ý chỉ đọc, nhưng bài không kiểm Homebrew có ghi thêm vào cache riêng của nó khi chạy hay không. Lệnh thứ hai chạy lại script với PATH trỏ vào thư mục không tồn tại để kiểm nhánh “không có brew”.

import json
import os
import shutil
import subprocess
from collections import Counter

from policy import FAMILIES, assess, decide
from spdx import SpdxError

brew = shutil.which("brew")
if brew is None:
    print("Homebrew: không có trên máy này, bỏ qua")
    print("kiểm tra Homebrew xong")
    raise SystemExit(0)

env = {**os.environ, "HOMEBREW_NO_AUTO_UPDATE": "1", "HOMEBREW_NO_ANALYTICS": "1"}
done = subprocess.run(
    [brew, "info", "--json=v2", "--installed"],
    capture_output=True, text=True, check=True, env=env, timeout=300,
)  # fmt: skip
data = json.loads(done.stdout)
licenses = [formula.get("license") for formula in data["formulae"]]
declared = [text for text in licenses if text]
print(f"formula đã cài: {len(licenses)}; có trường license: {len(declared)}; để trống: {len(licenses) - len(declared)}")
with_key = sum("license" in cask for cask in data["casks"])
print(f"cask đã cài: {len(data['casks'])}; cask có khóa license trong JSON: {with_key}")

verdicts, refused = [], []
for text in declared:
    try:
        verdicts.append((text, assess(text)))
    except SpdxError:
        refused.append(text)
print(f"chuỗi khác nhau: {len(set(declared))}; bộ đọc từ chối: {len(refused)}")
operators = Counter(op for text in declared for op in ("OR", "AND", "WITH") if f" {op} " in text)
print(f"chuỗi có OR: {operators['OR']}, có AND: {operators['AND']}, có WITH: {operators['WITH']}")
families = Counter(verdict.family for _, verdict in verdicts)
print("nhóm sau khi chọn nhánh OR: " + ", ".join(f"{name} {families[name]}" for name in FAMILIES))
for scenario, label in (("internal", "nội bộ"), ("distribute", "phân phối")):
    counts = Counter(decide(verdict, scenario) for _, verdict in verdicts)
    print(f"{label}: allow {counts['allow']}, review {counts['review']}, block {counts['block']}")
grep_hit = {n for n, (text, _) in enumerate(verdicts) if "gpl" in text.lower()}
needs_look = {n for n, (_, verdict) in enumerate(verdicts) if decide(verdict, "distribute") != "allow"}
print(f"tìm chuỗi GPL báo {len(grep_hit)} formula; chính sách (phân phối) báo {len(needs_look)}")
print(f"báo thừa: {len(grep_hit - needs_look)}; bỏ sót: {len(needs_look - grep_hit)}")
missed = Counter(verdicts[n][1].family for n in needs_look - grep_hit)
print("bỏ sót theo nhóm: " + ", ".join(f"{name} {missed[name]}" for name in FAMILIES if missed[name]))
unknown = Counter(text.split()[0] for _, verdict in verdicts for kind, text in verdict.flags if kind == "unknown")
common = ", ".join(f"{name} x{count}" for name, count in unknown.most_common(6))
print(f"id chưa có trong chính sách: {len(unknown)} loại; phổ biến nhất: {common}")
print("kiểm tra Homebrew xong")
python3 -B brewcheck.py
PATH=/nonexistent "$(command -v python3)" -B brewcheck.py

Kết quả trên máy tác giả (Homebrew 7.0.7, 2026-10-04; số trên máy bạn sẽ khác, nên khối này không có shows):

formula đã cài: 167; có trường license: 166; để trống: 1
cask đã cài: 15; cask có khóa license trong JSON: 0
chuỗi khác nhau: 62; bộ đọc từ chối: 0
chuỗi có OR: 16, có AND: 18, có WITH: 7
nhóm sau khi chọn nhánh OR: permissive 85, weak-copyleft 23, strong-copyleft 27, network-copyleft 0, unknown 31
nội bộ: allow 135, review 0, block 31
phân phối: allow 85, review 50, block 31
tìm chuỗi GPL báo 54 formula; chính sách (phân phối) báo 81
báo thừa: 2; bỏ sót: 29
bỏ sót theo nhóm: weak-copyleft 5, unknown 24
id chưa có trong chính sách: 46 loại; phổ biến nhất: Python-2.0 x4, OLDAP-2.8 x3, Unicode-3.0 x2, MIT-Modern-Variant x2, LGPL-2.0-or-later x2, MIT-CMU x2
kiểm tra Homebrew xong

Đọc kết quả:

  • JSON của Homebrew đúng là biểu thức SPDX. Cả 166 chuỗi đọc được bằng bộ đọc ở trên (từ chối 0), trong đó 16 chuỗi có OR, 18 có AND và 7 có WITH. Ngoại lệ và hợp nhiều giấy phép không hiếm: bảng nhận diện phải hiểu chúng chứ không chỉ khớp tên một mã.
  • Cask không có metadata giấy phép trong JSON này. 15 cask đã cài, không cask nào có khóa license; kiểm kê chỉ từ formula bỏ ngoài các ứng dụng cài bằng cask, nên cần nguồn khác cho phần đó. Một formula (1 trong 167) để trống trường license: thiếu metadata cũng là một ca cần người xem.
  • unknown là phần lớn việc bảo trì. Với bảng 22 mã, 31 trong 166 formula (18,7%) rơi vào unknown và bị chặn ở cả hai kịch bản. Chúng thuộc 46 loại mã khác nhau, mà mười mã phổ biến nhất bài đã kiểm đều là mã hiện hành của SPDX License List (kiểm ngày 2026-10-04): unknown nghĩa là chưa có trong bảng của bạn, không nghĩa là sai. Danh sách 3.29.0 có 708 mã hiện hành và 32 mã deprecated (đếm các thẻ mã trong HTML của trang), nên bảng 22 mã luôn chừa một đuôi dài. Mỗi mã thêm vào bảng là một lần người đọc giấy phép và quyết định nhóm; quy tắc theo tiền tố (như mọi mã bắt đầu bằng LGPL-) giảm công việc nhưng xếp nhầm các mã ngoại lệ, và bài không làm.
  • Tìm chuỗi GPL bỏ sót nhiều hơn báo thừa. Nó báo 54 formula; chính sách ở kịch bản phân phối báo 81. Hai formula chứa GPL được chính sách cho qua vì chọn được nhánh dễ hơn; 29 formula chính sách cần xem mà chuỗi không chứa GPL: 24 là mã lạ và 5 là weak-copyleft không có chữ GPL (trên máy này là MPL-2.0).
  • Chọn kịch bản trước khi đọc con số. Ở cột nội bộ 135 formula allow còn ở cột phân phối chỉ 85, hiệu số 50 review đều là weak-copyleft hoặc strong-copyleft. Kịch bản là đầu vào của quyết định; nó không suy ra được từ danh sách phần mềm.

Trên Ubuntu hoặc Debian (chưa chạy)

Bài chưa chạy gì trên Linux và không có số đo nào cho apt, dpkg hay snap. Điều bài biết từ nguồn: tệp copyright của gói Debian có thể theo định dạng ở trên với cú pháp khác SPDX (bảng so sánh); bài không khảo sát có bao nhiêu gói dùng định dạng máy đọc được. Khi chạy trên Linux, cần làm ba việc trước khi tin con số: lấy danh sách gói và chuỗi giấy phép từ nguồn có thể kiểm, đọc tay các chuỗi bị report.py từ chối (nhiều khả năng là chuỗi kiểu Debian), và dựng bảng ánh xạ tên Debian sang mã SPDX có đối chiếu với trang đặc tả. Pip, npm, cargo và snap là các kênh khác, mỗi kênh có metadata riêng mà bài không đo.

Quyết định và bẫy

Tình huốngQuyết địnhCăn cứ trong bài
Cột giấy phép là chuỗi tự doĐọc bằng bộ đọc đúng đặc tả, không tìm chuỗi conMẫu giả báo thừa 2 và bỏ sót 7; Homebrew báo thừa 2 và bỏ sót 29
Gặp A OR B AND CĐọc là A OR (B AND C)Đặc tả: AND ưu tiên hơn OR; test ghim cây
Gặp ORChọn nhánh, ghi nhánh đã chọn vào kiểm kêBáo cáo mục “Nhánh OR đã chọn”; nghĩa vụ chỉ áp dụng cho giấy phép được chọn
Gặp ANDLấy nhóm khó nhất của các vếPhải tuân thủ cả hai vế; MIT AND GPL-3.0-only là strong-copyleft
Gặp WITHChỉ đổi nhóm khi ngoại lệ có trong chính sách và có người đọcClasspath-exception-2.0 hạ GPL; Bison-exception-2.2 chỉ gắn cờ
Gặp mã cũ như GPL-2.0 hoặc GPL-2.0+Đọc thành bản -only hoặc -or-later và gắn cờ sửa metadataBảng deprecated của SPDX: “only” và “or later” trong tên đầy đủ
Gặp mã lạ hoặc LicenseRef-…Chặn đến khi người đọc và thêm vào chính sáchHomebrew: 31 trong 166 formula (18,7%) với bảng 22 mã; mã phổ biến nhất vẫn là mã SPDX hợp lệ
Chuỗi sai cú pháp hoặc ô trốngChặn, không đoántau-bad, upsilon-empty, phi-slash trong mẫu
Nguồn là cask hoặc kênh không có metadataLiệt kê riêng, không coi là “không có giấy phép”15 cask, 0 có khóa license trong JSON
“Dùng nội bộ” nhưng chuyển bản cho nhà thầuChuyển sang kịch bản phân phốiGNU GPL FAQ #InternalDistribution: cung cấp bản sao cho nhà thầu dùng bên ngoài là phân phối
Chuỗi từ DebianKhông đưa thẳng vào bộ đọc SPDX; ánh xạ có kiểm hoặc đọc tayToán tử chữ thường, dấu phẩy đổi ưu tiên, with … exception là chữ tự do

Giới hạn

  • Bài không phải tư vấn pháp lý. Nhóm rủi ro, hai cột kịch bản và việc hạ nhóm theo ngoại lệ liên kết là chính sách mẫu của bài, dựng để minh họa; bài không đọc văn bản LGPL, MPL, EPL hay Apache-2.0 và không xét tương thích giữa các giấy phép trong cùng một sản phẩm. Các trích dẫn GNU GPL FAQ là câu trả lời cho câu hỏi cụ thể của từng mục, không thay cho việc đọc giấy phép của phần mềm bạn dùng.
  • Bộ đọc theo đặc tả SPDX 2.3 (phụ lục D), bài không đọc các bản 3.x của đặc tả. Bộ đọc kiểm cú pháp, không kiểm mã và mã ngoại lệ có trong danh sách hay không; việc đó nằm ở chính sách (unknown). Bài chưa chạy bộ đọc trên tập chuỗi giấy phép lớn nào ngoài 23 dòng giả và 166 chuỗi Homebrew của một máy.
  • Quy tắc mã cũ của GNU tổng quát hóa từ sáu hàng đã đọc và từ một câu của trang SPDX; các mã deprecated khác (ví dụ AGPL-1.0, mã gộp ngoại lệ) không được ánh xạ. Mã thay thế -only, -or-later là suy luận của bài, chưa phải dữ liệu của bảng.
  • Số đo Homebrew thuộc một máy macOS arm64, Homebrew 7.0.7, ngày 2026-10-04. Chuỗi license là dữ liệu do người đóng gói khai, bài không đối chiếu với giấy phép thật của từng formula và không xét phụ thuộc bắc cầu. Bài không kiểm brew info có ghi cache của Homebrew hay không; đã đặt HOMEBREW_NO_AUTO_UPDATE=1 và HOMEBREW_NO_ANALYTICS=1 để giảm tác dụng phụ.
  • Chưa chạy trên Linux, apt/dpkg, snap, pip, npm hay cargo; bài không đo mức độ phủ của tệp copyright định dạng máy đọc được và không viết bộ đổi Debian sang SPDX.
  • Lab không ghi tệp ngoài thư mục bạn đã tạo; xóa thư mục đó là dọn xong.

Học tiếp và nguồn

  • Setup macOS đầy đủ: cài và quản lý phần mềm bằng Homebrew và Brewfile (mục 2 và 3), nơi danh sách formula của bài có nguồn gốc.
  • SPDX, SPDX License Expressions (đặc tả 2.3, phụ lục D): ngữ pháp, thứ tự ưu tiên, chữ hoa chữ thường, +, WITH, LicenseRef.
  • SPDX, SPDX License List (bản 3.29.0, 2026-09-16): mã giấy phép và bảng “Deprecated License Identifiers”.
  • SPDX, MIT License và Classpath-exception-2.0: văn bản điều kiện giữ thông báo của MIT và văn bản ngoại lệ liên kết.
  • Debian, Machine-readable debian/copyright file (bản 1.0): cú pháp trường License, and/or, dấu phẩy, with … exception, so sánh với SPDX.
  • Homebrew, Formula Cookbook: trường license, :any_of, :all_of, :with, :public_domain.
  • GNU, GPL FAQ: #GPLRequireSourcePostedPublic, #InternalDistribution, #LinkingWithGPL, #UnreleasedModsAGPL.

Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc Python 3.14.4 và Homebrew 7.0.7 trên macOS arm64, không có nghiệm thu Linux.

Mã thoát của job: để bộ lập lịch biết khi nào dừng và khi nào thử lại

Câu hỏi bài này trả lời: một job chạy dưới Kubernetes hay AWS Batch chỉ báo kết quả cho bộ lập lịch bằng một con số; những cách viết script nào khiến con số đó thành 0 dù việc đã hỏng; shell tự đặt những mã nào; và một hợp đồng mã thoát tối thiểu trông ra sao để lỗi tạm thời được thử lại có giới hạn còn lỗi xác thực thì dừng ngay?

Cần biết trước: shell cơ bản ($?, pipeline, ||), mã trạng thái HTTP, đọc Python ngắn. Lab dùng bash 3.2.57 (bản đi kèm macOS), curl 8.7.1 và Python 3.14.4 (thư viện chuẩn) trên macOS arm64, với một API thử chạy cục bộ ở cổng do hệ điều hành cấp; không cần mạng ngoài. Lab chưa chạy trên Linux, trên bash hay curl bản khác, trên Kubernetes hay AWS Batch: phần nền tảng chỉ trích tài liệu đọc ngày 2026-10-04 và ghi rõ là chưa chạy.

Bộ lập lịch chỉ đọc một con số

Khi tiến trình của job kết thúc, thứ bộ lập lịch nhận được là mã thoát. Ví dụ podFailurePolicy trong tài liệu Job của Kubernetes viết: “an exit code of 0 means that the container succeeded”, còn “any other exit code represents that the container failed, and hence the entire Pod. The Pod will be re-created if the total number of restarts is below backoffLimit”. AWS Batch liệt kê trong các tình huống thất bại có thể thử lại: “Any non-zero exit code from a container job”.

Có hai hướng sai, và cả hai đều tốn kém:

  • Báo 0 khi việc đã hỏng. Không tầng nào phía sau thấy lỗi: không thử lại, không cảnh báo, và job kế tiếp đọc dữ liệu hỏng như dữ liệu tốt.
  • Báo cùng một mã khác 0 cho mọi lỗi. Bộ lập lịch không phân biệt được lỗi nên thử lại (mạng chập chờn) với lỗi thử lại vô ích (thiếu token), nên chỉ còn hai lựa chọn: thử lại tất cả hoặc không thử lại gì.

Bài đi từ hướng sai thứ nhất sang cách giải hướng thứ hai bằng một hợp đồng mã thoát.

Ba cách làm mã thoát thành 0 dù việc đã hỏng

Mỗi cách có một bản sai và một bản sửa trong lab, số liệu ở phần kết quả bên dưới:

Cách viếtVì sao thoát 0Dấu vết sai còn lạiBản sửa
lệnh || truelệnh lỗi nằm trong danh sách ||; trạng thái cả danh sách là truedòng “xong” vẫn in, bước sau chạy tiếpbỏ || true; lab thoát 1 (mã của cp)
pipeline không pipefailtrạng thái pipeline là của lệnh cuốidữ liệu đầu vào cụt, bước sau nhận thiếuset -o pipefail; lab thoát 3 (mã lệnh đầu)
curl không --failHTTP 401 vẫn là một phản hồi nhận được nên curl thoát 0tệp đích chứa JSON báo lỗi như thể là dữ liệu--fail: thoát 22 và không tạo tệp

Căn cứ trong tài liệu: “The exit status of a pipeline is the exit status of the last command in the pipeline, unless the pipefail option is enabled”; với pipefail, trạng thái là mã của “the last (rightmost) command to exit with a non-zero status”. Về set -e, tài liệu Bash nói shell không thoát khi lệnh lỗi là một phần của any command executed in a && or || list except the command following the final && or || hoặc của any command in a pipeline but the last (subject to the state of the pipefail shell option). Với curl, mã 22 là “HTTP page not retrieved” và “This return code only appears if –fail is used”.

--fail chưa đủ để phân loại

Hai ca sau cần cách xử lý khác hẳn nhau: HTTP 401 là sai cấu hình, thử lại vô ích; HTTP 503 là sự cố tạm thời, thử lại có thể qua. Nhưng --fail cho cả hai cùng mã 22 (dòng curl --fail: HTTP 401 thoát 22, HTTP 503 thoát 22 trong kết quả), nên bộ lập lịch không có gì để phân biệt. Tài liệu curl còn ghi --fail “is not fail-safe and there are occasions where non-successful response codes slip through, especially when authentication is involved (response codes 401 and 407)”. Vì hai lý do đó, fetch.sh trong lab không dựa vào --fail: nó đọc %{http_code} (theo tài liệu curl là “The numerical response code that was found in the last retrieved HTTP(S) or FTP(s) transfer”) rồi tự đổi sang mã thoát của hợp đồng. Lab không tái hiện được trường hợp “slip through” vì nó cần cơ chế xác thực nhiều bước; câu đó chỉ là trích tài liệu.

Bỏ qua lỗi: đúng một mã đã biết

Có lúc một lệnh thất bại là chấp nhận được, ví dụ một bước trả mã 4 để nói “không có gì để làm”. Cách an toàn là bỏ qua đúng mã đó và để mọi mã khác đi qua: step "$1" || { rc=$?; [ "$rc" -eq 4 ] || exit "$rc"; }. Trong lab, mã 4 cho chạy tiếp (thoát 0) còn mã 9 thoát 9 chứ không bị nuốt. || true là bản không phân biệt mã nào.

Mã do shell đặt

Một số con số đã có nghĩa cố định trước khi job của bạn viết dòng nào:

MãKhi nàoCa trong labCăn cứ
126tìm thấy lệnh nhưng không thực thi được./noexec.sh thiếu quyền thực thiBash: “If a command is found but is not executable, the return status is 126”; POSIX: “the exit status shall be 126”
127không tìm thấy lệnhkhong-co-lenhBash: “a status of 127”; POSIX: “the exit status shall be 127”
128+Ntiến trình chết vì tín hiệu số NSIGTERM (N là 15) cho 143Bash: “Bash uses the value 128+N as the exit status”; POSIX: “an exit status greater than 128” và “in an implementation-defined manner, which signal”

Mã của ứng dụng nên tránh 126, 127 và từ 128 trở lên, để mỗi con số trong log chỉ có một nghĩa. Mục POSIX nói trên là “2.8.2 Exit Status for Commands” trong bản POSIX.1-2024.

Hợp đồng mã thoát của bài

MãNghĩaBộ lập lịch nênCa trong lab
0xong, tệp đích đã ghiđi tiếpHTTP 200
10lỗi tạm thờithử lại có giới hạnHTTP 408, 429, 500, 503; quá thời gian (curl 28); cổng đóng (curl 7)
11xác thực hoặc ủy quyềndừng và báo người sửa cấu hìnhHTTP 401, 403
1lỗi khácdừng và để người đọc logHTTP 404; HTTP 302 vì fetch.sh không tự theo chuyển hướng

Mã 10 và 11 là quy ước của bài, không phải chuẩn: chúng nhỏ hơn 126 và khác 1, mã chung chung nhất. Việc xếp HTTP 408, 429 và 5xx vào “tạm thời” cũng là lựa chọn của bài; 500 có thể là lỗi lặp lại mãi, nên thử lại luôn phải có giới hạn. Theo tài liệu curl, mã 7 là “Failed to connect to host” và mã 28 là “Operation timeout”; --max-time đặt thời gian tối đa cho mỗi lần truyền và “Prevents your batch jobs from hanging for hours due to slow networks or links going down”, nên fetch.sh luôn đặt nó.

Lab: tải có phân loại và thử lại có giới hạn

Tạo thư mục trống rồi lưu bốn tệp:

  • api.py là API thử bằng thư viện chuẩn: /seed đòi header Authorization đúng, /flaky trả 503 hai lần rồi 200, /slow chậm 3 giây, /status/NNN trả đúng mã NNN.
  • fetch.sh tải một URL và đổi kết quả thành mã thoát của hợp đồng. Nó không dùng set -e vì cần đọc $? của curl rồi tự quyết; đọc %{http_code} thay vì dựa vào --fail; ghi vào tệp tạm rồi mv để chỉ lần thành công mới tạo hoặc thay tệp đích; có trap dọn tệp tạm.
  • jobwrap.py chạy một lệnh tối đa ba lần, chỉ thử lại khi mã nằm trong RETRYABLE (chỉ có 10), nghỉ tăng dần giữa các lần, và trả mã của lần cuối. Python báo cái chết vì tín hiệu bằng mã âm (“A negative value -N indicates that the child was terminated by signal N”), nên wrapper đổi sang 128+N như shell để một con số trong log có cùng nghĩa ở mọi chỗ.
  • cases.py chạy mọi ca và assert từng kết quả: lệch một mã là script thoát khác 0.
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

TOKEN = "demo-token"
hits = {"flaky": 0}


class Handler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass  # giữ stderr sạch cho phần đo

    def reply(self, status, body):
        payload = json.dumps(body).encode()
        try:
            self.send_response(status)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
        except (BrokenPipeError, ConnectionResetError):
            pass  # client đã bỏ đi, như ca quá thời gian

    def do_GET(self):
        if self.path == "/seed":
            if self.headers.get("Authorization") == f"Bearer {TOKEN}":
                return self.reply(200, {"rows": 3})
            return self.reply(401, {"error": "unauthorized"})
        if self.path == "/flaky":
            hits["flaky"] += 1
            return self.reply(503 if hits["flaky"] <= 2 else 200, {"attempt": hits["flaky"]})
        if self.path == "/slow":
            time.sleep(3)
            return self.reply(200, {"slow": True})
        if self.path.startswith("/status/"):
            return self.reply(int(self.path.rsplit("/", 1)[1]), {"path": self.path})
        return self.reply(404, {"error": "not found"})


def start():
    """Mở server ở cổng do hệ điều hành cấp; trả về (server, địa chỉ gốc)."""
    server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    port = server.server_address[1]
    return server, f"http://127.0.0.1:{port}"
#!/usr/bin/env bash
# Tải $1 về tệp $2 và đổi kết quả thành mã thoát theo hợp đồng của job:
#   0 thành công | 10 lỗi tạm thời, thử lại được | 11 xác thực hoặc ủy quyền, không thử lại | 1 lỗi khác
# Không dùng `set -e`: script tự đọc mã của curl rồi tự quyết mã thoát.
set -uo pipefail
url=$1
out=$2
tmp=$(mktemp "$out.XXXXXX") || exit 1
trap 'rm -f "$tmp"' EXIT
status=$(curl --silent --show-error --max-time "${MAX_TIME:-30}" --output "$tmp" \
  --write-out '%{http_code}' --header "Authorization: Bearer ${TOKEN:-}" "$url")
rc=$?
if [ "$rc" -ne 0 ]; then
  case $rc in
    7 | 28) exit 10 ;; # không kết nối được hoặc quá thời gian: thường là tạm thời
    *) exit 1 ;;
  esac
fi
case $status in
  2??) mv "$tmp" "$out" ;; # chỉ thành công mới tạo hoặc thay tệp đích
  401 | 403) exit 11 ;;
  408 | 429 | 5??) exit 10 ;;
  *) exit 1 ;;
esac
import argparse
import subprocess
import sys
import time

RETRYABLE = {10}  # chỉ mã "lỗi tạm thời" của hợp đồng mới được thử lại


def run(command, attempts, backoff):
    code = 1
    for attempt in range(1, attempts + 1):
        code = subprocess.run(command).returncode
        if code < 0:
            code = 128 - code  # bị tín hiệu N giết: báo 128+N như shell
        print(f"lần {attempt}: mã {code}", file=sys.stderr, flush=True)
        if code not in RETRYABLE or attempt == attempts:
            return code
        time.sleep(backoff * attempt)
    return code


parser = argparse.ArgumentParser()
parser.add_argument("--attempts", type=int, default=3)
parser.add_argument("--backoff", type=float, default=0.01)
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
command = args.command[1:] if args.command[:1] == ["--"] else args.command
sys.exit(run(command, args.attempts, args.backoff))
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

import api

LAB = Path.cwd()
server, base = api.start()
work = Path(tempfile.mkdtemp(prefix="joblab-"))


def run(command, token, extra):
    env = {**os.environ, "API": base, **extra}
    env.pop("TOKEN", None)
    if token:
        env["TOKEN"] = api.TOKEN
    return subprocess.run(command, cwd=work, env=env, capture_output=True, text=True, timeout=60)


def sh(script, *args, token=False, **extra):
    return run(["bash", "-c", script, "_", *args], token, extra)


def fetch_once(url, out="probe.json", token=False, **extra):
    return run(["bash", str(LAB / "fetch.sh"), url, out], token, extra).returncode


def wrapped(*command, token=False):
    """Chạy lệnh qua jobwrap.py; trả về (mã thoát, mã của từng lần thử)."""
    wrapper = [sys.executable, "-B", str(LAB / "jobwrap.py"), "--attempts", "3", "--backoff", "0.01", "--"]
    done = run([*wrapper, *command], token, {})
    attempts = [int(line.rsplit(" ", 1)[1]) for line in done.stderr.splitlines() if line.startswith("lần ")]
    return done.returncode, attempts


def fetch(url, out="seed.json", token=False):
    return wrapped("bash", str(LAB / "fetch.sh"), url, out, token=token)


def seed_text():
    path = work / "seed.json"
    return path.read_text() if path.exists() else "(không có tệp)"


try:
    curl_version = subprocess.run(["curl", "--version"], capture_output=True, text=True).stdout.split()[1]
    bash_version = sh("echo $BASH_VERSION").stdout.strip()
    print(f"công cụ: curl {curl_version}, bash {bash_version}, python {sys.version.split()[0]}")

    print("== Ba cách nuốt lỗi và bản sửa")
    bad = sh("cp missing.txt out.txt || true; echo xong")
    good = sh("set -e; cp missing.txt out.txt; echo xong")
    print(
        f"`|| true` sau lệnh lỗi: bản sai thoát {bad.returncode} và vẫn in {bad.stdout.strip()!r}; "
        f"bản sửa thoát {good.returncode}"
    )
    assert (bad.returncode, good.returncode) == (0, 1) and "xong" not in good.stdout

    producer = "producer() { echo dong1; return 3; }; "
    bad = sh(producer + "producer | wc -l > /dev/null")
    good = sh("set -o pipefail; " + producer + "producer | wc -l > /dev/null")
    print(f"pipeline không pipefail: bản sai thoát {bad.returncode}; bản sửa thoát {good.returncode}")
    assert (bad.returncode, good.returncode) == (0, 3)

    bad = sh('curl -s "$API/seed" -o seed.json')
    body = seed_text()
    (work / "seed.json").unlink()
    good = sh('curl -s --fail "$API/seed" -o seed.json')
    print(
        f"curl không --fail gặp HTTP 401: bản sai thoát {bad.returncode} và để lại {body}; "
        f"bản sửa thoát {good.returncode}, tệp: {seed_text()}"
    )
    assert bad.returncode == 0 and "unauthorized" in body
    assert good.returncode == 22 and not (work / "seed.json").exists()

    codes = [sh(f'curl -s --fail "$API/status/{n}" -o /dev/null').returncode for n in (401, 503)]
    print(f"curl --fail: HTTP 401 thoát {codes[0]}, HTTP 503 thoát {codes[1]}: cùng một mã")
    assert codes == [22, 22]

    rule = 'step() { return "$1"; }; step "$1" || { rc=$?; [ "$rc" -eq 4 ] || exit "$rc"; }; echo tiếp'
    known, unknown = sh(rule, "4"), sh(rule, "9")
    print(f"bỏ qua đúng một mã đã biết (4): mã 4 thoát {known.returncode}, mã 9 thoát {unknown.returncode}")
    assert (known.returncode, unknown.returncode) == (0, 9)

    print("== Mã do shell đặt")
    (work / "noexec.sh").write_text("echo hi\n")
    for label, script, expected in (
        ("thành công", "true", 0),
        ("lệnh không tồn tại", "khong-co-lenh", 127),
        ("tệp không có quyền thực thi", "./noexec.sh", 126),
        ("tiến trình con bị SIGTERM", "sleep 30 & kill -TERM $!; wait $!", 143),
    ):
        code = sh(script).returncode
        print(f"{label} -> {code}")
        assert code == expected

    print("== Hợp đồng: từ kết quả HTTP hoặc mạng sang mã thoát")
    for label, path, extra, expected in (
        ("HTTP 200", "/status/200", {}, 0),
        ("HTTP 302 (không theo chuyển hướng)", "/status/302", {}, 1),
        ("HTTP 401", "/status/401", {}, 11),
        ("HTTP 403", "/status/403", {}, 11),
        ("HTTP 404", "/status/404", {}, 1),
        ("HTTP 408", "/status/408", {}, 10),
        ("HTTP 429", "/status/429", {}, 10),
        ("HTTP 500", "/status/500", {}, 10),
        ("HTTP 503", "/status/503", {}, 10),
        ("quá thời gian (curl 28)", "/slow", {"MAX_TIME": "1"}, 10),
    ):
        code = fetch_once(base + path, **extra)
        print(f"{label} -> {code}")
        assert code == expected
    dead, dead_base = api.start()
    dead.shutdown()
    dead.server_close()
    code = fetch_once(dead_base + "/seed")
    print(f"cổng đóng (curl 7) -> {code}")
    assert code == 10
    leftovers = sorted(p.name for p in work.glob("probe.json.*"))
    print(f"tệp tạm còn sót lại sau các lần gọi ở trên: {leftovers}")
    assert leftovers == []

    print("== Khởi tạo gọi API có xác thực")
    naive = sh('curl -s "$API/seed" -o seed.json; echo "khởi tạo xong"')
    print(f"init cũ, thiếu token: thoát {naive.returncode}, seed.json = {seed_text()}")
    assert naive.returncode == 0 and "unauthorized" in seed_text()
    (work / "seed.json").unlink()
    code, attempts = fetch(base + "/seed")
    print(f"fetch.sh thiếu token: các lần {attempts} -> thoát {code}, tệp: {seed_text()}")
    assert (code, attempts) == (11, [11]) and not (work / "seed.json").exists()
    code, attempts = fetch(base + "/seed", token=True)
    print(f"fetch.sh có token: các lần {attempts} -> thoát {code}, seed.json = {seed_text()}")
    assert (code, attempts) == (0, [0]) and seed_text() == '{"rows": 3}'
    code, attempts = fetch(base + "/seed")
    print(f"lần sau thiếu token: các lần {attempts} -> thoát {code}, seed.json vẫn là {seed_text()}")
    assert (code, attempts) == (11, [11]) and seed_text() == '{"rows": 3}'

    print("== Wrapper thử lại có giới hạn")
    for label, path, expected in (
        ("/flaky trả 503 hai lần rồi 200", "/flaky", (0, [10, 10, 0])),
        ("503 mãi", "/status/503", (10, [10, 10, 10])),
        ("HTTP 404 (lỗi khác)", "/status/404", (1, [1])),
    ):
        result = fetch(base + path, out="out.json", token=True)
        print(f"{label}: các lần {result[1]} -> thoát {result[0]}")
        assert result == expected
    result = wrapped("bash", "-c", "kill -TERM $$")
    print(f"job bị SIGTERM: các lần {result[1]} -> thoát {result[0]}")
    assert result == (143, [143])
finally:
    server.shutdown()
    shutil.rmtree(work, ignore_errors=True)
python3 -B cases.py
công cụ: curl 8.7.1, bash 3.2.57(1)-release, python 3.14.4
== Ba cách nuốt lỗi và bản sửa
`|| true` sau lệnh lỗi: bản sai thoát 0 và vẫn in 'xong'; bản sửa thoát 1
pipeline không pipefail: bản sai thoát 0; bản sửa thoát 3
curl không --fail gặp HTTP 401: bản sai thoát 0 và để lại {"error": "unauthorized"}; bản sửa thoát 22, tệp: (không có tệp)
curl --fail: HTTP 401 thoát 22, HTTP 503 thoát 22: cùng một mã
bỏ qua đúng một mã đã biết (4): mã 4 thoát 0, mã 9 thoát 9
== Mã do shell đặt
thành công -> 0
lệnh không tồn tại -> 127
tệp không có quyền thực thi -> 126
tiến trình con bị SIGTERM -> 143
== Hợp đồng: từ kết quả HTTP hoặc mạng sang mã thoát
HTTP 200 -> 0
HTTP 302 (không theo chuyển hướng) -> 1
HTTP 401 -> 11
HTTP 403 -> 11
HTTP 404 -> 1
HTTP 408 -> 10
HTTP 429 -> 10
HTTP 500 -> 10
HTTP 503 -> 10
quá thời gian (curl 28) -> 10
cổng đóng (curl 7) -> 10
tệp tạm còn sót lại sau các lần gọi ở trên: []
== Khởi tạo gọi API có xác thực
init cũ, thiếu token: thoát 0, seed.json = {"error": "unauthorized"}
fetch.sh thiếu token: các lần [11] -> thoát 11, tệp: (không có tệp)
fetch.sh có token: các lần [0] -> thoát 0, seed.json = {"rows": 3}
lần sau thiếu token: các lần [11] -> thoát 11, seed.json vẫn là {"rows": 3}
== Wrapper thử lại có giới hạn
/flaky trả 503 hai lần rồi 200: các lần [10, 10, 0] -> thoát 0
503 mãi: các lần [10, 10, 10] -> thoát 10
HTTP 404 (lỗi khác): các lần [1] -> thoát 1
job bị SIGTERM: các lần [143] -> thoát 143

Đọc kết quả:

  • Ba cách nuốt lỗi đều thoát 0 và để lại dấu vết sai. || true vẫn in “xong”; pipeline không pipefail thoát 0 dù lệnh đầu trả 3; curl không --fail ghi JSON báo lỗi vào seed.json. Bản sửa thoát 1, 3 và 22 và không để lại tệp.
  • --fail cho mã 22 với cả 401 lẫn 503. Một mã không đủ để bộ lập lịch chọn giữa dừng và thử lại, nên hợp đồng phải do script tự đặt.
  • Shell tự đặt 127, 126 và 143. Lệnh không tồn tại, tệp không thực thi được và SIGTERM (128 + 15) cho ba con số khác nhau, không cần job làm gì.
  • Bảng hợp đồng ra đúng ở cả hai nhánh mạng. Cổng đóng (curl 7) và quá thời gian (curl 28) đều thành 10, 401 và 403 thành 11, 404 và 302 thành 1. Sau chuỗi ca lỗi, thư mục không còn tệp tạm.
  • Khởi tạo thiếu token. Bản cũ thoát 0 và để seed.json chứa JSON báo lỗi, bước sau sẽ đọc nó như dữ liệu. fetch.sh thoát 11, không tạo tệp, và wrapper chỉ chạy một lần. Sau một lần thành công, lần chạy thiếu token tiếp theo vẫn thoát 11 và giữ nguyên {"rows": 3} vì tệp đích chỉ bị thay khi nhận 2xx.
  • Wrapper chỉ thử lại mã 10. /flaky ra các lần 10, 10, 0 rồi thoát 0; 503 mãi ra 10, 10, 10 và thoát 10 (hết lượt vẫn báo lỗi, không đổi thành 0); 404 (mã 1) và SIGTERM (mã 143) chỉ chạy một lần.

Tác giả đã phá thử 13 chỗ của fetch.sh và jobwrap.py trong bản sao ngoài lab (đổi 401 hoặc 403 thành lỗi chung, bỏ 5xx hay 408 khỏi “tạm thời”, bỏ mã 7 hoặc 28, bỏ trap, ghi đè tệp đích khi 401, coi 3xx là thành công, thử lại cả mã 11 hoặc mã 1, bỏ đổi tín hiệu thành 128+N, chỉ thử một lần) và cases.py đỏ ở cả 13 lần. Hai chỗ đầu tiên chưa bị bắt (3xx và mã 1) dẫn tới hai ca HTTP 302 và HTTP 404 trong lab.

Thử lại có giới hạn: wrapper làm và không làm gì

  • Chỉ mã 10 mới thử lại. Lỗi xác thực (11), lỗi khác (1) và cái chết vì tín hiệu (143) đều dừng ngay. Việc không thử lại tín hiệu là lựa chọn của bài: job bị dừng từ ngoài thường là quyết định của bộ lập lịch hay người vận hành.
  • Hết lượt vẫn là thất bại. Sau ba lần thoát 10, wrapper trả 10: bộ lập lịch thấy lỗi và có thể cảnh báo. Wrapper không bao giờ đổi lỗi thành 0.
  • Khoảng nghỉ của lab chỉ để chạy nhanh. 0,01 giây nhân số lần là quá ngắn cho dịch vụ thật; thực tế cần khoảng nghỉ dài hơn, có jitter và tổng thời hạn, như bài Batch job: chạy lại an toàn sau lỗi giữa chừng đã nêu.
  • Chạy lại chỉ an toàn khi job không gây hiệu ứng kép. Wrapper không biết job có idempotent hay không; bài batch job nói cách chọn identity và checkpoint để chạy lại không nhân đôi kết quả.
  • Hai tầng thử lại nhân nhau. Wrapper trong container và số lần thử lại của nền tảng (backoffLimit, attempts) cho số lần chạy thật bằng tích của hai tầng. Hãy để một tầng quyết định, hoặc tính tích đó vào ngân sách.

Ánh xạ sang Kubernetes và AWS Batch (chưa chạy)

Cả hai nền tảng đều có cách đọc mã thoát, và cả hai mặc định thử lại khi không có luật nào khớp:

Nền tảngMã không khớp luật nàoCăn cứ
Kubernetes podFailurePolicyxử lý mặc định: Pod được tạo lại nếu số lần khởi động lại còn dưới backoffLimit“When no rule matches the Pod failure, the default handling applies”
AWS Batch evaluateOnExitjob được thử lại“If evaluateOnExit is specified, but none of the retry strategies match, then the job is retried”

Vì vậy “chỉ thử lại mã 10” cần một luật dừng cho phần còn lại.

Kubernetes. Tài liệu Job nói các luật trong spec.podFailurePolicy.rules “are evaluated in order. Once a rule matches a Pod failure, the remaining rules are ignored”. Hành động FailJob đánh dấu Job thất bại và dừng mọi Pod đang chạy; Count xử lý theo cách mặc định và tăng bộ đếm backoffLimit. Ví dụ của tài liệu dùng onExitCodes với operator: In và values: [42] cho FailJob, trong Pod có restartPolicy: Never, và đây là điều kiện bắt buộc: “you must also define that Job’s pod template with .spec.restartPolicy set to Never”. Với hợp đồng của bài, một luật NotIn là đủ:

spec:
  backoffLimit: 6
  podFailurePolicy:
    rules:
      - action: FailJob
        onExitCodes:
          containerName: main
          operator: NotIn
          values: [10]

Tài liệu tham chiếu API Job nói mã thoát 0 “are excluded from the requirement check” nên luật chỉ xét container đã thất bại, và NotIn thỏa khi “at least one container exit code (…) is not in the set of specified values”. Mã 10 không khớp luật nào nên rơi vào xử lý mặc định (thử lại đến backoffLimit); mọi mã khác làm Job thất bại ngay.

AWS Batch. Tài liệu cho phép attempts “between 1 and 10”, và khi dùng evaluateOnExit thì phải đặt cả attempts. Mẫu tài liệu đề nghị là các mục RETRY riêng rồi một mục cuối thoát với mọi lý do (“add a final entry that exits for any reason”):

"retryStrategy": {
  "attempts": 3,
  "evaluateOnExit": [
    { "action": "RETRY", "onExitCode": "10" },
    { "action": "EXIT", "onReason": "*" }
  ]
}

onExitCode là “a glob pattern to match against the decimal representation of the ExitCode”, chỉ gồm chữ số và có thể kết thúc bằng * “so that only the start of the string needs to be an exact match”. Hệ quả cho hợp đồng này: 10 khớp đúng mã 10, còn 1* khớp cả 1, 10, 11 và 143, một cái bẫy khi chọn mã gần nhau. Các trang đã đọc không nêu thứ tự đánh giá các mục; ví dụ của tài liệu đặt mục EXIT ở cuối cùng.

Cả hai đoạn cấu hình chỉ theo tài liệu, chưa chạy. Trước khi tin, hãy chạy một job cố ý thoát 10, một job thoát 11 và một job thoát 1 trên cụm hay tài khoản của bạn, rồi đếm số lần Pod hay attempt thật.

Giới hạn

  • Lab chỉ chạy với bash 3.2.57, curl 8.7.1 và Python 3.14.4 trên macOS arm64. Chưa chạy trên Linux, bash 5, dash, zsh hay BusyBox, và chưa thử curl bản khác; tài liệu curl đọc là bản hiện hành, không phải riêng bản 8.7.1.
  • Kubernetes và AWS Batch chỉ được trích tài liệu, không chạy. Tài liệu AWS tự nhắc tối đa 6 mục evaluateOnExit ở trang hướng dẫn nhưng 5 điều kiện ở trang API, nên bài không nêu con số nào.
  • Mã 10 và 11, danh sách mã HTTP “tạm thời” và việc không thử lại tín hiệu đều là quy ước của bài, không phải chuẩn. Một dịch vụ khác có thể cần danh sách khác.
  • API thử là giả lập: không TLS, không chuyển hướng, không Retry-After, không giới hạn tốc độ, không xác thực nhiều bước. Trường hợp --fail để lọt 401 hoặc 407 mà tài liệu curl nêu không được tái hiện.
  • Ba cách nuốt lỗi là ba cách hay gặp trong lab, không phải danh sách đầy đủ; bài không xét trap, exit trong subshell hay các cách khác.
  • Lab truyền token qua tham số --header cho ngắn; bài không bàn cách giữ bí mật token trong job.
  • Lab không đo thời gian thật của thử lại (khoảng nghỉ 0,01 giây) và không mô phỏng hiệu ứng ngoài của job nên không chứng minh chạy lại an toàn.
  • Lab không ghi tệp ngoài thư mục bạn đã tạo và thư mục tạm; mọi server tự tắt khi cases.py kết thúc, kể cả khi assert hỏng.

Học tiếp và nguồn

Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc bash 3.2.57, curl 8.7.1 và Python 3.14.4 trên macOS arm64.

Đọc EXPLAIN ANALYZE từ một truy vấn chọn đơn hàng

Câu hỏi bài này trả lời: truy vấn đọc ít đơn nhưng chạy chậm; làm sao tìm công việc thừa trong plan, đối chiếu ước lượng với số đo và kiểm thay đổi có giúp giảm công việc đó?

Cần biết trước: SQL SELECT, WHERE, index cơ bản và cách dùng shell. Bài dùng PostgreSQL 18.6 với dữ liệu giả ở lab database. Phần chạy kiểm dùng server cục bộ trên macOS arm64; Docker/Linux của lab đang chờ kiểm. Không suy kết quả này sang một database khác.

Chuẩn bị cùng một dữ liệu

Tạo thư mục trống. Chép bốn file từ cách A của bài lab: lab-common.sh, lab-local.sh, seed-pg.sql, seed-mysql.sql. Các file nằm ngay trong bài lab, không cần tải asset. Các lệnh dưới đây chạy bằng bash; mỗi khối dùng một shell riêng nên đều nạp lại hàm client.

Nếu đang có lab của bài trước, dùng thư mục mới để giữ phép thử độc lập: lab_seed xóa và tạo lại schema của lab. Cả hai engine được dựng theo fixture chung, nhưng bài này chỉ đo plan của PostgreSQL.

. ./lab-local.sh
lab_up
lab_seed
lab_whoami

Truy vấn giữ nguyên trong cả hai lượt:

SELECT id, total_cents
FROM wiki_lab.orders
WHERE customer_id = 1 AND status = 'paid';

Nó trả 1.000 hàng. Con số này còn kiểm được từ công thức seed: customer_id = 1 khi bình phương số thứ tự chia hết cho 2.000, tức số thứ tự là bội của 100; có 1.000 bội như vậy từ 1 tới 100.000, tất cả thuộc trạng thái paid vì chia dư cho 100 bằng 0.

. ./lab-local.sh
rows=$(lab_psql -At -c "SELECT count(*) FROM wiki_lab.orders WHERE customer_id=1 AND status='paid'")
test "$rows" = 1000
echo "rows $rows"

EXPLAIN: xem ước lượng trước khi thực thi

. ./lab-local.sh
{ printf 'EXPLAIN '; cat query.sql; } | lab_psql -At

EXPLAIN thông thường tạo plan cho câu truy vấn nhưng không chạy phần thực thi của nó. Nó vẫn có thể lấy khóa và thực hiện công việc lập kế hoạch; đừng coi đây là phép thử không có tác động trên hệ thống đang có tải. Ví dụ này chỉ dùng câu SELECT trên fixture.

Trong một dòng dạng Seq Scan ... (cost=... rows=... width=...):

  • Seq Scan là cách đọc bảng. Filter nằm ở dòng dưới: mỗi hàng đọc lên được kiểm điều kiện.
  • Hai số cost là chi phí ước lượng trước khi có thể trả hàng đầu và chi phí nếu node chạy hết. Đơn vị do tham số cost của planner quyết định; cost không phải milliseconds.
  • rows là số hàng node dự kiến đưa lên node cha mỗi lần gọi, không phải số hàng nó phải đọc.
  • width là số byte trung bình của một hàng đầu ra theo ước lượng.

Cost ở node cha đã gồm cost của các node con. Cộng chúng lại sẽ tính trùng. Khi có LIMIT, node cha có thể ngừng lấy hàng trước khi node con chạy hết.

ANALYZE và BUFFERS: đối chiếu với công việc thật

Thu cả bản text để đọc và JSON để kiểm cấu trúc. Hai lệnh này thực thi truy vấn hai lần; cache có thể đã ấm ở lần thứ hai.

. ./lab-local.sh
{ printf 'EXPLAIN (ANALYZE, BUFFERS) '; cat query.sql; } | lab_psql -At | tee before.txt
{ printf 'EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) '; cat query.sql; } | lab_psql -At > before.json
lab_psql -At -f query.sql > rows-before.txt

Output ở lượt kiểm ngày 2026-10-03 (thời gian và estimate thay đổi ở lượt chạy khác):

Seq Scan on orders  (cost=0.00..2236.00 rows=712 width=8) (actual time=0.008..2.810 rows=1000.00 loops=1)
  Filter: ((customer_id = 1) AND (status = 'paid'::text))
  Rows Removed by Filter: 99000
  Buffers: shared hit=736
Planning:
  Buffers: shared hit=77
Planning Time: 0.169 ms
Execution Time: 2.843 ms

Ước lượng 712 hàng, thực tế 1.000 hàng: estimate thấp khoảng 1,4 lần. Scan đã xét 100.000 hàng để giữ lại 1.000; 99.000 hàng bị loại. Cả 736 lượt truy cập block ở node scan là hit, vì lab vừa nạp dữ liệu, không phải vì đọc ít dữ liệu.

Đọc plan từ nguồn dữ liệu ở dưới lên. Với một scan đơn giản, bắt đầu bằng:

  1. Tên node: đọc toàn bảng hay đi qua index?
  2. rows trong phần cost: planner dự kiến bao nhiêu hàng đầu ra?
  3. actual ... rows ... loops: đo được bao nhiêu hàng mỗi vòng, và node được gọi bao nhiêu vòng?
  4. Rows Removed by Filter: đã đọc rồi bỏ bao nhiêu hàng?
  5. Buffers: đã truy cập những khối dữ liệu nào; phần nào là hit, phần nào là read?

actual time=a..b dùng milliseconds: thời gian tới hàng đầu và tới khi hoàn tất node, trung bình mỗi vòng nếu có nhiều loops. Thời gian node cha bao gồm thời gian con; không cộng mọi dòng thành tổng thời gian truy vấn. Việc đo thời gian từng node cũng có overhead.

shared hit là block đã có trong shared buffer của PostgreSQL. shared read là block phải nạp vào đó, nhưng dữ liệu vẫn có thể đến từ cache của hệ điều hành; read không chứng minh đã đọc đĩa vật lý. Tổng này đếm lượt truy cập block, không phải số block khác nhau. Trong plan nhiều tầng, buffer của node cha bao gồm con nên cũng không cộng lại tùy tiện.

Planning Time đo lập kế hoạch; Execution Time đo thực thi trong server, không bao gồm mọi chi phí truyền dữ liệu và xử lý ở ứng dụng. Không dùng số này thay trực tiếp độ trễ API.

Đổi một yếu tố: thêm index, giữ nguyên truy vấn

Index sau chỉ là công cụ cho phép thử; cách chọn thứ tự cột là chủ đề riêng. Không đổi điều kiện, lượng dữ liệu hay cưỡng ép planner bằng cách tắt sequential scan.

. ./lab-local.sh
lab_whoami
lab_psql -c 'CREATE INDEX orders_customer_status ON wiki_lab.orders(customer_id, status)'
{ printf 'EXPLAIN (ANALYZE, BUFFERS) '; cat query.sql; } | lab_psql -At | tee after.txt
{ printf 'EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) '; cat query.sql; } | lab_psql -At > after.json
lab_psql -At -f query.sql > rows-after.txt
LC_ALL=C sort rows-before.txt > sorted-before.txt
LC_ALL=C sort rows-after.txt > sorted-after.txt
cmp sorted-before.txt sorted-after.txt
echo 'cùng 1000 hàng'

Output cùng lượt kiểm:

Bitmap Heap Scan on orders  (cost=11.59..779.37 rows=712 width=8) (actual time=0.127..0.876 rows=1000.00 loops=1)
  Recheck Cond: ((customer_id = 1) AND (status = 'paid'::text))
  Heap Blocks: exact=736
  Buffers: shared hit=736 read=2
  ->  Bitmap Index Scan on orders_customer_status  (cost=0.00..11.41 rows=712 width=0) (actual time=0.082..0.082 rows=1000.00 loops=1)
        Index Cond: ((customer_id = 1) AND (status = 'paid'::text))
        Index Searches: 1
        Buffers: shared read=2
Planning:
  Buffers: shared hit=100 read=1
Planning Time: 0.244 ms
Execution Time: 0.914 ms
cùng 1000 hàng

Index tìm 1.000 vị trí, nhưng chúng rải khắp bảng: vẫn phải chạm 736 heap block. Mức giảm công việc ở đây là bỏ qua việc lọc 99.000 hàng, không phải giảm số block của bảng. Bitmap Index Scan tạo tập vị trí; Bitmap Heap Scan lấy các hàng theo block. Hai read ở index cũng đã nằm trong thống kê của node cha.

SQL không có ORDER BY không đảm bảo thứ tự trả hàng, nên so sánh hai tập sau khi sort. Plan nhanh hơn mà thiếu hoặc đổi hàng là một thay đổi sai.

Ở phép thử này, điều cần so là cách đọc và lượng công việc: trước đó đọc rồi lọc toàn bảng; sau đó index tìm các vị trí phù hợp trước khi lấy hàng. Không kết luận rằng mọi truy vấn đều cần index: điều kiện trả phần lớn bảng có thể vẫn khiến planner chọn scan, và index làm phát sinh chi phí ghi, dung lượng và bảo trì.

Loops: một node có thể chạy nhiều lần

Một truy vấn nhỏ minh họa node con được gọi theo từng khách:

. ./lab-local.sh
lab_psql -At <<'SQL'
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.id, o.id
FROM wiki_lab.customers c
CROSS JOIN LATERAL (
  SELECT id FROM wiki_lab.orders WHERE customer_id=c.id LIMIT 1
) o
WHERE c.id IN (1, 2);
SQL
lab_psql -At <<'SQL' > loops.json
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT c.id, o.id
FROM wiki_lab.customers c
CROSS JOIN LATERAL (
  SELECT id FROM wiki_lab.orders WHERE customer_id=c.id LIMIT 1
) o
WHERE c.id IN (1, 2);
SQL

Phần node con ở lượt kiểm:

  ->  Limit  (cost=0.29..3.35 rows=1 width=4) (actual time=0.003..0.003 rows=1.00 loops=2)
        Buffers: shared hit=6
        ->  Index Scan using orders_customer_status on orders  (cost=0.29..1444.55 rows=472 width=4) (actual time=0.003..0.003 rows=1.00 loops=2)
              Index Cond: (customer_id = c.id)
              Index Searches: 2
              Buffers: shared hit=6

Node con trả một hàng mỗi vòng và chạy hai vòng, nên đã trả tổng hai hàng. Với số hàng hiển thị đã làm tròn, phép nhân rows × loops chỉ là xấp xỉ. Với nested loop lớn, node con trả rất ít hàng vẫn có thể tốn nhiều công việc vì bị gọi quá nhiều lần.

Kiểm tự động phần kết luận, không khóa thời gian

Script dưới kiểm output JSON của hai lần đo: query trả đúng 1.000 hàng, có estimate/actual/loops/buffer, bản đầu đọc tuần tự và lọc 99.000 hàng, bản sau sử dụng index. Nó không khóa thời gian hay số buffer theo máy này; nếu planner của máy bạn chọn một plan khác thì kiểm sẽ đỏ để bạn đọc lại kết luận.

import json
from pathlib import Path

def nodes(plan):
    yield plan
    for child in plan.get('Plans', []):
        yield from nodes(child)

before = json.loads(Path('before.json').read_text())[0]['Plan']
after = json.loads(Path('after.json').read_text())[0]['Plan']
for plan in (before, after):
    assert plan['Actual Rows'] == 1000
    assert plan['Actual Loops'] == 1
    assert 'Plan Rows' in plan
    assert 'Shared Hit Blocks' in plan
    assert 'Shared Read Blocks' in plan
assert before['Node Type'] == 'Seq Scan'
assert before['Rows Removed by Filter'] == 99000
assert any(node.get('Index Name') == 'orders_customer_status' for node in nodes(after))
assert after['Total Cost'] < before['Total Cost']
loop_plan = json.loads(Path('loops.json').read_text())[0]['Plan']
assert loop_plan['Actual Rows'] == 2
assert any(node['Node Type'] == 'Limit' and node['Actual Loops'] == 2
           and node['Actual Rows'] == 1 for node in nodes(loop_plan))
print('plan và số hàng đạt')
python3 check-plan.py

Reset schema, chạy lại từ dữ liệu mới để kiểm fixture không phụ thuộc thay đổi từ lượt trước. Reset này cũng gỡ index vừa thêm.

. ./lab-local.sh
lab_seed
test "$(lab_psql -At -c "SELECT count(*) FROM wiki_lab.orders WHERE customer_id=1 AND status='paid'")" = 1000
test -z "$(lab_psql -At -c "SELECT to_regclass('wiki_lab.orders_customer_status')")"
{ printf 'EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) '; cat query.sql; } | lab_psql -At > before.json
lab_psql -c 'CREATE INDEX orders_customer_status ON wiki_lab.orders(customer_id, status)'
{ printf 'EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) '; cat query.sql; } | lab_psql -At > after.json
python3 check-plan.py
echo 'reset đạt'
. ./lab-local.sh
lab_clean
test ! -e lab.env

Cùng thuật ngữ, khác engine

MySQL hỗ trợ EXPLAIN FORMAT=TREE và EXPLAIN ANALYZE với cây có cost, số hàng và số vòng. Nó không dùng cú pháp PostgreSQL EXPLAIN (ANALYZE, BUFFERS) và không có phần BUFFERS tương đương trong cây đó. Không mang cách đọc shared hit/read sang MySQL, cũng không so trực tiếp cost giữa hai engine.

Phần MySQL này dựa trên tài liệu EXPLAIN của MySQL 8.4; bài không chạy đo plan MySQL 26.7 và không khẳng định mọi chi tiết của 8.4 áp dụng cho 26.7.

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

  • ANALYZE thực sự chạy câu lệnh. Với UPDATE, DELETE hay lời gọi hàm có tác dụng phụ, nó có thể thay dữ liệu. Bọc transaction rồi rollback bảo vệ được nhiều thay đổi dữ liệu nhưng không hoàn tác mọi tác động, chẳng hạn cấp số sequence hoặc một thao tác ra hệ thống ngoài. Chỉ dùng SELECT trên fixture ở bài này.
  • Cache và thống kê thay đổi. ANALYZE lấy mẫu, nên estimate có thể hơi khác giữa các lần reset. Lần chạy sau thường đã có cache; bài không đo cold cache hay tốc độ ổ đĩa. Không gọi việc reset dữ liệu là xóa cache.
  • Số đo là ví dụ, không phải cam kết tốc độ. Hai plan công bố thuộc phiên bản, dữ liệu và máy kiểm này. Một lần chạy không đủ chứng minh mức cải thiện ổn định; muốn benchmark cần nhiều lượt, cùng điều kiện và đo cả workload ghi.
  • relation does not exist: kiểm đã chép đủ file fixture, gọi lab_up, lab_seed và dùng tên wiki_lab.orders.
  • Plan không có index: kiểm dữ liệu, điều kiện lọc, index có tồn tại và thống kê; đừng tắt scan chỉ để có hình đúng như bài.
  • rows ước lượng lệch xa actual rows: rà thống kê và tương quan dữ liệu trước khi kết luận thiếu index. Planner dự đoán sai selectivity có thể chọn đường đi sai.

Học tiếp

Thử đổi customer_id=1 thành một khách khác hoặc bỏ điều kiện khách để quan sát planner đổi quyết định. Giữ truy vấn và kết quả cố định khi so hai cấu hình; bài composite index tiếp theo sẽ so hai thứ tự cột thay vì thêm mọi index có thể nghĩ ra.

Nguồn tham khảo

  • PostgreSQL 18, Using EXPLAIN, phần EXPLAIN Basics, EXPLAIN ANALYZE và Caveats; đọc ngày 2026-10-03.
  • PostgreSQL 18, EXPLAIN, mô tả ANALYZE, BUFFERS, FORMAT và tác dụng phụ; đọc ngày 2026-10-03.
  • MySQL 8.4, EXPLAIN Statement, cú pháp TREE và ANALYZE; đọc ngày 2026-10-03, không thay bằng chứng chạy engine 26.7.

Composite index: cùng ba cột, khác khoảng phải đọc

Câu hỏi bài này trả lời: query đã có index đủ các cột lọc; vì sao đổi thứ tự cột vẫn thay đổi lượng công việc, và lựa chọn nào phù hợp với query đang cần?

Cần biết trước: SQL cơ bản, đọc EXPLAIN ANALYZE và lab database. Bài chạy PostgreSQL 18.6 trên macOS arm64 với dữ liệu giả. MySQL chỉ có phần so sánh nguyên lý; Docker/Linux của fixture chưa kiểm.

Đặt query và mục tiêu trước khi tạo index

Yêu cầu: lấy 20 đơn paid mới nhất của một khách, từ ngày đã chọn. Giữ nguyên query, dữ liệu và số hàng cần trả khi so hai cấu hình.

Tạo thư mục sạch, chép bốn file của cách A từ bài lab (lab-common.sh, lab-local.sh, seed-pg.sql, seed-mysql.sql). Lab chỉ dùng socket riêng; gọi lab_whoami trước khi tạo hoặc gỡ index.

. ./lab-local.sh
lab_up
lab_seed
lab_whoami
SELECT id, total_cents, created_at
FROM wiki_lab.orders
WHERE customer_id = 1
  AND status = 'paid'
  AND created_at >= timestamp '2025-06-01'
ORDER BY created_at DESC
LIMIT 20;

Trong fixture, created_at không trùng giữa các đơn: 37 và 525.600 nguyên tố cùng nhau, nên công thức thời gian không lặp trong 100.000 số thứ tự. Vì vậy ORDER BY created_at DESC cho thứ tự xác định ở lab. Với dữ liệu thật có thời gian trùng, cần cột phá hòa như id trong query và thiết kế lại index theo thứ tự đó.

Hai index ứng viên:

Cấu hìnhThứ tự khóaĐiều cần kiểm
A(customer_id, status, created_at DESC)Chọn nhóm khách/trạng thái trước, lấy phần thời gian trong nhóm
B(created_at DESC, customer_id, status)Đi theo thời gian toàn bộ, kiểm khách/trạng thái ở mỗi phần liên quan

Ở A, hai điều kiện equality khóa hai cột đầu. Các entry trong nhóm đó được xếp theo thời gian, nên vừa giới hạn khoảng scan vừa đáp ứng ORDER BY/LIMIT. Ở B, range thời gian đứng trước; hai điều kiện sau vẫn có thể được kiểm ngay trong index, nhưng không tự thu hẹp thành một khoảng liên tiếp của một khách.

Không rút ra luật “cột có nhiều giá trị nhất luôn đứng đầu”. Khi cả hai cột đầu đều bị equality, mục tiêu của query và các query khác dùng prefix thường quan trọng hơn việc đổi thứ tự hai cột đó. Bài này so equality trước range với range trước equality.

Đo lượt A, chỉ có A

. ./lab-local.sh
lab_psql -c 'CREATE INDEX orders_a ON wiki_lab.orders(customer_id, status, created_at DESC)'
{ printf 'EXPLAIN (ANALYZE, BUFFERS) '; cat query.sql; } | lab_psql -At | tee plan-a.txt
{ printf 'EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) '; cat query.sql; } | lab_psql -At > plan-a.json
lab_psql -At -f query.sql > rows-a.txt
lab_psql -At -c "SELECT pg_relation_size('wiki_lab.orders_a')" > size-a.txt
echo "size_a $(cat size-a.txt)"

Output thật ở lượt kiểm ngày 2026-10-03:

Limit  (cost=0.42..64.13 rows=20 width=16) (actual time=0.018..0.038 rows=20.00 loops=1)
  Buffers: shared hit=20 read=3
  ->  Index Scan using orders_a on orders  (cost=0.42..1309.66 rows=411 width=16) (actual time=0.018..0.036 rows=20.00 loops=1)
        Index Cond: ((customer_id = 1) AND (status = 'paid'::text) AND (created_at >= '2025-06-01 00:00:00'::timestamp without time zone))
        Index Searches: 1
        Buffers: shared hit=20 read=3
Planning:
  Buffers: shared hit=134 read=5
Planning Time: 0.259 ms
Execution Time: 0.048 ms
size_a 4087808

LIMIT 20 làm node cha ngừng sau đủ hàng. Cost và rows của scan bên dưới vẫn có thể biểu diễn việc chạy hết khoảng lọc; đừng coi nó đã thực sự trả hết chừng đó hàng.

Gỡ A, đo lượt B trên cùng dữ liệu

Không để cả hai index tồn tại rồi đo một query: planner có thể tiếp tục chọn A và bạn sẽ tưởng đang kiểm B.

. ./lab-local.sh
lab_psql -c 'DROP INDEX wiki_lab.orders_a'
lab_psql -c 'CREATE INDEX orders_b ON wiki_lab.orders(created_at DESC, customer_id, status)'
test -z "$(lab_psql -At -c "SELECT to_regclass('wiki_lab.orders_a')")"
{ printf 'EXPLAIN (ANALYZE, BUFFERS) '; cat query.sql; } | lab_psql -At | tee plan-b.txt
{ printf 'EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) '; cat query.sql; } | lab_psql -At > plan-b.json
lab_psql -At -f query.sql > rows-b.txt
cmp rows-a.txt rows-b.txt
test "$(wc -l < rows-b.txt | tr -d ' ')" = 20
lab_psql -At -c "SELECT pg_relation_size('wiki_lab.orders_b')" > size-b.txt
echo "size_b $(cat size-b.txt)"
echo 'cùng 20 hàng theo thứ tự'

Output cùng lượt kiểm:

Limit  (cost=0.42..155.33 rows=20 width=16) (actual time=0.027..0.077 rows=20.00 loops=1)
  Buffers: shared hit=26 read=11
  ->  Index Scan using orders_b on orders  (cost=0.42..3183.89 rows=411 width=16) (actual time=0.026..0.076 rows=20.00 loops=1)
        Index Cond: ((created_at >= '2025-06-01 00:00:00'::timestamp without time zone) AND (customer_id = 1) AND (status = 'paid'::text))
        Index Searches: 1
        Buffers: shared hit=26 read=11
Planning:
  Buffers: shared hit=136 read=1
Planning Time: 0.214 ms
Execution Time: 0.087 ms
size_b 4071424
cùng 20 hàng theo thứ tự

A truy cập 23 block, B 37 block trong các lượt đo này, cả hai trả đúng 20 hàng và không có Sort. B vẫn sử dụng index, không phải index “vô dụng”; A khớp cách truy vấn chọn nhóm khách trước nên ít công việc hơn ở phép thử này. Chênh lệch kích thước index rất nhỏ và ngược chiều chênh lệch công việc đọc: index lớn hơn chút không tự có nghĩa query chậm hơn.

Đọc cả Index Cond, Buffers và node có Sort hay không. Việc tên mọi cột xuất hiện trong Index Cond không chứng minh chúng đều đã giảm khoảng scan như nhau. Planner có thể đi qua nhiều entry hơn để tìm đủ 20 hàng dù chỉ đưa 20 hàng lên node cha.

Hai lượt dùng cùng dữ liệu nhưng cache không giống hoàn toàn: CREATE INDEX vừa đọc bảng, và bản JSON chạy sau bản text. Đây là phép thử cơ chế đọc, không phải benchmark cold cache hoặc cam kết tốc độ.

Index còn có chi phí ghi

Lưu lượng ghi là một phần của lựa chọn. Thử insert 5.000 hàng mới với ba cấu hình: chỉ khóa chính, thêm A, thêm B. Mỗi lượt reset dữ liệu để không mang thay đổi của lượt trước vào; mọi insert đều rollback.

BEGIN;
EXPLAIN (ANALYZE, BUFFERS, WAL, FORMAT JSON)
INSERT INTO wiki_lab.orders(id, customer_id, status, total_cents, created_at)
SELECT g, 1, 'paid', 1000, timestamp '2025-06-01' + g * interval '1 minute'
FROM generate_series(100001, 105000) AS g;
ROLLBACK;
. ./lab-local.sh
for variant in none a b; do
  lab_seed
  case "$variant" in
    a) lab_psql -c 'CREATE INDEX orders_a ON wiki_lab.orders(customer_id, status, created_at DESC)' ;;
    b) lab_psql -c 'CREATE INDEX orders_b ON wiki_lab.orders(created_at DESC, customer_id, status)' ;;
  esac
  lab_psql -At -f write.sql > "write-$variant.json"
  test "$(lab_psql -At -c 'SELECT count(*) FROM wiki_lab.orders')" = 100000
done
echo 'write fixtures giữ 100000 hàng'

ROLLBACK không xóa WAL đã sinh và không làm cache trở lại trạng thái cũ; nó chỉ hoàn tác việc thêm hàng vào tập dữ liệu nhìn thấy. Vì vậy không dùng số WAL này làm dự báo trực tiếp về dung lượng production. Không gọi reset là xóa cache.

import json
from pathlib import Path

def nodes(plan):
    yield plan
    for child in plan.get('Plans', []):
        yield from nodes(child)

for name in ('a', 'b'):
    document = json.loads(Path(f'plan-{name}.json').read_text())[0]
    plan = document['Plan']
    assert plan['Actual Rows'] == 20
    assert any(node.get('Index Name') == f'orders_{name}' for node in nodes(plan))
    assert not any(node.get('Index Name') == f'orders_{"b" if name == "a" else "a"}'
                   for node in nodes(plan))
    size = int(Path(f'size-{name}.txt').read_text())
    assert size > 0
    print(f'{name}: rows=20 index_bytes={size} shared_blocks='
          f'{plan["Shared Hit Blocks"] + plan["Shared Read Blocks"]}')
assert Path('rows-a.txt').read_text() == Path('rows-b.txt').read_text()
wal_records = {}
for name in ('none', 'a', 'b'):
    document = json.loads(Path(f'write-{name}.json').read_text())[0]
    plan = document['Plan']
    assert any(node['Actual Rows'] == 5000 for node in nodes(plan))
    assert plan['WAL Records'] > 0
    assert plan['WAL Bytes'] > 0
    wal_records[name] = plan['WAL Records']
    print(f'write_{name}: rows=5000 wal_records={plan["WAL Records"]} '
          f'wal_bytes={plan["WAL Bytes"]} execution_ms={document["Execution Time"]}')
assert wal_records['a'] > wal_records['none']
assert wal_records['b'] > wal_records['none']
print('kết quả query và ba phép đo ghi đạt')
python3 check.py

Output cùng lượt kiểm; giá trị dung lượng và số WAL được đo trong lab, còn thời gian đổi ở lượt chạy khác:

a: rows=20 index_bytes=4087808 shared_blocks=23
b: rows=20 index_bytes=4071424 shared_blocks=37
write_none: rows=5000 wal_records=10013 wal_bytes=764880 execution_ms=3.416
write_a: rows=5000 wal_records=15068 wal_bytes=1332444 execution_ms=8.135
write_b: rows=5000 wal_records=15064 wal_bytes=1323236 execution_ms=6.356
kết quả query và ba phép đo ghi đạt

So cấu hình chỉ khóa chính với thêm index: số WAL records tăng từ khoảng 10.000 lên 15.000 khi insert cùng 5.000 hàng. Hai index khác thứ tự có chi phí ghi gần nhau về records trong phép thử; đây không phải lý do giữ cả hai nếu workload không cần cả hai.

Một lượt đo ghi không chứng minh thứ tự A luôn ghi nhanh hơn B hoặc mức chậm cố định khi thêm index. WAL records là bằng chứng server làm thêm công việc bảo trì cấu trúc, còn bytes chịu ảnh hưởng full-page images và checkpoint; thời gian chịu ảnh hưởng cache, nền máy và các lượt chạy trước. Nếu workload ghi quan trọng, đo nhiều lượt với dữ liệu/cấu hình checkpoint ổn định rồi mới quyết định.

Covering và index-only theo engine

Query đọc id và total_cents, hai cột chưa nằm trong A hay B. PostgreSQL phải lấy chúng từ heap. Có thể thêm INCLUDE (id, total_cents) vào A để đủ dữ liệu cho một index-only scan, nhưng “đủ cột” mới chỉ là điều kiện cần: visibility map còn phải cho biết các hàng ở heap page đã visible để tránh đọc heap. Đọc thêm Heap Fetches trong plan; tên Index Only Scan tự nó không chứng minh đã bỏ hoàn toàn heap access.

MySQL/InnoDB lưu khóa chính cùng entry của secondary index; query chỉ đọc khóa chính và cột đã có trong index có thể được cover. PostgreSQL không tự thêm giá trị khóa chính vào mọi secondary index theo cách đó. Với query của bài, total_cents vẫn cần được cung cấp; không suy kết quả PostgreSQL thành MySQL hay dùng cú pháp INCLUDE của PostgreSQL cho MySQL.

Phần MySQL dựa trên InnoDB Index Types của MySQL 8.4, không có đo plan MySQL ở bài này.

Có thể dùng index dù thiếu cột đầu không?

Có thể. Tài liệu PostgreSQL 18 mô tả skip scan: planner đôi khi lặp phép tìm theo các giá trị của cột đầu để dùng điều kiện ở cột sau. Nó có lợi khi số giá trị đầu ít và các lượt tìm có thể bỏ qua nhiều leaf page; không phải lời hứa rằng thiếu cột đầu lúc nào cũng nhanh.

Bởi vậy luật “không lọc cột đầu thì index tuyệt đối không dùng được” sai cho phiên bản này. Mặt khác, “đủ cả ba cột trong WHERE nên thứ tự không quan trọng” cũng không đúng. Xem plan của query và dữ liệu cụ thể.

Reset, kiểm lại và dọn

. ./lab-local.sh
lab_seed
test -z "$(lab_psql -At -c "SELECT to_regclass('wiki_lab.orders_b')")"
lab_psql -c 'CREATE INDEX orders_a ON wiki_lab.orders(customer_id, status, created_at DESC)'
lab_psql -At -f query.sql > rows-reset.txt
cmp rows-a.txt rows-reset.txt
echo 'reset đọc đạt'
. ./lab-local.sh
lab_clean
test ! -e lab.env

Bài tập biến thể và lỗi thường gặp

  1. Bỏ điều kiện customer_id: A còn gom theo khách trước, trong khi B đi theo thời gian. Ghi plan và tập kết quả mới; đừng mang kết luận của query cũ sang query này.
  2. Bỏ LIMIT hoặc lấy phần lớn bảng: planner có thể chọn scan và sort. Đó có thể là quyết định hợp lý, không tự tắt scan để giữ hình đẹp.
  3. Đổi khách nóng sang khách ít đơn: so row estimate với actual; tần suất giá trị là một phần của selectivity. status='paid' chiếm 70% toàn bộ fixture nên riêng điều kiện này không chọn lọc cao.
  4. Thử INCLUDE rồi VACUUM trong lab, kiểm Heap Fetches trước/sau. Đây là bài tập chưa đo trong bài; không sửa database thật để tái hiện.

Plan vẫn chọn index kia: kiểm đã DROP index ứng viên trước và dùng namespace lab. Index rất lớn: xem cột payload, kiểu dữ liệu và số entry; thêm cột không miễn phí. Query đổi kết quả sau index: kiểm ORDER BY có phá hòa đủ chưa, LIMIT có ổn định không và cả hai lượt có cùng dữ liệu không.

Học tiếp và nguồn

Nguồn chính thức đọc ngày 2026-10-03; số đo trong bài thuộc PostgreSQL 18.6/macOS arm64, không có nghiệm thu Docker/Linux, Podman hay MySQL plan.

Khóa ngoại: chi phí nằm trong trigger, không nằm trong plan

Câu hỏi bài này trả lời: khóa ngoại có làm chậm thao tác ghi không, vì sao xóa một lô hàng cha có thể mất hơn nửa giây dù plan của chính câu lệnh chỉ tra theo khóa chính, và khi nào cột tham chiếu cần index riêng?

Cần biết trước: SQL cơ bản, đọc EXPLAIN ANALYZE và lab database. Bài chạy PostgreSQL 18.6 trên macOS arm64 với dữ liệu giả; MySQL chỉ có một phép kiểm về index tự tạo. Docker/Linux của fixture chưa kiểm.

Khóa ngoại kiểm tra theo hai hướng

Khai báo FOREIGN KEY (order_id) REFERENCES orders(id) làm server tự kiểm hai việc mỗi khi dữ liệu đổi:

Bạn làm gìServer kiểm gìIndex mà việc kiểm dùng
INSERT hoặc đổi khóa ở bảng conHàng cha có tồn tại khôngKhóa chính hoặc unique của bảng cha: luôn có
DELETE hoặc đổi khóa ở bảng chaCòn hàng con trỏ tới không, hoặc xóa, đặt NULL hàng con theo ON DELETECột tham chiếu của bảng con: PostgreSQL không tự tạo

Tài liệu PostgreSQL 18 nói rõ cả hai vế. Cột được tham chiếu phải là khóa chính hoặc unique nên bên cha luôn có index để tra. Còn xóa hàng cha hoặc đổi cột được tham chiếu buộc server phải quét bảng con để tìm hàng khớp giá trị cũ, nên thường nên đánh index cột tham chiếu; vì việc đó không phải lúc nào cũng cần và có nhiều cách đánh index, khai báo khóa ngoại không tự tạo nó. Bài này đo hậu quả của vế cuối.

Khóa ngoại được hiện thực bằng trigger hệ thống. Lab dưới đây thêm một khóa ngoại có ON DELETE CASCADE rồi liệt kê các trigger nó sinh ra; mỗi lượt đo đều nằm trong giao dịch và rollback nên dữ liệu không đổi.

Tạo thư mục sạch, chép bốn file của cách A từ bài lab (lab-common.sh, lab-local.sh, seed-pg.sql, seed-mysql.sql) rồi dựng lab:

. ./lab-local.sh
lab_up
lab_seed
lab_whoami
BEGIN;
ALTER TABLE wiki_lab.order_items
  ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id)
  REFERENCES wiki_lab.orders(id) ON DELETE CASCADE;
SELECT tgrelid::regclass AS bang,
       regexp_replace(pg_get_triggerdef(oid), '^CREATE CONSTRAINT TRIGGER "[^"]+" ', '') AS dinh_nghia
FROM pg_trigger
WHERE tgconstraint <> 0
  AND tgrelid IN ('wiki_lab.orders'::regclass, 'wiki_lab.order_items'::regclass)
ORDER BY 1, 2;
ROLLBACK;
. ./lab-local.sh
lab_psql -At -F ' | ' -f triggers.sql

Output thật ở lượt kiểm ngày 2026-10-04:

wiki_lab.orders | AFTER DELETE ON wiki_lab.orders FROM wiki_lab.order_items NOT DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION "RI_FKey_cascade_del"()
wiki_lab.orders | AFTER UPDATE ON wiki_lab.orders FROM wiki_lab.order_items NOT DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION "RI_FKey_noaction_upd"()
wiki_lab.order_items | AFTER INSERT ON wiki_lab.order_items FROM wiki_lab.orders NOT DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION "RI_FKey_check_ins"()
wiki_lab.order_items | AFTER UPDATE ON wiki_lab.order_items FROM wiki_lab.orders NOT DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION "RI_FKey_check_upd"()

Bốn trigger AFTER ... FOR EACH ROW, hai ở mỗi bảng, ứng với hai hướng kiểm tra trong bảng ở trên. Chúng chạy sau khi plan của câu lệnh đã xong, và đó là lý do chi phí của chúng khó thấy.

Chi phí không nằm trong plan của câu lệnh

Xóa 100 đơn đầu của fixture. Bản thứ nhất để order_items.order_id không có index, bản thứ hai tạo index ngay trước khi thêm khóa ngoại:

BEGIN;
ALTER TABLE wiki_lab.order_items
  ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id)
  REFERENCES wiki_lab.orders(id) ON DELETE CASCADE;
EXPLAIN (ANALYZE, COSTS OFF) DELETE FROM wiki_lab.orders WHERE id <= 100;
ROLLBACK;
BEGIN;
CREATE INDEX order_items_order_id ON wiki_lab.order_items(order_id);
ALTER TABLE wiki_lab.order_items
  ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id)
  REFERENCES wiki_lab.orders(id) ON DELETE CASCADE;
EXPLAIN (ANALYZE, COSTS OFF) DELETE FROM wiki_lab.orders WHERE id <= 100;
ROLLBACK;
. ./lab-local.sh
echo '== không index ở bảng con'
lab_psql -At -f delete-no-index.sql
echo '== có index ở bảng con'
lab_psql -At -f delete-index.sql
== không index ở bảng con
Delete on orders (actual time=0.034..0.034 rows=0.00 loops=1)
  ->  Index Scan using orders_pkey on orders (actual time=0.004..0.009 rows=100.00 loops=1)
        Index Cond: (id <= 100)
        Index Searches: 1
Trigger for constraint fk_items_order: time=645.067 calls=100
Execution Time: 645.221 ms
== có index ở bảng con
Delete on orders (actual time=0.078..0.078 rows=0.00 loops=1)
  ->  Index Scan using orders_pkey on orders (actual time=0.003..0.008 rows=100.00 loops=1)
        Index Cond: (id <= 100)
        Index Searches: 1
Trigger for constraint fk_items_order: time=0.329 calls=100
Execution Time: 0.461 ms

Hai plan của DELETE giống hệt nhau và đều rất nhanh: node Delete mất vài chục micro giây để tìm 100 hàng theo khóa chính. Toàn bộ chênh lệch nằm ở dòng Trigger for constraint: 645 ms so với 0,3 ms cho đúng 100 lần gọi. Mỗi hàng cha bị xóa gọi một câu DELETE trên order_items để cascade; khi bảng con có 300.000 hàng và không có index, mỗi câu phải quét cả bảng. 645 ms chia cho 100 hàng là khoảng 6,4 ms mỗi hàng cha, gần với thời gian một lần quét tuần tự bảng đó ở mục skip scan bên dưới (7,3 ms).

Tài liệu PostgreSQL giải thích vì sao dòng này tách khỏi node: trigger AFTER chạy sau khi cả plan xong nên thời gian của chúng không nằm trong node Delete; EXPLAIN ANALYZE in tổng thời gian mỗi trigger thành dòng riêng. Cũng theo tài liệu, trigger hoãn (deferred) chỉ chạy lúc cuối giao dịch nên EXPLAIN ANALYZE không đo chúng. Hai hệ quả thực tế: nhìn plan của câu lệnh hoặc node chậm nhất sẽ không thấy nguyên nhân, và khóa ngoại khai báo DEFERRABLE INITIALLY DEFERRED giấu chi phí kiểm ra khỏi EXPLAIN ANALYZE.

Một lượt đo có thể là may rủi, nên lab chạy mỗi bản ba lần và đòi lượt nhanh nhất của bản không index chậm hơn lượt chậm nhất của bản có index ít nhất 20 lần. Ngưỡng này là kiểm tra chống đo hỏng, không phải lời hứa về tốc độ trên máy khác:

. ./lab-local.sh
for run in 1 2 3; do
  lab_psql -At -f delete-no-index.sql | grep '^Trigger for constraint' | sed 's/^/không index: /'
  lab_psql -At -f delete-index.sql | grep '^Trigger for constraint' | sed 's/^/có index: /'
done | tee delete.txt
awk -F'time=' '
  /^không index/ { split($2, a, " "); if (lo == "" || a[1] + 0 < lo + 0) lo = a[1] }
  /^có index/ { split($2, b, " "); if (b[1] + 0 > hi + 0) hi = b[1] }
  END {
    printf "nhanh nhất không index %.3f ms, chậm nhất có index %.3f ms\n", lo, hi
    exit !(lo + 0 > 20 * hi)
  }' delete.txt
không index: Trigger for constraint fk_items_order: time=640.512 calls=100
có index: Trigger for constraint fk_items_order: time=0.379 calls=100
nhanh nhất không index 639.563 ms, chậm nhất có index 0.379 ms

Index ở cột con cũng có giá

Chiều ngược lại: chèn 20.000 dòng vào order_items trong ba cấu hình, không khóa ngoại, có khóa ngoại, có khóa ngoại kèm index cột con. Mỗi dòng mới phải được kiểm là có hàng cha.

\echo == không khóa ngoại
BEGIN;
EXPLAIN (ANALYZE, COSTS OFF)
INSERT INTO wiki_lab.order_items
SELECT g, (g % 100000) + 1, 'sku-x', 1, 100 FROM generate_series(300001, 320000) AS g;
ROLLBACK;
\echo == có khóa ngoại
BEGIN;
ALTER TABLE wiki_lab.order_items
  ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES wiki_lab.orders(id);
EXPLAIN (ANALYZE, COSTS OFF)
INSERT INTO wiki_lab.order_items
SELECT g, (g % 100000) + 1, 'sku-x', 1, 100 FROM generate_series(300001, 320000) AS g;
ROLLBACK;
\echo == có khóa ngoại và index cột con
BEGIN;
CREATE INDEX order_items_order_id ON wiki_lab.order_items(order_id);
ALTER TABLE wiki_lab.order_items
  ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES wiki_lab.orders(id);
EXPLAIN (ANALYZE, COSTS OFF)
INSERT INTO wiki_lab.order_items
SELECT g, (g % 100000) + 1, 'sku-x', 1, 100 FROM generate_series(300001, 320000) AS g;
ROLLBACK;
. ./lab-local.sh
lab_psql -At -f insert.sql | grep -E '^==|^Insert on|^Trigger for constraint|^Execution Time'
== không khóa ngoại
Insert on order_items (actual time=17.177..17.177 rows=0.00 loops=1)
Execution Time: 17.195 ms
== có khóa ngoại
Insert on order_items (actual time=18.622..18.622 rows=0.00 loops=1)
Trigger for constraint fk_items_order: time=35.984 calls=20000
Execution Time: 55.062 ms
== có khóa ngoại và index cột con
Insert on order_items (actual time=39.895..39.895 rows=0.00 loops=1)
Trigger for constraint fk_items_order: time=37.356 calls=20000
Execution Time: 77.718 ms

Có hai khoản chi phí ghi, và chúng khác nhau về bản chất. Khóa ngoại thêm 20.000 lần kiểm tra cha, tốn khoảng 36 ms vì mỗi lần là một lần tra theo khóa chính: rẻ, nhưng không phải bằng không (tổng thời gian từ 17 lên 55 ms trong lượt này). Index ở cột con không làm trigger chậm đi mà làm node Insert chậm hơn (từ 18,6 lên 39,9 ms) vì mỗi dòng phải được ghi thêm vào index. Đây là giá thật của index, trả ở mọi lần ghi vào bảng con.

So hai bảng đo: khoản 21 ms thêm cho 20.000 dòng chèn là cái giá để mỗi hàng cha bị xóa từ khoảng 6,4 ms xuống khoảng 3 micro giây (645 ms và 0,33 ms cho 100 hàng ở mục trên). Giá đó đáng trả khi bảng cha có xóa hoặc đổi khóa, hoặc khi cột con xuất hiện trong join và điều kiện lọc; nó không đáng khi bảng cha chỉ thêm, cột con không bao giờ được tra và bảng con ghi rất nhiều.

Cột khóa ngoại nên đứng đâu trong index

Lời khuyên quen thuộc là đặt cột khóa ngoại đầu tiên. Với PostgreSQL 18 điều đó không còn là điều kiện cần, vì skip scan cho phép dùng index nhiều cột khi cột đầu có ít giá trị. Câu hỏi thực tế là kiểm tra của khóa ngoại tra theo order_id = giá trị rẻ đến đâu với từng kiểu index. Lab mô phỏng đúng câu tra đó (SELECT 1 FROM ONLY order_items x WHERE order_id = 12345 FOR KEY SHARE OF x) và đếm số lần tìm (Index Searches):

\echo == index (qty, order_id): cột đầu có 3 giá trị
BEGIN;
CREATE INDEX ix_qty_order ON wiki_lab.order_items(qty, order_id);
ANALYZE wiki_lab.order_items;
EXPLAIN (ANALYZE, COSTS OFF) SELECT 1 FROM ONLY wiki_lab.order_items x WHERE x.order_id = 12345 FOR KEY SHARE OF x;
ROLLBACK;
\echo == index (sku, order_id): cột đầu có 500 giá trị
BEGIN;
CREATE INDEX ix_sku_order ON wiki_lab.order_items(sku, order_id);
ANALYZE wiki_lab.order_items;
EXPLAIN (ANALYZE, COSTS OFF) SELECT 1 FROM ONLY wiki_lab.order_items x WHERE x.order_id = 12345 FOR KEY SHARE OF x;
ROLLBACK;
\echo == index (order_id)
BEGIN;
CREATE INDEX ix_order ON wiki_lab.order_items(order_id);
ANALYZE wiki_lab.order_items;
EXPLAIN (ANALYZE, COSTS OFF) SELECT 1 FROM ONLY wiki_lab.order_items x WHERE x.order_id = 12345 FOR KEY SHARE OF x;
ROLLBACK;
\echo == không index, quét tuần tự không song song như trigger
BEGIN;
SET LOCAL max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, COSTS OFF) SELECT 1 FROM ONLY wiki_lab.order_items x WHERE x.order_id = 12345 FOR KEY SHARE OF x;
ROLLBACK;
. ./lab-local.sh
lab_psql -At -f skip.sql | grep -E '^==|Index Searches'
== index (qty, order_id): cột đầu có 3 giá trị
        Index Searches: 5
== index (sku, order_id): cột đầu có 500 giá trị
        Index Searches: 643
== index (order_id)
        Index Searches: 1
== không index, quét tuần tự không song song như trigger
. ./lab-local.sh
lab_psql -At -f skip.sql | grep -E '^==|Index Scan|Seq Scan|^Execution Time'
== index (qty, order_id): cột đầu có 3 giá trị
  ->  Index Scan using ix_qty_order on order_items x (actual time=0.017..0.031 rows=3.00 loops=1)
Execution Time: 0.046 ms
== index (sku, order_id): cột đầu có 500 giá trị
  ->  Index Scan using ix_sku_order on order_items x (actual time=0.382..1.847 rows=3.00 loops=1)
Execution Time: 1.857 ms
== index (order_id)
  ->  Index Scan using ix_order on order_items x (actual time=0.009..0.011 rows=3.00 loops=1)
Execution Time: 0.017 ms
== không index, quét tuần tự không song song như trigger
  ->  Seq Scan on order_items x (actual time=0.317..7.317 rows=3.00 loops=1)
Execution Time: 7.341 ms

Index chỉ có order_id cần một lần tìm và 0,017 ms. Index (qty, order_id) vẫn dùng được nhờ skip scan: cột đầu chỉ có ba giá trị nên planner lặp phép tìm theo từng giá trị, tổng cộng 5 lần tìm và 0,046 ms. Index (sku, order_id) có 500 giá trị ở cột đầu cần 643 lần tìm, chậm hơn chừng trăm lần index đúng, nhưng trong lượt đo này vẫn nhanh hơn quét tuần tự bảng 300.000 hàng (1,9 so với 7,3 ms). Số lần tìm do executor quyết định, không suy ra được chỉ từ số giá trị khác biệt của cột đầu. Tài liệu PostgreSQL 18 nói skip scan hiệu quả khi cột đầu có ít giá trị, còn khi có nhiều thì thường planner chọn quét tuần tự. Ở lượt đo này planner vẫn chọn index với 500 giá trị đầu, nên đừng coi “nhiều giá trị thì chắc chắn quét bảng” là quy tắc cứng; hãy đo với dữ liệu của bạn.

Quy tắc dùng được: đặt cột khóa ngoại đầu tiên, trừ khi index đó chủ yếu phục vụ truy vấn khác và cột đầu có rất ít giá trị. Quy tắc này không áp dụng nguyên cho MySQL: tài liệu MySQL 26.7 yêu cầu cột khóa ngoại là các cột đầu của index theo cùng thứ tự, và bài này không đo skip scan ở đó.

Khóa ngoại và khóa hàng

Kiểm tra phía con không chỉ đọc hàng cha: nó khóa hàng đó ở mức FOR KEY SHARE. Tài liệu PostgreSQL mô tả mức này yếu nhất trong bốn mức khóa hàng: nó chặn DELETE và mọi UPDATE đổi giá trị khóa, nhưng không chặn UPDATE khác, SELECT FOR NO KEY UPDATE, SELECT FOR SHARE hay SELECT FOR KEY SHARE.

Đang giữ FOR KEY SHARE trên hàng chaCó chặn không
DELETE hàng chaCó
SELECT ... FOR UPDATE hàng chaCó
UPDATE không đổi cột khóaKhông (nó chỉ cần FOR NO KEY UPDATE)
Một khóa ngoại khác kiểm cùng hàng chaKhông (FOR KEY SHARE không xung đột với chính nó)

Lab dưới đây dựng hai phiên. Phiên giữ chèn một dòng con rồi ngủ 8 giây trong giao dịch chưa commit; trong lúc đó phiên kiểm xem bảng khóa ở mức bảng, rồi thử ba thao tác trên hàng cha:

. ./lab-local.sh
lab_seed
lab_psql -c 'CREATE INDEX order_items_order_id ON wiki_lab.order_items(order_id)'
lab_psql -c 'ALTER TABLE wiki_lab.order_items ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES wiki_lab.orders(id)'
( lab_psql -At -c "BEGIN; INSERT INTO wiki_lab.order_items VALUES (900001, 1, 'sku-x', 1, 100); SELECT pg_sleep(8); COMMIT;" > holder.log 2>&1 ) &
holder=$!
i=0
until [ "$(lab_psql -At -c "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'PgSleep'")" = 1 ]; do
  i=$((i + 1))
  [ "$i" -lt 100 ] || { echo 'phiên giữ không tới điểm chờ' >&2; exit 1; }
  sleep 0.1
done
echo '== khóa mức bảng của phiên đang chèn dòng con'
modes=$(lab_psql -At -F ' ' -c "SELECT c.relname, l.mode FROM pg_locks l JOIN pg_stat_activity a USING (pid) JOIN pg_class c ON c.oid = l.relation WHERE a.wait_event = 'PgSleep' AND l.locktype = 'relation' AND c.relname IN ('orders', 'order_items') ORDER BY 1, 2")
echo "$modes"
[ "$(echo "$modes" | grep -cvE ' (RowShareLock|RowExclusiveLock)$')" = 0 ] || { echo 'có khóa mạnh hơn ROW EXCLUSIVE' >&2; exit 1; }
echo '== SELECT FOR UPDATE NOWAIT trên hàng cha'
lab_psql -At -c "SELECT id FROM wiki_lab.orders WHERE id = 1 FOR UPDATE NOWAIT" 2>&1 || true
echo '== UPDATE cột không phải khóa của hàng cha'
lab_psql -At -c "SET lock_timeout = '1s'; UPDATE wiki_lab.orders SET status = 'paid' WHERE id = 1" 2>&1 && echo 'update xong, không phải chờ'
echo '== DELETE hàng cha'
lab_psql -At -c "SET lock_timeout = '500ms'; DELETE FROM wiki_lab.orders WHERE id = 1" 2>&1 || true
wait "$holder"
cat holder.log
bash twosessions.sh
== khóa mức bảng của phiên đang chèn dòng con
order_items RowExclusiveLock
orders RowShareLock
== SELECT FOR UPDATE NOWAIT trên hàng cha
ERROR:  could not obtain lock on row in relation "orders"
== UPDATE cột không phải khóa của hàng cha
update xong, không phải chờ
== DELETE hàng cha
ERROR:  canceling statement due to lock timeout
CONTEXT:  while deleting tuple (735,42) in relation "orders"

Phiên chèn dòng con chỉ giữ RowExclusiveLock trên bảng con và RowShareLock trên bảng cha ở mức bảng. Ở mức hàng, việc kiểm khóa ngoại chặn đúng những gì bảng trên nói: FOR UPDATE bị từ chối ngay, DELETE chờ tới hết lock_timeout rồi bị hủy, còn UPDATE không đổi khóa đi qua không chờ. Vì vậy khóa ngoại không làm mọi thay đổi hàng cha phải xếp hàng, chỉ những thay đổi xóa hoặc đổi khóa.

Hướng xóa hàng cha cũng nên xem khóa mức bảng. Lab dưới đây reset dữ liệu và thêm khóa ngoại không index ngoài giao dịch đo (vì chính lệnh ALTER TABLE ... ADD FOREIGN KEY giữ khóa mạnh hơn và sẽ làm lẫn kết quả), rồi xóa một hàng cha mới (chưa có dòng con) trong giao dịch và liệt kê khóa của chính phiên đó trước khi rollback. Bước kiểm dừng lab nếu thấy bất kỳ mode nào ngoài RowShareLock và RowExclusiveLock:

BEGIN;
INSERT INTO wiki_lab.orders VALUES (900001, 1, 'pending', 100, timestamp '2026-01-01');
DELETE FROM wiki_lab.orders WHERE id = 900001;
SELECT c.relname, l.mode
FROM pg_locks l JOIN pg_class c ON c.oid = l.relation
WHERE l.pid = pg_backend_pid() AND l.locktype = 'relation'
  AND c.relname IN ('orders', 'order_items')
ORDER BY 1, 2;
ROLLBACK;
. ./lab-local.sh
lab_seed
lab_psql -c 'ALTER TABLE wiki_lab.order_items ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES wiki_lab.orders(id)'
lab_psql -At -F ' ' -f locks-delete.sql | tee locks-delete.txt
if grep -qvE ' (RowShareLock|RowExclusiveLock)$' locks-delete.txt; then
  echo 'có khóa mạnh hơn ROW EXCLUSIVE' >&2
  exit 1
fi
echo 'chỉ có ROW SHARE và ROW EXCLUSIVE'
order_items RowShareLock
orders RowExclusiveLock
orders RowShareLock
chỉ có ROW SHARE và ROW EXCLUSIVE

Ở hướng xóa cha, hai bảng cũng chỉ giữ RowShareLock và RowExclusiveLock; không có mode nào mạnh hơn ROW EXCLUSIVE. Tài liệu PostgreSQL ghi thêm rằng server không nhớ thông tin hàng đã khóa trong bộ nhớ nên không giới hạn số hàng khóa cùng lúc. Các số đo ở cả hai hướng không cho thấy khóa bảng mạnh nào. Bài này không kiểm engine khác, nên không nói gì về việc ở đó khóa ngoại thiếu index có dẫn tới khóa cả bảng hay không; với PostgreSQL, hậu quả đo được của thiếu index con là chi phí quét lặp ở phía cha, không phải khóa bảng.

Tìm khóa ngoại chưa có index ở cột con

Truy vấn dưới đây liệt kê khóa ngoại không có index hợp lệ nào bắt đầu bằng đúng các cột của nó (so sánh theo tập, không theo thứ tự). Nó là truy vấn bảo thủ: index (qty, order_id) ở mục trước vẫn dùng được nhờ skip scan nhưng bị liệt kê, vì cột khóa ngoại không đứng đầu. Dùng kết quả như danh sách để xem xét, không phải bản án.

SELECT c.conrelid::regclass AS bang_con, c.conname
FROM pg_constraint c
WHERE c.contype = 'f'
  AND c.connamespace = 'wiki_lab'::regnamespace
  AND NOT EXISTS (
    SELECT 1 FROM pg_index i
    WHERE i.indrelid = c.conrelid
      AND i.indisvalid
      AND (i.indkey::int2[])[0:cardinality(c.conkey) - 1] @> c.conkey
      AND c.conkey @> (i.indkey::int2[])[0:cardinality(c.conkey) - 1]
  );
. ./lab-local.sh
lab_seed
lab_psql -c 'ALTER TABLE wiki_lab.order_items ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES wiki_lab.orders(id)'
echo '== chưa có index'
lab_psql -At -F ' ' -f missing.sql
lab_psql -c 'CREATE INDEX order_items_order_id ON wiki_lab.order_items(order_id)'
echo '== có index (order_id)'
lab_psql -At -F ' ' -f missing.sql
lab_psql -c 'DROP INDEX wiki_lab.order_items_order_id'
lab_psql -c 'CREATE INDEX ix_qty_order ON wiki_lab.order_items(qty, order_id)'
echo '== chỉ có index (qty, order_id)'
lab_psql -At -F ' ' -f missing.sql
== chưa có index
wiki_lab.order_items fk_items_order
== có index (order_id)
== chỉ có index (qty, order_id)
wiki_lab.order_items fk_items_order

MySQL InnoDB: index tự tạo

MySQL giải quyết vấn đề ở trên theo hướng ngược lại. Tài liệu MySQL 26.7 ghi rằng MySQL yêu cầu index trên khóa ngoại và khóa được tham chiếu để kiểm tra khóa ngoại nhanh, không quét bảng; ở bảng tham chiếu phải có index với các cột khóa ngoại đứng đầu theo cùng thứ tự, và index đó được tạo tự động nếu chưa có. Cũng theo tài liệu, index này có thể bị gỡ lặng lẽ về sau nếu bạn tạo index khác dùng được cho ràng buộc.

. ./lab-local.sh
echo '== trước khi thêm khóa ngoại'
lab_mysql -N -e "SELECT index_name, column_name FROM information_schema.statistics WHERE table_schema = 'wiki_lab' AND table_name = 'order_items' ORDER BY 1, seq_in_index"
lab_mysql -e "ALTER TABLE wiki_lab.order_items ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES wiki_lab.orders(id)"
echo '== sau khi thêm khóa ngoại'
lab_mysql -N -e "SELECT index_name, column_name FROM information_schema.statistics WHERE table_schema = 'wiki_lab' AND table_name = 'order_items' ORDER BY 1, seq_in_index"
== trước khi thêm khóa ngoại
PRIMARY id
== sau khi thêm khóa ngoại
fk_items_order order_id
PRIMARY id

Vì lab của bài chạy hai engine từ cùng seed không có khóa ngoại, đây là chỗ hai engine bắt đầu khác nhau ngay khi bạn thêm ràng buộc. Phép kiểm này chỉ xác nhận index được tạo; chi phí xóa cha, cascade và chèn con ở MySQL chưa được đo trong bài, và index tự tạo vẫn trả giá ghi như index tự tạo bằng tay.

Quyết định cho từng khóa ngoại

  1. Hỏi ba câu: hàng cha có bị xóa hoặc đổi khóa không (kể cả qua CASCADE)? Cột con có xuất hiện trong join hoặc điều kiện lọc không? Bảng con đủ lớn để một lần quét tốn thời gian đo được không? Nếu một trong ba là có, tạo index bắt đầu bằng cột khóa ngoại.
  2. Không thêm index khi bảng cha chỉ thêm và không bao giờ xóa hay đổi khóa, cột con không dùng để tra, và bảng con chịu ghi nặng: index là chi phí ghi thật (21 ms thêm cho 20.000 dòng ở lab).
  3. Coi ON DELETE CASCADE là một câu DELETE trên bảng con cho mỗi hàng cha. Xóa hàng loạt hàng cha mà bảng con không có index tốn thời gian tỉ lệ với số hàng cha nhân với kích thước bảng con.
  4. Khi DELETE hoặc cascade chậm mà plan nhanh, đọc các dòng Trigger for constraint trong EXPLAIN (ANALYZE) trước khi tìm nguyên nhân ở chỗ khác, và nhớ rằng trigger hoãn không hiện ở đó.
  5. Rà định kỳ bằng truy vấn catalog ở trên và xem từng dòng kết quả.

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

  • Số đo thuộc PostgreSQL 18.6 và MySQL 26.7 trên một máy macOS arm64, một fixture. Lab đòi tỉ lệ tối thiểu 20 lần, không đòi con số tuyệt đối; hãy tự đo trên dữ liệu và phần cứng của bạn.
  • Chưa đo: tác dụng của index cột con lên join và điều kiện lọc (bài chỉ đo chi phí của chính khóa ngoại), khóa ngoại trên bảng phân vùng, DEFERRABLE và ON UPDATE, MATCH FULL, hiệu năng nạp dữ liệu lớn khi khóa ngoại đang bật, hành vi nhiều engine khác, và so sánh với việc kiểm tra tính toàn vẹn ở ứng dụng.
  • Truy vấn mô phỏng ở mục skip scan là câu tra tương đương, không phải câu trigger thực sự chạy; số lần tìm của trigger có thể khác nếu server dùng đường đi khác.
  • EXPLAIN ANALYZE thực sự chạy câu lệnh. Lab đặt mọi thứ trong BEGIN ... ROLLBACK để không đổi dữ liệu; không làm vậy trên database thật.
  • Lỗi thường gặp: tạo index sau khi cascade đã chậm rồi kết luận “khóa ngoại làm chậm” chỉ vì thiếu một index; đo hai lượt với cache khác nhau và so nhau; đặt khóa ngoại và index trong cùng migration mà không kiểm thời gian khóa bảng khi chạy trên dữ liệu thật.

Dọn

. ./lab-local.sh
lab_clean
test ! -e lab.env

Học tiếp và nguồn

Nguồn chính thức đọc ngày 2026-10-04; số đo trong bài thuộc PostgreSQL 18.6 và MySQL 26.7 trên macOS arm64, không có nghiệm thu Docker/Linux.

MVCC và isolation: cùng lịch hai session, khác dữ liệu nhìn thấy

Câu hỏi bài này trả lời: session B đã cập nhật hoặc commit, vì sao A vẫn đọc giá trị cũ, đọc giá trị mới, bị chờ hoặc nhận lỗi khi lấy khóa?

Cần biết trước: SQL transaction, COMMIT/ROLLBACK và lab database. Bài chạy PostgreSQL 18.6 và MySQL 26.7.0/InnoDB bằng socket riêng trên macOS arm64, Python 3.14.4 stdlib. Docker/Linux của fixture chưa kiểm. Không suy kết quả giữa engine chỉ từ tên isolation.

MVCC giữ thông tin phiên bản để một phép đọc chọn dữ liệu phù hợp với snapshot. Isolation quy định snapshot và xung đột được xử lý thế nào; FOR UPDATE không phải một plain SELECT. PostgreSQL MVCC mô tả mô hình đa phiên bản, còn InnoDB multi-versioning dùng undo để dựng phiên bản cũ. Lab quan sát visibility/khóa, không đo heap/undo bytes hoặc khả năng ghi nhanh hơn của engine.

Một hàng giả, lịch có điểm dừng

Tạo thư mục trống, chép bốn file cách A của bài lab: lab-common.sh, lab-local.sh, seed-pg.sql, seed-mysql.sql. Bài thêm bảng wiki_lab.stock có (id=1, qty=10). Mỗi case reset về 10 khi các transaction trước đã kết thúc.

. ./lab-local.sh
lab_up
lab_seed
lab_whoami
lab_psql -c 'CREATE TABLE wiki_lab.stock (id integer PRIMARY KEY, qty integer NOT NULL); INSERT INTO wiki_lab.stock VALUES (1,10);'
lab_mysql -e 'CREATE TABLE wiki_lab.stock (id INT PRIMARY KEY, qty INT NOT NULL) ENGINE=InnoDB; INSERT INTO wiki_lab.stock VALUES (1,10);'

Lịch cho đọc thường:

BướcABBarrier
1BEGIN và đọc qty=10—Kết quả SELECT của A
2—BEGIN, UPDATE qty=20, chưa COMMITMarker sau UPDATE
3Đọc khi B chưa commit—Kết quả của A
4—COMMITMarker sau COMMIT
5Đọc lại trong transaction cũ—Kết quả của A
6ROLLBACK, mở transaction mới, đọc—Kết quả mới phải 20

Riêng MySQL Serializable, plain SELECT trong transaction tường minh lấy shared lock; bước B UPDATE sẽ chờ A. Controller xác nhận cạnh chờ qua data_lock_waits, A đọc lại rồi COMMIT để B tiếp tục. Không giả rằng B đã commit trong khi nó còn bị khóa.

Controller hai connection thật

Lưu isolation.py. Các query chỉ dùng ID cố định/bảng lab. Marker đi sau SQL, nên nhận được marker nghĩa là câu trước đã hoàn thành. Poll bảng lock-wait xác nhận chờ; timeout là giới hạn chống treo, không là bằng chứng SQL đã chạy.

import os
import queue
import signal
import subprocess
import threading
import time

CLIENT = {
    "pg": ". ./lab-local.sh; lab_psql -At",
    "mysql": ". ./lab-local.sh; lab_mysql --batch --raw --skip-column-names --unbuffered",
}


def query(engine, sql):
    result = subprocess.run(["bash", "-c", CLIENT[engine]], input=sql, text=True,
                            capture_output=True, timeout=15, check=True)
    return result.stdout.strip()


class Session:
    def __init__(self, engine, inspect_error=False):
        self.engine = engine
        client = CLIENT[engine]
        if engine == "pg":
            client += " -v VERBOSITY=verbose"
            if inspect_error:
                client += " -v ON_ERROR_STOP=0"
        self.process = subprocess.Popen(
            ["bash", "-c", client], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
            stderr=subprocess.PIPE, text=True, bufsize=1, start_new_session=True)
        self.lines = queue.Queue()
        self.reader = threading.Thread(target=self.read, daemon=True)
        self.reader.start()
        identity = "SELECT pg_backend_pid();" if engine == "pg" else "SELECT CONNECTION_ID();"
        self.id = int(self.scalar(identity))
        if engine == "pg":
            self.mark("SET statement_timeout='10s';")
        else:
            self.mark("SET SESSION innodb_lock_wait_timeout=10;")

    def read(self):
        for line in self.process.stdout:
            self.lines.put(line.strip())
        self.lines.put(None)

    def send(self, sql):
        self.process.stdin.write(sql + "\n")
        self.process.stdin.flush()

    def collect(self):
        values = []
        while True:
            value = self.lines.get(timeout=10)
            assert value is not None, "client kết thúc trước marker"
            if value == "barrier":
                return values
            values.append(value)

    def ask(self, sql):
        self.send(sql + " SELECT 'barrier';")
        return self.collect()

    def mark(self, sql):
        assert self.ask(sql) == []

    def scalar(self, sql):
        values = self.ask(sql)
        assert len(values) == 1, values
        return values[0]

    def begin(self, level):
        if self.engine == "pg":
            self.mark(f"BEGIN ISOLATION LEVEL {level};")
            assert self.scalar("SHOW transaction_isolation;") == level.lower()
        else:
            self.mark(f"SET SESSION TRANSACTION ISOLATION LEVEL {level}; START TRANSACTION;")
            assert self.scalar("SELECT @@transaction_isolation;") == level.replace(" ", "-")

    def finish(self):
        self.process.stdin.close()
        code = self.process.wait(timeout=10)
        self.reader.join(timeout=2)
        return code, self.process.stderr.read()

    def close(self):
        if self.process.poll() is None:
            os.killpg(self.process.pid, signal.SIGTERM)
            try:
                self.process.wait(timeout=3)
            except subprocess.TimeoutExpired:
                os.killpg(self.process.pid, signal.SIGKILL)
                self.process.wait(timeout=3)


def reset(engine):
    query(engine, "UPDATE wiki_lab.stock SET qty=10 WHERE id=1;")


def value(session, lock=False):
    suffix = " FOR UPDATE" if lock else ""
    return int(session.scalar("SELECT qty FROM wiki_lab.stock WHERE id=1" + suffix + ";"))


def finish_ok(*sessions):
    for session in sessions:
        code, error = session.finish()
        assert code == 0 and not error, "SQL không được có lỗi ngoài dự kiến"


def wait_edge(writer, reader):
    sql = f"""SELECT count(*) FROM performance_schema.data_lock_waits w
JOIN performance_schema.threads r ON r.THREAD_ID=w.REQUESTING_THREAD_ID
JOIN performance_schema.threads b ON b.THREAD_ID=w.BLOCKING_THREAD_ID
WHERE r.PROCESSLIST_ID={writer.id} AND b.PROCESSLIST_ID={reader.id};"""
    deadline = time.monotonic() + 8
    while time.monotonic() < deadline:
        if int(query("mysql", sql)) > 0:
            return
        time.sleep(0.02)
    raise AssertionError("Không thấy cạnh writer chờ reader")


def ordinary(engine, level):
    reset(engine)
    a, b = Session(engine), Session(engine)
    try:
        a.begin(level)
        b.begin("READ COMMITTED")
        first = value(a)
        assert first == 10
        if engine == "mysql" and level == "SERIALIZABLE":
            b.send("UPDATE wiki_lab.stock SET qty=20 WHERE id=1; SELECT 'barrier';")
            wait_edge(b, a)
            again = value(a)
            assert again == 10
            a.mark("COMMIT;")
            assert b.collect() == []
            b.mark("COMMIT;")
            a.begin(level)
            fresh = value(a)
            assert fresh == 20
            a.mark("ROLLBACK;")
            print("mysql SERIALIZABLE first=10 writer_wait=yes second=10 new_tx=20")
        else:
            b.mark("UPDATE wiki_lab.stock SET qty=20 WHERE id=1;")
            during = value(a)
            b.mark("COMMIT;")
            after = value(a)
            expected_during = 20 if engine == "mysql" and level == "READ UNCOMMITTED" else 10
            expected_after = 20 if level in ("READ UNCOMMITTED", "READ COMMITTED") else 10
            assert (during, after) == (expected_during, expected_after)
            a.mark("ROLLBACK;")
            a.begin(level)
            fresh = value(a)
            assert fresh == 20
            a.mark("ROLLBACK;")
            print(f"{engine} {level} first={first} uncommitted={during} committed={after} new_tx={fresh}")
        finish_ok(a, b)
    finally:
        a.close()
        b.close()


def locking(engine, level):
    reset(engine)
    expect_error = engine == "pg" and level in ("REPEATABLE READ", "SERIALIZABLE")
    a, b = Session(engine, inspect_error=expect_error), Session(engine)
    try:
        a.begin(level)
        b.begin("READ COMMITTED")
        assert value(a) == 10
        b.mark("UPDATE wiki_lab.stock SET qty=20 WHERE id=1; COMMIT;")
        if expect_error:
            a.send("SELECT qty FROM wiki_lab.stock WHERE id=1 FOR UPDATE; ROLLBACK; SELECT 'barrier';")
            assert a.collect() == []
            code, error = a.finish()
            assert code == 0 and error.count("40001") == 1
            assert "could not serialize access due to concurrent update" in error
            print(f"pg {level} locking=40001 rollback=yes")
        else:
            current = value(a, lock=True)
            plain = value(a)
            assert current == 20
            assert plain == (10 if level == "REPEATABLE READ" else 20)
            a.mark("ROLLBACK;")
            finish_ok(a)
            print(f"{engine} {level} locking={current} plain_after={plain} rollback=yes")
        finish_ok(b)
        assert query(engine, "SELECT qty FROM wiki_lab.stock WHERE id=1;") == "20"
    finally:
        a.close()
        b.close()


for engine in ("pg", "mysql"):
    for level in ("READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"):
        ordinary(engine, level)
for level in ("READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"):
    locking("pg", level)
locking("mysql", "REPEATABLE READ")
for engine in ("pg", "mysql"):
    reset(engine)
    assert query(engine, "SELECT qty FROM wiki_lab.stock WHERE id=1;") == "10"
print("reset qty=10; all cases passed")

Chạy từ thư mục chứa file lab. lab_target kiểm socket/server riêng trước controller; khi có lỗi vẫn chạy khối cleanup ở cuối bài.

. ./lab-local.sh
lab_target
python3 isolation.py

Expected theo lịch trên; cần đếm và so lại khi chạy trên môi trường khác:

pg READ UNCOMMITTED first=10 uncommitted=10 committed=20 new_tx=20
pg READ COMMITTED first=10 uncommitted=10 committed=20 new_tx=20
pg REPEATABLE READ first=10 uncommitted=10 committed=10 new_tx=20
pg SERIALIZABLE first=10 uncommitted=10 committed=10 new_tx=20
mysql READ UNCOMMITTED first=10 uncommitted=20 committed=20 new_tx=20
mysql READ COMMITTED first=10 uncommitted=10 committed=20 new_tx=20
mysql REPEATABLE READ first=10 uncommitted=10 committed=10 new_tx=20
mysql SERIALIZABLE first=10 writer_wait=yes second=10 new_tx=20
pg READ COMMITTED locking=20 plain_after=20 rollback=yes
pg REPEATABLE READ locking=40001 rollback=yes
pg SERIALIZABLE locking=40001 rollback=yes
mysql REPEATABLE READ locking=20 plain_after=10 rollback=yes
reset qty=10; all cases passed

Đọc output theo engine

PostgreSQL: RU có hành vi như RC, nên không thấy B chưa commit. RC đọc snapshot theo statement; RR giữ snapshot nên sau B commit vẫn thấy 10. Serializable đọc thường cũng thấy 10 ở lịch này. Nó thêm kiểm dependency để các transaction commit có thể tương đương một thứ tự tuần tự; ổn định một hàng chưa chứng minh ứng dụng giữ được mọi invariant. Xung đột có thể trả SQLSTATE 40001, cần retry toàn transaction. Manual isolation PG18 mô tả snapshot, SSI và retry.

InnoDB: RU đã đọc 20 trước COMMIT (dirty read); nếu B rollback thì ứng dụng từng dùng dữ liệu không tồn tại ở kết quả commit. RC đọc lại thấy 20 sau commit; RR giữ read view từ consistent read đầu tiên nên vẫn 10. Serializable trong transaction tường minh khác: SELECT của A giữ shared lock, writer B chưa UPDATE xong. writer_wait=yes đến từ bảng lock-wait, không phải thời gian ngủ. Manual isolation MySQL26.7 nêu khác biệt này.

Ở locking case, A đọc thường 10, B đã commit 20 rồi A mới FOR UPDATE. PG RC khóa/đọc 20; PG RR/Serializable không thể khóa phiên bản đã bị thay sau snapshot, nhận 40001 rồi rollback. Controller chỉ tắt ON_ERROR_STOP cho hai client quan sát lỗi này, để gửi ROLLBACK và marker; nó kiểm stderr đúng một 40001, không bỏ lỗi rồi báo query thành công. Ứng dụng thật phải dừng luồng và retry theo chính sách, không dùng chế độ tiếp tục SQL sau lỗi của lab.

MySQL RR locking read thấy 20, nhưng plain SELECT sau đó vẫn đọc snapshot 10 vì A chưa tự ghi gì. Trộn hai kiểu đọc có thể khiến cùng transaction thấy hai trạng thái khó hiểu. Consistent reads và locking reads phân biệt read view với đọc lấy khóa; đây là quan sát cơ chế, không khuyến nghị trộn chúng cho nghiệp vụ.

Transaction dài, reset và giới hạn

  • BEGIN và “đã tạo snapshot dữ liệu” không luôn cùng thời điểm; lịch này chủ động đọc bảng trước khi B cập nhật. PG RR lấy snapshot ở câu cần snapshot đầu tiên; InnoDB RR ở consistent read đầu tiên. Không dùng thời điểm click BEGIN để suy visibility khi chưa đọc.
  • Một transaction thấy thay đổi của chính nó; bảng output không có A UPDATE nên chưa thử trường hợp đó. Autocommit làm mỗi statement thành transaction riêng; không thể so hai SELECT cách nhau với lịch RR tường minh ở đây.
  • “Reader không chặn writer” chỉ đúng phạm vi đọc MVCC thích hợp. Locking read, InnoDB Serializable và khóa DDL có thể chặn. Snapshot lâu còn trì hoãn dọn phiên bản cũ; bài chưa đo bloat/VACUUM/purge.
  • Lab chưa thử phantom/range lock, write skew, SSI dependency cycle hoặc distributed transaction. Không đổi một bảng minh họa thành bảng bảo đảm chung cho mọi DB. Bài tập mở: thêm một insert giữa hai lần đếm để thử phantom, hoặc hai hàng invariant để thử write skew; cần thiết kế lịch và expected riêng trước khi chạy.
  • Timeout chỉ chống treo; nó không thay cho quan sát lock. Lỗi schema/socket/isolation phải xử lý trước, không sửa địa chỉ client sang database thật.

Controller kết thúc mỗi case bằng COMMIT/ROLLBACK, kiểm trạng thái cuối và reset qty=10. Chạy lại toàn controller từ reset:

. ./lab-local.sh
lab_target
python3 isolation.py
. ./lab-local.sh
lab_clean
echo cleaned

Học tiếp deadlock/retry, N+1 và split query, EXPLAIN ANALYZE. Nguồn manual đúng phiên bản đọc ngày 2026-10-03; dữ liệu và controller do bài tự dựng.

VACUUM: chỗ trống tái sử dụng và snapshot còn giữ phiên bản cũ

Câu hỏi bài này trả lời: đã DELETE và chạy VACUUM, vì sao bảng còn lớn và phiên bản cũ chưa được dọn?

Cần biết trước: transaction và MVCC/isolation, cùng lab database. Lab chạy PostgreSQL 18.6 native qua socket riêng trên macOS arm64, Python 3.14.4 stdlib. Docker/Linux chưa kiểm. Fixture có MySQL nhưng bài chỉ đo PostgreSQL.

UPDATE tạo phiên bản mới; DELETE làm phiên bản cũ không còn visible với các snapshot mới. PostgreSQL chỉ được dọn khi phiên bản đó không còn cần cho transaction liên quan. VACUUM thường làm chỗ trống dùng lại được trong relation; ANALYZE thu thập thống kê cho planner. Hai việc có thể chạy riêng hoặc kết hợp. Routine vacuuming PG18 giải thích các mục đích này, gồm visibility map và chống transaction ID wraparound.

Dữ liệu và phép đo

Tạo thư mục trống, chép bốn file cách A của bài lab: lab-common.sh, lab-local.sh, seed-pg.sql, seed-mysql.sql. Lưu controller bên dưới thành vacuum.py.

Bảng wiki_lab.vacuum_demo có 4000 hàng giả, payload 160 ký tự và index trên qty. UPDATE toàn bảng đổi qty được index để tránh HOT update cho workload này; DELETE 1000 hàng. Reader A giữ snapshot REPEATABLE READ từ trước writer commit. Không có writer ngoài controller trong khi lấy số đo.

Tạm tắt autovacuum chỉ ở bảng disposable này để daemon không chen vào các bước; không tắt ở server hoặc bảng nghiệp vụ. Mỗi lượt reset tạo lại bảng, cleanup hủy database lab. Không dùng cấu hình này làm hướng dẫn vận hành.

Controller dùng extension pgstattuple đi kèm PostgreSQL. Nó cần extension đã được cài vào database và quyền đọc tương ứng; role lab có quyền quản trị trong database riêng. Không tự cấp quyền trên database thật.

Các cột output:

CộtÝ nghĩa
visibleCOUNT của client mới, khác snapshot reader A
physical_live/deadTuple được pgstattuple phân loại khi quét heap
freeFree space trong heap, byte
heap/index/totalByte từ pg_relation_size, pg_indexes_size, pg_total_relation_size
estimated_deadn_dead_tup, số ước lượng của pg_stat_user_tables
vacuum/analyzeCounter maintenance thủ công của bảng

pgstattuple phân loại dead bằng HeapTupleSatisfiesDirty; dead theo metric này chưa đồng nghĩa VACUUM được phép xóa khi còn snapshot cũ. Scan không là ảnh chụp atomic nếu có writer đồng thời. Heap còn header/pointer/alignment nên live bytes + dead bytes + free không bằng toàn file. Cumulative statistics ghi rõ n_dead_tup là ước lượng; controller không khóa nó vào số dead vật lý chính xác.

Controller: marker sau SQL, khóa chờ có quan sát

Ba client chỉ dùng bảng lab. A giữ snapshot, B giữ khóa ShareUpdateExclusive tạm thời, C thử VACUUM. Controller chỉ release B sau khi thấy yêu cầu khóa của C trong pg_locks; không dùng sleep để đoán câu lệnh đã chạy. Deadline chống treo, poll chỉ chờ điều kiện quan sát được.

import json
import os
import queue
import signal
import subprocess
import threading
import time

CLIENT = ". ./lab-local.sh; lab_psql -At"
TABLE = "wiki_lab.vacuum_demo"
VACUUM = f"VACUUM (TRUNCATE false, INDEX_CLEANUP on) {TABLE};"


def query(sql):
    result = subprocess.run(["bash", "-c", CLIENT], input=sql, text=True,
                            capture_output=True, timeout=15, check=True)
    return result.stdout.strip()


class Session:
    def __init__(self):
        self.process = subprocess.Popen(
            ["bash", "-c", CLIENT], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
            stderr=subprocess.PIPE, text=True, bufsize=1, start_new_session=True)
        self.lines = queue.Queue()
        self.reader = threading.Thread(target=self.read, daemon=True)
        self.reader.start()
        self.id = int(self.ask("SELECT pg_backend_pid();")[0])
        assert self.ask("SET statement_timeout='12s';") == []

    def read(self):
        for line in self.process.stdout:
            self.lines.put(line.strip())
        self.lines.put(None)

    def send(self, sql):
        self.process.stdin.write(sql + " SELECT 'barrier';\n")
        self.process.stdin.flush()

    def collect(self):
        values = []
        while True:
            value = self.lines.get(timeout=15)
            assert value is not None, "client kết thúc trước marker"
            if value == "barrier":
                return values
            values.append(value)

    def ask(self, sql):
        self.send(sql)
        return self.collect()

    def close(self):
        if self.process.poll() is None:
            os.killpg(self.process.pid, signal.SIGTERM)
            try:
                self.process.wait(timeout=3)
            except subprocess.TimeoutExpired:
                os.killpg(self.process.pid, signal.SIGKILL)
                self.process.wait(timeout=3)
        self.reader.join(timeout=2)


def measure(label):
    row = json.loads(query(f"""
SELECT row_to_json(m) FROM (
  SELECT (SELECT count(*) FROM {TABLE}) AS visible,
         p.tuple_count AS physical_live, p.dead_tuple_count AS dead,
         p.free_space AS free, pg_relation_size('{TABLE}') AS heap,
         pg_indexes_size('{TABLE}') AS index,
         pg_total_relation_size('{TABLE}') AS total,
         s.n_dead_tup AS estimated_dead, s.vacuum_count AS vacuum,
         s.analyze_count AS analyze
  FROM pgstattuple('{TABLE}'::regclass) p
  JOIN pg_stat_user_tables s ON s.relid='{TABLE}'::regclass
) m;
"""))
    print(label, " ".join(f"{key}={value}" for key, value in row.items()))
    return row


def run():
    query(f"""
DROP TABLE IF EXISTS {TABLE};
CREATE TABLE {TABLE} (
  id integer PRIMARY KEY, qty integer NOT NULL, payload text NOT NULL
) WITH (autovacuum_enabled=false);
CREATE INDEX vacuum_demo_qty ON {TABLE}(qty);
INSERT INTO {TABLE} SELECT g, 10, repeat('x',160) FROM generate_series(1,4000) g;
ANALYZE {TABLE};
""")
    before = measure("before")
    assert before["visible"] == before["physical_live"] == 4000
    assert before["dead"] == 0 and before["analyze"] == 1
    sessions = []
    try:
        for _ in range(3):
            sessions.append(Session())
        a, b, c = sessions
        assert a.ask("BEGIN ISOLATION LEVEL REPEATABLE READ;") == []
        assert a.ask(f"SELECT count(*) FROM {TABLE};") == ["4000"]
        assert b.ask("BEGIN;") == []
        assert b.ask(f"LOCK TABLE {TABLE} IN SHARE UPDATE EXCLUSIVE MODE;") == []
        c.send(VACUUM)
        deadline = time.monotonic() + 10
        while query(f"SELECT count(*) FROM pg_locks WHERE pid={c.id} "
                    f"AND relation='{TABLE}'::regclass "
                    "AND mode='ShareUpdateExclusiveLock' AND NOT granted;") != "1":
            assert time.monotonic() < deadline, "không quan sát được VACUUM chờ khóa"
            time.sleep(0.02)
        assert b.ask("COMMIT;") == []
        assert c.collect() == []
        print("lock requested=ShareUpdateExclusiveLock observed_wait=yes released=yes")

        query(f"UPDATE {TABLE} SET qty=20; DELETE FROM {TABLE} WHERE id<=1000;")
        changed = measure("changed")
        assert changed["visible"] == changed["physical_live"] == 3000
        assert changed["dead"] == 5000 and changed["heap"] > before["heap"]
        assert a.ask(f"SELECT count(*) FROM {TABLE} WHERE qty=10;") == ["4000"]
        query(f"ANALYZE {TABLE};")
        analyzed = measure("analyzed")
        assert analyzed["analyze"] == changed["analyze"] + 1
        assert analyzed["vacuum"] == changed["vacuum"]
        assert analyzed["dead"] == changed["dead"]

        query(VACUUM)
        held = measure("vacuum_held")
        assert held["dead"] == changed["dead"]
        assert held["vacuum"] == analyzed["vacuum"] + 1
        assert held["analyze"] == analyzed["analyze"]
        assert held["heap"] == changed["heap"]
        assert a.ask(f"SELECT count(*) FROM {TABLE} WHERE qty=10;") == ["4000"]
        assert a.ask("ROLLBACK;") == []
        query(VACUUM)
        reclaimed = measure("vacuum_released")
        assert reclaimed["dead"] == 0 and reclaimed["free"] > held["free"]
        assert reclaimed["heap"] == held["heap"]
        assert reclaimed["analyze"] == held["analyze"]
        query(f"INSERT INTO {TABLE} SELECT g,20,repeat('x',160) "
              "FROM generate_series(4001,5000) g;")
        refill = measure("refill")
        assert refill["visible"] == 4000 and refill["heap"] == reclaimed["heap"]
        assert refill["free"] < reclaimed["free"]
        print("snapshot old_rows=4000 new_rows=3000; dead held=5000 released=0")
        print("analyze_only=yes heap_unchanged_after_vacuum=yes refill_reused=yes")
    finally:
        for session in reversed(sessions):
            session.close()


query("CREATE EXTENSION IF NOT EXISTS pgstattuple;")
print("server", query("SHOW server_version;"))
print("autovacuum", query("SHOW autovacuum;"),
      "threshold", query("SHOW autovacuum_vacuum_threshold;"),
      "scale", query("SHOW autovacuum_vacuum_scale_factor;"),
      "maximum", query("SHOW autovacuum_vacuum_max_threshold;"))
run()
print("all checks passed; sessions closed")

Chạy từ fixture sạch

. ./lab-local.sh
lab_up
lab_seed
lab_whoami
. ./lab-local.sh
lab_target
python3 vacuum.py

Controller in số đo từng bước trước assertions. Không dùng KB từ thí nghiệm khác làm expected. Giá trị byte phụ thuộc row layout/version/page size; nếu assertion heap/refill không đúng trên môi trường mới, đọc số đo và xác định nguyên nhân trước khi kết luận.

lock requested=ShareUpdateExclusiveLock observed_wait=yes released=yes
snapshot old_rows=4000 new_rows=3000; dead held=5000 released=0
analyze_only=yes heap_unchanged_after_vacuum=yes refill_reused=yes
all checks passed; sessions closed

Đọc kết quả và chọn maintenance

Hai lượt trên môi trường đã nêu cho cùng số đo dưới đây (byte, không phải benchmark):

BướcLive/dead vật lýHeapFreeIndexTotalVacuum/analyze
Before4000/081920080015564810076160/1
Changed3000/50001638400160028672019660801/1
Analyzed3000/50001638400160028672019660801/2
Vacuum held3000/50001638400160028672019660802/2
Vacuum released3000/01638400102110028672019660803/2
Refill4000/0163840081720030310419824643/2

Vacuum counter ở Changed đã là 1 vì case khóa chạy một VACUUM trước workload. Trong lượt này estimated_dead lần lượt 0/5000/5000/5000/0/0, khớp số quét vật lý; không suy hai metric luôn bằng nhau trong hệ thống có ghi đồng thời.

Sau writer commit, client mới thấy 3000 hàng còn A vẫn thấy 4000 hàng qty=10. ANALYZE tăng analyze_count; số dead vật lý giữ nguyên khi snapshot còn cần chúng. Nó không thay VACUUM. Sau plain VACUUM khi A còn mở, dead vẫn 5000; VACUUM chạy xong không có nghĩa đã dọn mọi phiên bản.

Sau A rollback, VACUUM dọn dead về 0 và free tăng. Heap bytes giữ nguyên vì bài chủ động dùng TRUNCATE false; INSERT 1000 hàng tiếp theo dùng chỗ trống, không tăng heap trong workload đã kiểm. Đây là bằng chứng tái sử dụng heap, không chứng minh index/TOAST cũng giảm hay một tỷ lệ bloat phổ quát. Controller in riêng index/total; không gộp chúng với heap.

VACUUM PG18 cho biết plain VACUUM mặc định có thể truncate các page rỗng cuối file; bước đó cần AccessExclusive. Vì vậy “plain VACUUM không bao giờ trả dung lượng cho OS” cũng sai. Lab tắt truncate để tách việc reclaim trong heap khỏi việc cắt file.

Khóa chờ đã thấy là ShareUpdateExclusive, xung đột với cùng mode của B. Sau release, VACUUM hoàn thành dù A còn AccessShare từ SELECT. Không suy “VACUUM không bao giờ khóa/chặn”: DDL, maintenance khác và truncate có quan hệ xung đột riêng.

VACUUM FULL rewrite relation và cần AccessExclusive cùng dung lượng tạm cho bản mới. Autovacuum không tự chạy FULL. Bài không chạy FULL và không có số đo rút file/timing của nó; đây là lựa chọn cần maintenance window, không là phản xạ khi thấy n_dead_tup cao. ANALYZE PG18 mô tả thống kê phục vụ planner; ghép VACUUM (ANALYZE) khi cần cả hai mục đích.

Autovacuum và lỗi thường gặp

Theo tham số PG18, ngưỡng UPDATE/DELETE dựa threshold + scale_factor × ước lượng số tuple, có trần autovacuum_vacuum_max_threshold khi khác -1. Mặc định tài liệu là 50, 0.2 và 100000000. Có ngưỡng INSERT riêng, ngưỡng ANALYZE riêng và công việc chống wraparound; không áp một công thức cho mọi lý do chạy vacuum. Controller in giá trị server thật; bảng lab có override tắt daemon nên không đo thời điểm autovacuum kích hoạt.

  • Giữ autovacuum trong vận hành; chỉnh theo tốc độ thay đổi, bảng, I/O và thời gian reclaim thực tế. Không lấy scale_factor của một case nhỏ làm giá trị tối ưu cho mọi bảng.
  • Stats cập nhật có độ trễ và có thể được cache trong transaction; query đo ở đây dùng client mới. n_dead_tup không là bằng chứng chính xác dung lượng lãng phí hoặc số tuple có thể xóa ngay.
  • VACUUM không chạy trong transaction block. Controller gọi nó ở client autocommit, không bên trong transaction A/B.
  • Transaction dài cần snapshot có thể giữ horizon; kiểm xact_start/backend_xmin và tình trạng transaction, không chỉ session đang active. Không tự terminate transaction của người khác để đạt số đo.
  • Slot replication/standby feedback cũng có thể giữ horizon nhưng chưa thử trong lab này. Chưa đo wraparound, index-only scan, throughput, parallel vacuum hoặc VACUUM FULL; không khẳng định kết quả hiệu năng từ số dead.
  • pgstattuple quét bảng và cần quyền; chọn phạm vi/chi phí thích hợp, không coi full scan là telemetry miễn phí.

Reset, chạy lại và dọn lab

Controller tự DROP/CREATE đúng một bảng riêng khi mọi session lượt trước đã đóng, không cần xóa bảng seed khác. Chạy lại từ cùng fixture:

. ./lab-local.sh
lab_target
python3 vacuum.py
. ./lab-local.sh
lab_clean
echo cleaned

Học tiếp EXPLAIN ANALYZE và MVCC/isolation. Nguồn manual18 đọc ngày 2026-10-03; dữ liệu/controller là ví dụ riêng, các byte chỉ có ý nghĩa trong môi trường đã đo.

Replication lag: đã ghi thành công, vì sao đọc vẫn thiếu?

Câu hỏi: một đơn hàng đã commit trên primary nhưng truy vấn replica chưa thấy: đo ở đâu, và chọn đường đọc thế nào?

Cần biết trước: SQL, shell và snapshot/isolation. Lab cần bộ chương trình PostgreSQL 18.6 (initdb, pg_ctl, pg_basebackup, psql), không cần dịch vụ đang chạy. Đã kiểm trên macOS arm64; Docker/Linux và failover chưa kiểm.

Ba thời điểm khác nhau

Commit trên primary, nhận WAL trên replica và replay WAL trên replica là ba sự kiện. Một SELECT mới trên replica chỉ đọc trạng thái đã replay. Physical streaming trong lab này là asynchronous: primary không chờ replica áp dụng bản ghi trước khi trả thành công. Nó sao chép WAL của cluster, không chạy lại nguyên câu SQL của ứng dụng. Cơ chế streaming PostgreSQL.

Ví dụ: giao diện nhận thành công cho đơn 101, sau đó GET được router đưa sang replica. Nếu replica chưa replay transaction, GET có thể trả “không tìm thấy”. Chưa đủ dữ kiện để kết luận mất dữ liệu. Cũng cần kiểm cache, database đang kết nối và snapshot của transaction đọc, thay vì mặc định mọi dữ liệu cũ đều do replication.

Lab: hai cluster riêng, chỉ socket Unix

Topology này cần hai server; đây không phải hai connection tới cùng database. Cả hai có thư mục dữ liệu và socket riêng, không nghe TCP, không chạm database đang chạy trên máy. Thư mục tạm chỉ chủ sở hữu truy cập được; trust chỉ dùng cho lab này. Không dùng cấu hình đó với server có mạng. Hai cluster cùng phiên bản; không có tablespace riêng.

Tạo một thư mục trống, lưu file repl.sh dưới đây. Lệnh cleanup tìm namespace qua repl.env, nên vẫn dọn được nếu khởi tạo hỏng giữa chừng.

repl_env() {
  [ -f repl.env ] && [ ! -L repl.env ] && [ -O repl.env ] || return 1
  . ./repl.env
  case "$REPL_DIR" in /tmp/wiki-repl.*) ;; *) return 1 ;; esac
  [ -d "$REPL_DIR" ] && [ ! -L "$REPL_DIR" ] && [ -O "$REPL_DIR" ] &&
    [ -f "$REPL_DIR/.wiki-repl" ] || return 1
}

repl_sql() (
  node=$1
  shift
  case "$node" in primary|replica) ;; *) exit 2 ;; esac
  repl_env || exit 1
  unset PGSERVICE PGHOSTADDR PGPASSWORD PGOPTIONS
  PGSERVICEFILE=/dev/null PGPASSFILE="$REPL_DIR/no-pgpass" \
    PGOPTIONS='-c timezone=UTC -c client_min_messages=warning' \
    psql -X -q -w -h "$REPL_DIR/$node-socket" -p 5432 -U lab -d postgres \
      -v ON_ERROR_STOP=1 "$@"
)

repl_wait() {
  node=$1
  query=$2
  attempts=${3:-100}
  while [ "$attempts" -gt 0 ]; do
    value=$(repl_sql "$node" -At -c "$query") || return 2
    [ "$value" != t ] || return 0
    attempts=$((attempts - 1))
    sleep 0.1
  done
  echo 'wait timeout' >&2
  return 1
}

repl_up() {
  for tool in initdb pg_ctl pg_basebackup psql; do
    command -v "$tool" >/dev/null || return 1
  done
  [ ! -e repl.env ] || { echo 'lab đã tồn tại; cleanup trước' >&2; return 1; }
  REPL_DIR=$(mktemp -d /tmp/wiki-repl.XXXXXX) || return 1
  printf 'export REPL_DIR=%q\n' "$REPL_DIR" > repl.env || return 1
  chmod 600 repl.env || return 1
  touch "$REPL_DIR/.wiki-repl" || return 1
  mkdir "$REPL_DIR/primary-socket" "$REPL_DIR/replica-socket" || return 1
  initdb -D "$REPL_DIR/primary" -U lab --auth=trust -E UTF8 --locale=C >/dev/null || return 1
  pg_ctl -D "$REPL_DIR/primary" -l "$REPL_DIR/primary.log" -w -t 30 \
    -o "-c listen_addresses='' -c unix_socket_directories='$REPL_DIR/primary-socket' -c unix_socket_permissions=0700 -c max_slot_wal_keep_size=64MB" \
    start >/dev/null || return 1
  [ "$(repl_sql primary -At -c 'SHOW server_version_num')" = 180006 ] || return 1
  repl_sql primary <<'SQL' || return 1
CREATE ROLE wiki_repl WITH LOGIN REPLICATION;
CREATE SCHEMA wiki_repl;
CREATE TABLE wiki_repl.orders (
  id integer PRIMARY KEY,
  status text NOT NULL,
  created_at timestamptz NOT NULL
);
SQL
  (
    unset PGSERVICE PGHOSTADDR PGPASSWORD PGOPTIONS
    PGSERVICEFILE=/dev/null PGPASSFILE="$REPL_DIR/no-pgpass" \
      pg_basebackup -h "$REPL_DIR/primary-socket" -p 5432 -U wiki_repl \
        -D "$REPL_DIR/replica" -X stream -R --checkpoint=fast \
        --slot=wiki_replica --create-slot -w
  ) || return 1
  pg_ctl -D "$REPL_DIR/replica" -l "$REPL_DIR/replica.log" -w -t 30 \
    -o "-c listen_addresses='' -c unix_socket_directories='$REPL_DIR/replica-socket' -c unix_socket_permissions=0700 -c cluster_name=wiki_replica" \
    start >/dev/null || return 1
  repl_wait primary "SELECT count(*)=1 FROM pg_stat_replication WHERE state='streaming' AND sync_state='async'" || return 1
  repl_target || return 1
}

repl_target() {
  repl_env || return 1
  for node in primary replica; do
    [ "$(repl_sql "$node" -At -c 'SHOW unix_socket_directories')" = "$REPL_DIR/$node-socket" ] || return 1
    [ -z "$(repl_sql "$node" -At -c 'SHOW listen_addresses')" ] || return 1
    [ "$(repl_sql "$node" -At -c 'SHOW server_version_num')" = 180006 ] || return 1
  done
  [ "$(repl_sql primary -At -c 'SELECT pg_is_in_recovery()')" = f ] || return 1
  [ "$(repl_sql replica -At -c 'SELECT pg_is_in_recovery()')" = t ] || return 1
}

repl_case() {
  case "$1" in 101|202) id=$1 ;; *) return 2 ;; esac
  repl_target || return 1
  repl_sql replica -At -c 'SELECT pg_wal_replay_pause()' >/dev/null || return 1
  repl_wait replica "SELECT pg_get_wal_replay_pause_state()='paused'" || return 1
  repl_sql primary -c "INSERT INTO wiki_repl.orders VALUES ($id, 'accepted', clock_timestamp())" || return 1
  barrier=$(repl_sql primary -At -c 'SELECT pg_current_wal_lsn()') || return 1
  repl_wait replica "SELECT pg_last_wal_receive_lsn()>='$barrier'::pg_lsn" || return 1
  primary_row=$(repl_sql primary -At -c "SELECT id, status, created_at FROM wiki_repl.orders WHERE id=$id") || return 1
  [ -n "$primary_row" ] || return 1
  [ "$(repl_sql replica -At -c "SELECT count(*) FROM wiki_repl.orders WHERE id=$id")" = 0 ] || return 1
  [ "$(repl_sql replica -At -c "SELECT pg_last_wal_replay_lsn()<'$barrier'::pg_lsn AND pg_wal_lsn_diff(pg_last_wal_receive_lsn(),pg_last_wal_replay_lsn())>0")" = t ] || return 1
  printf 'paused id=%s primary=1 replica=0 received=t replayed=f backlog_positive=t\n' "$id"
  wait_status=0
  repl_wait replica "SELECT pg_last_wal_replay_lsn()>='$barrier'::pg_lsn" 2 || wait_status=$?
  [ "$wait_status" = 1 ] || { echo 'phải timeout, không được lẫn query lỗi' >&2; return 1; }
  echo 'wait_while_paused=timeout'
  repl_sql replica -At -c 'SELECT pg_wal_replay_resume()' >/dev/null || return 1
  repl_wait replica "SELECT pg_last_wal_replay_lsn()>='$barrier'::pg_lsn" || return 1
  replica_row=$(repl_sql replica -At -c "SELECT id, status, created_at FROM wiki_repl.orders WHERE id=$id") || return 1
  [ "$primary_row" = "$replica_row" ] || return 1
  printf 'caught_up id=%s replica=1 same_id_status_timestamp=t\n' "$id"
}

repl_reset() {
  repl_target || return 1
  repl_sql primary -c 'TRUNCATE wiki_repl.orders' || return 1
  barrier=$(repl_sql primary -At -c 'SELECT pg_current_wal_lsn()') || return 1
  repl_wait replica "SELECT pg_last_wal_replay_lsn()>='$barrier'::pg_lsn" || return 1
  [ "$(repl_sql replica -At -c 'SELECT count(*) FROM wiki_repl.orders')" = 0 ] || return 1
  echo 'reset replica=0'
}

repl_clean() {
  [ -e repl.env ] || return 0
  repl_env || return 1
  for node in replica primary; do
    if pg_ctl -D "$REPL_DIR/$node" status >/dev/null 2>&1; then
      pg_ctl -D "$REPL_DIR/$node" -m fast -w -t 30 stop >/dev/null || return 1
    fi
  done
  rm -rf "$REPL_DIR" || return 1
  rm -f repl.env
}

pg_basebackup -R tạo cấu hình standby và thông tin connection; slot giữ WAL primary cần cho replica. Lab giới hạn retention và chỉ ghi vài hàng, rồi xóa cả cluster. Trong vận hành phải theo dõi dung lượng: replica lỗi lâu có thể làm WAL giữ lại tăng lớn. Lab không thêm archive hoặc diễn tập phục hồi backup. pg_basebackup.

Xác minh đúng vai trước khi ghi

set -euo pipefail
. ./repl.sh
repl_up
printf 'primary recovery=%s\n' "$(repl_sql primary -At -c 'SELECT pg_is_in_recovery()')"
printf 'replica recovery=%s\n' "$(repl_sql replica -At -c 'SELECT pg_is_in_recovery()')"
repl_sql primary -At -F ' ' -c 'SELECT state,sync_state FROM pg_stat_replication'
repl_sql primary -At -F ' ' -c 'SELECT slot_name,slot_type,active FROM pg_replication_slots'
primary recovery=f
replica recovery=t
streaming async
wiki_replica physical t

Không nhận “connection thành công” là đủ: truy vấn role và socket mới phân biệt hai server. Hot standby không nhận DML thông thường; nếu client ghi vào đó thì routing hoặc vai server đã sai. Hot standby.

Thử ghi trực tiếp vào replica phải bị từ chối:

set -euo pipefail
. ./repl.sh
repl_sql replica -c "INSERT INTO wiki_repl.orders VALUES (999,'wrong_route',clock_timestamp())"

Tạm dừng apply, giữ đường nhận WAL chạy

set -euo pipefail
. ./repl.sh
repl_case 101
paused id=101 primary=1 replica=0 received=t replayed=f backlog_positive=t
wait_while_paused=timeout
caught_up id=101 replica=1 same_id_status_timestamp=t

repl_case đợi trạng thái paused thật, ghi ID cùng timestamp, lấy LSN barrier sau commit rồi đợi replica nhận tới đó. Khi replay vẫn bị dừng, fresh SELECT không thấy ID. Nó kiểm wait-replay có timeout, resume, đợi replay và so toàn hàng, kể cả timestamp. Không dùng “sleep đủ lâu” để kết luận đã đồng bộ. WAL vẫn nhận khi replay bị pause; vì thế đừng giữ pause vô hạn trên server thật. Recovery control.

Có thể xem hàng đã đồng bộ bằng lệnh:

. ./repl.sh
repl_sql replica -c 'SELECT id,status,created_at FROM wiki_repl.orders'

Timestamp cụ thể phụ thuộc lần chạy; không dùng nó làm số đo latency cố định.

Reset và chạy lại

set -euo pipefail
. ./repl.sh
repl_reset
repl_case 202
reset replica=0
paused id=202 primary=1 replica=0 received=t replayed=f backlog_positive=t
wait_while_paused=timeout
caught_up id=202 replica=1 same_id_status_timestamp=t

Dọn cả hai server, kể cả sau lỗi

Khi tự chạy, luôn thực hiện khối này ở cuối; nếu một bước hỏng, cũng chạy cleanup. Verifier của bài chạy cleanup trong finally. Nó không xóa thư mục ngoài namespace có dấu riêng và thuộc chủ sở hữu hiện tại. Replica dừng trước primary.

set -euo pipefail
. ./repl.sh
if [ -f repl.env ]; then
  repl_env
  old_dir=$REPL_DIR
  repl_clean
  [ ! -e "$old_dir" ]
fi
repl_clean
[ ! -e repl.env ]
echo 'cleanup ok'

Đọc metric nào để trả lời câu hỏi nào?

Câu hỏiQuan sátGiới hạn
Replica có kết nối?Sender pg_stat_replication, receiver pg_stat_wal_receiverStreaming không chứng minh một transaction đã visible
WAL đã tới replica?pg_last_wal_receive_lsn() so barrier sau commitNhận/flush khác replay
WAL đã áp dụng?pg_last_wal_replay_lsn() so cùng barrierĐọc mới mới thấy; snapshot cũ có thể giữ dữ liệu cũ
Bao nhiêu byte đang chờ apply?pg_wal_lsn_diff(receive_lsn,replay_lsn)Đơn vị byte WAL, không phải số đơn hàng hoặc giây
Thời gian gần đây?Sender write_lag, flush_lag, replay_lagMetric thời gian xác nhận gần đây; idle có thể NULL, không phải dự báo catch-up

pg_last_xact_replay_timestamp() trả timestamp primary ghi cho commit/abort của transaction cuối replica đã replay. Khi không có transaction mới, lấy đồng hồ hiện tại trừ giá trị cũ vẫn tăng dù không có backlog. Hãy kiểm LSN và hoạt động ghi cùng cửa sổ quan sát. Định nghĩa replication stats.

Chọn đường đọc theo yêu cầu

Yêu cầuLựa chọnChi phí/điều kiện
Người vừa đặt hàng phải thấy ngay kết quả commitĐọc primary ở request tiếp theoTăng tải đọc primary; snapshot/cache vẫn phải đúng
Dashboard chấp nhận dữ liệu chậmĐọc replicaĐặt ngân sách freshness và theo dõi backlog
Muốn đọc replica sau một ghi xác địnhMang barrier của cùng primary, đợi replica replay tới đó với timeout; hết hạn fallback/đáp lỗiRouting phải kiểm đúng replica/cluster/timeline; không suy LSN của cluster khác
Cần commit chờ standby áp dụngCấu hình synchronous standby và synchronous_commit=remote_applyChờ apply tăng latency và phụ thuộc standby; không áp cho mọi replica

synchronous_commit=on cùng synchronous standby chủ yếu chờ WAL được flush bền vững, khác việc chờ apply. remote_apply có điều kiện riêng; không sửa một tham số rồi tuyên bố mọi đường đọc đều consistent. Lab này không bật synchronous standby và không đo các chế độ đó. WAL/commit settings.

Replica cũng áp dụng DELETE hoặc thao tác ghi sai từ primary. Nó hỗ trợ availability và đọc, không thay retention/version lịch sử và kiểm restore của backup. Một base backup dùng để dựng replica trong lab không chứng minh đã có quy trình khôi phục. Backup bằng SQL dump.

Giới hạn phép thử: cùng máy, cùng phiên bản, vài hàng, trì hoãn replay chủ động; không đo độ trễ mạng, tải ghi lớn, failover, RPO/RTO hay MySQL replication. Kết quả chứng minh receive và replay tách biệt ở lịch chạy này; không hứa thời gian đồng bộ cho production. Học tiếp về VACUUM/snapshot dài và đọc execution plan.

Import CSV: đo row-by-row, batch và COPY trên cùng dữ liệu

Câu hỏi: tăng batch giúp được bao nhiêu trong một phép thử cụ thể, và làm sao biết cách nhanh hơn vẫn nạp đúng dữ liệu?

Cần biết trước: Python/SQL cơ bản và lab database dùng riêng. Chép các file của cách A vào thư mục trống. Lab dưới đây dùng PostgreSQL18.6, Python3.14.4 và psql, chỉ thư viện chuẩn; không cần driver hay ORM. Docker/Linux, MySQL import, dataset10M và cold cache chưa đo.

Giữ bài toán giống nhau

Một bảng mới trong namespace lab, dữ liệu đơn hàng giả. Mỗi hàng có ID, customer ID và số tiền cent. Dữ liệu dùng công thức với seed17, không lấy thông tin thật. Ba phương án giữ nguyên logged table, PK, FK, CHECK, secondary index và một transaction cho cả file. Row-by-row ở đây là một câu INSERT/hàng, không phải một commit/hàng. Vì vậy không gán chênh lệch đo được cho autocommit.

Phương ánCông việc clientCông việc gửi database
rowPython đọc CSV, chuyển số và dựng SQLN câu INSERT trong một session/transaction
batchCùng chuyển số, gom tối đa400hàngMột INSERT nhiều VALUES/lô, cùng transaction
copyClient psql đọc file CSVMột \copy, server phân tích CSV và kiểm constraint

\copy đọc file ở client, khác COPY FROM '/path' đọc file trên server. Cả hai vẫn phải xử lý dữ liệu và kiểm ràng buộc. Lab không dùng FREEZE, unlogged table, ON_ERROR ignore hoặc tắt WAL/durability. COPY, psql và \copy.

Chốt phép đo trước khi chạy

  • Các cỡ1000/5000/10000, ba lượt mỗi tổ hợp: 27 sample. Warm-up riêng không tính.
  • Thứ tự row/batch/copy luân phiên qua ba lượt để bớt ưu thế của cách chạy sau.
  • Timer gồm đọc/serializeCSV, khởi tạo psql, gửi SQL, thực hiện và commit; không gồm sinh CSV, reset bảng hay validation sau nạp. Cả ba đều tạo connection mới mỗi lượt.
  • Reset bằng TRUNCATE giữ schema/index/constraint. Không xóa cache hệ điều hành hay shared buffers: đây là phép đo sau warm-up, không phải cold-start.
  • Đối chiếu count và SHA256 của CSV export theo ID, so toàn bộ ô với input, không chỉ count hoặc tổng tiền. Validation chạy ngoài timer.
  • Cỡ này nằm trong RAM; row/batch dựng request trong bộ nhớ. Không gọi đây là streaming importer có bộ nhớ giới hạn. Protocol, serialization và parser cùng đổi giữa các cách.

Không dùng phép đo này để cô lập network RTT: tất cả connection qua socket local. Gom transaction cũng đổi chi phí commit; phải đo riêng nếu muốn so autocommit. Hướng dẫn nạp dữ liệu.

Mã chạy được từ thư mục lab

Lưu bulk.py. Hàm load dùng cùng một transaction, gặp lỗi thì psql thoát và connection đóng khiến transaction chưa commit bị rollback. Không tiếp tục chạy COMMIT sau lỗi.

from __future__ import annotations

import csv
import hashlib
import json
import os
import platform
import statistics
import subprocess
import sys
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import TypedDict


class Sample(TypedDict):
    n: int
    method: str
    repeat: int
    seconds: float
    csv_sha256: str


LAB_DIR = os.environ["LAB_DIR"]
METHODS = ("row", "batch", "copy")
SIZES = (1000, 5000, 10000)
SEED = 17


def sql(statement: str) -> bytes:
    env = dict(os.environ)
    for key in ("PGSERVICE", "PGHOSTADDR", "PGPASSWORD", "PGOPTIONS"):
        env.pop(key, None)
    env.update(
        PGSERVICEFILE="/dev/null",
        PGPASSFILE=str(Path(LAB_DIR) / "no-pgpass"),
        PGOPTIONS="-c client_min_messages=warning",
    )
    command = [
        "psql",
        "-X",
        "-q",
        "-w",
        "-h",
        LAB_DIR,
        "-p",
        "5432",
        "-U",
        "lab",
        "-d",
        "postgres",
        "-v",
        "ON_ERROR_STOP=1",
        "-At",
    ]
    return subprocess.run(
        command,
        input=statement.encode(),
        capture_output=True,
        check=True,
        env=env,
        timeout=60,
    ).stdout


def generate(path: Path, n: int) -> None:
    with path.open("w", newline="") as output:
        writer = csv.writer(output, lineterminator="\n")
        writer.writerow(("id", "customer_id", "total_cents"))
        writer.writerows(
            (i, i * SEED % 2000 + 1, (i * 7919 + SEED) % 50000 + 100)
            for i in range(1, n + 1)
        )


def load(method: str, path: Path) -> None:
    if method == "copy":
        body = f"\\copy wiki_lab.import_orders FROM '{path.name}' WITH (FORMAT csv, HEADER true)\n"
    else:
        with path.open(newline="") as source:
            reader = csv.reader(source)
            next(reader)
            rows = list(reader)
        if any(len(row) != 3 for row in rows):
            raise ValueError("CSV phải có đúng ba cột")
        values = ["(" + ",".join(str(int(cell)) for cell in row) + ")" for row in rows]
        batch_size = 1 if method == "row" else 400
        body = (
            "\n".join(
                "INSERT INTO wiki_lab.import_orders VALUES "
                + ",".join(values[i : i + batch_size])
                + ";"
                for i in range(0, len(values), batch_size)
            )
            + "\n"
        )
    sql("BEGIN;\n" + body + "COMMIT;\n")


def validate(path: Path, n: int) -> str:
    count = int(sql("SELECT count(*) FROM wiki_lab.import_orders;"))
    exported = sql(
        "\\copy (SELECT id,customer_id,total_cents FROM wiki_lab.import_orders ORDER BY id) TO STDOUT WITH (FORMAT csv, HEADER true)\n"
    )
    expected = path.read_bytes()
    assert count == n and exported == expected, "count hoặc nội dung khác CSV"
    return hashlib.sha256(exported).hexdigest()


def fault_checks(valid: Path) -> int:
    with valid.open(newline="") as source:
        original = list(csv.reader(source))
    completed = 0
    for kind in ("duplicate", "foreign_key", "check", "type", "columns"):
        rows = [row.copy() for row in original]
        if kind == "duplicate":
            rows[-1][0] = rows[1][0]
        elif kind == "foreign_key":
            rows[-1][1] = "9999"
        elif kind == "check":
            rows[-1][2] = "-1"
        elif kind == "type":
            rows[-1][2] = "bad_int"
        else:
            rows[-1].append("extra")
        bad = Path("bad.csv")
        with bad.open("w", newline="") as output:
            csv.writer(output, lineterminator="\n").writerows(rows)
        for method in METHODS:
            sql("TRUNCATE wiki_lab.import_orders;")
            try:
                load(method, bad)
            except subprocess.CalledProcessError, ValueError:
                pass
            else:
                raise AssertionError(f"input {kind} được chấp nhận bởi {method}")
            assert int(sql("SELECT count(*) FROM wiki_lab.import_orders;")) == 0
            load(method, valid)
            validate(valid, 1000)
            completed += 1
    return completed


def main() -> None:
    assert Path(LAB_DIR, ".wiki-lab").is_file()
    assert sql("SHOW unix_socket_directories;").decode().strip() == LAB_DIR
    settings = (
        sql(
            "SELECT current_setting('server_version_num'),current_setting('fsync'),current_setting('full_page_writes'),current_setting('synchronous_commit'),current_setting('wal_level'),current_setting('shared_buffers');"
        )
        .decode()
        .strip()
        .split("|")
    )
    assert settings[:4] == ["180006", "on", "on", "on"] and settings[4] == "replica"
    sql("""
DROP TABLE IF EXISTS wiki_lab.import_orders;
CREATE TABLE wiki_lab.import_orders (
  id integer PRIMARY KEY,
  customer_id integer NOT NULL REFERENCES wiki_lab.customers(id),
  total_cents integer NOT NULL CHECK (total_cents >= 0)
);
CREATE INDEX import_customer ON wiki_lab.import_orders(customer_id,id);
""")
    for n in SIZES:
        generate(Path(f"data-{n}.csv"), n)
    warm = Path("data-1000.csv")
    for method in METHODS:
        sql("TRUNCATE wiki_lab.import_orders;")
        load(method, warm)
        validate(warm, 1000)
    samples: list[Sample] = []
    for n in SIZES:
        path = Path(f"data-{n}.csv")
        for repeat in range(3):
            order = METHODS[repeat:] + METHODS[:repeat]
            for method in order:
                sql("TRUNCATE wiki_lab.import_orders;")
                started = time.perf_counter()
                load(method, path)
                elapsed = time.perf_counter() - started
                digest = validate(path, n)
                samples.append(
                    {
                        "n": n,
                        "method": method,
                        "repeat": repeat + 1,
                        "seconds": elapsed,
                        "csv_sha256": digest,
                    }
                )
    fault_cases = fault_checks(warm)
    assert len(samples) == 27 and fault_cases == 15
    summaries = []
    for n in SIZES:
        for method in METHODS:
            durations = [
                r["seconds"] for r in samples if r["n"] == n and r["method"] == method
            ]
            assert len(durations) == 3
            mean = statistics.mean(durations)
            summaries.append(
                {
                    "n": n,
                    "method": method,
                    "mean_s": mean,
                    "min_s": min(durations),
                    "max_s": max(durations),
                    "stdev_s": statistics.stdev(durations),
                    "rows_per_s": n / mean,
                }
            )
    metadata = {
        "checked_at": datetime.now(UTC).isoformat(),
        "python": platform.python_version(),
        "system": platform.system(),
        "architecture": platform.machine(),
        "logical_cpus": os.cpu_count(),
        "postgresql": settings,
        "seed": SEED,
        "batch": 400,
        "cache": "warm-up, no eviction",
        "scope": "CSVread/serialize/psql/commit",
    }
    payload = {
        "metadata": metadata,
        "samples": samples,
        "summaries": summaries,
        "fault_cases": fault_cases,
        "retries": fault_cases,
    }
    Path("bulk-results.json").write_text(json.dumps(payload, indent=2) + "\n")
    sys.stdout.write("BENCH_RESULT " + json.dumps(payload) + "\n")
    sys.stdout.write(
        "samples=27 checksum=all_equal fault_cases=15 retries=15 durability=on\n"
    )
    sql("DROP TABLE wiki_lab.import_orders;")


if __name__ == "__main__":
    main()

Dựng fixture và chạy

set -euo pipefail
. ./lab-local.sh
lab_up
lab_seed
lab_whoami
lab_versions
set -euo pipefail
. ./lab.env
python3 bulk.py
samples=27 checksum=all_equal fault_cases=15 retries=15 durability=on

bulk-results.json giữ từng sample và metadata của lần chạy. So nội dung thành công không chứng minh toàn importer production: ở đây dữ liệu mới, không upsert hay hiệu ứng ngoài database. Với input lỗi, cả ba phải để0hàng; sau sửa file, retry nạp đúng1000hàng. Không bỏ constraint để nhận tốc độ đẹp hơn. Với file lớn cần staging, checkpoint và chính sách retry phù hợp transaction boundary; đó là bài toán khác phép đo này.

Cleanup luôn phải chạy

set -euo pipefail
. ./lab-local.sh
if [ -f lab.env ]; then
  . ./lab.env
  old_dir=$LAB_DIR
  lab_clean
  [ ! -e "$old_dir" ]
fi
lab_clean
[ ! -e lab.env ]
echo 'cleanup ok'

Kết quả và giới hạn kết luận

Đo ngày2026-10-03 lúc07:21:54UTC: macOS27.0.1, arm64,10logicalCPU, Python3.14.4 và PostgreSQL18.6 qua Unix socket; shared_buffers128MB. fsync/full_page_writes/synchronous_commit đều on, wal_level=replica. Đây là một máy local, workload nhỏ sau warm-up; không đo cạnh tranh production. Thời gian là giây, throughput = số hàng / mean wall time; stddev chỉ từ ba lượt. Khi đọc kết quả, kiểm từng sample trong JSON thay vì chỉ nhìn mean.

HàngCáchMean (s)Min (s)Max (s)Stddev (s)Hàng/s
1000row0.0289450.0275620.0298210.00121234548
1000batch0.0145480.0139350.0157690.00105868738
1000copy0.0141620.0134960.0146760.00060570613
5000row0.0992650.0970830.1004860.00189450370
5000batch0.0291530.0288900.0294350.000273171511
5000copy0.0243750.0243220.0244320.000055205125
10000row0.1913790.1893330.1947090.00290852252
10000batch0.0492660.0472730.0521860.002584202981
10000copy0.0374930.0374100.0376200.000112266716

Ở10000hàng, COPY đạt mean0.037493s, batch0.049266s, row0.191379s. Ở1000hàng, khoảng min/max của batch và COPY chồng nhau; ba lượt chưa đủ để kết luận COPY luôn hơn batch. Khởi tạo client và serialization vẫn nằm trong timer. Tất cả27lượt khớp toàn bộ CSV export;15input lỗi để0hàng và15retry nạp đúng. Lần chạy lại tạo JSON mới; không kỳ vọng timing hoặc checksum của metadata giống nhau.

Vì sao không suy10M là đã đo?

Mô hình tỷ lệ T(10M) ≈ T(10k) × 1000 chỉ là giả định về cùng throughput. Nó bỏ qua giới hạn RAM của request dựng sẵn, tốc độ WAL/đĩa, checkpoint, cache, index lớn, constraint, contention và replication. Cần benchmark cỡ lớn hơn cùng workload để kiểm từng giả định; chưa chạy10M thì không ghi một mốc thời gian thành kết quả đo.

Trong PostgreSQL, thay wal_level, bỏ index/constraint hoặc giảm bảo đảm durability làm phép so khác điều kiện. COPY FREEZE liên quan tuple freezing, không đồng nghĩa “không ghi WAL”. Giữ cấu hình và đo lại nếu một điều kiện đổi; đọc trade-off thay vì copy lệnh tắt kiểm tra. Độ tin cậy WAL.

Học tiếp: chi phí index, EXPLAIN ANALYZE và replication lag. Không có ngưỡng số hàng chung buộc mọi ứng dụng bỏ ORM hay bắt buộc chọn một batch size.

Deadlock: hai transaction giữ khóa theo thứ tự ngược nhau

Câu hỏi bài này trả lời: một query đang chờ khóa có phải deadlock không, transaction nào bị hủy và retry ở đâu để không xử lý một yêu cầu hai lần?

Cần biết trước: BEGIN/COMMIT/ROLLBACK và lab database. Bài đã chạy MySQL 26.7.0/InnoDB trên macOS arm64, Python 3.14.4, dữ liệu giả. Không có phép thử PostgreSQL, Docker/Linux hay Podman ở bài này.

Lịch làm việc tạo vòng chờ

Hai hàng có khóa chính 1 và 2. Mỗi yêu cầu tăng cả hai balance lên 1, ghi mã yêu cầu vào bảng riêng trong cùng transaction. Hai yêu cầu hợp lệ A/B cần đưa mỗi balance từ 100 lên 102, với đúng hai mã đã commit.

BướcSession ASession BKhóa và trạng thái
A1BEGIN, ghi mã A, UPDATE hàng 1Chưa bắt đầuA giữ khóa hàng 1, chưa commit
B1Dừng tại đâyBEGIN, ghi mã B, UPDATE hàng 2B giữ khóa hàng 2, chưa commit
A2UPDATE hàng 2, câu lệnh chưa trả vềDừng tại đâyA chờ B; mới có một cạnh chờ
B2Vẫn chờUPDATE hàng 1B cần khóa của A: vòng A → B → A

Ở A2, B vẫn có thể commit để nhả khóa; đó là chờ bình thường. B2 mới đóng vòng. Khi phát hiện vòng, InnoDB hủy một transaction để transaction còn lại đi tiếp. Không gán trước victim là A hoặc B; lựa chọn đó không phải hợp đồng của ứng dụng.

Tạo thư mục trống và chép bốn file của cách A trong bài lab: lab-common.sh, lab-local.sh, seed-pg.sql, seed-mysql.sql. Server dùng socket riêng, không lấy địa chỉ database từ cấu hình máy.

. ./lab-local.sh
lab_up
lab_seed
lab_whoami
lab_mysql -N -e 'SELECT @@version, @@innodb_deadlock_detect, @@innodb_rollback_on_timeout'

Lab dưới đòi deadlock detection bật và rollback-on-timeout tắt, là cấu hình được kiểm ở lượt chạy này. Nếu khác, dừng để hiểu cấu hình thay vì tự đổi biến global trên server.

CREATE TABLE IF NOT EXISTS wiki_lab.accounts (
  id INT PRIMARY KEY,
  balance INT NOT NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS wiki_lab.requests (
  id CHAR(1) PRIMARY KEY
) ENGINE=InnoDB;
DELETE FROM wiki_lab.requests;
DELETE FROM wiki_lab.accounts;
INSERT INTO wiki_lab.accounts VALUES (1, 100), (2, 100);

Nếu làm bằng hai terminal, mở cùng thư mục, . ./lab-local.sh, rồi lab_mysql wiki_lab ở mỗi terminal. Sau khi nạp setup, thực hiện từng ô của lịch, dừng đúng A1/B1 và chỉ gõ B2 sau khi A2 đang chờ:

. ./lab-local.sh
lab_whoami
lab_mysql < setup.sql
-- A1, terminal A
BEGIN;
INSERT INTO wiki_lab.requests VALUES ('A');
UPDATE wiki_lab.accounts SET balance = balance + 1 WHERE id = 1;
-- B1, terminal B
BEGIN;
INSERT INTO wiki_lab.requests VALUES ('B');
UPDATE wiki_lab.accounts SET balance = balance + 1 WHERE id = 2;
-- A2, terminal A: chờ ở đây
UPDATE wiki_lab.accounts SET balance = balance + 1 WHERE id = 2;
-- B2, terminal B
UPDATE wiki_lab.accounts SET balance = balance + 1 WHERE id = 1;

Chỉ COMMIT ở session có UPDATE thứ hai thành công. Session nhận 1213 đã mất toàn transaction; đừng tiếp tục các bước còn lại như thể thay đổi đầu tiên còn tồn tại. Sau khi kiểm xong, ROLLBACK cả hai session và thoát trước khi reset.

Chạy lịch có điểm đồng bộ và assertion

Để không phụ thuộc tốc độ gõ, controller sau mở hai MySQL client thật. SELECT marker xác nhận A1/B1 đã xong; bảng performance_schema.data_lock_waits xác nhận A2 đang chờ B trước khi gửi B2. Thời hạn 10 giây chỉ để phát hiện lab treo, không dùng sleep làm bằng chứng đã lấy khóa.

Lưu thành deadlock.py. File chỉ gọi client socket của lab, dùng mã A/B cố định và cất báo cáo InnoDB trong thư mục thực hành. Không dùng controller này làm thư viện retry của ứng dụng.

import os
import queue
import signal
import subprocess
import threading
import time
from pathlib import Path

CLIENT = '. ./lab-local.sh; lab_mysql --batch --raw --skip-column-names --unbuffered'

def query(sql):
    result = subprocess.run(['bash', '-c', CLIENT], input=sql, text=True,
                            capture_output=True, timeout=15, check=True)
    return result.stdout.strip()

class Session:
    def __init__(self, inspect_error=False):
        client = CLIENT + (' --force' if inspect_error else '')
        self.process = subprocess.Popen(
            ['bash', '-c', client], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
            stderr=subprocess.PIPE, text=True, bufsize=1, start_new_session=True)
        self.lines = queue.Queue()
        self.reader = threading.Thread(target=self.read, daemon=True)
        self.reader.start()
        self.send('SELECT CONNECTION_ID();')
        self.id = int(self.line())

    def read(self):
        for line in self.process.stdout:
            self.lines.put(line.strip())
        self.lines.put(None)

    def line(self):
        value = self.lines.get(timeout=10)
        assert value is not None, 'client kết thúc trước marker'
        return value

    def send(self, sql):
        self.process.stdin.write(sql + '\n')
        self.process.stdin.flush()

    def mark(self, sql, label):
        self.send(sql + f" SELECT '{label}';")
        assert self.line() == label

    def finish(self):
        self.process.stdin.close()
        code = self.process.wait(timeout=10)
        self.reader.join(timeout=2)
        return code, self.process.stderr.read()

    def close(self):
        if self.process.poll() is None:
            os.killpg(self.process.pid, signal.SIGTERM)
            try:
                self.process.wait(timeout=3)
            except subprocess.TimeoutExpired:
                os.killpg(self.process.pid, signal.SIGKILL)
                self.process.wait(timeout=3)

def wait_edge(waiter, owner):
    sql = f'''
    SELECT COUNT(*) FROM performance_schema.data_lock_waits w
    JOIN performance_schema.threads r ON r.THREAD_ID = w.REQUESTING_THREAD_ID
    JOIN performance_schema.threads b ON b.THREAD_ID = w.BLOCKING_THREAD_ID
    JOIN performance_schema.data_locks l
      ON l.ENGINE = w.ENGINE AND l.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID
    WHERE r.PROCESSLIST_ID = {waiter.id} AND b.PROCESSLIST_ID = {owner.id}
      AND l.OBJECT_SCHEMA = 'wiki_lab' AND l.OBJECT_NAME = 'accounts';
    '''
    deadline = time.monotonic() + 10
    while time.monotonic() < deadline:
        if int(query(sql)) > 0:
            return
        time.sleep(0.02)
    raise AssertionError('không thấy cạnh chờ của hai session lab')

def reset():
    query(Path('setup.sql').read_text())

def balances(value, requests):
    assert query('SELECT balance FROM wiki_lab.accounts ORDER BY id;') == f'{value}\n{value}'
    assert int(query('SELECT COUNT(*) FROM wiki_lab.requests;')) == requests

def retry(request):
    assert request in ('A', 'B')
    sql = f'''BEGIN;
    INSERT INTO wiki_lab.requests VALUES ('{request}');
    UPDATE wiki_lab.accounts SET balance = balance + 1 WHERE id = 1;
    UPDATE wiki_lab.accounts SET balance = balance + 1 WHERE id = 2;
    COMMIT;'''
    for attempt in range(3):
        result = subprocess.run(['bash', '-c', CLIENT], input=sql, text=True,
                                capture_output=True, timeout=15)
        if result.returncode == 0:
            return True
        if 'ERROR 1062 ' in result.stderr:
            # Chỉ INSERT mã request có thể trùng trong transaction này.
            return False
        if 'ERROR 1213 (40001)' not in result.stderr or attempt == 2:
            raise RuntimeError(result.stderr)
        time.sleep(0.02 * (attempt + 1))
    raise AssertionError('retry vượt giới hạn')

def cycle(round_number):
    reset()
    a, b = Session(inspect_error=True), Session(inspect_error=True)
    try:
        a.mark("BEGIN; INSERT INTO wiki_lab.requests VALUES ('A'); "
               'UPDATE wiki_lab.accounts SET balance=balance+1 WHERE id=1;', 'A1')
        b.mark("BEGIN; INSERT INTO wiki_lab.requests VALUES ('B'); "
               'UPDATE wiki_lab.accounts SET balance=balance+1 WHERE id=2;', 'B1')
        a.send("UPDATE wiki_lab.accounts SET balance=balance+1 WHERE id=2; SELECT 'A2';")
        wait_edge(a, b)
        b.send("UPDATE wiki_lab.accounts SET balance=balance+1 WHERE id=1; SELECT 'B2';")
        assert a.line() == 'A2' and b.line() == 'B2'
        active = {}
        for name, session in (('A', a), ('B', b)):
            session.send(f"SELECT COUNT(*) FROM wiki_lab.requests WHERE id='{name}';")
            active[name] = int(session.line())
        assert sorted(active.values()) == [0, 1]
        rolled_back = a if active['A'] == 0 else b
        rolled_back.send('SELECT GROUP_CONCAT(balance ORDER BY id) FROM wiki_lab.accounts;')
        assert rolled_back.line() == '100,100'
        for name, session in (('A', a), ('B', b)):
            session.mark('COMMIT;' if active[name] == 1 else 'ROLLBACK;', 'closed')
        results = {'A': a.finish(), 'B': b.finish()}
        victims = [name for name, (code, error) in results.items()
                   if 'ERROR 1213 (40001)' in error]
        assert len(victims) == 1
        victim = victims[0]
        winner = 'B' if victim == 'A' else 'A'
        assert active[victim] == 0 and active[winner] == 1
        assert results[winner] == (0, '')
        assert all('ERROR 1205 ' not in error for _, error in results.values())
        balances(101, 1)
        assert query('SELECT id FROM wiki_lab.requests;') == winner
        status = query('SHOW ENGINE INNODB STATUS;')
        Path(f'innodb-status-{round_number}.txt').write_text(status)
        assert 'LATEST DETECTED DEADLOCK' in status
        assert 'accounts' in status and 'PRIMARY' in status
        assert 'WE ROLL BACK TRANSACTION' in status
        print(f'vòng {round_number}: ERROR 1213 (40001), đúng một victim; balance=101, requests=1')
        print('báo cáo InnoDB có vòng chờ trên accounts/PRIMARY và transaction bị rollback')
        assert retry(victim)
        assert not retry(winner)
        assert not retry(victim)
        balances(102, 2)
        print('retry toàn transaction và gửi trùng: balance=102, requests=2')
    finally:
        a.close()
        b.close()

def ordered():
    reset()
    a, b = Session(), Session()
    try:
        a.mark("BEGIN; INSERT INTO wiki_lab.requests VALUES ('A'); "
               'UPDATE wiki_lab.accounts SET balance=balance+1 WHERE id=1;', 'A1')
        b.send("BEGIN; INSERT INTO wiki_lab.requests VALUES ('B'); "
               'UPDATE wiki_lab.accounts SET balance=balance+1 WHERE id=1;')
        wait_edge(b, a)
        a.send('UPDATE wiki_lab.accounts SET balance=balance+1 WHERE id=2; COMMIT;')
        assert a.finish() == (0, '')
        b.send('UPDATE wiki_lab.accounts SET balance=balance+1 WHERE id=2; COMMIT;')
        assert b.finish() == (0, '')
        balances(102, 2)
        print('cùng thứ tự 1 rồi 2: có chờ, hai commit, không deadlock')
    finally:
        a.close()
        b.close()

def timeout():
    reset()
    owner, waiter = Session(), Session(inspect_error=True)
    try:
        owner.mark('BEGIN; UPDATE wiki_lab.accounts SET balance=balance+1 WHERE id=1;', 'held')
        waiter.mark('SET SESSION innodb_lock_wait_timeout=1; BEGIN; '
                    'UPDATE wiki_lab.accounts SET balance=balance+7 WHERE id=2;', 'W1')
        waiter.send("UPDATE wiki_lab.accounts SET balance=balance+1 WHERE id=1; "
                    "SELECT 'timeout_finished';")
        assert waiter.line() == 'timeout_finished'
        waiter.send('SELECT balance FROM wiki_lab.accounts WHERE id=2;')
        assert waiter.line() == '107'
        assert query('SELECT balance FROM wiki_lab.accounts WHERE id=2;') == '100'
        waiter.mark('ROLLBACK;', 'waiter_rolled_back')
        code, error = waiter.finish()
        assert 'ERROR 1205 ' in error and 'ERROR 1213 ' not in error
        owner.mark('ROLLBACK;', 'rolled_back')
        assert owner.finish() == (0, '')
        balances(100, 0)
        print('timeout ERROR 1205: waiter còn thấy 107; explicit rollback trả về 100')
    finally:
        owner.close()
        waiter.close()

assert query('SELECT @@innodb_deadlock_detect, @@innodb_rollback_on_timeout;') == '1\t0'
cycle(1)
cycle(2)
ordered()
timeout()
reset()
balances(100, 0)
print('reset và kiểm dữ liệu đạt')

Controller dùng --force ở phép thử lỗi để giữ connection và đọc trạng thái ngay sau lỗi: victim không còn mã yêu cầu hay balance đã tăng; waiter timeout vẫn thấy thay đổi trước đó. Chỉ winner được COMMIT; các connection lỗi có ROLLBACK tường minh. Đây là cách quan sát trong lab, không phải cách tiếp tục script nghiệp vụ sau lỗi.

Hàm retry chạy client batch không có --force: lỗi làm dừng script và đóng connection, nên không chạy tiếp COMMIT; connection đóng cũng giải phóng transaction còn mở. Nó chỉ retry 1213, tối đa ba lần, bắt đầu lại từ BEGIN bằng cùng mã yêu cầu. Mã trùng 1062 được coi là đã xử lý chỉ vì ở fixture này duy nhất INSERT mã yêu cầu có thể sinh lỗi đó; ứng dụng nhiều unique constraint phải nhận diện đúng constraint, không bắt mọi 1062 như thành công.

python3 deadlock.py
vòng 1: ERROR 1213 (40001), đúng một victim; balance=101, requests=1
báo cáo InnoDB có vòng chờ trên accounts/PRIMARY và transaction bị rollback
retry toàn transaction và gửi trùng: balance=102, requests=2
vòng 2: ERROR 1213 (40001), đúng một victim; balance=101, requests=1
báo cáo InnoDB có vòng chờ trên accounts/PRIMARY và transaction bị rollback
retry toàn transaction và gửi trùng: balance=102, requests=2
cùng thứ tự 1 rồi 2: có chờ, hai commit, không deadlock
timeout ERROR 1205: waiter còn thấy 107; explicit rollback trả về 100
reset và kiểm dữ liệu đạt

Đọc lỗi, báo cáo và trạng thái transaction

1213/40001 là deadlock ở phép thử này. Trước khi đóng connection, victim đọc được hai balance 100 và không thấy mã yêu cầu của mình: thay đổi đầu tiên cũng đã rollback. Sau COMMIT của winner, hai balance bằng 101 và chỉ mã winner tồn tại. Retry victim đưa balance lên 102; gửi lại hai mã đều không tăng balance nữa.

Mở innodb-status-1.txt trong thư mục lab để tìm LATEST DETECTED DEADLOCK, các transaction, khóa đang giữ/đang chờ và dòng victim. SHOW ENGINE INNODB STATUS chỉ chứa deadlock gần nhất; không thay thế lịch sử mọi deadlock. Trên hệ thống thật, báo cáo có thể chứa SQL và định danh vận hành: chọn đoạn cần thiết và khử định danh trước khi chia sẻ.

Tránh cố chụp cả hai cạnh trong data_lock_waits sau B2: detector có thể giải vòng trước khi query quan sát chạy. Controller xác nhận cạnh A → B trước B2, rồi dùng lỗi và báo cáo để chứng minh vòng đã hình thành.

Chờ khóa, deadlock và timeout có kết quả khác nhau

Trường hợpBằng chứng labTrạng thái cần xử lý
Chờ có thể giảiB đợi A ở bản sửa, A commit rồi B tiếp tụcChưa phải lỗi; giới hạn thời gian chờ theo yêu cầu
Deadlock1213, báo cáo vòng, một victimInnoDB rollback toàn transaction; retry toàn đơn vị công việc
Lock timeout1205 khi owner vẫn giữ hàng 1Với rollback-on-timeout tắt, chỉ statement lỗi được rollback; transaction trước đó có thể còn

Phép thử timeout cho waiter sửa hàng 2 thành 107 trước khi chờ hàng 1. Sau 1205, chính connection đó vẫn đọc được 107, trong khi connection ngoài thấy 100. ROLLBACK waiter rồi owner đưa mọi hàng về 100. Đây là bằng chứng thay đổi trước lỗi còn trong transaction; chủ động ROLLBACK trước khi trả connection vào pool hoặc retry toàn transaction. InnoDB Error Handling mô tả khác biệt rollback này.

Sửa thứ tự và đặt retry ở biên nghiệp vụ

Cho cả A/B UPDATE hàng 1 rồi hàng 2. Trong lịch đã kiểm, B chờ hàng 1, không giữ hàng 2 để chặn A; A hoàn thành rồi B tiếp tục. Phép thử này loại vòng hai hàng, không chứng minh ứng dụng không thể deadlock ở unique index, foreign key hay một đường code khác. Rà thứ tự nhất quán trên toàn đơn vị nghiệp vụ; giữ transaction ngắn.

Với ứng dụng dùng driver, đặt vòng retry bao ngoài BEGIN → ghi mã yêu cầu → thay đổi → COMMIT. Khi gặp 1213, rollback/loại connection lỗi theo hợp đồng driver, backoff có jitter với số lần và deadline hữu hạn, rồi chạy lại toàn transaction. Đọc lại dữ liệu trong lượt mới. Khi hết giới hạn, trả lỗi có thể điều tra; không lặp vô hạn hoặc retry mọi lỗi SQL.

Khóa mã yêu cầu phải được ghi atomically cùng thay đổi. Nếu commit thành công nhưng phản hồi mất, gửi lại cùng mã sẽ không tăng balance lần nữa. Ví dụ chỉ bảo vệ hai UPDATE trong database; gọi API thanh toán/gửi email bên ngoài transaction cần cơ chế riêng như outbox và khóa idempotency ở nơi nhận.

Dọn và thử biến thể

. ./lab-local.sh
lab_clean
test ! -e lab.env
  1. Đổi cả hai session sang cùng thứ tự và xác nhận có cạnh chờ nhưng không có 1213.
  2. Cho một đường code vẫn lấy khóa theo thứ tự cũ: kiểm vòng trở lại, đừng chỉ sửa một caller.
  3. Đổi số hàng và thứ tự thao tác, vẽ lại cạnh chờ rồi kiểm báo cáo. Không suy rằng chỉ có hai transaction mới tạo được vòng.

Không thấy deadlock: thường đã COMMIT quá sớm, chạy hai câu trong hai connection khác nhau hoặc detector đang tắt. Không thấy cạnh chờ: xác nhận đúng session/socket lab, transaction còn mở và quyền đọc Performance Schema. Lỗi 1205 trước B2: lịch chưa được phối hợp kịp; tăng thời gian hợp lý trong lab, không gọi đó là deadlock.

Học tiếp và nguồn

Nguồn đọc ngày 2026-10-03. Lab kiểm cơ chế và số lần xử lý trên dữ liệu giả, không đo tải production hay hứa retry sẽ luôn thành công.

Migration database: mỗi DDL giữ khóa nào và chặn ai

Câu hỏi bài này trả lời: một câu ALTER TABLE hoặc CREATE INDEX trong migration giữ khóa nào, chặn truy vấn nào, và vì sao chỉ cần nó đang chờ khóa cũng đủ làm cả bảng đứng?

Cần biết trước: SQL cơ bản, lab database và khóa ngoại (bài này dùng lại cách quan sát khóa bằng nhiều phiên). Bài chạy PostgreSQL 18.6 trên macOS arm64 với dữ liệu giả; MySQL không có phép đo nào. Docker/Linux của fixture chưa kiểm.

Mức khóa bảng, đọc theo hai câu hỏi

Mỗi câu lệnh lấy một khóa mức bảng, và với migration chỉ cần trả lời hai câu: nó chặn ai, và nó giữ bao lâu. Bảng dưới là phần của tài liệu PostgreSQL 18 mà migration hay chạm tới (tài liệu có tám mức, ở đây chọn sáu):

Mức khóa (pg_locks.mode)Câu lệnh điển hìnhChặn gì
AccessShareLockSELECTChỉ bị ACCESS EXCLUSIVE chặn
RowExclusiveLockINSERT, UPDATE, DELETE, MERGEBị chặn bởi SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE
ShareUpdateExclusiveLockVACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, một số dạng ALTER TABLEKhông chặn đọc, không chặn ghi; xung đột với chính nó và các mức từ SHARE trở lên
ShareLockCREATE INDEX (không CONCURRENTLY)Chặn ghi, cho đọc
ShareRowExclusiveLockADD FOREIGN KEY, CREATE TRIGGERChặn ghi, cho đọc
AccessExclusiveLockNhiều dạng ALTER TABLE, DROP TABLE, TRUNCATE, REINDEX, VACUUM FULLChặn mọi thứ, kể cả SELECT

Ba điều đáng nhớ từ tài liệu. Chỉ khóa ACCESS EXCLUSIVE mới chặn được SELECT thường. Khóa được giữ cho tới hết giao dịch chứa câu lệnh, nên một DDL nằm trong giao dịch dài giữ khóa lâu hơn chính nó. Và ALTER TABLE lấy ACCESS EXCLUSIVE trừ khi tài liệu của từng dạng ghi khác đi.

Bảng chỉ nói mức khóa, không nói thời gian giữ và không nói chuyện chờ đợi. Hai chủ đề đó là phần còn lại của bài.

Tạo thư mục sạch, chép bốn file của cách A từ bài lab (lab-common.sh, lab-local.sh, seed-pg.sql, seed-mysql.sql) rồi dựng lab. File lab-wait.sh dưới đây dùng cho các bước hai phiên: nó hỏi pg_stat_activity cho tới khi phiên nền tới đúng điểm cần, thay vì đoán bằng sleep:

wait_for() {
  i=0
  until [ "$(lab_psql -At -c "$1")" = "$2" ]; do
    i=$((i + 1))
    [ "$i" -lt 150 ] || { echo "hết thời gian chờ: $1" >&2; return 1; }
    sleep 0.1
  done
}
. ./lab-local.sh
lab_up
lab_seed
lab_whoami

Đo mức khóa của từng DDL

Mỗi DDL chạy trong một giao dịch rồi rollback. Lab in các mức khóa mà chính phiên đó đang giữ trên bảng, và với DDL đổi cấu trúc bảng còn in cột rewritten: relfilenode của bảng có đổi không, tức bảng có bị ghi lại thành file mới không.

\set ON_ERROR_STOP on
\echo == ADD COLUMN không default
BEGIN;
SELECT pg_relation_filenode('wiki_lab.orders') AS before \gset
ALTER TABLE wiki_lab.orders ADD COLUMN note text;
SELECT string_agg(l.mode, ',' ORDER BY l.mode), pg_relation_filenode('wiki_lab.orders') <> :before AS rewritten FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation = 'wiki_lab.orders'::regclass;
ROLLBACK;
\echo == ADD COLUMN default hằng
BEGIN;
SELECT pg_relation_filenode('wiki_lab.orders') AS before \gset
ALTER TABLE wiki_lab.orders ADD COLUMN flag boolean NOT NULL DEFAULT false;
SELECT string_agg(l.mode, ',' ORDER BY l.mode), pg_relation_filenode('wiki_lab.orders') <> :before AS rewritten FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation = 'wiki_lab.orders'::regclass;
ROLLBACK;
\echo == ADD COLUMN default volatile
BEGIN;
SELECT pg_relation_filenode('wiki_lab.orders') AS before \gset
ALTER TABLE wiki_lab.orders ADD COLUMN stamp timestamptz DEFAULT clock_timestamp();
SELECT string_agg(l.mode, ',' ORDER BY l.mode), pg_relation_filenode('wiki_lab.orders') <> :before AS rewritten FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation = 'wiki_lab.orders'::regclass;
ROLLBACK;
\echo == ALTER COLUMN TYPE bigint
BEGIN;
SELECT pg_relation_filenode('wiki_lab.orders') AS before \gset
ALTER TABLE wiki_lab.orders ALTER COLUMN total_cents TYPE bigint;
SELECT string_agg(l.mode, ',' ORDER BY l.mode), pg_relation_filenode('wiki_lab.orders') <> :before AS rewritten FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation = 'wiki_lab.orders'::regclass;
ROLLBACK;
\echo == CREATE INDEX
BEGIN;
CREATE INDEX ix_status ON wiki_lab.orders(status);
SELECT string_agg(l.mode, ',' ORDER BY l.mode) FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation = 'wiki_lab.orders'::regclass;
ROLLBACK;
\echo == ANALYZE
BEGIN;
ANALYZE wiki_lab.orders;
SELECT string_agg(l.mode, ',' ORDER BY l.mode) FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation = 'wiki_lab.orders'::regclass;
ROLLBACK;
\echo == ADD CHECK có quét
BEGIN;
ALTER TABLE wiki_lab.orders ADD CONSTRAINT chk_total CHECK (total_cents > 0);
SELECT string_agg(l.mode, ',' ORDER BY l.mode) FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation = 'wiki_lab.orders'::regclass;
ROLLBACK;
\echo == ADD CHECK NOT VALID
BEGIN;
ALTER TABLE wiki_lab.orders ADD CONSTRAINT chk_total CHECK (total_cents > 0) NOT VALID;
SELECT string_agg(l.mode, ',' ORDER BY l.mode) FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation = 'wiki_lab.orders'::regclass;
ROLLBACK;
\echo == ADD FOREIGN KEY
BEGIN;
ALTER TABLE wiki_lab.order_items ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES wiki_lab.orders(id);
SELECT l.relation::regclass, string_agg(l.mode, ',' ORDER BY l.mode) FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation IN ('wiki_lab.orders'::regclass, 'wiki_lab.order_items'::regclass) GROUP BY 1 ORDER BY 1;
ROLLBACK;
\echo == ADD FOREIGN KEY NOT VALID
BEGIN;
ALTER TABLE wiki_lab.order_items ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES wiki_lab.orders(id) NOT VALID;
SELECT l.relation::regclass, string_agg(l.mode, ',' ORDER BY l.mode) FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation IN ('wiki_lab.orders'::regclass, 'wiki_lab.order_items'::regclass) GROUP BY 1 ORDER BY 1;
ROLLBACK;

VALIDATE CONSTRAINT chỉ có nghĩa khi ràng buộc đã tồn tại ở dạng NOT VALID, nên nó có file riêng và chạy sau khi lab thêm hai ràng buộc đó:

\set ON_ERROR_STOP on
\echo == VALIDATE FOREIGN KEY
BEGIN;
ALTER TABLE wiki_lab.order_items VALIDATE CONSTRAINT fk_items_order;
SELECT l.relation::regclass, string_agg(l.mode, ',' ORDER BY l.mode) FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation IN ('wiki_lab.orders'::regclass, 'wiki_lab.order_items'::regclass) GROUP BY 1 ORDER BY 1;
ROLLBACK;
\echo == VALIDATE CHECK
BEGIN;
ALTER TABLE wiki_lab.orders VALIDATE CONSTRAINT chk_total;
SELECT string_agg(l.mode, ',' ORDER BY l.mode) FROM pg_locks l WHERE l.pid = pg_backend_pid() AND l.relation = 'wiki_lab.orders'::regclass;
ROLLBACK;

Đầu ra mong đợi viết thành file để lab so đúng từng dòng bằng diff: một dòng thừa hay thiếu đều làm lab hỏng. Đây là chủ ý, vì khối hiển thị output chỉ kiểm tập con dòng.

== ADD COLUMN không default
AccessExclusiveLock | f
== ADD COLUMN default hằng
AccessExclusiveLock | f
== ADD COLUMN default volatile
AccessExclusiveLock,ShareLock | t
== ALTER COLUMN TYPE bigint
AccessExclusiveLock,ShareLock | t
== CREATE INDEX
ShareLock
== ANALYZE
ShareUpdateExclusiveLock
== ADD CHECK có quét
AccessExclusiveLock
== ADD CHECK NOT VALID
AccessExclusiveLock
== ADD FOREIGN KEY
wiki_lab.orders | AccessShareLock,RowShareLock,ShareRowExclusiveLock
wiki_lab.order_items | AccessShareLock,ShareRowExclusiveLock
== ADD FOREIGN KEY NOT VALID
wiki_lab.orders | AccessShareLock,ShareRowExclusiveLock
wiki_lab.order_items | AccessShareLock,ShareRowExclusiveLock
== VALIDATE FOREIGN KEY
wiki_lab.orders | AccessShareLock,RowShareLock
wiki_lab.order_items | AccessShareLock,ShareUpdateExclusiveLock
== VALIDATE CHECK
ShareUpdateExclusiveLock
. ./lab-local.sh
{
  lab_psql -At -F ' | ' -f modes.sql
  lab_psql -c 'ALTER TABLE wiki_lab.order_items ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES wiki_lab.orders(id) NOT VALID'
  lab_psql -c 'ALTER TABLE wiki_lab.orders ADD CONSTRAINT chk_total CHECK (total_cents > 0) NOT VALID'
  lab_psql -At -F ' | ' -f validate.sql
} > modes.actual
diff modes.expected modes.actual
cat modes.actual
echo 'mức khóa khớp từng dòng'

Đọc bảng này theo ba nhóm:

  • Cùng khóa mạnh nhất, khác thời gian giữ. ADD COLUMN không default và với default hằng đều lấy AccessExclusiveLock, và rewritten là f: tài liệu nói default không volatile được tính một lần lúc chạy lệnh rồi lưu trong metadata của bảng, và lab xác nhận file của bảng không đổi. Với default volatile (như clock_timestamp()) tài liệu nói cả bảng và index bị ghi lại, còn đổi kiểu cột thì thường phải ghi lại; lab đo cả hai (đổi integer sang bigint) có rewritten là t, trong lúc ACCESS EXCLUSIVE vẫn được giữ.
  • Cùng AccessExclusiveLock, một bản quét bảng một bản không. ADD CHECK có quét và ADD CHECK ... NOT VALID đều lấy ACCESS EXCLUSIVE; khác biệt theo tài liệu là NOT VALID bỏ qua bước quét ban đầu, nên thời gian giữ khóa mạnh không còn phụ thuộc kích thước bảng (suy ra từ tài liệu, bài không đo riêng cặp này). Bước VALIDATE sau đó chỉ lấy ShareUpdateExclusiveLock, mức không chặn đọc và ghi.
  • ADD FOREIGN KEY là ngoại lệ so với các ràng buộc khác: tài liệu ghi nó chỉ cần ShareRowExclusiveLock thay vì ACCESS EXCLUSIVE, nhưng lab cho thấy mức đó nằm trên cả hai bảng, và mức này chặn ghi. Bản NOT VALID cũng lấy mức đó nhưng bỏ qua bước quét nên chỉ cần giữ ngắn; VALIDATE hạ xuống ShareUpdateExclusiveLock ở bảng con và chỉ còn RowShareLock ở bảng cha.

Các dòng AccessShareLock, RowShareLock và ShareLock đi kèm trong kết quả là những khóa khác mà chính phiên đó đang giữ trên cùng bảng; bài chỉ đọc mức mạnh nhất vì mức đó quyết định ai bị chặn. Cũng nhớ rằng bảng chỉ nói về mức khóa, không nói thời gian giữ: cùng AccessExclusiveLock, một lần ghi lại bảng tốn thời gian theo kích thước bảng còn một lần đổi metadata thì không. Lab dưới đây đo chênh lệch đó trên fixture nhỏ, nên chỉ nên đọc tỉ lệ:

\set ON_ERROR_STOP on
BEGIN;
\echo == default hằng, chỉ đổi metadata
\timing on
ALTER TABLE wiki_lab.orders ADD COLUMN flag boolean NOT NULL DEFAULT false;
\timing off
ROLLBACK;
BEGIN;
\echo == default volatile, ghi lại bảng
\timing on
ALTER TABLE wiki_lab.orders ADD COLUMN stamp timestamptz DEFAULT clock_timestamp();
\timing off
ROLLBACK;
. ./lab-local.sh
lab_psql -At -f timing.sql | tee timing.txt
awk '/^Time:/ { time[++n] = $2 + 0 } END { printf "ghi lại bảng chậm hơn %.0f lần\n", time[2] / time[1]; exit !(time[2] > 5 * time[1]) }' timing.txt
echo 'tỉ lệ ghi lại bảng đạt'
== default hằng, chỉ đổi metadata
Time: 0.503 ms
== default volatile, ghi lại bảng
Time: 53.070 ms
ghi lại bảng chậm hơn 106 lần

Fixture chỉ có 100.000 hàng và các số trên là một lần chạy trên một máy: lab đòi tỉ lệ ít nhất 5 lần, không đòi con số cụ thể. Bài không đo ở quy mô lớn hơn; tài liệu ghi việc dựng lại bảng hoặc index với bảng lớn có thể mất nhiều thời gian và tạm thời cần tới gấp đôi dung lượng đĩa, còn bản đổi metadata thì không ghi lại gì.

DDL đang chờ khóa cũng chặn người khác

Phần dễ bị bỏ sót của migration không nằm ở lúc DDL chạy mà ở lúc nó chờ khóa. Lab ba vòng dưới đây dùng ba phiên nền. Một phiên đọc dài giữ ACCESS SHARE (chỉ đọc, không xung đột với ai trừ ACCESS EXCLUSIVE) trong vài giây; một phiên chạy ALTER TABLE cần ACCESS EXCLUSIVE nên phải chờ; phiên thứ ba là một SELECT bình thường.

  • Vòng 1, DDL không có lock_timeout: phiên thứ ba hết thời gian chờ dù phiên đọc dài không hề chặn nó.
  • Vòng 2, DDL đặt lock_timeout = 300ms: DDL bỏ cuộc sau 0,3 giây và phiên thứ ba chạy ngay.
  • Vòng 3, vòng lặp retry với lock_timeout = 200ms: DDL thử lại tới khi phiên đọc dài kết thúc, trong lúc đó một phiên thăm dò đọc mỗi 0,4 giây và không được bị lỗi.

Vòng 3 còn dùng một phép kiểm nên chạy trước mọi migration: liệt kê các giao dịch của client đã mở quá một ngưỡng, kèm trạng thái và đoạn đầu câu lệnh.

SELECT pid, state, now() - xact_start AS open_for, left(query, 60) AS query
FROM pg_stat_activity
WHERE backend_type = 'client backend'
  AND xact_start < now() - :'threshold'::interval
  AND pid <> pg_backend_pid()
ORDER BY xact_start;
set -eu
. ./lab-local.sh
. ./lab-wait.sh
lab_seed
echo '== vòng 1: DDL không lock_timeout'
( lab_psql -At -c "BEGIN; SELECT count(*) FROM wiki_lab.orders; SELECT pg_sleep(6); COMMIT;" > a1.log 2>&1 ) &
a1=$!
wait_for "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'PgSleep'" 1
( lab_psql -At -c "ALTER TABLE wiki_lab.orders ADD COLUMN note text" > b1.log 2>&1 ) &
b1=$!
wait_for "SELECT count(*) FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND query LIKE 'ALTER TABLE%'" 1
echo 'phiên đọc dài chỉ giữ ACCESS SHARE; DDL đang chờ'
out=$(lab_psql -v VERBOSITY=terse -At -c "SET statement_timeout = '1500ms'" -c "SELECT count(*) FROM wiki_lab.orders WHERE id = 1" 2>&1 || true)
echo "$out"
echo "$out" | grep -q 'statement timeout'
wait "$a1" "$b1"
echo '== vòng 2: DDL có lock_timeout'
lab_psql -c 'ALTER TABLE wiki_lab.orders DROP COLUMN note'
( lab_psql -At -c "BEGIN; SELECT count(*) FROM wiki_lab.orders; SELECT pg_sleep(6); COMMIT;" > a2.log 2>&1 ) &
a2=$!
wait_for "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'PgSleep'" 1
out=$(lab_psql -v VERBOSITY=terse -At -c "SET lock_timeout = '300ms'" -c "ALTER TABLE wiki_lab.orders ADD COLUMN note text" 2>&1 || true)
echo "$out"
echo "$out" | grep -q 'lock timeout'
[ "$(lab_psql -At -c "SET statement_timeout = '1500ms'" -c "SELECT count(*) FROM wiki_lab.orders WHERE id = 1" | tail -n 1)" = 1 ]
echo 'đọc chạy ngay'
wait "$a2"
echo '== vòng 3: retry với lock_timeout ngắn'
( lab_psql -At -c "BEGIN; SELECT count(*) FROM wiki_lab.orders; SELECT pg_sleep(4); COMMIT;" > a3.log 2>&1 ) &
a3=$!
wait_for "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'PgSleep'" 1
sleep 1
lab_psql -At -F ' | ' -v threshold='500 milliseconds' -f long-txns.sql | tee long.txt
[ "$(wc -l < long.txt)" -eq 1 ]
( n=0
  until lab_psql -At -c "SET lock_timeout = '200ms'" -c "ALTER TABLE wiki_lab.orders ADD COLUMN note text" > /dev/null 2>&1; do
    n=$((n + 1))
    sleep 0.3
  done
  echo "$n" > attempts.txt ) &
m3=$!
errors=0
for poll in 1 2 3 4 5 6 7 8; do
  lab_psql -At -c "SET statement_timeout = '1000ms'" -c "SELECT 1 FROM wiki_lab.orders WHERE id = 1" > /dev/null 2>&1 || errors=$((errors + 1))
  sleep 0.4
done
wait "$a3" "$m3"
echo "số lần retry $(cat attempts.txt), đọc lỗi $errors"
[ "$(cat attempts.txt)" -ge 1 ]
[ "$errors" -eq 0 ]
echo 'retry không chặn đọc'
bash queue.sh
== vòng 1: DDL không lock_timeout
phiên đọc dài chỉ giữ ACCESS SHARE; DDL đang chờ
ERROR:  canceling statement due to statement timeout
== vòng 2: DDL có lock_timeout
ERROR:  canceling statement due to lock timeout
đọc chạy ngay
== vòng 3: retry với lock_timeout ngắn
18861 | active | 00:00:01.189596 | BEGIN; SELECT count(*) FROM wiki_lab.orders; SELECT pg_sleep
số lần retry 5, đọc lỗi 0
retry không chặn đọc

Vòng 1 là điều bảng mức khóa không cho thấy: yêu cầu ACCESS EXCLUSIVE đã vào hàng đợi khóa, và truy vấn đến sau nó xếp phía sau, kể cả SELECT vốn không xung đột với phiên đang giữ khóa. Một migration chạy “vài giây” mà trúng một giao dịch dài có thể làm ứng dụng đứng cho tới khi giao dịch dài đó xong, hoặc cho tới khi DDL tự bỏ vì lock_timeout. Vòng 2 là đối chứng: phiên đọc dài vẫn còn đó, nhưng DDL đã rút khỏi hàng đợi và SELECT chạy ngay, nên thủ phạm làm kẹt truy vấn là DDL đang xếp hàng chứ không phải phiên đọc dài.

Tài liệu PostgreSQL mô tả lock_timeout là giới hạn thời gian một câu lệnh chờ lấy khóa, áp riêng cho mỗi lần xin khóa; 0 (mặc định) là chờ vô hạn. Tài liệu không khuyên đặt nó trong postgresql.conf vì sẽ ảnh hưởng mọi phiên; hãy đặt trong phiên chạy migration, như lab. Vòng 2 cho thấy tác dụng: DDL tự rút lui và hàng đợi được dọn. Vòng 3 ghép thêm retry: mỗi lần xin khóa chỉ chặn người khác tối đa 200 ms, và phiên thăm dò không lỗi lần nào.

Phép kiểm long-txns.sql ở trên đáng giữ trong quy trình migration. Lab dùng ngưỡng 500 ms vì phiên đọc chỉ giữ vài giây; trên hệ thống thật ngưỡng do bạn chọn theo thời gian giao dịch bình thường. Phiên ở trạng thái idle in transaction đáng ngờ nhất vì nó giữ khóa mà không làm gì. Nếu có giao dịch dài thì chờ nó xong hoặc xử lý với người sở hữu nó trước, thay vì trông vào lock_timeout để cứu.

Index và khóa ngoại: bản chặn ghi và bản không chặn

Hai lab nữa kiểm điều bảng ở đầu bài nói về SHARE, SHARE ROW EXCLUSIVE và SHARE UPDATE EXCLUSIVE: ba mức này khác nhau ở việc có chặn ghi hay không. Mỗi lab dùng một phiên giữ DDL trong giao dịch mở vài giây (thay cho thời gian quét hoặc dựng index của một bảng lớn thật) và một phiên ghi INSERT với lock_timeout = 500ms.

set -eu
. ./lab-local.sh
. ./lab-wait.sh
lab_seed
echo '== CREATE INDEX thường'
( lab_psql -At -c "BEGIN; CREATE INDEX ix_plain ON wiki_lab.orders(status); SELECT pg_sleep(4); COMMIT;" > p.log 2>&1 ) &
p=$!
wait_for "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'PgSleep'" 1
lab_psql -At -F ' ' -c "SELECT l.mode FROM pg_locks l JOIN pg_stat_activity a USING (pid) WHERE a.wait_event = 'PgSleep' AND l.relation = 'wiki_lab.orders'::regclass AND l.mode <> 'AccessShareLock' ORDER BY 1"
echo "đọc: $(lab_psql -At -c "SET statement_timeout = '1500ms'" -c "SELECT count(*) FROM wiki_lab.orders" | tail -n 1)"
out=$(lab_psql -v VERBOSITY=terse -At -c "SET lock_timeout = '500ms'" -c "INSERT INTO wiki_lab.orders VALUES (900001, 1, 'pending', 100, timestamp '2026-01-01')" 2>&1 || true)
echo "$out" | grep -q 'lock timeout'
echo "ghi: $(echo "$out" | grep -o 'canceling statement due to lock timeout')"
wait "$p"
echo '== CREATE INDEX CONCURRENTLY'
( lab_psql -At -c "BEGIN; SELECT count(*) FROM wiki_lab.orders; SELECT pg_sleep(6); COMMIT;" > old.log 2>&1 ) &
old=$!
wait_for "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'PgSleep'" 1
( lab_psql -At -c "CREATE INDEX CONCURRENTLY ix_conc ON wiki_lab.orders(status)" > c.log 2>&1 ) &
c=$!
wait_for "SELECT count(*) FROM pg_stat_activity WHERE query LIKE 'CREATE INDEX CONCURRENTLY%' AND wait_event_type = 'Lock'" 1
lab_psql -At -F ' ' -c "SELECT a.wait_event, l.mode FROM pg_locks l JOIN pg_stat_activity a USING (pid) WHERE a.query LIKE 'CREATE INDEX CONCURRENTLY%' AND l.relation = 'wiki_lab.orders'::regclass ORDER BY 2"
lab_psql -v VERBOSITY=terse -At -c "SET lock_timeout = '500ms'" -c "INSERT INTO wiki_lab.orders VALUES (900002, 1, 'pending', 100, timestamp '2026-01-01')"
echo 'ghi: insert xong, không chờ'
wait "$old" "$c"
echo "index hợp lệ: $(lab_psql -At -c "SELECT indisvalid FROM pg_index WHERE indexrelid = 'wiki_lab.ix_conc'::regclass")"
bash writers.sh
== CREATE INDEX thường
ShareLock
đọc: 100000
ghi: canceling statement due to lock timeout
== CREATE INDEX CONCURRENTLY
virtualxid ShareUpdateExclusiveLock
ghi: insert xong, không chờ
index hợp lệ: t

CREATE INDEX thường giữ ShareLock: đọc đi qua (100.000), ghi bị chặn tới hết lock_timeout. CREATE INDEX CONCURRENTLY giữ ShareUpdateExclusiveLock nên ghi vẫn chạy, và nó tự chờ ở virtualxid, tức đang đợi giao dịch cũ kết thúc, đúng như tài liệu: cần quét bảng hai lần và chờ mọi giao dịch có thể sửa dữ liệu hoặc dùng index. Tài liệu cũng ghi hai ràng buộc không đo trong lab: bản CONCURRENTLY không chạy được trong khối giao dịch, và nếu gặp lỗi giữa chừng nó để lại index ở trạng thái invalid, bị bỏ qua khi truy vấn nhưng vẫn tốn chi phí cập nhật, nên phải dọn.

Lab cuối so ADD FOREIGN KEY thường với cách tách NOT VALID rồi VALIDATE:

set -eu
. ./lab-local.sh
. ./lab-wait.sh
lab_seed
echo '== ADD FOREIGN KEY có quét'
( lab_psql -At -c "BEGIN; ALTER TABLE wiki_lab.order_items ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES wiki_lab.orders(id); SELECT pg_sleep(4); COMMIT;" > f.log 2>&1 ) &
f=$!
wait_for "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'PgSleep'" 1
out=$(lab_psql -v VERBOSITY=terse -At -c "SET lock_timeout = '500ms'" -c "INSERT INTO wiki_lab.order_items VALUES (900001, 1, 'sku-x', 1, 100)" 2>&1 || true)
echo "$out" | grep -q 'lock timeout'
echo "ghi vào bảng con: $(echo "$out" | grep -o 'canceling statement due to lock timeout')"
wait "$f"
lab_psql -c 'ALTER TABLE wiki_lab.order_items DROP CONSTRAINT fk_items_order'
echo '== NOT VALID rồi VALIDATE'
lab_psql -c 'ALTER TABLE wiki_lab.order_items ADD CONSTRAINT fk_items_order FOREIGN KEY (order_id) REFERENCES wiki_lab.orders(id) NOT VALID'
out=$(lab_psql -v VERBOSITY=sqlstate -At -c "INSERT INTO wiki_lab.order_items VALUES (900003, 999999999, 'sku-x', 1, 100)" 2>&1 || true)
echo "$out" | grep -q 23503
echo "dòng mồ côi vẫn bị từ chối: $(echo "$out" | grep -o 23503)"
( lab_psql -At -c "BEGIN; ALTER TABLE wiki_lab.order_items VALIDATE CONSTRAINT fk_items_order; SELECT pg_sleep(4); COMMIT;" > v.log 2>&1 ) &
v=$!
wait_for "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'PgSleep'" 1
lab_psql -v VERBOSITY=terse -At -c "SET lock_timeout = '500ms'" -c "INSERT INTO wiki_lab.order_items VALUES (900002, 1, 'sku-x', 1, 100)"
echo 'ghi vào bảng con: insert xong, không chờ'
wait "$v"
bash fkwriters.sh
== ADD FOREIGN KEY có quét
ghi vào bảng con: canceling statement due to lock timeout
== NOT VALID rồi VALIDATE
dòng mồ côi vẫn bị từ chối: 23503
ghi vào bảng con: insert xong, không chờ

Với ADD FOREIGN KEY thường, ghi vào bảng con bị chặn tới hết lock_timeout vì phiên DDL giữ ShareRowExclusiveLock suốt giao dịch của nó. Với cách tách đôi, bước ADD ... NOT VALID bỏ qua bước quét, còn bước VALIDATE chỉ giữ ShareUpdateExclusiveLock ở bảng con nên ghi vẫn chạy. Ràng buộc NOT VALID vẫn áp cho các hàng mới ngay từ lúc thêm: lab chèn một dòng con trỏ tới cha không tồn tại và nhận SQLSTATE 23503 (foreign_key_violation), nên không có khoảng hở cho dữ liệu sai chèn vào trước khi VALIDATE xong. Với các hàng đã có sẵn, tài liệu nói VALIDATE quét bảng để bảo đảm không hàng nào vi phạm; bài không đo trường hợp bảng đã có dòng mồ côi từ trước.

Mẫu migration và checklist

Template dưới đây ghép các phép đo thành một hàm chạy một DDL: đặt lock_timeout ngắn, chỉ thử lại khi mã lỗi là 55P03 (lock_not_available), bỏ ngay với mọi lỗi khác (lỗi cú pháp là 42601, syntax_error) và giới hạn số lần thử. Mã lỗi lấy bằng VERBOSITY=sqlstate của psql; các mã trên đều có trong phụ lục mã lỗi của tài liệu PostgreSQL 18.

set -eu
. ./lab-local.sh
. ./lab-wait.sh
ddl_with_retry() {
  statement="$1"
  max="${2:-30}"
  n=0
  while :; do
    if out=$(lab_psql -v VERBOSITY=sqlstate -c "SET lock_timeout = '200ms'" -c "$statement" 2>&1); then
      echo "xong sau $n lần retry"
      return 0
    fi
    case "$out" in
      *55P03*)
        n=$((n + 1))
        [ "$n" -le "$max" ] || { echo "bỏ cuộc sau $max lần retry" >&2; return 1; }
        sleep 0.3
        ;;
      *)
        echo "lỗi không phải khóa, không retry: $out" >&2
        return 1
        ;;
    esac
  done
}
lab_seed
( lab_psql -At -c "BEGIN; SELECT count(*) FROM wiki_lab.orders; SELECT pg_sleep(3); COMMIT;" > h.log 2>&1 ) &
h=$!
wait_for "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'PgSleep'" 1
ddl_with_retry 'ALTER TABLE wiki_lab.orders ADD COLUMN note text'
wait "$h"
if ddl_with_retry 'ALTER TABLE wiki_lab.orders ADD COLUMN'; then
  echo 'lẽ ra phải dừng' >&2
  exit 1
fi
echo 'lỗi cú pháp dừng ngay, không retry'
( lab_psql -At -c "BEGIN; SELECT count(*) FROM wiki_lab.orders; SELECT pg_sleep(4); COMMIT;" > h2.log 2>&1 ) &
h2=$!
wait_for "SELECT count(*) FROM pg_stat_activity WHERE wait_event = 'PgSleep'" 1
if ddl_with_retry 'ALTER TABLE wiki_lab.orders ADD COLUMN limited text' 2 2> limit.err; then
  echo 'lẽ ra phải bỏ cuộc' >&2
  exit 1
fi
cat limit.err
wait "$h2"
bash retry.sh
xong sau 5 lần retry
lỗi cú pháp dừng ngay, không retry
bỏ cuộc sau 2 lần retry
lỗi không phải khóa, không retry: ERROR:  42601

Hàm chỉ là khung: nó thiếu ghi log từng lần thử, cảnh báo khi vượt ngưỡng và quy tắc bỏ cuộc của đội bạn. Checklist dưới đây tách phần máy kiểm được khỏi phần cần người quyết:

Câu hỏi trước khi chạyMáy kiểm được khôngCách kiểm hoặc ai quyết
DDL này lấy mức khóa nào, trên những bảng nào?CóChạy thử trong giao dịch rồi rollback như lab, đọc pg_locks
Có giao dịch đã mở quá lâu không?CóTruy vấn pg_stat_activity theo xact_start
DDL có ghi lại bảng không?Cópg_relation_filenode trước và sau trong giao dịch thử
Migration đặt lock_timeout và chỉ retry mã 55P03 chưa?CóReview script, test bằng phiên giữ khóa như lab
Có cách tách phần nặng thành hai bước không quét hoặc không chặn ghi?Một phầnNOT VALID rồi VALIDATE, CREATE INDEX CONCURRENTLY; người chọn
Ứng dụng chạy được với cả schema cũ và schema mới trong lúc đổi?KhôngNgười quyết: thêm cấu trúc mới trước, chuyển ứng dụng dần, xóa cấu trúc cũ ở bước riêng
Có bản sao lưu dùng được và cách quay lại nếu migration hỏng?Một phầnKiểm bản sao lưu khôi phục được là việc của người; rollback phải thử
Cửa sổ chạy, người chịu trách nhiệm, tiêu chí dừng?KhôngNgười quyết trước khi chạy, không ghi trong lúc xử lý sự cố

Các dòng “Một phần” và “Không” cho thấy giới hạn của máy: nó chỉ kiểm được thứ nó đo được. Lab không thể cho biết bản sao lưu của bạn có khôi phục được hay ứng dụng có chạy được với cả hai phiên bản schema; hai điều đó phải thử thật, trên môi trường tương đương, trước ngày chạy.

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

  • Số đo thuộc PostgreSQL 18.6 trên một máy macOS arm64 với bảng 100.000 và 300.000 hàng. Bài chứng minh cơ chế (mức khóa, hàng đợi, ghi lại bảng), không đo độ lớn: thời gian ghi lại bảng phụ thuộc kích thước bảng và index, nên phải thử trên bản sao có kích thước gần thật trước khi tin một con số.
  • Chưa đo: MySQL và DDL trực tuyến của nó, replica và độ trễ nhân bản khi DDL chạy, bảng phân vùng, DROP COLUMN và đổi tên cột (tương thích với ứng dụng), lỗi giữa chừng của CREATE INDEX CONCURRENTLY (chỉ có lời tài liệu), SET NOT NULL với ràng buộc kiểm đã có, và hệ quả của việc các dạng ALTER TABLE ghi lại bảng không an toàn với MVCC (tài liệu nói giao dịch dùng snapshot cũ có thể thấy bảng rỗng sau khi bảng được ghi lại).
  • Thời gian chờ trong lab (3 đến 6 giây giữ khóa, 200 đến 500 ms lock_timeout) được chọn để chạy ổn định trên máy thử; trên máy rất chậm có thể cần nới.
  • Lỗi thường gặp: đặt lock_timeout toàn cục thay vì trong phiên migration; retry mù mọi lỗi; chạy nhiều DDL trong một giao dịch rồi giữ khóa của tất cả tới cuối; thêm index thường lên bảng đang ghi nhiều; kiểm migration trên bảng nhỏ không có giao dịch dài rồi chạy production có giao dịch báo cáo kéo dài.

Dọn

. ./lab-local.sh
lab_clean
test ! -e lab.env

Học tiếp và nguồn

  • Khóa ngoại: kiểm tra ngầm, khóa hàng và index cột con.
  • Composite index: thứ tự cột và chi phí ghi khi thêm index.
  • Lab database dùng chung: dữ liệu, reset và dấu nhận diện của lab.
  • PostgreSQL 18, Explicit Locking: các mức khóa bảng, bảng xung đột và việc giữ khóa tới cuối giao dịch.
  • PostgreSQL 18, ALTER TABLE: mức khóa từng dạng, ghi lại bảng, NOT VALID và VALIDATE CONSTRAINT.
  • PostgreSQL 18, CREATE INDEX: CONCURRENTLY, hai lần quét, chờ giao dịch cũ và index invalid.
  • PostgreSQL 18, Client Connection Defaults: lock_timeout.
  • PostgreSQL 18, Error Codes: 55P03 (lock_not_available), 42601 (syntax_error), 23503 (foreign_key_violation).

Nguồn chính thức đọc ngày 2026-10-04; số đo trong bài thuộc PostgreSQL 18.6 trên macOS arm64, không có nghiệm thu Docker/Linux hay engine khác.

Scale database: chứng minh nút thắt trước khi thêm thành phần

Câu hỏi: query, connection, replica, partition hay shard giải quyết đúng phần nào đang nghẽn?

Cần biết trước: EXPLAIN, composite index, replication lag và quan sát database. Bài là khung ra quyết định cho PostgreSQL 18; phần pooling đối chiếu tài liệu PgBouncer hiện tại ngày 2026-10-03 (website hiển thị 1.26.0). Chưa chạy benchmark PgBouncer, sharding hoặc failover. Ba profile dưới là tình huống giả định, không phải số đo production.

Đặt ngân sách trước giải pháp

Ghi request cần phục vụ, p95/p99 và deadline, tỷ lệ lỗi chấp nhận, ngân sách đọc stale, cửa sổ đo, workload và mức tăng tải. Phân rã latency thành chờ pool, thực thi/lock DB, và xử lý ứng dụng/mạng; không cộng percentile riêng thành p99 tổng. Đo trong cùng cửa sổ: throughput hoàn tất, queue depth/age, số connection active/idle, wait event, CPU/I/O, plan/buffers và WAL/replica. Khi metric mất, sửa đường quan sát trước; up=0 không chứng minh DB đang chậm hay khỏe.

Không dùng CCU để chọn số connection hoặc shard. Người đang online có thể không gửi request; một request có thể gọi nhiều SQL hoặc giữ transaction lâu. Ví dụ giả định, hệ thống ổn định hoàn tất 100 DB transaction/s, mỗi transaction giữ connection trung bình 0,02s: occupancy trung bình khoảng 2 connection. Đây chỉ là phép tính trung bình với cùng boundary và steady state, không phải cấu hình pool=2; burst, tail, transaction chờ khóa và deadline cần phép thử riêng. Queue không ổn định thì phép suy này không đủ.

Ba profile và phép thử tiếp theo

Profile giả địnhTín hiệu cần xác nhậnƯu tiên thửDấu hiệu đã chọn sai
Đọc nhiềuMột nhóm query đọc chiếm thời gian/buffers, latency DB cao dù pool ít chờSửa query/index và lượng dữ liệu trả; sau đó route phần đọc chấp nhận stale sang replicaQuery vẫn quét quá nhiều, hoặc replica replay không theo kịp; thêm node chỉ nhân công việc
Ghi nhiềuWAL/I/O/lock hoặc index maintenance chiếm chi phí, không chỉ tổng R/W ratioGiữ transaction ngắn, cùng thứ tự khóa, batch đúng atomicity; đo index/durability trước partition/shardCùng hot key vẫn tranh khóa, hoặc commit chậm ở primary; replica không chia tải ghi này
Quá nhiều connectionNhiều connection idle/churn, pool wait và số backend/CPU tăng cùng burstBound/reuse pool, admission/deadline; kiểm session contract trước chọn pool modeSQL/lock vẫn chậm, active backend vẫn nghẽn; tăng max connection chỉ tăng cạnh tranh

Một hệ thống có thể gặp cả ba. Ưu tiên phần đang làm vỡ ngân sách của request quan trọng; không có thứ tự bắt buộc “query→pool→replica” cho mọi hệ thống. Đổi một nhóm biến, giữ workload và durability/consistency cần thiết, chạy lại và đối chiếu lỗi lẫn latency.

Pooling giữ connection, không sửa SQL

Pool ứng dụng tái dùng connection và giới hạn số request vào DB. Pool ở ngoài như PgBouncer thêm một hàng đợi và điểm vận hành riêng; đo thời gian chờ ở từng pool để tránh che queue này bằng queue khác. max_client_conn là client; default_pool_size là số server connection tối đa cho mỗi cặp user/database, còn giới hạn DB/user khác cũng ảnh hưởng tổng backend. Không đặt một số nhỏ cho một pool rồi coi đó là trần mọi connection. Pool configuration.

Session mode giữ server connection đến khi client ngắt; transaction mode trả về sau transaction. Statement mode cấm transaction nhiều statement, nên không dùng cho BEGIN→ghi request ID→hai UPDATE→COMMIT của lab deadlock. Session state, LISTEN, temp table và prepared statement cần đối chiếu driver/protocol/config; hỗ trợ protocol-level prepared plan không đồng nghĩa SQL PREPARE dùng được ở transaction mode. Không dựa vào một generic “pool reset” để chứng minh tenant state được xóa. Feature map.

Nếu CPU thấp mà request chờ row lock, thêm backend không nhả khóa. Nếu app giữ connection trong lúc gọi API chậm, trước tiên giảm phạm vi giữ transaction/connection. Giới hạn queue và deadline theo caller; overload có kết quả quan sát được thay vì cho mọi request treo vô hạn.

Replica và partition có ranh giới khác nhau

Read replica có thể nhận những query đọc mà hợp đồng cho phép dữ liệu cũ. Đọc-sau-ghi cần route primary hoặc barrier/timeout đã kiểm; synchronous commit cũng phụ thuộc ack mode và replica được chọn, không bảo đảm mọi SELECT trên mọi replica luôn mới. Replica thêm chi phí apply WAL, vận hành và failover; không tự là backup/restore. Standby behavior.

Partition chia một bảng theo key/range và có thể pruning khi query phù hợp; không tự thêm máy hoặc chia tải ghi primary. Nó hữu ích cho pruning/retention/lifecycle khi predicate và partition key khớp. Query không pruning, quá nhiều partition hoặc hot partition vẫn có thể chậm. Constraint unique/PRIMARY của bảng partitioned PostgreSQL phải bao gồm mọi cột partition key; không bỏ constraint toàn cục cho tiện thiết kế. Đánh giá lock và vận hành attach/detach theo version. Partitioning limitations.

Giá phải trả và phép kiểm bác bỏ

Lựa chọnVận hành thêmConsistency/failure cần giữBằng chứng khiến dừng hoặc đổi hướng
Query/indexStatistics, index build/maintenance và theo dõi planKết quả query/constraint không đổi; index thêm chi phí ghiPlan/buffers không giảm ở workload mục tiêu, write budget xấu đi
Bounded poolQueue, timeout, reset/session và tổng backendKhông trả connection đang transaction lỗi; tenant state đúngQueue age vượt deadline dù DB chưa bận: kiểm hold time/lỗi lease
ReplicaRouting, WAL retention/apply, health, failover rehearsalStale read/read-after-write/RPO theo hợp đồngApply tụt xa hoặc query yêu cầu dữ liệu mới; route đó trở lại primary
PartitionKey/range, retention, pruning, DDL/constraintUnique/FK/query semantics được giữQuery không pruning hoặc hot partition vẫn chiếm tải
ShardingRouter/key, rebalancing, backup/restore nhiều shard, migrationCross-shard transaction/JOIN, hotspot, partial failureHot key tập trung một shard hoặc business transaction phải đi qua nhiều shard

Chỉ xét sharding khi đã chứng minh giới hạn của một node phù hợp workload, và có key phân bố được công việc/dữ liệu. Không chỉ đợi hết ổ đĩa, cũng không chia shard vì bảng “nhiều hàng”. Liệt kê các query/transaction bắt buộc cross-shard, kế hoạch đổi key và cách thử recovery trước khi chốt. Tách OLTP/analytics cũng cần freshness và quyền truy cập ở nơi nhận, không chỉ thêm pipeline.

Dùng bằng chứng có sẵn đúng phạm vi

  • Bulk import đo cùng CSV/constraint/transaction trên local PG18.6: đây là cách kiểm batch/COPY, không dự báo write capacity production hay 10M hàng.
  • Replication lab chứng minh pause/replay barrier và stale read, chưa chứng nhận RPO hoặc failover tự động.
  • Observability lab đối chiếu metric/query và phát hiện exporter mất; chưa đo overhead production hoặc chọn ngưỡng SLO.
  • VACUUM giữ cơ chế snapshot và reclaim; không tắt autovacuum như bước scale mặc định. Deadlock giữ order/retry semantics.

Record quyết định gồm vấn đề/ngân sách, baseline, phương án nhỏ nhất sẽ thử, điều kiện bác bỏ, kết quả cùng môi trường và rủi ro chưa kiểm. Chỉ thêm thành phần khi evidence cho thấy nó phục vụ một yêu cầu cụ thể; lưu lý do bằng ADR.

Hash table: va chạm, load factor và vì sao O(1) chỉ là kỳ vọng

Câu hỏi bài này trả lời: vì sao tra cứu bằng hash table được gọi là O(1), nhưng một bảng quá đầy hoặc một hàm băm tệ lại chậm theo kiểu O(n), và load factor làm số bước dò (probe) đổi bao nhiêu?

Cần biết trước: Python cơ bản và ký hiệu big-O. Lab dùng thư viện chuẩn của Python 3.14.4 trên macOS arm64, không cần database hay mạng. Phần quan sát dict chỉ đúng cho bản Python này; bài không suy ra phiên bản hay ngôn ngữ khác.

Hàm băm, mảng và hai cách xử lý va chạm

Hash table đổi khóa bất kỳ thành chỉ số mảng: chỉ số = băm(khóa) mod m, với m là số ô (bucket). Số khóa có thể có lớn hơn m rất nhiều, nên có hai khóa rơi vào cùng một ô là điều không tránh được (nguyên lý chuồng bồ câu). Hai cách xử lý phổ biến:

  • Chaining (nối chuỗi): mỗi ô giữ một danh sách các khóa rơi vào ô đó. Tìm kiếm đi tới ô rồi so sánh lần lượt trong danh sách.
  • Open addressing (địa chỉ mở), ở đây là linear probing: mọi khóa nằm ngay trong mảng. Khóa đụng ô đã có người thì dò sang ô kế tiếp cho tới ô trống; tìm kiếm đi theo đúng đường đó và dừng ở ô chứa khóa, hoặc ở ô trống đầu tiên khi khóa không có.

Bài đo “probe” với nghĩa riêng cho từng cách: với chaining là số khóa phải so sánh; với linear probing là số ô phải xem, tính cả ô chứa khóa hoặc ô trống chặn đường dò. Tạo thư mục trống rồi lưu hai cách cài tối thiểu (chưa có xóa và chưa nới bảng):

import hashlib
from collections.abc import Callable


def spread(key: int) -> int:
    """Hàm băm gần như ngẫu nhiên: 64 bit đầu của BLAKE2b."""
    digest = hashlib.blake2b(key.to_bytes(8, "little"), digest_size=8).digest()
    return int.from_bytes(digest, "little")


def remainder_only(key: int) -> int:
    """Hàm băm tệ: giữ nguyên khóa, bảng sẽ lấy phần dư khi chia."""
    return key


class Chaining:
    """Mỗi bucket là một danh sách; probe là số khóa phải so sánh."""

    def __init__(self, size: int, hash_fn: Callable[[int], int] = spread) -> None:
        self.size = size
        self.hash_fn = hash_fn
        self.buckets: list[list[int]] = [[] for _ in range(size)]

    def insert(self, key: int) -> bool:
        """Trả về True nếu bucket đã có khóa khác (va chạm)."""
        bucket = self.buckets[self.hash_fn(key) % self.size]
        collided = bool(bucket)
        bucket.append(key)
        return collided

    def search(self, key: int) -> tuple[bool, int]:
        bucket = self.buckets[self.hash_fn(key) % self.size]
        probes = 0
        for stored in bucket:
            probes += 1
            if stored == key:
                return True, probes
        return False, probes


class LinearProbing:
    """Một mảng ô; probe là số ô phải xem, gồm ô chứa khóa hoặc ô trống chặn đường dò."""

    def __init__(self, size: int, hash_fn: Callable[[int], int] = spread) -> None:
        self.size = size
        self.hash_fn = hash_fn
        self.slots: list[int | None] = [None] * size
        self.count = 0

    def insert(self, key: int) -> bool:
        """Trả về True nếu ô đầu tiên đã bị chiếm (va chạm)."""
        if self.count == self.size:
            raise ValueError("bảng đã đầy")
        index = self.hash_fn(key) % self.size
        collided = self.slots[index] is not None
        while self.slots[index] is not None:
            index = (index + 1) % self.size
        self.slots[index] = key
        self.count += 1
        return collided

    def search(self, key: int) -> tuple[bool, int]:
        index = self.hash_fn(key) % self.size
        probes = 1
        while self.slots[index] is not None:
            if self.slots[index] == key:
                return True, probes
            index = (index + 1) % self.size
            probes += 1
        return False, probes
python3 --version

Va chạm đến rất sớm

Nếu băm rải đều, xác suất n khóa đầu đều rơi vào ô khác nhau xấp xỉ exp(−n²/2m). Khóa đầu tiên đụng ô đã có người vì thế thường xuất hiện sau khoảng √(2·ln 2·m) khóa, tức 1,18·√m (nghịch lý ngày sinh). Với m = 16.384 đó là khoảng 151 khóa, khi load factor mới chưa tới 1%. Lab lặp 2.000 lần: mỗi lần cho khóa ngẫu nhiên vào bảng 16.384 ô cho tới khi có va chạm, rồi so trung vị với công thức:

import math
import random
import statistics

from hashtable import spread

SIZE = 1 << 14
TRIALS = 2000


def first_collision(rng: random.Random) -> int:
    seen: set[int] = set()
    while True:
        bucket = spread(rng.getrandbits(48)) % SIZE
        if bucket in seen:
            return len(seen) + 1
        seen.add(bucket)


rng = random.Random(17)
counts = [first_collision(rng) for _ in range(TRIALS)]
median = statistics.median(counts)
formula = math.sqrt(2 * math.log(2) * SIZE)
print(f"bảng {SIZE} bucket, {TRIALS} lần thử")
print(f"khóa thứ {median:.0f} (trung vị) là khóa đầu tiên đụng bucket đã có người")
print(f"công thức sqrt(2 ln 2 · m) = {formula:.1f}; load factor lúc đó {median / SIZE:.4f}")
assert abs(median - formula) / formula < 0.1
print("va chạm đầu tiên khớp công thức")
python3 -B birthday.py
bảng 16384 bucket, 2000 lần thử
khóa thứ 154 (trung vị) là khóa đầu tiên đụng bucket đã có người
công thức sqrt(2 ln 2 · m) = 150.7; load factor lúc đó 0.0094
va chạm đầu tiên khớp công thức

Va chạm không báo hiệu bảng đang quá đầy; nó là trạng thái bình thường, nên mọi hash table phải có cách xử lý nó ngay từ đầu. Thứ cần điều khiển là mỗi va chạm tốn bao nhiêu bước dò, và đó là việc của load factor.

Load factor quyết định số probe

Load factor α = n/m là số khóa chia số ô. Với băm đều và độc lập, số probe kỳ vọng theo α như sau (chaining chịu được α lớn hơn 1, linear probing buộc α < 1):

CáchTìm thấy (probe kỳ vọng)Không thấy (probe kỳ vọng)
Chaining1 + α/2 − α/(2m)α
Linear probing½·(1 + 1/(1−α))½·(1 + 1/(1−α)²)

Đây là các công thức kinh điển của giáo trình, trong đó phần linear probing là phân tích của Knuth (The Art of Computer Programming, tập 3, mục 6.4). Bài không có bản sách để dẫn trang nên không dựa vào việc đã đọc sách mà kiểm bằng đo: lab dựng bảng 65.536 ô, nạp tới α = 0,25; 0,5; 0,75; 0,9, lấy trung bình trên 3 bảng với khóa khác nhau, và dùng 20.000 khóa chưa có để đo trường hợp “không thấy”. Mỗi cặp số in dạng đo/công thức; lab dừng nếu có số nào lệch quá 10%. Cột va chạm là tỉ lệ khóa chèn vào bucket đã có khóa trong chaining, công thức xấp xỉ 1 − (1 − e^(−α))/α.

import random
import statistics

from hashtable import Chaining, LinearProbing

SIZE = 1 << 16
FRESH = 20_000
LOADS = (0.25, 0.5, 0.75, 0.9)
SEEDS = (17, 18, 19)


def draw(rng: random.Random, count: int, avoid: set[int]) -> list[int]:
    keys: list[int] = []
    seen = set(avoid)
    while len(keys) < count:
        key = rng.getrandbits(48)
        if key not in seen:
            seen.add(key)
            keys.append(key)
    return keys


def run(table_class, load: float, seed: int) -> tuple[float, float, float]:
    rng = random.Random(seed)
    count = int(SIZE * load)
    keys = draw(rng, count, set())
    fresh = draw(rng, FRESH, set(keys))
    table = table_class(SIZE)
    collisions = sum(table.insert(key) for key in keys)
    found = [table.search(key) for key in keys]
    missing = [table.search(key) for key in fresh]
    assert all(ok for ok, _ in found) and not any(ok for ok, _ in missing)
    return (
        collisions / count,
        statistics.fmean(p for _, p in found),
        statistics.fmean(p for _, p in missing),
    )


def average(table_class, load: float) -> tuple[float, ...]:
    rows = [run(table_class, load, seed) for seed in SEEDS]
    return tuple(statistics.fmean(column) for column in zip(*rows))


def close(measured: float, formula: float) -> bool:
    return abs(measured - formula) / formula < 0.1


for load in LOADS:
    count = int(SIZE * load)
    occupied = SIZE * (1 - (1 - 1 / SIZE) ** count)
    c_collide, c_ok, c_fail = average(Chaining, load)
    _, l_ok, l_fail = average(LinearProbing, load)
    f_collide = 1 - occupied / count
    f_c_ok = 1 + load / 2 - load / (2 * SIZE)
    f_c_fail = load
    f_l_ok = 0.5 * (1 + 1 / (1 - load))
    f_l_fail = 0.5 * (1 + 1 / (1 - load) ** 2)
    print(
        f"load {load:.2f} | va chạm {c_collide:.4f}/{f_collide:.4f}"
        f" | chaining thấy {c_ok:.3f}/{f_c_ok:.3f} vắng {c_fail:.3f}/{f_c_fail:.3f}"
        f" | linear thấy {l_ok:.3f}/{f_l_ok:.3f} vắng {l_fail:.3f}/{f_l_fail:.3f}"
    )
    assert close(c_collide, f_collide) and close(c_ok, f_c_ok) and close(c_fail, f_c_fail)
    assert close(l_ok, f_l_ok) and close(l_fail, f_l_fail)
print("đo khớp công thức, sai số dưới 10%")
python3 -B probes.py
load 0.25 | va chạm 0.1149/0.1152 | chaining thấy 1.125/1.125 vắng 0.250/0.250 | linear thấy 1.164/1.167 vắng 1.386/1.389
load 0.50 | va chạm 0.2134/0.2131 | chaining thấy 1.252/1.250 vắng 0.500/0.500 | linear thấy 1.502/1.500 vắng 2.508/2.500
load 0.75 | va chạm 0.2968/0.2965 | chaining thấy 1.375/1.375 vắng 0.755/0.750 | linear thấy 2.505/2.500 vắng 8.487/8.500
load 0.90 | va chạm 0.3418/0.3406 | chaining thấy 1.452/1.450 vắng 0.904/0.900 | linear thấy 5.521/5.500 vắng 51.871/50.500

Số đo bám công thức sát hơn nhiều so với ngưỡng 10% mà lab đòi. Ba điều đọc ra từ bảng:

  • Chaining tăng chậm, linear probing tăng vọt. Từ α = 0,5 lên 0,9, số so sánh của chaining khi khóa vắng đi từ 0,5 lên 0,9; số ô linear probing phải xem đi từ 2,5 lên 51,9, gấp khoảng 20 lần. Giáo trình giải thích bằng hiện tượng cụm: các ô bị chiếm dính thành đoạn liền, đoạn dài dễ bị trúng hơn và còn dài thêm khi có khóa mới rơi vào (bài không đo độ dài cụm).
  • Va chạm có sớm và nhiều. Ngay ở α = 0,5, hơn một phần năm khóa chèn vào bucket đã có người (đo ở chaining). Chọn α thấp không tránh được va chạm, chỉ giữ cho chuỗi dò ngắn.
  • “O(1)” cần α bị chặn. Số probe là hằng số khi α nhỏ hơn một hằng số cố định, nên bảng thực tế cấp mảng lớn hơn và băm lại toàn bộ khóa khi α chạm ngưỡng. Mỗi lần nới tốn O(n) nhưng hiếm; nếu số ô tăng theo cấp số nhân thì chi phí trung bình trên mỗi lần chèn vẫn là hằng số (lập luận chuẩn của phân tích amortized, bài không đo chi phí nới).

Ngưỡng do người thiết kế chọn. Ghi chú trong mã nguồn CPython cho tải tối đa của dict viết rằng các tỉ lệ quanh 1/2 đến 2/3 có vẻ chạy tốt trong thực tế, và dict dùng 2/3 (xem phần quan sát dict bên dưới).

Công thức chỉ đúng khi băm đều

Mọi con số trên giả định hàm băm rải khóa đều và độc lập. Hàm băm tệ phá giả định đó mà không cần bảng đầy. Lab nạp 2.000 khóa là bội số của kích thước bảng (16.384 ô, α ≈ 0,12) hai lần: một lần qua hàm băm gần ngẫu nhiên (BLAKE2b), một lần qua hàm chỉ lấy dư (khóa giữ nguyên, bảng lấy khóa mod m). Đây là mô hình của khóa có bước nhảy trùng kích thước bảng, như địa chỉ căn lề hoặc mã tăng theo bước cố định. Dưới hàm chỉ lấy dư, mọi khóa rơi vào ô 0.

import statistics

from hashtable import Chaining, LinearProbing, remainder_only, spread

SIZE = 1 << 14
COUNT = 2000
FRESH = 1000

keys = [i * SIZE for i in range(COUNT)]
fresh = [(COUNT + i) * SIZE for i in range(FRESH)]
results = {}
for name, table_class in (("chaining", Chaining), ("linear", LinearProbing)):
    for label, hash_fn in (("BLAKE2b", spread), ("lấy dư", remainder_only)):
        table = table_class(SIZE, hash_fn)
        for key in keys:
            table.insert(key)
        found = statistics.fmean(table.search(key)[1] for key in keys)
        missing = statistics.fmean(table.search(key)[1] for key in fresh)
        results[name, label] = (found, missing)
        print(f"{name:8} {label:8} tìm thấy {found:8.2f} không thấy {missing:8.2f}")

for name in ("chaining", "linear"):
    assert results[name, "lấy dư"][0] == (COUNT + 1) / 2
assert results["chaining", "lấy dư"][1] == COUNT
assert results["linear", "lấy dư"][1] == COUNT + 1
assert all(results[n, "lấy dư"][0] > 100 * results[n, "BLAKE2b"][0] for n in ("chaining", "linear"))
print("hàm băm tệ làm probe tăng theo số khóa")
python3 -B badhash.py
chaining BLAKE2b  tìm thấy     1.06 không thấy     0.13
chaining lấy dư   tìm thấy  1000.50 không thấy  2000.00
linear   BLAKE2b  tìm thấy     1.07 không thấy     1.15
linear   lấy dư   tìm thấy  1000.50 không thấy  2001.00

Các dòng lấy dư trùng đúng với tính tay: mọi khóa nằm trong cùng một chuỗi (hoặc một đoạn ô liền nhau bắt đầu từ ô 0) và khóa thứ k cần k probe, nên tìm thấy trung bình (n+1)/2 = 1000,5; khóa vắng phải đi hết n khóa (chaining) hoặc n+1 ô kể cả ô trống (linear probing). Cùng bảng, cùng khóa, chỉ đổi hàm băm: cỡ 1 probe so với cỡ 1.000 probe. Vì vậy “O(1)” của hash table là kỳ vọng với một hàm băm và một phân phối khóa cho trước. Một thao tác vẫn có thể tốn O(n), và nạp n khóa có thể tốn O(n²).

Quan sát dict thật

CPython dùng open addressing nhưng chuỗi dò không phải linear probing (nguồn mô tả công thức j = (5·j) + 1 + perturb, với perturb dịch dần theo PERTURB_SHIFT để các bit cao của giá trị băm tham gia), nên công thức linear probing ở trên không áp thẳng cho dict; chúng giải thích vì sao người thiết kế giữ α thấp. Bốn quan sát sau chạy trên dict thật của Python 3.14.4:

  1. Thứ tự duyệt là thứ tự chèn. Hướng dẫn Python nói list(d) trả các khóa theo thứ tự chèn, và ghi chú phát hành 3.7 nói tính chất này là một phần chính thức của đặc tả ngôn ngữ. Vì vậy không thể đọc ra vị trí ô hay giá trị băm từ thứ tự duyệt.
  2. Mốc nới. Lab đếm các lần sys.getsizeof(d) đổi khi chèn khóa số nguyên rồi so với mô phỏng chính sách từ hai hằng số trong dictobject.c: tải dùng được của bảng n ô là USABLE_FRACTION(n) = (n << 1)/3, và khi đầy, bảng mới có số ô là lũy thừa của 2 nhỏ nhất không nhỏ hơn GROWTH_RATE = số mục × 3 (bảng nhỏ nhất có 8 ô).
  3. Băm chuỗi có salt, băm số thì không. Tài liệu dòng lệnh nói nếu không đặt PYTHONHASHSEED (hoặc đặt random) thì giá trị băm của str và bytes được seed ngẫu nhiên; đặt một số nguyên thì seed cố định; đặt 0 tắt randomization. Số nguyên thì băm bằng phép lấy dư theo số nguyên tố sys.hash_info.modulus, nên dự đoán được; lab còn dựng 10.000 số nguyên khác nhau có cùng giá trị băm để xem dict thật chịu ra sao.
  4. Cùng hash thì mỗi thao tác tốn O(n). Lab đếm số lần gọi __eq__ khi mọi khóa cùng giá trị băm và khi mỗi khóa một giá trị.
import os
import subprocess
import sys
import time

ORDER = [5, 3, 9, 1, 7, 2, 8, 4]


def insertion_order() -> None:
    table: dict[int, None] = {}
    for key in ORDER:
        table[key] = None
    assert list(table) == ORDER
    print("thứ tự duyệt", list(table), "= thứ tự chèn")


def observed_resizes(limit: int) -> list[int]:
    table: dict[int, None] = {}
    last = sys.getsizeof(table)
    marks = []
    for key in range(limit):
        table[key] = None
        size = sys.getsizeof(table)
        if size != last:
            marks.append(len(table))
            last = size
    return marks


def predicted_resizes(limit: int) -> list[int]:
    marks = [1]
    size = 8
    used = 1
    while used < limit:
        if used == size * 2 // 3:
            size = 1 << (3 * used - 1).bit_length()
            marks.append(used + 1)
        used += 1
    return marks


def resize_policy() -> None:
    seen = observed_resizes(700)
    expected = predicted_resizes(700)
    print("mốc nới quan sát được", seen)
    print("mốc theo hằng số trong nguồn", expected)
    assert seen == expected
    print("mốc nới khớp chính sách 2/3 và gấp 3 số mục")


def child_hash(seed: str | None) -> str:
    env = {k: v for k, v in os.environ.items() if k != "PYTHONHASHSEED"}
    if seed is not None:
        env["PYTHONHASHSEED"] = seed
    result = subprocess.run(
        [sys.executable, "-c", "print(hash('hash-demo'))"],
        env=env,
        capture_output=True,
        text=True,
        check=True,
    )
    return result.stdout.strip()


def hash_seed() -> None:
    free = [child_hash(None) for _ in range(3)]
    fixed = [child_hash("0") for _ in range(2)]
    assert len(set(free)) == 3, free
    assert fixed[0] == fixed[1]
    print("hash(str) ở 3 tiến trình không đặt seed: 3 giá trị khác nhau")
    print("hash(str) ở 2 tiến trình PYTHONHASHSEED=0: cùng một giá trị")
    assert hash(12345) == 12345
    assert hash(12345 + sys.hash_info.modulus) == 12345
    print("hash(12345) =", hash(12345), "; thuật toán băm chuỗi:", sys.hash_info.algorithm)


def build_seconds(keys: list[int]) -> float:
    start = time.perf_counter()
    table: dict[int, None] = {}
    for key in keys:
        table[key] = None
    return time.perf_counter() - start


def integer_collisions() -> None:
    modulus = sys.hash_info.modulus
    count = 10_000
    plain = list(range(1, count + 1))
    crafted = [i * modulus for i in range(1, count + 1)]
    assert len(set(crafted)) == count
    assert {hash(key) for key in crafted} == {0}
    fast = min(build_seconds(plain) for _ in range(3))
    slow = build_seconds(crafted)
    print(f"{count} bội số khác nhau của P đều có hash {hash(modulus)}")
    print(f"dựng dict từ chúng chậm hơn {slow / fast:.0f} lần so với {count} số liên tiếp")
    assert slow > 50 * fast
    print("số nguyên cùng hash dựng được mà không cần biết bí mật nào")


class Key:
    __slots__ = ("value", "bucket")
    comparisons = 0

    def __init__(self, value: int, bucket: int) -> None:
        self.value = value
        self.bucket = bucket

    def __hash__(self) -> int:
        return self.bucket

    def __eq__(self, other: object) -> bool:
        Key.comparisons += 1
        return isinstance(other, Key) and self.value == other.value


def equality_calls(count: int, same_hash: bool) -> tuple[int, dict[Key, None]]:
    Key.comparisons = 0
    table: dict[Key, None] = {}
    for value in range(count):
        table[Key(value, 7 if same_hash else value)] = None
    return Key.comparisons, table


def worst_case() -> None:
    table: dict[Key, None] = {}
    for count in (500, 1000, 2000):
        distinct_calls, _ = equality_calls(count, same_hash=False)
        same_calls, table = equality_calls(count, same_hash=True)
        assert distinct_calls == 0 and same_calls == count * (count - 1) // 2
        print(f"n={count}: hash khác nhau {distinct_calls} lần __eq__, cùng hash {same_calls} lần")
    Key.comparisons = 0
    assert Key(-5, 7) not in table
    assert Key.comparisons == 2000
    print("tra một khóa vắng khi cả 2000 khóa cùng hash:", Key.comparisons, "lần __eq__")


insertion_order()
resize_policy()
hash_seed()
integer_collisions()
worst_case()
print("dict thật khớp mô hình")
python3 -B realdict.py
thứ tự duyệt [5, 3, 9, 1, 7, 2, 8, 4] = thứ tự chèn
mốc nới quan sát được [1, 6, 11, 22, 43, 86, 171, 342, 683]
mốc theo hằng số trong nguồn [1, 6, 11, 22, 43, 86, 171, 342, 683]
mốc nới khớp chính sách 2/3 và gấp 3 số mục
hash(str) ở 3 tiến trình không đặt seed: 3 giá trị khác nhau
hash(str) ở 2 tiến trình PYTHONHASHSEED=0: cùng một giá trị
hash(12345) = 12345 ; thuật toán băm chuỗi: siphash13
10000 bội số khác nhau của P đều có hash 0
số nguyên cùng hash dựng được mà không cần biết bí mật nào
n=500: hash khác nhau 0 lần __eq__, cùng hash 124750 lần
n=1000: hash khác nhau 0 lần __eq__, cùng hash 499500 lần
n=2000: hash khác nhau 0 lần __eq__, cùng hash 1999000 lần
tra một khóa vắng khi cả 2000 khóa cùng hash: 2000 lần __eq__
dict thật khớp mô hình

Đọc kết quả:

  • Mốc nới trùng khít. Các lần getsizeof đổi ở khóa thứ 1, 6, 11, 22, 43, 86, 171, 342, 683 đúng bằng mô phỏng: bảng đầu có 8 ô và dùng được 5; khi chèn khóa thứ 6 bảng nới lên 16 ô (dùng được 10), rồi 32, 64… mỗi lần gấp đôi. Mốc được đo gián tiếp qua sys.getsizeof, hàm này chỉ tính bộ nhớ trực tiếp của chính đối tượng; chính sách nới là chi tiết triển khai của bản v3.14.4, không phải hợp đồng của ngôn ngữ.
  • Số nguyên không được salt, và cùng hash dựng được mà không cần bí mật. hash(12345) bằng chính 12345, và hash(12345 + P) cũng vậy với P = sys.hash_info.modulus: hai số nguyên khác nhau có cùng giá trị băm, và người ngoài tự dựng được chúng. Lab lấy 10.000 bội số khác nhau của P (hash đều bằng 0) và thấy dựng dict từ chúng chậm hơn cỡ vài nghìn lần so với 10.000 số liên tiếp (3.354 lần ở một lần chạy; lab chỉ đòi trên 50 lần vì thời gian dao động theo máy). Tài liệu chỉ nói randomization áp cho str và bytes. Mục đích của randomization theo tài liệu là chống tấn công từ chối dịch vụ dùng đầu vào chọn trước để đẩy việc dựng dict về trường hợp xấu nhất O(n²), đúng hiện tượng mà phép đo này và phép đo __eq__ ngay dưới cho thấy.
  • __eq__ chỉ được gọi khi hash trùng. Với hash khác nhau, dict không gọi __eq__ lần nào trong lúc chèn. Với hash trùng, khóa thứ k phải so với k−1 khóa trước: tổng đúng n(n−1)/2 lần (124.750, 499.500 rồi 1.999.000: gấp đôi n thì công việc gấp bốn), và tra một khóa vắng tốn n lần. Hệ quả cho __hash__ tự viết: tài liệu chỉ đòi hai đối tượng bằng nhau phải có cùng hash, nên trả về hằng số vẫn đúng nhưng biến dict thành danh sách chậm; nên trộn các thành phần tham gia phép so sánh, ví dụ băm tuple của chúng như tài liệu gợi ý.

Chọn gì trong từng tình huống

Tình huốngQuyết địnhCăn cứ trong bài
Tra cứu theo khóa, không cần thứ tự khóaDùng dict hoặc map của ngôn ngữα bị chặn nên probe kỳ vọng là hằng số
Khóa do người ngoài hệ thống gửi vàoGiới hạn số khóa và kích thước đầu vào mỗi lần; giữ randomization bật cho str/bytes; đừng coi khóa số nguyên là ngẫu nhiên (bài không đo biện pháp giảm nhẹ nào)Seed 0 tắt randomization; bội số của P cùng hash, dict chậm cỡ vài nghìn lần
Tự viết __hash__ cho kiểu của mìnhBăm tuple các trường tham gia __eq__; kiểm số lần __eq__ khi nạp dữ liệu mẫuHash trùng đẩy mỗi thao tác về O(n)
Cần duyệt theo thứ tự khóa hoặc truy vấn theo dảiDùng cấu trúc có thứ tự (cây cân bằng, chỉ mục B-tree) thay vì hash tabledict giữ thứ tự chèn, không sắp theo khóa
Tự cài bảng băm (để học hoặc để nhúng)Nới bảng khi α chạm ngưỡng; với open addressing đừng để α tiến gần 1Linear probing: vắng tốn khoảng 52 ô ở α = 0,9
Nghi một hàm băm rải không đều trên dữ liệu thậtĐo độ dài chuỗi dò hoặc số lần so sánh trên chính dữ liệu đó, không suy từ công thứcCông thức chỉ đúng với băm đều; hàm lấy dư cho cỡ 1.000 so với 1

Giới hạn

  • Số đo thuộc Python 3.14.4 trên macOS arm64. Quan sát về dict (mốc nới, siphash13, không gọi __eq__ khi hash khác) là chi tiết triển khai của bản này; bài không kiểm bản Python khác, PyPy hay ngôn ngữ khác như Java, Go, Rust.
  • Đơn vị đo là số probe và số lần gọi __eq__, không phải thời gian. Bài không đo cache, kích thước khóa, chi phí hàm băm hay tốc độ thật của chaining so với open addressing; cách đọc số đo xem Đọc benchmark.
  • Bài không cài xóa (cần tombstone trong open addressing), quadratic probing, double hashing, Robin Hood hay cuckoo hashing, và không đo chi phí của một lần nới bảng, chỉ đo mốc nới của dict.
  • Công thức linear probing không áp trực tiếp cho dict vì chuỗi dò của dict khác; bài dùng chúng để giải thích tác dụng của α, không để dự đoán tốc độ của dict.
  • Hai thái cực được đo là hàm băm gần ngẫu nhiên và hàm chỉ lấy dư trên khóa có cấu trúc đặc biệt. Dữ liệu thật nằm giữa hai thái cực đó, và bài không đo hàm băm nào ngoài BLAKE2b, hash() của Python.
  • Lab không dựng server hay tệp ngoài thư mục bạn đã tạo; xóa thư mục đó là dọn xong.

Học tiếp và nguồn

  • Đọc benchmark: số đo và ngoại suy: cách đọc các con số probe và thời gian mà không suy quá phép đo.
  • Python 3.14, Data model: object.__hash__: yêu cầu duy nhất của giá trị băm và việc hash() cắt giá trị trả về.
  • Python 3.14, Dòng lệnh và biến môi trường: PYTHONHASHSEED, hash randomization và mục đích chống từ chối dịch vụ.
  • Python 3.14, Hướng dẫn: cấu trúc dữ liệu: list(d) theo thứ tự chèn và khóa phải là kiểu bất biến.
  • Python 3.14, Các kiểu chuẩn: băm của kiểu số theo phép lấy dư.
  • Python 3.14, sys: sys.hash_info và sys.getsizeof.
  • Python, What’s New in 3.7: thứ tự chèn của dict trở thành một phần của đặc tả ngôn ngữ.
  • CPython v3.14.4, Objects/dictobject.c: USABLE_FRACTION, GROWTH_RATE, PERTURB_SHIFT và công thức chuỗi dò.
  • Knuth, The Art of Computer Programming, tập 3, mục 6.4 (sách, không có liên kết): phân tích hashing mà bảng công thức ở trên dựa vào.

Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc Python 3.14.4 trên macOS arm64, không có nghiệm thu Linux hay ngôn ngữ khác.

Bloom filter và skip list: ngẫu nhiên có chủ đích, sai số đo được

Câu hỏi bài này trả lời: Bloom filter báo nhầm “có” để đổi lấy bộ nhớ nhỏ, skip list tung đồng xu thay cho phép xoay cây; sai số của hai thứ đó dự đoán và đo được đến đâu, và khi nào không nên dùng chúng?

Cần biết trước: hash table (hàm băm và va chạm) và ký hiệu big-O. Lab dùng thư viện chuẩn của Python 3.14.4 trên macOS arm64, không cần database hay mạng. Số đo không nói gì về tốc độ thật của Redis, RocksDB hay thư viện nào khác.

Hai cách dùng ngẫu nhiên

Cả hai cấu trúc dùng ngẫu nhiên, nhưng cái giá khác nhau. Bloom filter chạy với thời gian cố định và đôi khi trả lời sai, chỉ theo một chiều: có thể báo nhầm “có”, không bao giờ báo nhầm “không”. Skip list luôn trả lời đúng; thứ ngẫu nhiên là thời gian chạy, có kỳ vọng O(log n) nhưng từng lần tìm có thể chậm hơn.

Bài đọc hai nguồn: bài báo của Pugh về skip list (CACM 1990, bản công khai trên trang một môn học, đọc đủ cả bài) và bản khảo sát của Broder và Mitzenmacher về Bloom filter (Internet Mathematics, 2004, đọc phần toán học). Bài báo gốc của Bloom (1970) không có bản công khai đọc được lúc viết nên bài không dựa vào nó.

Bloom filter

Cơ chế và công thức

Bloom filter là một mảng m bit, ban đầu toàn 0, cùng k hàm băm có giá trị trong 0..m−1. Thêm một khóa: đặt k bit mà k hàm băm chỉ tới. Hỏi một khóa: nếu có bit nào bằng 0 thì khóa chắc chắn chưa được thêm; nếu cả k bit đều bằng 1 thì khóa có thể có, hoặc đó là dương tính giả do các khóa khác đã đặt đúng những bit này. Bit chỉ được đặt chứ không bị xóa, nên khóa đã thêm luôn báo “có”: không có âm tính giả.

Đại lượngCông thứcGhi chú
Dương tính giả sau khi thêm n khóaf = (1 − e^(−kn/m))^kGiả định k hàm băm độc lập và rải đều
k làm f nhỏ nhấtk = ln 2 · (m/n)Khi đó mỗi bit bằng 1 với xác suất 1/2 và f = (1/2)^k ≈ 0,6185^(m/n)
Số bit mỗi khóa để đạt f ≤ εm/n ≥ log₂(1/ε) / ln 2 ≈ 1,44·log₂(1/ε)Khảo sát nêu cận dưới n·log₂(1/ε) bit cho mọi cách biểu diễn có sai số tối đa ε; Bloom filter nằm trong 1,44 lần cận đó

Khảo sát cũng nêu ví dụ m = 8n: xác suất dương tính giả chỉ hơn 0,02. Lab dưới đây dựng lại các con số đó.

Lab: dương tính giả theo k và theo mục tiêu

Tạo thư mục trống rồi lưu cách cài tối thiểu. Mỗi bit chiếm một byte cho dễ đọc (đóng gói bit sẽ nhỏ hơn 8 lần); k vị trí lấy từ k lần băm BLAKE2b riêng của (khóa, chỉ số), gần với giả định “hàm băm độc lập” của công thức.

import hashlib
from collections.abc import Iterator


def positions(key: int, k: int, m: int) -> Iterator[int]:
    """k vị trí bit; mỗi vị trí lấy từ một lần băm BLAKE2b riêng của (khóa, chỉ số)."""
    for i in range(k):
        digest = hashlib.blake2b(
            key.to_bytes(8, "little") + bytes([i]), digest_size=8
        ).digest()
        yield int.from_bytes(digest, "little") % m


class Bloom:
    def __init__(self, m: int, k: int) -> None:
        self.m = m
        self.k = k
        self.bits = bytearray(m)

    def add(self, key: int) -> None:
        for position in positions(key, self.k, self.m):
            self.bits[position] = 1

    def __contains__(self, key: int) -> bool:
        return all(self.bits[position] for position in positions(key, self.k, self.m))

Lab dùng n = 10.000 khóa và 200.000 khóa chưa có để đếm dương tính giả. Mỗi cấu hình dựng 3 filter với khóa khác nhau rồi lấy trung bình. Lab dừng nếu có khóa đã thêm bị báo “không có”, nếu số đo lệch công thức từ 10% trở lên, hoặc nếu k cho dương tính giả thấp nhất không rơi vào 5 hoặc 6 (công thức cho k tối ưu là 5,55 khi m = 8n). Phần hai chọn kích thước từ mục tiêu: m/n = −ln ε / (ln 2)² bit mỗi khóa và k = round(−log₂ ε).

import math
import random

from bloom import Bloom

N = 10_000
FRESH = 200_000
SEEDS = (1, 2, 3)


def draw(seed: int, count: int, avoid: set[int]) -> list[int]:
    rng = random.Random(seed)
    seen = set(avoid)
    keys: list[int] = []
    while len(keys) < count:
        key = rng.getrandbits(48)
        if key not in seen:
            seen.add(key)
            keys.append(key)
    return keys


def measure(m: int, k: int) -> float:
    rates = []
    for seed in SEEDS:
        members = draw(seed, N, set())
        fresh = draw(seed + 100, FRESH, set(members))
        bloom = Bloom(m, k)
        for key in members:
            bloom.add(key)
        assert all(key in bloom for key in members), "có âm tính giả"
        rates.append(sum(1 for key in fresh if key in bloom) / FRESH)
    return sum(rates) / len(rates)


def formula(m: int, n: int, k: int) -> float:
    return (1 - math.exp(-k * n / m)) ** k


def close(measured: float, expected: float) -> bool:
    return abs(measured - expected) / expected < 0.1


m = 8 * N
measured_by_k = {}
for k in range(1, 11):
    measured = measure(m, k)
    expected = formula(m, N, k)
    measured_by_k[k] = measured
    print(f"m/n=8 k={k:2}: đo {measured:.3%} công thức {expected:.3%}")
    assert close(measured, expected)
best = min(measured_by_k, key=measured_by_k.__getitem__)
print(f"k tối ưu theo công thức ln2·m/n = {math.log(2) * m / N:.2f}; đo thấp nhất ở k={best}")
assert best in (5, 6)
for target in (0.1, 0.01, 0.001):
    bits = -math.log(target) / math.log(2) ** 2
    size = math.ceil(N * bits)
    k = round(-math.log2(target))
    measured = measure(size, k)
    expected = formula(size, N, k)
    print(f"mục tiêu {target:.1%}: {bits:.2f} bit/khóa, k={k}: đo {measured:.3%} công thức {expected:.3%}")
    assert close(measured, expected) and close(measured, target)
print("không có âm tính giả; tỉ lệ dương tính giả đo khớp công thức, sai số dưới 10%")
python3 -B bloom_fp.py
m/n=8 k= 1: đo 11.801% công thức 11.750%
m/n=8 k= 2: đo 4.921% công thức 4.893%
m/n=8 k= 3: đo 3.081% công thức 3.058%
m/n=8 k= 4: đo 2.410% công thức 2.397%
m/n=8 k= 5: đo 2.178% công thức 2.168%
m/n=8 k= 6: đo 2.165% công thức 2.158%
m/n=8 k= 7: đo 2.263% công thức 2.293%
m/n=8 k= 8: đo 2.517% công thức 2.549%
m/n=8 k= 9: đo 2.904% công thức 2.922%
m/n=8 k=10: đo 3.367% công thức 3.419%
k tối ưu theo công thức ln2·m/n = 5.55; đo thấp nhất ở k=6
mục tiêu 10.0%: 4.79 bit/khóa, k=3: đo 10.058% công thức 10.071%
mục tiêu 1.0%: 9.59 bit/khóa, k=7: đo 1.020% công thức 1.004%
mục tiêu 0.1%: 14.38 bit/khóa, k=10: đo 0.102% công thức 0.100%
không có âm tính giả; tỉ lệ dương tính giả đo khớp công thức, sai số dưới 10%

Đọc kết quả:

  • k có điểm tối ưu. Với 8 bit mỗi khóa, k = 1 cho 11,8% dương tính giả; tăng k làm giảm tới k = 5 hoặc 6 (khoảng 2,2%, đúng ví dụ “hơn 0,02” của khảo sát), rồi dương tính giả tăng lại vì thêm hàm băm là thêm bit 1 và filter đầy dần.
  • Công thức đủ để chọn kích thước. Số đo bám công thức trong khoảng 2% ở mọi dòng. Muốn dương tính giả 1% thì cần khoảng 9,6 bit mỗi khóa và 7 hàm băm, muốn 0,1% thì 14,4 bit và 10 hàm băm; con số này phụ thuộc m/n, không phụ thuộc kích thước của khóa. Với khóa 8 byte (64 bit), 9,6 bit mỗi khóa nhỏ hơn khoảng 6,7 lần chỉ riêng phần dữ liệu khóa thô, chưa tính chi phí của một hash table.
  • Không có âm tính giả ở mọi cấu hình. Lab kiểm từng khóa đã thêm trên cả 39 filter đã dựng (13 cấu hình, mỗi cấu hình 3 filter).

Lab: vì sao không xóa được bằng cách xóa bit

Khảo sát nêu rõ: xóa một khóa bằng cách đặt lại các bit của nó về 0 có thể xóa luôn bit mà khóa khác dùng chung, khiến filter không còn phản ánh đúng tập khóa. Lab tìm hai khóa dùng chung bit trong filter 64 bit với k = 3, “xóa” khóa thứ nhất rồi hỏi khóa thứ hai, vẫn là thành viên:

from bloom import Bloom, positions

M, K = 64, 3


def shared_pair() -> tuple[int, int]:
    seen: dict[int, set[int]] = {}
    for key in range(1, 10_000):
        bits = set(positions(key, K, M))
        for other, other_bits in seen.items():
            if bits & other_bits:
                return other, key
        seen[key] = bits
    raise RuntimeError("không tìm thấy cặp dùng chung bit")


first, second = shared_pair()
bloom = Bloom(M, K)
bloom.add(first)
bloom.add(second)
assert first in bloom and second in bloom
shared = sorted(set(positions(first, K, M)) & set(positions(second, K, M)))
for position in positions(first, K, M):
    bloom.bits[position] = 0
print(f"hai khóa dùng chung bit {shared}")
print("sau khi xóa bit của khóa thứ nhất, khóa thứ hai (vẫn là thành viên) báo", "có" if second in bloom else "không có")
assert second not in bloom
print("xóa bit tạo ra âm tính giả")
python3 -B bloom_delete.py
hai khóa dùng chung bit [41]
sau khi xóa bit của khóa thứ nhất, khóa thứ hai (vẫn là thành viên) báo không có
xóa bit tạo ra âm tính giả

Bloom filter chuẩn vì vậy chỉ thêm, không xóa. Khảo sát mô tả biến thể counting Bloom filter: mỗi ô là một bộ đếm nhỏ (khoảng 4 bit đủ cho phần lớn ứng dụng theo phân tích mà khảo sát dẫn), thêm thì tăng, xóa thì giảm. Bài không cài và không đo biến thể này.

Skip list

Cơ chế và công thức

Skip list là danh sách liên kết có thứ tự, trong đó mỗi nút có một số con trỏ tiến (gọi là tầng) chọn ngẫu nhiên: mọi nút có tầng 1, rồi với xác suất p nút lên thêm tầng 2, và tiếp tục với xác suất p cho mỗi tầng kế (hàm random_level trong bài báo). Tìm kiếm xuất phát từ tầng cao nhất: đi tiếp trên tầng đó khi khóa của nút kế còn nhỏ hơn khóa cần tìm, nếu không thì hạ xuống tầng dưới; xuống tới tầng 1 thì nút kế là chỗ khóa cần tìm nằm, nếu nó có. Không có phép xoay hay cân bằng: chèn chỉ nối lại vài con trỏ. Bài báo ghi giả định của phân tích: người dùng không biết mức của các nút, vì nếu biết thì có thể xóa mọi nút không ở tầng 1 để ép trường hợp xấu nhất.

Đại lượngCông thức theo bài báop = 1/2p = 1/4
Tỉ lệ nút có từ tầng i trở lênp^(i−1)50%, 25%, 12,5%25%, 6,25%, 1,56%
Con trỏ trung bình mỗi nút1/(1−p)21,33
Cận trên số so sánh trung bìnhL(n)/p + 1/(1−p) + 1, L(n) = log_(1/p) n2·log₂n + 32·log₂n + 2,33

Bài báo khuyên dùng p = 1/4 trừ khi độ biến thiên của thời gian chạy là mối quan tâm chính, khi đó dùng p = 1/2: cùng chi phí tìm kiếm cỡ 2·log₂n, nhưng ít con trỏ hơn mỗi nút và dao động nhiều hơn. Bài báo còn tính cận xác suất cho trường hợp chậm bất thường (ví dụ với p = 1/2 và 4.096 phần tử, xác suất một lần tìm tốn hơn ba lần kỳ vọng nhỏ hơn một phần 200 triệu); lab bên dưới không đo đuôi cỡ đó.

Lab: số tầng, con trỏ và số so sánh

Lab cài đúng vòng lặp trong bài báo: NIL là nút có khóa lớn hơn mọi khóa, mỗi phép thử khóa nút kế < khóa cần tìm tính một so sánh, cộng một so sánh bằng ở cuối.

import random
from collections.abc import Iterator

MAX_LEVEL = 32


class Node:
    __slots__ = ("key", "forward")

    def __init__(self, key: float, level: int) -> None:
        self.key = key
        self.forward: list[Node | None] = [None] * level


class SkipList:
    def __init__(self, p: float, rng: random.Random) -> None:
        self.p = p
        self.rng = rng
        self.nil = Node(float("inf"), 0)
        self.header = Node(float("-inf"), MAX_LEVEL)
        self.header.forward = [self.nil] * MAX_LEVEL
        self.level = 1

    def random_level(self) -> int:
        level = 1
        while self.rng.random() < self.p and level < MAX_LEVEL:
            level += 1
        return level

    def insert(self, key: int) -> None:
        update: list[Node] = [self.header] * MAX_LEVEL
        node = self.header
        for i in range(self.level - 1, -1, -1):
            while node.forward[i].key < key:  # type: ignore[union-attr]
                node = node.forward[i]  # type: ignore[assignment]
            update[i] = node
        level = self.random_level()
        self.level = max(self.level, level)
        new = Node(key, level)
        for i in range(level):
            new.forward[i] = update[i].forward[i]
            update[i].forward[i] = new

    def search(self, key: int) -> tuple[bool, int]:
        """Trả về (có không, số lần so sánh khóa)."""
        comparisons = 0
        node = self.header
        for i in range(self.level - 1, -1, -1):
            while True:
                comparisons += 1
                if node.forward[i].key < key:  # type: ignore[union-attr]
                    node = node.forward[i]  # type: ignore[assignment]
                else:
                    break
        comparisons += 1
        return node.forward[0].key == key, comparisons  # type: ignore[union-attr]

    def levels(self) -> Iterator[int]:
        node = self.header.forward[0]
        while node is not self.nil:
            yield len(node.forward)  # type: ignore[union-attr]
            node = node.forward[0]  # type: ignore[union-attr]

Lab dựng 10 skip list cho mỗi cặp (p, n) với n = 256, 2.048 và 16.384, mỗi danh sách có khóa và mức khác nhau, rồi tìm 5.000 khóa có sẵn trên mỗi danh sách. Lab dừng nếu số so sánh trung bình nằm ngoài khoảng 80% đến 100% của cận trên của bài báo, nếu con trỏ mỗi nút lệch 1/(1−p) từ 3% trở lên, nếu tỉ lệ nút theo tầng lệch p^(i−1) từ 5% trở lên ở n lớn nhất, nếu độ tăng số so sánh mỗi lần gấp đôi n ngoài khoảng 1,5 đến 2,5, hoặc nếu p = 1/4 không ít con trỏ hơn và dao động nhiều hơn p = 1/2.

import math
import random
import statistics

from skiplist import SkipList

SIZES = (1 << 8, 1 << 11, 1 << 14)
LISTS = 10
SEARCHES = 5_000


def build(p: float, n: int, seed: int) -> tuple[SkipList, list[int], random.Random]:
    rng = random.Random(seed)
    keys = rng.sample(range(10 * n), n)
    skip = SkipList(p, rng)
    for key in keys:
        skip.insert(key)
    return skip, keys, rng


def study(p: float, n: int) -> dict[str, float]:
    counts: list[int] = []
    list_means: list[float] = []
    pointers: list[float] = []
    tops: list[int] = []
    at_least = [0, 0, 0]
    for seed in range(LISTS):
        skip, keys, rng = build(p, n, seed)
        levels = list(skip.levels())
        assert len(levels) == n
        results = [skip.search(rng.choice(keys)) for _ in range(SEARCHES)]
        assert all(found for found, _ in results)
        found_counts = [c for _, c in results]
        counts += found_counts
        list_means.append(statistics.fmean(found_counts))
        pointers.append(statistics.fmean(levels))
        tops.append(skip.level)
        for index, tier in enumerate((2, 3, 4)):
            at_least[index] += sum(1 for level in levels if level >= tier)
    return {
        "mean": statistics.fmean(counts),
        "stdev": statistics.pstdev(counts),
        "low": min(list_means),
        "high": max(list_means),
        "pointers": statistics.fmean(pointers),
        "top": statistics.fmean(tops),
        **{f"tier{t}": at_least[i] / (LISTS * n) for i, t in enumerate((2, 3, 4))},
    }


table: dict[tuple[float, int], dict[str, float]] = {}
for p in (0.5, 0.25):
    for n in SIZES:
        row = table[p, n] = study(p, n)
        depth = math.log(n, 1 / p)
        bound = depth / p + 1 / (1 - p) + 1
        print(
            f"p={p} n={n}: so sánh TB {row['mean']:.2f} (cận trên {bound:.2f},"
            f" mỗi danh sách {row['low']:.2f}-{row['high']:.2f}), độ lệch chuẩn {row['stdev']:.2f}"
            f" | con trỏ/nút {row['pointers']:.3f} ({1 / (1 - p):.3f})"
            f" | tầng cao nhất TB {row['top']:.1f} (L(n)+1/(1-p) = {depth + 1 / (1 - p):.1f})"
        )
        assert 0.8 * bound <= row["mean"] <= bound
        assert abs(row["pointers"] * (1 - p) - 1) < 0.03
        assert row["top"] <= depth + 1 / (1 - p) + 1
for p in (0.5, 0.25):
    row = table[p, SIZES[-1]]
    expected = [p ** (tier - 1) for tier in (2, 3, 4)]
    measured = [row[f"tier{tier}"] for tier in (2, 3, 4)]
    print(f"p={p} n={SIZES[-1]}: tỉ lệ nút từ tầng 2/3/4 trở lên " + " ".join(f"{a:.4f}/{b:.4f}" for a, b in zip(measured, expected)))
    assert all(abs(a - b) / b < 0.05 for a, b in zip(measured, expected))
    slope = (row["mean"] - table[p, SIZES[0]]["mean"]) / 6
    print(f"p={p}: thêm {slope:.2f} so sánh mỗi lần gấp đôi n")
    assert 1.5 <= slope <= 2.5
half, quarter = table[0.5, SIZES[-1]], table[0.25, SIZES[-1]]
print(
    f"n={SIZES[-1]}: p=1/2 {half['mean']:.2f} so sánh, lệch chuẩn {half['stdev']:.2f}, {half['pointers']:.2f} con trỏ/nút;"
    f" p=1/4 {quarter['mean']:.2f} so sánh, lệch chuẩn {quarter['stdev']:.2f}, {quarter['pointers']:.2f} con trỏ/nút"
)
assert abs(half["mean"] - quarter["mean"]) / half["mean"] < 0.15
assert quarter["stdev"] > half["stdev"] and quarter["pointers"] < 0.7 * half["pointers"]
print("số tầng, con trỏ và số so sánh khớp bài báo")
python3 -B skiplist_stats.py
p=0.5 n=256: so sánh TB 17.04 (cận trên 19.00, mỗi danh sách 15.00-22.50), độ lệch chuẩn 3.78 | con trỏ/nút 1.997 (2.000) | tầng cao nhất TB 9.3 (L(n)+1/(1-p) = 10.0)
p=0.5 n=2048: so sánh TB 23.34 (cận trên 25.00, mỗi danh sách 20.90-26.70), độ lệch chuẩn 4.48 | con trỏ/nút 2.007 (2.000) | tầng cao nhất TB 12.3 (L(n)+1/(1-p) = 13.0)
p=0.5 n=16384: so sánh TB 28.59 (cận trên 31.00, mỗi danh sách 26.70-31.03), độ lệch chuẩn 4.85 | con trỏ/nút 1.999 (2.000) | tầng cao nhất TB 14.4 (L(n)+1/(1-p) = 16.0)
p=0.25 n=256: so sánh TB 15.59 (cận trên 18.33, mỗi danh sách 13.75-18.43), độ lệch chuẩn 5.25 | con trỏ/nút 1.319 (1.333) | tầng cao nhất TB 5.1 (L(n)+1/(1-p) = 5.3)
p=0.25 n=2048: so sánh TB 21.45 (cận trên 24.33, mỗi danh sách 19.35-23.40), độ lệch chuẩn 6.41 | con trỏ/nút 1.330 (1.333) | tầng cao nhất TB 6.8 (L(n)+1/(1-p) = 6.8)
p=0.25 n=16384: so sánh TB 26.78 (cận trên 30.33, mỗi danh sách 25.46-28.42), độ lệch chuẩn 7.67 | con trỏ/nút 1.331 (1.333) | tầng cao nhất TB 8.0 (L(n)+1/(1-p) = 8.3)
p=0.5 n=16384: tỉ lệ nút từ tầng 2/3/4 trở lên 0.5002/0.5000 0.2480/0.2500 0.1248/0.1250
p=0.5: thêm 1.92 so sánh mỗi lần gấp đôi n
p=0.25 n=16384: tỉ lệ nút từ tầng 2/3/4 trở lên 0.2486/0.2500 0.0614/0.0625 0.0156/0.0156
p=0.25: thêm 1.87 so sánh mỗi lần gấp đôi n
n=16384: p=1/2 28.59 so sánh, lệch chuẩn 4.85, 2.00 con trỏ/nút; p=1/4 26.78 so sánh, lệch chuẩn 7.67, 1.33 con trỏ/nút
số tầng, con trỏ và số so sánh khớp bài báo

Đọc kết quả:

  • Tầng và con trỏ khớp công thức. Ở n = 16.384, tỉ lệ nút theo tầng bám p^(i−1) trong 1% (ví dụ 0,2480 so với 0,2500 ở p = 1/2, tầng 3), và số con trỏ mỗi nút là 1,999 và 1,331 so với 2 và 1,33.
  • Số so sánh dưới cận và tăng đều theo log n. Trung bình 28,6 (p = 1/2) và 26,8 (p = 1/4) ở n = 16.384, dưới cận trên 31,0 và 30,3 của bài báo; mỗi lần gấp đôi n thêm khoảng 1,9 so sánh, đúng hệ số 2 của L(n)/p. Với log₂ 16.384 = 14, tìm kiếm nhị phân trên mảng đã sắp cần cỡ 14 so sánh, nên skip list tốn cỡ gấp đôi số so sánh, đổi lại chèn không phải dời phần tử; bài báo cũng ghi skip list so sánh nhiều hơn các cấu trúc khác.
  • Kỳ vọng không phải bảo đảm. Mỗi danh sách có hình dạng riêng: trung bình từng danh sách ở n = 16.384 và p = 1/2 dao động từ 26,7 đến 31,0 so sánh. Một danh sách cụ thể có thể chậm hơn kỳ vọng, và độ lệch chuẩn (gộp 10 danh sách) cho thấy từng lần tìm còn dao động hơn nữa.
  • Chọn p là chọn giữa bộ nhớ và độ ổn định. p = 1/4 cho cùng cỡ chi phí (26,8 so với 28,6 so sánh) với 1,33 con trỏ mỗi nút thay vì 2, nhưng độ lệch chuẩn của số so sánh lớn hơn (7,7 so với 4,9). Điều này khớp lời khuyên của bài báo: dùng p = 1/4 trừ khi độ biến thiên của thời gian là mối quan tâm chính.

Chọn gì trong từng tình huống

Tình huốngQuyết địnhCăn cứ trong bài
Cần biết “chắc chắn không có” hay “có thể có” trên tập lớnBloom filter; tính m và k từ dương tính giả chấp nhận đượcKhông có âm tính giả; f đo khớp công thức
Không được phép báo nhầm “có”Đừng dùng Bloom filter làm câu trả lời cuối; dùng làm bước lọc trước rồi tra nguồn thậtDương tính giả luôn khác 0
Cần xóa khóa khỏi tậpBiến thể có bộ đếm hoặc cấu trúc khác; không xóa bitLab xóa bit cho âm tính giả
Cần tập có thứ tự với cài đặt đơn giản, chấp nhận O(log n) kỳ vọngSkip listLuôn trả đúng; số so sánh đo ≈ 2·log₂n
Cần chặn trên cho từng thao tácCây cân bằng thay vì skip listSkip list chỉ có kỳ vọng và cận xác suất
Bộ nhớ trên mỗi phần tử quan trọng, độ biến thiên thời gian chấp nhận đượcSkip list với p = 1/41,33 so với 2 con trỏ mỗi nút, cùng cỡ số so sánh
Người ngoài có thể quan sát hoặc điều khiển mức của nútĐừng dùng bộ sinh số ngẫu nhiên đoán trước đượcGiả định trong bài báo của Pugh

Giới hạn

  • Số đo thuộc Python 3.14.4 trên macOS arm64. Đơn vị đo là số so sánh, số bit và dương tính giả, không phải thời gian; bài không đo tốc độ thật, cache hay bộ nhớ của cài đặt nào, và không nói gì về Redis, RocksDB hay thư viện khác.
  • Bloom filter trong lab dùng k lần băm BLAKE2b độc lập, sát giả định của công thức. Bộ băm nhanh hơn dùng trong thực tế (ví dụ tổ hợp từ hai giá trị băm) có thể lệch công thức; bài không đo.
  • Công thức dương tính giả là xấp xỉ; khảo sát nêu cả dạng chính xác hơn (1 − (1 − 1/m)^(kn))^k. Với m cỡ chục nghìn bit trở lên hai dạng khác nhau không đáng kể, và bài đo bằng filter thật nên không dựa vào việc chọn dạng nào.
  • Skip list trong lab chỉ có chèn và tìm; xóa, truy vấn khoảng và cập nhật đồng thời (bài báo có công trình riêng về cập nhật đồng thời) không được cài và không được đo. Bộ sinh số ngẫu nhiên của Python không dành cho dùng trong tình huống có kẻ tấn công.
  • Cận trên của bài báo là cận của kỳ vọng. Số so sánh đo thấp hơn cận từ khoảng 1,7 đến 3,6 so sánh; bài không đối chiếu với phân tích chính xác. Mỗi danh sách có hình dạng riêng nên trung bình từng danh sách dao động quanh kỳ vọng (cột “mỗi danh sách”); kỳ vọng không phải bảo đảm cho một danh sách cụ thể.
  • Không đo đuôi xác suất cỡ một phần triệu; bài chỉ trích điều bài báo tính.
  • Lab không dựng server hay tệp ngoài thư mục bạn đã tạo; xóa thư mục đó là dọn xong.

Học tiếp và nguồn

Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc Python 3.14.4 trên macOS arm64, không có nghiệm thu Linux hay ngôn ngữ khác.

LSM-tree so với B-tree: ghi tuần tự đổi lấy đọc nhiều bảng hơn

Câu hỏi bài này trả lời: LSM-tree ghi nhanh vì chỉ ghi tuần tự, vậy cái giá là gì: mỗi entry bị ghi lại bao nhiêu lần, mỗi lần tra cứu phải mở bao nhiêu bảng, và Bloom filter cùng compaction đổi hai con số đó ra sao khi đặt cạnh B-tree?

Cần biết trước: composite index (B-tree), Bloom filter và skip list và hash table. Lab là mô phỏng đếm bằng thư viện chuẩn của Python 3.14.4 trên macOS arm64, không ghi tệp thật và không đo thời gian: đây không phải benchmark của engine thật.

Ba thành phần và ba thước đo

LSM-tree (log-structured merge-tree) không sửa dữ liệu tại chỗ. Mỗi lần ghi đi vào memtable, một cấu trúc có thứ tự nằm trong RAM; wiki RocksDB ghi rằng cài đặt mặc định của memtable dựa trên skip list, và memtable đầy thì thành bất biến rồi một luồng nền ghi nó ra tệp SST. Mỗi lần xả như vậy tạo một bảng (SSTable, trong code gọi là run) đã sắp xếp và bất biến trên đĩa. Càng nhiều bảng thì mỗi lần tra cứu càng phải xem nhiều bảng, nên compaction gộp các bảng lại, giữ bản mới nhất của mỗi khóa và bỏ các bản bị ghi đè (tài liệu LevelDB còn nói compaction bỏ dấu xóa khi không tầng sâu hơn nào còn chứa khoảng khóa đó). Mỗi bảng có thể kèm một Bloom filter trong RAM để bỏ qua bảng chắc chắn không có khóa; RocksDB tạo filter cho từng tệp SST mới khi cấu hình chính sách filter.

Bài báo gốc (O’Neil, Cheng, Gawlick, O’Neil, Acta Informatica 1996) mô tả cùng ý dưới dạng nhiều thành phần C0 (trong bộ nhớ) và C1, C2… (trên đĩa, kích thước tăng dần) nối nhau bằng một tiến trình gộp cuốn chiếu (rolling merge). Bài nói thẳng về cái giá: tra cứu cần phản hồi ngay có thể mất hiệu quả I/O, nên LSM-tree hữu ích nhất khi số lần insert nhiều hơn số lần tra cứu, và với ba thành phần trở lên mỗi lần tra cứu thường tốn thêm khoảng một lần đọc trang cho mỗi thành phần trên đĩa.

Bài đo ba thứ, đều theo đơn vị đếm:

  • Write amplification (WA): số entry mà xả memtable và compaction ghi xuống bảng, chia cho số entry người dùng ghi.
  • Read amplification: số bảng phải đọc cho một lần tra cứu khóa. Đọc Bloom filter trong RAM không tính là đọc bảng.
  • Space amplification: số entry đang lưu chia cho số khóa còn sống.

Byte, thời gian, cache của hệ điều hành và nén nằm ngoài mô hình.

Lab: memtable, bảng bất biến và ba chính sách compaction

Tạo thư mục trống rồi lưu mô phỏng. ratio là hệ số kích thước giữa hai tầng liền nhau. Chính sách leveled giữ tối đa một bảng mỗi tầng: khi bảng của tầng i đạt memtable × ratio^(i+1) entry thì cả bảng được gộp xuống tầng i+1. Chính sách tiered giữ tới ratio bảng mỗi tầng và gộp chúng thành một bảng ở tầng sau khi đủ ratio. Chính sách none không bao giờ gộp. Mỗi bảng có Bloom filter 10 bit mỗi entry và k = 7 (theo bài trước, dương tính giả lý thuyết khoảng 0,82%); bộ trộn 64 bit nhanh thay cho BLAKE2b để dựng filter mỗi lần compaction, và lab đo dương tính giả qua số bảng đọc thừa nên không dựa vào giả định độc lập của công thức.

import bisect
from collections import defaultdict

MASK = (1 << 64) - 1


def mix(x: int) -> int:
    """Bộ trộn 64 bit (dạng splitmix64): đủ nhanh để dựng Bloom filter nhiều lần."""
    x = (x + 0x9E3779B97F4A7C15) & MASK
    x = ((x ^ (x >> 30)) * 0xBF58476D1CE4E5B9) & MASK
    x = ((x ^ (x >> 27)) * 0x94D049BB133111EB) & MASK
    return x ^ (x >> 31)


class Bloom:
    def __init__(self, count: int, bits_per_key: int = 10, k: int = 7) -> None:
        self.m = max(64, count * bits_per_key)
        self.k = k
        self.bits = bytearray(self.m)

    def positions(self, key: int) -> list[int]:
        h1 = mix(key)
        h2 = mix(h1) | 1
        return [(h1 + i * h2) % self.m for i in range(self.k)]

    def add(self, key: int) -> None:
        for position in self.positions(key):
            self.bits[position] = 1

    def __contains__(self, key: int) -> bool:
        return all(self.bits[position] for position in self.positions(key))


class Run:
    """SSTable: danh sách khóa đã sắp, bất biến sau khi tạo, kèm Bloom filter."""

    def __init__(self, items: list[tuple[int, int]]) -> None:
        self.keys = [key for key, _ in items]
        self.values = [value for _, value in items]
        self.bloom = Bloom(len(items))
        for key in self.keys:
            self.bloom.add(key)

    def __len__(self) -> int:
        return len(self.keys)

    def find(self, key: int) -> int | None:
        index = bisect.bisect_left(self.keys, key)
        if index < len(self.keys) and self.keys[index] == key:
            return self.values[index]
        return None


def merge(newest_first: list[Run]) -> list[tuple[int, int]]:
    """Gộp các run đã sắp; với khóa trùng giữ bản của run mới nhất."""
    merged: dict[int, int] = {}
    for run in reversed(newest_first):
        merged.update(zip(run.keys, run.values))
    return sorted(merged.items())


class LSM:
    """policy: none (không compact), tiered (gộp `ratio` run cùng tầng) hoặc leveled (mỗi tầng một run)."""

    def __init__(self, memtable_size: int, ratio: int, policy: str) -> None:
        assert policy in ("none", "tiered", "leveled")
        self.memtable: dict[int, int] = {}
        self.memtable_size = memtable_size
        self.ratio = ratio
        self.policy = policy
        self.levels: list[list[Run]] = []
        self.written: dict[int, int] = defaultdict(int)
        self.user_writes = 0

    def put(self, key: int, value: int) -> None:
        self.user_writes += 1
        self.memtable[key] = value
        if len(self.memtable) >= self.memtable_size:
            run = Run(sorted(self.memtable.items()))
            self.memtable = {}
            self.add_run(0, run)

    def add_run(self, level: int, run: Run) -> None:
        while len(self.levels) <= level:
            self.levels.append([])
        if self.policy == "leveled":
            existing = self.levels[level]
            if existing:
                run = Run(merge([run, *existing]))
            self.written[level] += len(run)
            capacity = self.memtable_size * self.ratio ** (level + 1)
            if len(run) >= capacity:
                self.levels[level] = []
                self.add_run(level + 1, run)
            else:
                self.levels[level] = [run]
            return
        self.written[level] += len(run)
        self.levels[level].append(run)
        if self.policy == "tiered" and len(self.levels[level]) >= self.ratio:
            runs = self.levels[level]
            self.levels[level] = []
            self.add_run(level + 1, Run(merge(list(reversed(runs)))))

    def get(self, key: int, use_bloom: bool) -> tuple[int | None, int]:
        """Trả về (giá trị, số run phải đọc); Bloom filter nằm trong RAM nên không tính là đọc."""
        if key in self.memtable:
            return self.memtable[key], 0
        reads = 0
        for level in self.levels:
            for run in reversed(level):
                if use_bloom and key not in run.bloom:
                    continue
                reads += 1
                value = run.find(key)
                if value is not None:
                    return value, reads
        return None, reads

    def runs(self) -> int:
        return sum(len(level) for level in self.levels)

    def stored(self) -> int:
        return len(self.memtable) + sum(len(run) for level in self.levels for run in level)

Compaction đổi chi phí ghi lấy chi phí đọc

Hai workload, memtable 128 entry. Workload chèn: 50.000 khóa ngẫu nhiên khác nhau vào store trống, rồi tra khóa có và khóa vắng (10.000 lần mỗi loại; riêng none dùng 1.000 lần vì có 390 bảng). Workload ghi đè: 50.000 lần ghi vào 10.000 khóa. Lab dừng nếu có lần tra nào trả sai giá trị, nếu số bảng đọc cho khóa vắng khi không dùng Bloom khác số bảng đang có, nếu số bảng đọc thừa khi có Bloom lệch 25% trở lên so với số bảng × 0,82% hoặc Bloom giảm đọc dưới 80 lần, nếu số lần ghi mỗi tầng lệch công thức từ 8% trở lên ở các tầng đã quay đủ ít nhất 3 vòng, nếu leveled r = 10 có WA không lớn hơn 10, nếu tiered r = 4 ghi không ít hơn leveled r = 4, hoặc nếu tiered r = 10 không tốn dung lượng hơn leveled r = 10 ở workload ghi đè.

import math
import random
import statistics

from lsm import LSM

MEMTABLE = 128
INSERTS = 50_000
KEYSPACE = 10_000
UPDATES = 50_000
FALSE_POSITIVE = (1 - math.exp(-7 / 10)) ** 7
CONFIGS = (("leveled", 4), ("leveled", 10), ("tiered", 4), ("tiered", 10), ("none", 0))


def probe(tree: LSM, sample: list[int], use_bloom: bool) -> float:
    return statistics.fmean(tree.get(key, use_bloom)[1] for key in sample)


def insert_only(policy: str, ratio: int) -> dict:
    rng = random.Random(7)
    keys = rng.sample(range(10_000_000), INSERTS)
    tree = LSM(MEMTABLE, ratio, policy)
    for seq, key in enumerate(keys):
        tree.put(key, seq)
    probes = 1_000 if policy == "none" else 10_000
    present = [rng.choice(keys) for _ in range(probes)]
    seen = set(keys)
    absent: list[int] = []
    while len(absent) < probes:
        key = rng.randrange(10_000_000)
        if key not in seen:
            absent.append(key)
    truth = dict(zip(keys, range(INSERTS)))
    assert all(tree.get(key, use)[0] == truth[key] for key in present[:300] for use in (False, True))
    assert all(tree.get(key, use)[0] is None for key in absent[:300] for use in (False, True))
    return {
        "runs": tree.runs(),
        "wa": sum(tree.written.values()) / tree.user_writes,
        "per_level": [tree.written[i] / tree.user_writes for i in range(len(tree.levels))],
        "present": (probe(tree, present, False), probe(tree, present, True)),
        "absent": (probe(tree, absent, False), probe(tree, absent, True)),
    }


def overwrite(policy: str, ratio: int) -> dict:
    rng = random.Random(11)
    tree = LSM(MEMTABLE, ratio, policy)
    truth: dict[int, int] = {}
    for seq in range(UPDATES):
        key = rng.randrange(KEYSPACE)
        tree.put(key, seq)
        truth[key] = seq
    assert all(tree.get(key, True)[0] == value for key, value in truth.items())
    return {
        "wa": sum(tree.written.values()) / tree.user_writes,
        "space": tree.stored() / len(truth),
    }


def run_all() -> dict[str, dict]:
    rows: dict[str, dict] = {}
    for policy, ratio in CONFIGS:
        name = f"{policy} r={ratio}" if ratio else policy
        rows[name] = {
            "policy": policy,
            "ratio": ratio,
            "insert": insert_only(policy, ratio),
            "overwrite": overwrite(policy, ratio),
        }
    return rows


def main() -> None:
    rows = run_all()
    for name, row in rows.items():
        first, second = row["insert"], row["overwrite"]
        print(
            f"{name:12}: {first['runs']:3} run | WA {first['wa']:5.2f}"
            f" | khóa có {first['present'][0]:6.2f} -> {first['present'][1]:5.2f} run"
            f" | khóa vắng {first['absent'][0]:6.2f} -> {first['absent'][1]:5.3f} run"
            f" | ghi đè: WA {second['wa']:5.2f}, không gian x{second['space']:.2f}"
        )
        assert first["absent"][0] == first["runs"]
        expected = first["runs"] * FALSE_POSITIVE
        assert abs(first["absent"][1] - expected) / expected < 0.25
        assert first["absent"][0] / first["absent"][1] > 80
    for name, row in rows.items():
        policy, ratio, per_level = row["policy"], row["ratio"], row["insert"]["per_level"]
        if policy == "none":
            assert abs(row["insert"]["wa"] - 1) < 0.01
            continue
        expected = (ratio + 1) / 2 if policy == "leveled" else 1.0
        full = [
            level
            for level in range(len(per_level))
            if INSERTS / (MEMTABLE * ratio ** (level + 1)) >= 3
        ]
        print(
            f"{name:12}: ghi mỗi tầng {' '.join(f'{w:.2f}' for w in per_level)}"
            f" (công thức {expected:.2f} cho tầng {', '.join(map(str, full))} đã quay đủ vòng)"
        )
        assert all(abs(per_level[level] - expected) / expected < 0.08 for level in full)
    assert rows["leveled r=10"]["insert"]["wa"] > 10
    assert rows["tiered r=4"]["insert"]["wa"] < rows["leveled r=4"]["insert"]["wa"]
    assert rows["tiered r=10"]["overwrite"]["space"] > rows["leveled r=10"]["overwrite"]["space"]
    print("mô phỏng khớp: đọc đúng giá trị, đếm bảng, công thức ghi mỗi tầng, tác dụng của Bloom filter")


if __name__ == "__main__":
    main()
python3 -B lsm_stats.py
leveled r=4 :   4 run | WA 10.20 | khóa có   3.63 ->  1.02 run | khóa vắng   4.00 -> 0.035 run | ghi đè: WA  7.69, không gian x1.81
leveled r=10:   2 run | WA 12.40 | khóa có   1.77 ->  1.00 run | khóa vắng   2.00 -> 0.016 run | ghi đè: WA 11.37, không gian x1.04
tiered r=4  :   6 run | WA  4.61 | khóa có   5.45 ->  1.04 run | khóa vắng   6.00 -> 0.052 run | ghi đè: WA  3.73, không gian x2.15
tiered r=10 :  12 run | WA  2.76 | khóa có   9.61 ->  1.07 run | khóa vắng  12.00 -> 0.103 run | ghi đè: WA  2.35, không gian x3.27
none        : 390 run | WA  1.00 | khóa có 203.03 ->  2.88 run | khóa vắng 390.00 -> 3.518 run | ghi đè: WA  0.99, không gian x5.00
leveled r=4 : ghi mỗi tầng 2.49 2.47 2.46 2.13 0.66 (công thức 2.50 cho tầng 0, 1, 2 đã quay đủ vòng)
leveled r=10: ghi mỗi tầng 5.49 5.38 1.54 (công thức 5.50 cho tầng 0, 1 đã quay đủ vòng)
tiered r=4  : ghi mỗi tầng 1.00 0.99 0.98 0.98 0.66 (công thức 1.00 cho tầng 0, 1, 2 đã quay đủ vòng)
tiered r=10 : ghi mỗi tầng 1.00 1.00 0.77 (công thức 1.00 cho tầng 0, 1 đã quay đủ vòng)
mô phỏng khớp: đọc đúng giá trị, đếm bảng, công thức ghi mỗi tầng, tác dụng của Bloom filter

Đọc kết quả. Mũi tên trong cột đọc là số bảng phải đọc khi không dùng Bloom filter rồi khi có:

  • Compaction đổi ghi lấy đọc. Không compact thì mỗi entry chỉ ghi một lần (WA 1,00) nhưng tra khóa vắng phải mở cả 390 bảng. Leveled r = 10 ghi mỗi entry 12,4 lần và chỉ còn 2 bảng. Tiered nằm giữa: WA 2,76 với 12 bảng, hoặc 4,61 với 6 bảng. Đây là điều bài báo gốc nói bằng ngôn ngữ chi phí I/O: gộp hoãn và theo lô làm insert rẻ, đổi lại mỗi tra cứu thêm việc.
  • Leveled ghi lại mỗi entry (r+1)/2 lần ở mỗi tầng. Số ghi mỗi tầng đo được là 2,49, 2,47, 2,46 ở r = 4 (công thức 2,5) và 5,49, 5,38 ở r = 10 (công thức 5,5). Lý do: một entry vào tầng i+1 ở lần gộp thứ j trong r lần của một chu kỳ, rồi bị ghi lại ở mọi lần gộp sau trong chu kỳ đó, trung bình 1 + (r−1)/2 lần. Tăng r làm ít tầng hơn nhưng mỗi tầng ghi lại nhiều hơn, nên WA tổng của leveled không giảm khi r tăng (10,2 ở r = 4, 12,4 ở r = 10). Bài báo gốc đếm r_i + 1 trang ghi cho mỗi trang chuyển xuống một thành phần trong gộp cuốn chiếu, và wiki RocksDB nhắc rằng WA của leveled compaction thường lớn hơn 10, cùng cỡ với 12,4 ở đây; ba cách gộp (cuốn chiếu, từng tệp, cả tầng) khác nhau nên hệ số cụ thể khác nhau, bài không đòi khớp.
  • Tiered ghi mỗi entry một lần mỗi tầng. WA bằng cỡ số tầng (số ghi mỗi tầng đều gần 1,00), đổi lại phải đọc nhiều bảng hơn và giữ nhiều bản cũ hơn: ở workload ghi đè, không gian là ×2,15 và ×3,27 so với ×1,81 và ×1,04 của leveled. Khớp với mô tả của RocksDB về universal compaction: nhắm WA thấp hơn và đổi bằng read amplification và space amplification.
  • Bloom filter cắt đọc khóa vắng ở mọi chính sách. Số bảng đọc thừa xấp xỉ số bảng × 0,82% (leveled r = 4: 4 bảng cho 0,035), tức giảm khoảng 110 đến 125 lần ở cả năm cấu hình. Tài liệu LevelDB cũng nói 10 bit mỗi khóa giảm số lần đọc đĩa không cần thiết cho Get() khoảng 100 lần. Với khóa có, còn khoảng 1,0 đến 1,07 bảng.
  • Bloom filter không thay compaction. Không compact mà có Bloom vẫn đọc 3,5 bảng cho khóa vắng và 2,88 bảng cho khóa có, so với 0,016 và 1,00 của leveled r = 10, và mỗi lần tra phải kiểm tra tới 390 filter trong RAM (bài không đo chi phí này). Bloom filter chỉ trả lời “khóa này có không”, nên không giúp quét theo dải (lập luận, không đo).
  • Compaction bỏ bản cũ. Workload ghi đè lưu 50.000 lần ghi cho 10.000 khóa: không compact giữ ×5,00 entry, còn compaction đưa về ×1,04 đến ×3,27 tùy chính sách. Số này phụ thuộc vào việc 10.000 khóa sống nằm ở tầng nào lúc kết thúc, nên chỉ có thứ tự giữa các chính sách là kết luận của bài.

B-tree theo mô hình đếm

Bài báo gốc đếm B-tree như sau: insert với khóa ngẫu nhiên đọc một trang lá rồi ghi lại nó, khoảng D_e + 1 trang ngẫu nhiên với D_e là số trang trung bình không nằm trong buffer lúc tìm; các trang lá ít khi được tham chiếu lại trước khi bị đẩy khỏi buffer nên các insert không gộp được vào cùng một lần ghi trang. LSM-tree thì gộp nhiều entry vào mỗi lần ghi (bài báo lấy ví dụ C0 bằng 1/25 C1 và 250 entry mỗi trang, cho khoảng 10 entry mỗi lần gộp). Lab dựng mô hình đếm cho tầng lá: 8.192 lá, mỗi lá 128 entry, khóa insert ngẫu nhiên đều, cache LRU giữ một phần f số lá, lá bẩn bị đẩy ra thì ghi xuống đĩa. Các nút trong, nhật ký ghi trước và việc tách lá không được tính.

import random
from collections import OrderedDict

ENTRIES_PER_PAGE = 128
LEAVES = 8_192
WARMUP = 4 * LEAVES
INSERTS = 100_000


def simulate(cached_fraction: float, seed: int = 5) -> tuple[float, float]:
    """Mô hình đếm: chỉ tầng lá, khóa ngẫu nhiên đều; lá bẩn bị đẩy khỏi cache thì ghi ra đĩa."""
    rng = random.Random(seed)
    capacity = int(LEAVES * cached_fraction)
    cache: OrderedDict[int, None] = OrderedDict()
    reads = writes = 0
    for step in range(WARMUP + INSERTS):
        counted = step >= WARMUP
        leaf = rng.randrange(LEAVES)
        if leaf in cache:
            cache.move_to_end(leaf)
            continue
        cache[leaf] = None
        reads += counted
        if len(cache) > capacity:
            cache.popitem(last=False)
            writes += counted
    return reads / INSERTS, writes / INSERTS


if __name__ == "__main__":
    for fraction in (0.01, 0.1, 0.5):
        reads, writes = simulate(fraction)
        print(
            f"cache {fraction:.0%} số lá: đọc {reads:.3f} ghi {writes:.3f} trang ngẫu nhiên mỗi insert"
            f" (mô hình 1-f = {1 - fraction:.3f}), {ENTRIES_PER_PAGE * writes:.1f} entry ghi ra mỗi entry chèn"
        )
        assert abs(reads - (1 - fraction)) < 0.02 and abs(writes - (1 - fraction)) < 0.02
python3 -B btree_model.py
cache 1% số lá: đọc 0.991 ghi 0.991 trang ngẫu nhiên mỗi insert (mô hình 1-f = 0.990), 126.8 entry ghi ra mỗi entry chèn
cache 10% số lá: đọc 0.903 ghi 0.903 trang ngẫu nhiên mỗi insert (mô hình 1-f = 0.900), 115.5 entry ghi ra mỗi entry chèn
cache 50% số lá: đọc 0.501 ghi 0.501 trang ngẫu nhiên mỗi insert (mô hình 1-f = 0.500), 64.1 entry ghi ra mỗi entry chèn

Đọc và ghi đều đúng 1 − f trang ngẫu nhiên mỗi insert (lab dừng nếu lệch từ 0,02 trở lên): mỗi lần trượt cache là một lần đọc lá, và lá vừa nạp đã bẩn nên khi bị đẩy ra là một lần ghi. Với trang 128 entry, đó là 64 đến 127 entry ghi ra mỗi entry chèn. Con số này phụ thuộc trực tiếp vào số entry mỗi trang và vào việc mỗi insert làm bẩn một trang riêng; B-tree thật có nhật ký ghi trước, checkpoint và cache lớn hơn nên không nên đọc nó như WA của một engine cụ thể.

Cùng một workload, cạnh nhau

Lab cuối chạy lại mô phỏng LSM (workload chèn) và mô hình B-tree với cache 10% số lá, rồi in chung. Cột “ghi ngẫu nhiên” là số trang ghi ngẫu nhiên mỗi insert: LSM bằng 0 theo cấu trúc vì xả memtable và compaction chỉ ghi bảng liền khối. Số trong ngoặc ở cột đọc là khi có Bloom filter.

from btree_model import ENTRIES_PER_PAGE, simulate
from lsm_stats import run_all

rows = run_all()
reads, writes = simulate(0.1)
print(f"{'cách':22} {'ghi: entry/insert':>18} {'ghi ngẫu nhiên':>15} {'đọc khóa vắng':>16} {'đọc khóa có':>14}")
print(
    f"{'B-tree, cache 10% lá':22} {ENTRIES_PER_PAGE * writes:18.1f} {writes:15.2f}"
    f" {1.0:16.2f} {1.0:14.2f}"
)
labels = (
    ("none", "LSM không compact"),
    ("tiered r=10", "LSM tiered r=10"),
    ("leveled r=10", "LSM leveled r=10"),
)
for name, label in labels:
    first = rows[name]["insert"]
    print(
        f"{label:22} {first['wa']:18.2f} {0:15.2f}"
        f" {first['absent'][0]:8.2f} ({first['absent'][1]:5.3f})"
        f" {first['present'][0]:7.2f} ({first['present'][1]:4.2f})"
    )
leveled = rows["leveled r=10"]["insert"]
assert ENTRIES_PER_PAGE * writes > 5 * leveled["wa"]
assert leveled["absent"][1] < 1.0 and abs(leveled["present"][1] - 1.0) < 0.1
assert rows["none"]["insert"]["absent"][1] > 100 * leveled["absent"][1]
print("LSM leveled có Bloom đọc cỡ B-tree nhưng ghi ít hơn nhiều; không compact thì đọc đắt")
python3 -B compare.py
cách                    ghi: entry/insert  ghi ngẫu nhiên    đọc khóa vắng    đọc khóa có
B-tree, cache 10% lá                115.5            0.90             1.00           1.00
LSM không compact                    1.00            0.00   390.00 (3.518)  203.03 (2.88)
LSM tiered r=10                      2.76            0.00    12.00 (0.103)    9.61 (1.07)
LSM leveled r=10                    12.40            0.00     2.00 (0.016)    1.77 (1.00)
LSM leveled có Bloom đọc cỡ B-tree nhưng ghi ít hơn nhiều; không compact thì đọc đắt

Trong mô hình đếm này, LSM leveled r = 10 có Bloom filter tra cứu điểm tốn cỡ B-tree (1,00 bảng khi có khóa, 0,016 khi vắng, so với một trang lá) trong khi ghi ra khoảng 12 entry mỗi entry chèn thay vì khoảng 115, và không có trang ghi ngẫu nhiên nào. Lợi ích đọc đó có điều kiện: compaction phải theo kịp để số bảng ở mức 2; Bloom filter chiếm 10 bit RAM mỗi entry; và bài không đo quét theo dải (mỗi bảng đều phải xem), độ trễ do compaction chạy nền, chi phí đọc của chính compaction, hay thời gian thật.

Chọn gì trong từng tình huống

Tình huốngQuyết địnhCăn cứ trong bài
Ghi liên tục, tra cứu ít hơn nhiềuLSM-treeKhông có trang ghi ngẫu nhiên; WA 1 đến 12 so với khoảng 115
Tra cứu điểm là chính, ghi ít hoặc vừaB-tree, hoặc LSM leveled có Bloom nếu chịu được compactionĐọc 1 trang; LSM leveled có Bloom đọc cỡ đó
Phải giảm chi phí ghi, chấp nhận đọc và dung lượngLSM tieredWA 2,76 đến 4,61 nhưng 6 đến 12 bảng và dung lượng ×2,15 đến ×3,27
Cần đọc ít bảng và dung lượng gọn, chịu ghi nhiềuLSM leveled2 đến 4 bảng, dung lượng ×1,04 đến ×1,81, WA 10 đến 12
Hay tra khóa không tồn tạiBloom filter trên từng bảngĐọc thừa giảm từ 4,00 xuống 0,035 bảng (leveled r = 4)
Quét theo dải hoặc cần độ trễ ổn địnhĐo riêng trên dữ liệu của bạnBài không đo; Bloom filter không giúp quét theo dải (lập luận)

Giới hạn

  • Mô phỏng đếm entry và lần đọc bảng; bài không đo byte, thời gian, I/O thật, cache của hệ điều hành, nén, nhật ký ghi trước, compaction song song hay độ trễ do compaction. Hằng số của đĩa và SSD nằm ngoài mô hình.
  • Chính sách leveled trong lab gộp cả tầng xuống tầng sau; tài liệu LevelDB và RocksDB gộp từng tệp chồng khoảng với tầng sau nên hệ số WA cụ thể khác, và bài không dùng lab để dự đoán WA của engine nào.
  • Bài không cài xóa với dấu xóa, quét theo dải, đọc nhất quán theo snapshot hay phục hồi sau sự cố; việc compaction bỏ dấu xóa chỉ nêu theo tài liệu LevelDB.
  • Mô hình B-tree chỉ có tầng lá, khóa insert ngẫu nhiên đều, trang 128 entry là tham số và quyết định trực tiếp con số ghi mỗi insert; không có tách lá, nút trong hay nhật ký ghi trước.
  • Bloom filter trong lab dùng bộ trộn nhanh thay cho BLAKE2b của bài trước, và đo dương tính giả qua số bảng đọc thừa thay vì suy từ công thức.
  • Số đo thuộc Python 3.14.4 trên macOS arm64. Bài không suy ra tốc độ hay hành vi của RocksDB, LevelDB, Cassandra hay engine nào khác.
  • Lab không ghi tệp ngoài thư mục bạn đã tạo; xóa thư mục đó là dọn xong.

Học tiếp và nguồn

Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc Python 3.14.4 trên macOS arm64, không có nghiệm thu Linux hay engine thật.

Gom code theo layer, module hay aggregate?

Câu hỏi bài này trả lời: một thay đổi nghiệp vụ đi qua những file nào, và cây thư mục nào giúp tìm đúng nơi sở hữu quy tắc mà vẫn giữ phụ thuộc rõ ràng?

Cần biết trước: hàm/class, import và transaction. Bài so ba cách bố trí cùng một ứng dụng đặt hàng giả lập, không chọn framework hay triển khai microservice. Các cây và đường dẫn đã kiểm nhất quán; phần hành vi là đặc tả minh họa, chưa phải ứng dụng chạy được.

Ba khái niệm trả lời ba câu hỏi

Khái niệmCâu hỏiVí dụ trong bài
LayerThành phần chịu trách nhiệm kỹ thuật gì, phụ thuộc vào đâu?HTTP nhận request, application điều phối, domain giữ quy tắc, adapter lưu SQL
ModuleNhóm code sở hữu vấn đề nghiệp vụ nào?ordering và inventory
AggregateNhóm đối tượng nào phải giữ quy tắc nhất quán qua một cửa cập nhật?Order cùng OrderItem, trạng thái và sự kiện hủy

Có thể kết hợp cả ba: chia module trước, giữ layer trong mỗi module, gom các kiểu domain theo aggregate. Chúng không phải ba kiến trúc loại trừ nhau; bài so ba mức bố trí để thấy mỗi mức làm rõ điều gì.

Tên folder không tự tạo bounded context, transaction hay service. Module inventory chỉ có ý nghĩa nếu sở hữu quyết định về tồn/reservation và có hợp đồng sử dụng rõ; một folder chung toàn bộ logic vẫn có thể phụ thuộc chằng chịt vào phần còn lại.

Giữ nguyên nghiệp vụ để so

Ứng dụng có đặt và hủy đơn, giữ và trả tồn kho. Order giữ các dòng hàng, mỗi dòng tham chiếu sản phẩm bằng product_id, quantity dương; dữ liệu giá là snapshot của đơn. StockItem sở hữu số lượng của một sản phẩm trong inventory, không trở thành entity con của Order chỉ vì Order cần sản phẩm đó.

Order là cửa cập nhật trạng thái của các dòng và của đơn. Application nạp Order qua OrderStore, gọi hành vi rồi lưu; SqlOrderStore thực hiện lưu trữ, còn OrdersHttp chuyển dữ liệu request thành lệnh. OrderStore là port của use case trong ví dụ này; nó không chứa query SQL.

Ở inventory, StockItem giữ 0 <= reserved <= on_hand; giữ/trả tồn đi qua hành vi của nó. Order chỉ lưu mã sản phẩm và reservation cần liên hệ, không tự sửa lượng tồn. Quy tắc hủy thuộc ordering, còn việc trả reservation phải được inventory kiểm lại và xử lý trùng theo hợp đồng riêng.

Các file ở ba cây đều là cùng 18 file, cùng vai trò. Phần notification/thanh toán chưa nằm trong case; không dùng chúng để làm một phương án trông phức tạp hơn phương án khác.

A — Layer ở ngoài, kiểu domain ở trong

app/
|-- interfaces/
|   |-- OrdersHttp.py
|   `-- InventoryHttp.py
|-- application/
|   |-- PlaceOrder.py
|   |-- CancelOrder.py
|   |-- ReserveStock.py
|   |-- ReleaseStock.py
|   |-- OrderStore.py
|   `-- InventoryStore.py
|-- domain/
|   |-- entities/
|   |   |-- Order.py
|   |   |-- OrderItem.py
|   |   `-- StockItem.py
|   |-- enums/
|   |   `-- OrderStatus.py
|   `-- events/
|       `-- OrderCancelled.py
|-- infrastructure/
|   |-- SqlOrderStore.py
|   `-- SqlInventoryStore.py
`-- tests/
    |-- TestPlaceOrder.py
    |-- TestCancelOrder.py
    `-- TestReleaseStock.py

Nhìn tầng kỹ thuật dễ: toàn bộ adapter ở một chỗ, toàn bộ entry HTTP ở một chỗ. Khi đọc một hành vi đặt hàng, cần đi qua các thư mục trên cùng; đồng thời entities/ gom Order và StockItem dù hai kiểu thuộc hai vấn đề khác nhau.

Cấu trúc này vẫn có thể bảo vệ Order qua method, có test và có module logic rõ. Đừng suy rằng cây theo loại đồng nghĩa anemic domain hoặc sai DDD. Điểm yếu cần đo là chi phí tìm code và phụ thuộc thật, không phải tên Entities.

B — Module ở ngoài, layer ở trong

app/
`-- modules/
    |-- ordering/
    |   |-- interfaces/
    |   |   `-- OrdersHttp.py
    |   |-- application/
    |   |   |-- PlaceOrder.py
    |   |   |-- CancelOrder.py
    |   |   `-- OrderStore.py
    |   |-- domain/
    |   |   |-- Order.py
    |   |   |-- OrderItem.py
    |   |   |-- OrderStatus.py
    |   |   `-- OrderCancelled.py
    |   |-- infrastructure/
    |   |   `-- SqlOrderStore.py
    |   `-- tests/
    |       |-- TestPlaceOrder.py
    |       `-- TestCancelOrder.py
    `-- inventory/
        |-- interfaces/
        |   `-- InventoryHttp.py
        |-- application/
        |   |-- ReserveStock.py
        |   |-- ReleaseStock.py
        |   `-- InventoryStore.py
        |-- domain/
        |   `-- StockItem.py
        |-- infrastructure/
        |   `-- SqlInventoryStore.py
        `-- tests/
            `-- TestReleaseStock.py

Người sửa hủy đơn bắt đầu ở ordering/; người sửa cách giữ tồn bắt đầu ở inventory/. Layer vẫn có trách nhiệm như A. Nhiều aggregate trong một module có thể làm domain/ đông; khi đó mới xét gom theo cụm domain.

Quyết định được che giấu: ordering sở hữu chuyển trạng thái Order; inventory sở hữu cách tính và giữ tồn. Hai module trao đổi qua use case/contract hoặc sự kiện, không sửa trực tiếp entity hay SQL adapter của nhau. Cây này không đòi hai database hoặc hai deploy độc lập.

C — Trong module, domain gom theo aggregate

app/
`-- modules/
    |-- ordering/
    |   |-- interfaces/
    |   |   `-- OrdersHttp.py
    |   |-- application/
    |   |   |-- PlaceOrder.py
    |   |   |-- CancelOrder.py
    |   |   `-- OrderStore.py
    |   |-- domain/
    |   |   `-- OrderAggregate/
    |   |       |-- Order.py
    |   |       |-- OrderItem.py
    |   |       |-- OrderStatus.py
    |   |       `-- OrderCancelled.py
    |   |-- infrastructure/
    |   |   `-- SqlOrderStore.py
    |   `-- tests/
    |       |-- TestPlaceOrder.py
    |       `-- TestCancelOrder.py
    `-- inventory/
        |-- interfaces/
        |   `-- InventoryHttp.py
        |-- application/
        |   |-- ReserveStock.py
        |   |-- ReleaseStock.py
        |   `-- InventoryStore.py
        |-- domain/
        |   `-- StockItemAggregate/
        |       `-- StockItem.py
        |-- infrastructure/
        |   `-- SqlInventoryStore.py
        `-- tests/
            `-- TestReleaseStock.py

Order, trạng thái và sự kiện cùng cụm nên dễ đọc hơn khi có nhiều aggregate. Đó là cách thể hiện quyền sở hữu domain trên đĩa; cơ chế bảo vệ vẫn là API của root, test và lưu trữ có kiểm soát.

CancelOrder, HTTP và SQL adapter vẫn ở layer tương ứng, không nhét mọi file liên quan Order vào OrderAggregate/. Một use case có thể điều phối nhiều ranh giới; aggregate không sở hữu framework hay giao thức HTTP. Aggregate StockItem một entity vẫn hợp lệ nếu có invariant riêng; không cần thêm entity con để folder có vẻ giống mẫu.

Cùng thay đổi: thêm quy tắc và lý do hủy

Yêu cầu mới: đơn đã gửi (SHIPPED) không được hủy; hủy cần lý do không chỉ gồm khoảng trắng; hủy lại đơn CANCELLED không sinh thêm sự kiện. Root sở hữu quyết định chuyển trạng thái. HTTP chỉ kiểm định dạng request; caller khác vẫn phải đi qua quy tắc của root.

Phác thảo hành vi, không phải code của một ứng dụng đã chạy:

Order.cancel(reason):
  nếu status == CANCELLED: trả về không có event mới
  nếu status == SHIPPED: từ chối, không đổi status
  nếu reason.trim() rỗng: từ chối, không đổi status
  status = CANCELLED
  tạo OrderCancelled(order_id, reason.trim())

Thứ tự có chủ đích: yêu cầu hủy lặp là no-op, không ghi đè lý do hủy đầu tiên. Chính sách đó là lựa chọn nghiệp vụ của case, không phải quy tắc phổ quát. Kiểm authorization ở use case trước khi gọi root; tính idempotent không cho phép người lạ hủy đơn.

CancelOrder nhận lý do, nạp root, gọi cancel, rồi lưu trạng thái và sự kiện theo một ranh giới commit đã chọn. Inventory nhận mã Order từ sự kiện để trả reservation; lý do mới không buộc nó sửa code nếu không thuộc hợp đồng inventory. Không giả định commit của Order và xử lý inventory là một transaction phân tán tự động.

File thay đổiA — layer/typeB — module/layerC — module/aggregate
Order.pydomain/entities/Order.pymodules/ordering/domain/Order.pymodules/ordering/domain/OrderAggregate/Order.py
OrderCancelled.pydomain/events/OrderCancelled.pymodules/ordering/domain/OrderCancelled.pymodules/ordering/domain/OrderAggregate/OrderCancelled.py
CancelOrder.pyapplication/CancelOrder.pymodules/ordering/application/CancelOrder.pymodules/ordering/application/CancelOrder.py
OrdersHttp.pyinterfaces/OrdersHttp.pymodules/ordering/interfaces/OrdersHttp.pymodules/ordering/interfaces/OrdersHttp.py
TestCancelOrder.pytests/TestCancelOrder.pymodules/ordering/tests/TestCancelOrder.pymodules/ordering/tests/TestCancelOrder.py

Cả ba đều sửa 5 file; gom thư mục không tự giảm công việc nghiệp vụ. A đi qua năm khu vực kỹ thuật; B/C giữ toàn bộ thay đổi dưới ordering, còn C đặt hai kiểu domain cùng cụm. Trong case nhỏ này, C thêm tên aggregate mà chưa giảm số file so với B. Khi root/enum/event tách nhiều nơi và nhiều aggregate cùng tồn tại, tính cục bộ có thể đáng giá hơn; phải kiểm trên lịch sử thay đổi của dự án.

Hướng phụ thuộc và phép kiểm hành vi

Trong cả ba cây, application dùng domain và port; adapter thực hiện port; HTTP gọi use case, còn wiring chọn adapter. Domain không import HTTP client, SQL driver hoặc lớp lưu cụ thể. Đây là hợp đồng của ví dụ; kiểm bằng import/build rule mới cưỡng chế được, đổi tên folder không cưỡng chế được.

Case cần testKết quả mong đợiĐiều test bảo vệ
CONFIRMED, lý do hợp lệCANCELLED, một event chứa lý do đã trimQuy tắc và payload
SHIPPED, lý do hợp lệTừ chối, trạng thái giữ nguyên, không eventKhông hủy sau gửi
CONFIRMED, lý do trắngTừ chối, trạng thái giữ nguyênLý do bắt buộc
CANCELLED, yêu cầu lặpKhông event mới, giữ lý do đầuKhông phát sinh trả tồn lặp từ root
Hai caller cùng nạp CONFIRMEDMột commit hợp lệ hoặc phát hiện xung độtConcurrency ở persistence, không chỉ method trong RAM

Case cuối cần transaction/version check hoặc cơ chế tương đương ở adapter. Hai instance Order riêng có thể cùng thấy CONFIRMED và cùng tạo event trong RAM; aggregate root không tự khóa database. Phát sự kiện ra ngoài còn cần chống phát/trả tồn trùng ở nơi lưu và nơi nhận.

Các case là acceptance criteria để người đọc triển khai, chưa có số test chạy hay benchmark của ba ứng dụng. Phép kiểm bài này kiểm cây/path và nội dung tài liệu; nó không chứng minh import đúng hay concurrency đã giải quyết.

Chọn và đổi cấu trúc theo chi phí thật

Hoàn cảnhCách bố trí đáng thửChi phí cần giữ trong tầm kiểm soát
Ứng dụng nhỏ, ít quy tắc, đội quen layerA có thể đủTheo dõi file theo một nghiệp vụ, tránh domain dùng adapter trực tiếp
Nhiều nhóm nghiệp vụ, quyền sở hữu rõBHợp đồng module, tránh gọi vào chi tiết nội bộ module khác
Nhiều aggregate và kiểu phụ thuộc riêng mỗi rootC bên trong BXác định invariant đúng; tránh tách aggregate chỉ theo bảng

Lấy vài thay đổi đã xảy ra: tìm root mất bao lâu, phải mở bao nhiêu vùng code, import nào đi qua biên và có sửa nhầm invariant không. Đếm file là một chỉ dấu; năm file nằm cạnh nhau vẫn có thể khó sửa nếu hợp đồng rối. Không suy “nhiều repository OSS dùng” thành “tối ưu cho đội mình”.

Chuyển A → B theo một nghiệp vụ trước: giữ hành vi/test, di chuyển code, sửa import/namespace, wiring, discovery test và cấu hình ORM/serializer nếu chúng dựa đường dẫn/tên. B → C chủ yếu đổi vị trí các kiểu domain, nhưng phải rà tên public được serialize hay reflection tìm class. Đổi folder thường không cần migration database nếu schema không đổi; không hứa chi phí luôn thấp.

Tránh vừa đổi folder vừa đổi mô hình dữ liệu, tách service và sửa chính sách hủy trong một lượt. Với mỗi bước, hỏi quyết định nào được giấu sau biên và caller cần biết gì. Nếu không trả lời được, tên module/aggregate có thể chỉ thêm thao tác điều hướng.

Lỗi thường gặp và học tiếp

  • Root nằm trong folder aggregate nhưng controller sửa status trực tiếp: biên trên cây chưa thành biên hành vi.
  • Shared/ chứa mọi kiểu dùng hai lần: cùng tên chưa chắc cùng ý nghĩa, và shared có thể buộc các module đổi cùng nhau.
  • Mỗi bảng thành một aggregate: xác định bằng invariant và đơn vị thay đổi nhất quán, không chỉ hình dạng SQL.
  • Gom theo module rồi lấy module làm service luôn: quyền sở hữu code và quyền triển khai là hai quyết định riêng, cần xét giao tiếp/dữ liệu/vận hành.

Deadlock qua hai session minh họa transaction và thứ tự lấy khóa: cây aggregate không làm các vấn đề đó tự biến mất. Tiêu chí hoàn tất có thể kiểm chứng giúp biến nhận định kiến trúc thành điều có thể review.

Nguồn sơ cấp đọc ngày 2026-10-03:

  • Microsoft, Design a microservice domain model: entity, root và invariant. Bài áp dụng nguyên lý cho ví dụ một ứng dụng, không mặc định microservice.
  • Microsoft, Design a DDD-oriented microservice: layer logic và hướng phụ thuộc. Vị trí port trong bài là lựa chọn thiết kế của ví dụ, không tuyên bố mọi mẫu đặt port cùng nơi.

Năm view để giải thích cùng một hệ thống

Câu hỏi bài này trả lời: cần sơ đồ nào để trả lời câu hỏi về người dùng, cấu trúc chạy, trình tự xử lý, nơi triển khai và quan hệ dữ liệu mà không trộn các mức chi tiết?

Cần biết trước: layer/module/aggregate, HTTP và transaction. Case tiếp tục ứng dụng đặt hàng của bài đó: hai module ordering/inventory trong một ứng dụng, không tách mỗi aggregate thành service. Đây là thiết kế minh họa; chưa triển khai hoặc kiểm tải/HA.

Chốt một mô hình trước khi vẽ

OrderSystem phục vụ khách đặt/hủy đơn và nhân viên cập nhật tồn. Không có thanh toán, email hoặc đối tác ngoài phạm vi case. BrowserUI là mã HTML/JS của hệ thống chạy trong trình duyệt; OrderApp là ứng dụng Python chứa HTTP, use case, domain và adapter; StoreSQL là PostgreSQL giữ hai schema ordering/inventory.

Order và các dòng giữ quy tắc trạng thái/quantity, StockItem giữ 0 <= reserved <= on_hand. Đặt hàng ở case này dùng một transaction SQL cục bộ để lưu đơn và reservation. Cần hai schema owner và hợp đồng gọi module rõ dù chung database; chọn transaction này không tự cho quyền gọi vào chi tiết domain/adapter của module khác.

ViewCâu hỏi chínhĐiều chủ động bỏ bớt
ContextAi dùng hệ thống và dùng để làm gì?Framework, bảng, node triển khai
ContainerỨng dụng/kho dữ liệu nào chạy và nói chuyện qua gì?Số replica, cấu trúc từng class
SequenceMột request diễn ra theo thứ tự nào, lỗi dừng ở đâu?Mọi use case và mọi query
DeploymentInstance nằm ở đâu trong một môi trường cụ thể?Kiến trúc nghiệp vụ thay thế
ERDBản số và khóa nào ràng buộc dữ liệu?Thời gian/giao thức mạng

Các sơ đồ dùng Mermaid cùng legend, giữ một source inline mỗi view. C4 không bắt buộc một ký pháp; điều quan trọng là cấp zoom và nhãn đọc được. Trang vẽ sơ đồ trực tiếp; khi JavaScript không tải được, mã sơ đồ vẫn còn để đọc.

1. Context: ai tương tác với OrderSystem?

Legend: [person] là vai trò người; [system] là phần mềm trong phạm vi. --label--> chỉ hướng người chủ động yêu cầu hành vi; phản hồi có nhưng không phải một hệ thống mới.

flowchart TB
    Customer["Customer · person"] -->|place/cancel order| OrderSystem["OrderSystem · system<br/>Order and inventory behavior"]
    Operator["Operator · person"] -->|adjust stock| OrderSystem

Customer không gọi trực tiếp database. Operator là vai trò khác, không mặc định được hủy đơn của khách; quyền thao tác phải được thiết kế và kiểm ở ứng dụng. Context cho thấy ranh giới trách nhiệm, không chứng minh cơ chế authorization đã chạy.

Không đặt Orders table hoặc OrderAggregate vào view này. Khi thêm cổng thanh toán thật, bổ sung hệ thống ngoài và quan hệ cụ thể; đừng vẽ sẵn một đối tác chưa có yêu cầu.

2. Container: bên trong có gì chạy?

Legend: [app] là ứng dụng chạy, [data] là kho dữ liệu. Cạnh có hành động và giao thức; các module trong cùng process dùng contract/in-process call, không phải HTTP hop.

flowchart TB
    Customer["Customer · person"] -->|interact| BrowserUI
    Operator["Operator · person"] -->|interact| BrowserUI
    subgraph OrderSystem["OrderSystem boundary"]
        BrowserUI["BrowserUI · app · HTML/JS<br/>User device · untrusted input"]
        OrderApp["OrderApp · app · Python<br/>ordering + inventory · contract calls"]
        StoreSQL[("StoreSQL · data · PostgreSQL<br/>ordering/inventory schemas")]
        BrowserUI -->|submit/read · HTTPS/JSON| OrderApp
        OrderApp -->|load/save · SQL/TLS| StoreSQL
    end

“Container” ở đây là khái niệm C4 cho ứng dụng/kho dữ liệu, không đồng nghĩa Docker container. BrowserUI là client của hệ thống nhưng môi trường thực thi thuộc thiết bị người dùng: không tin giá, status hay quyền do client tự gửi.

Ordering sở hữu orders/order_items/outbox, inventory sở hữu stock_items/reservations. OrderApp gọi hợp đồng inventory để giữ/trả tồn; module không đọc/ghi tùy ý vào schema bên kia. Outbox là bảng, không tự trở thành một broker hay container mới. EdgeProxy là hạ tầng triển khai, được làm rõ ở view 4.

3. Sequence: đặt đơn, commit rồi mới trả thành công

Legend: số là thứ tự; mũi tên liền là lời gọi, mũi tên đứt là phản hồi. [internal] chỉ việc bên trong cùng OrderApp. Khối alt tách các kết quả; không phải hai nhánh đều chạy.

sequenceDiagram
    actor Customer
    participant BrowserUI
    participant OrderApp
    participant StoreSQL
    Customer->>BrowserUI: 01 submit
    BrowserUI->>OrderApp: 02 POST [HTTPS]
    OrderApp->>OrderApp: 03 validate/auth [internal]
    OrderApp->>StoreSQL: 04 BEGIN [SQL/TLS]
    OrderApp->>StoreSQL: 05 find request_key
    StoreSQL-->>OrderApp: 06 existing/absent
    alt existing committed request
        OrderApp->>StoreSQL: end read transaction
        OrderApp-->>BrowserUI: return saved result, no new reservation
    else new request
        OrderApp->>OrderApp: 07 reserve stock [internal]
        OrderApp->>StoreSQL: 08 lock/check/update stock
        StoreSQL-->>OrderApp: 09 enough/missing
        alt missing stock
            OrderApp->>StoreSQL: 10 ROLLBACK
            OrderApp-->>BrowserUI: 11 409 failure
        else enough stock
            OrderApp->>OrderApp: 12 create Order [internal]
            OrderApp->>StoreSQL: 13 save order/items
            OrderApp->>StoreSQL: 14 save reservations
            OrderApp->>StoreSQL: 15 COMMIT
            StoreSQL-->>OrderApp: 16 commit confirmed
            OrderApp-->>BrowserUI: 17 201 order
            BrowserUI-->>Customer: 18 show result
        end
    end

Ở bước 3, server kiểm quyền và dữ liệu, tính lại giá theo nguồn đáng tin của case; không dùng total từ BrowserUI như bằng chứng giá đúng. Khi giữ nhiều StockItem, cần thứ tự lấy khóa nhất quán; xem deadlock.

Bước 5 tìm yêu cầu đã commit bằng request_key duy nhất. Nhánh đã có kết quả kết thúc transaction đọc trước khi trả kết quả cũ. Hai request trùng có thể cùng thấy absent: UNIQUE ở orders và xử lý xung đột/retry vẫn cần thiết; mũi tên “find” không tự cung cấp exactly-once.

Nhánh thiếu tồn rollback cả các cập nhật trước đó, trả thất bại; không để Order CONFIRMED nếu reservation chưa có. COMMIT xong mới trả 201. Nếu phản hồi mất sau commit, gửi lại cùng request_key để lấy kết quả đã lưu; timeout mạng tự nó không cho biết commit có xảy ra hay không.

Hủy đơn là use case khác: Order đổi trạng thái và ghi OrderCancelled vào outbox cùng commit; phần xử lý event trong OrderApp trả reservation qua contract inventory, có chống trùng. View này không vẽ toàn luồng hủy hoặc mô tả outbox như bảo đảm tự có: cần thiết kế retry/persistence cho luồng đó.

4. Deployment: ánh xạ instance vào staging minh họa

Legend: tên kết thúc #1 là một instance; các zone mô tả trust boundary. EdgeProxy kết thúc TLS; hop HTTP tới app chỉ ở mạng private đã giới hạn. Số instance là giả định staging, không phải thông số production đã đo.

flowchart TB
    subgraph Device["User device · untrusted"]
        BrowserUI["BrowserUI#1"]
    end
    subgraph Edge["Public edge"]
        EdgeProxy["EdgeProxy#1<br/>Reverse proxy · TLS termination"]
    end
    subgraph Application["Private application zone"]
        OrderApp["OrderApp#1 · Python process<br/>ordering + inventory"]
    end
    subgraph Data["Private data zone"]
        StoreSQL[("StoreSQL#1 · PostgreSQL<br/>ordering/inventory schemas")]
    end
    BrowserUI -->|submit/read · HTTPS/JSON · Internet boundary| EdgeProxy
    EdgeProxy -->|forward · HTTP/JSON · private app ingress| OrderApp
    OrderApp -->|load/save · SQL/TLS · database credential| StoreSQL

Môi trường staging minh họa có một app và một database; không có public database ingress, replica hoặc failover.

Container view nói BrowserUI gọi OrderApp qua tuyến HTTPS của hệ thống; deployment bổ sung hop EdgeProxy và vị trí kết thúc TLS. SQL/TLS nhất quán ở hai view. Nếu mạng nội bộ chưa đáp ứng chính sách tin cậy, cần TLS/mTLS cho hop app; nhãn “private” không tự bảo đảm mạng an toàn.

EdgeProxy chỉ được forward vào cổng app đã cho phép; StoreSQL chỉ nhận từ identity/network của OrderApp theo chính sách triển khai. Những dòng này là yêu cầu thiết kế, chưa có firewall/credential/HA được dựng. Một app và một database là điểm lỗi đơn; không gắn nhãn “high availability” cho sơ đồ này.

5. ERD: quan hệ dữ liệu và ownership

Legend: 1 -> 0..N nghĩa mỗi hàng bên phải thuộc đúng một hàng trái, phía trái có thể chưa có hàng con. ref chỉ tham chiếu ID qua biên module, không là FK được enforce ở ví dụ. Các FK được nêu tường minh bên dưới; ERD không phải aggregate map.

erDiagram
    direction TB
    orders ||--o{ order_items : "FK order_id"
    orders ||--o{ outbox : "FK order_id"
    stock_items ||--o{ reservations : "FK product_id"
    stock_items ||..o{ order_items : "ref product_id · application only"
    orders ||..o{ reservations : "ref order_id · application only"
    orders {
        bigint id PK
        string request_key UK
        bigint customer_id
        string status
    }
    order_items {
        bigint order_id PK,FK
        int line_no PK
        bigint product_id
    }
    outbox {
        string event_id PK
        bigint order_id FK
        string event_type
        json payload
    }
    stock_items {
        bigint product_id PK
        int on_hand
        int reserved
    }
    reservations {
        bigint order_id PK
        bigint product_id PK,FK
        int quantity
    }

orders, order_items và outbox thuộc schema ordering; stock_items và reservations thuộc schema inventory. Cạnh liền ghi FK; cạnh đứt ghi ref do ứng dụng bảo vệ, không có FK xuyên schema trong case này.

Orders có thể DRAFT chưa có dòng, nên ERD dùng 0..N; hành vi confirm yêu cầu ít nhất một dòng. FK chỉ ràng buộc tham chiếu, không ràng buộc “đơn confirmed phải có dòng”. Quantity của dòng/reservation dương, StockItem có 0 <= reserved <= on_hand; phải thiết kế enforcement trong domain và persistence phù hợp.

Hai ref không được vẽ thành FK âm thầm: case chọn tránh FK xuyên schema owner, đổi lại ứng dụng và kiểm đối soát phải bảo vệ tham chiếu/cleanup. Trong một monolith dùng FK xuyên schema cũng có thể hợp lý, nhưng đó là quyết định khác cần nói rõ chi phí coupling.

Một order có nhiều reservation theo sản phẩm, mỗi reservation giữ một product_id và một order_id. Tổng reserved và số reservation active phải nhất quán theo chính sách inventory; ERD chưa mô tả status/release history, index chi tiết hay migration SQL. Không dùng sơ đồ rút gọn này để tạo schema production trực tiếp.

Khi đã có DDL thật, có thể dán vào sql2erd.dev để xem ERD trong trình duyệt và xuất Mermaid hoặc ảnh; công cụ này là sản phẩm khác của tác giả, bài không cần đến nó. Vẫn đối chiếu sơ đồ sinh ra với legend ở trên: FK do DDL enforce khác ref do ứng dụng bảo vệ, nên kiểm xem công cụ có phân biệt hai loại cạnh này không trước khi dùng sơ đồ để review.

Đối chiếu view trước khi review

Điều cần đối chiếuKết quả của caseDấu hiệu lệch cần sửa
ActorCustomer/Operator có cùng vai trò ở context/containerMột view tự thêm admin hoặc đối tác
Runtimeordering/inventory cùng OrderAppSequence vẽ HTTP giữa hai module
ProtocolHTTPS ở tuyến client, HTTP private sau edge, SQL/TLS tới StoreSQLDeployment biến SQL thành REST hoặc thiếu hop TLS
TransactionReservation và Order cùng commit đặt hàngTrả 201 trước commit hoặc nhánh lỗi vẫn commit
StorageNăm bảng ở StoreSQL, ownership schema rõOutbox bỗng thành Kafka hay database ngoài
CardinalityERD cho DRAFT không dòng, confirm có invariant riêngVẽ bắt buộc 1..N rồi ví dụ có DRAFT rỗng
TrustClient untrusted, data zone không publicContainer có BrowserUI nối SQL trực tiếp

Một bộ view nhất quán vẫn có thể mô tả thiết kế sai. Review tiếp bằng yêu cầu: khi mất response/DB/instance thì hành vi nào cần giữ, ai có quyền sửa trạng thái, dữ liệu nào phải phục hồi và phép kiểm nào chứng minh được. Các sơ đồ không thay capacity planning, threat modeling hoặc test tích hợp.

Bài tập và nguồn

  1. Thêm thanh toán ngoài hệ thống: cập nhật context trước, rồi container/sequence/deployment; không chỉ thêm một mũi tên ở sequence.
  2. Tách inventory thành process riêng: transaction cục bộ của case không còn đủ; viết lại protocol và hành vi thất bại, không chỉ đổi tên module thành service.
  3. Thêm replica đọc: chỉ rõ query nào chấp nhận stale data, rồi kiểm sequence read-after-write; hình deployment không chứng minh dữ liệu mới xuất hiện ngay.

Nguồn sơ cấp đọc ngày 2026-10-03; các lựa chọn transaction/schema/deployment là thiết kế của ví dụ:

ADR: giữ lý do của một quyết định kiến trúc

Câu hỏi: sáu tháng sau, làm sao biết vì sao code chia thành ordering và inventory, và khi nào quyết định ấy cần đổi?

Cần biết trước: layer, module và aggregate, năm view hệ thống. Bài dùng cùng ứng dụng đơn hàng giả. ADR dưới đây là quyết định trong ví dụ, không phải phê duyệt một hệ thống production.

Quyết định cần ghi điều gì?

ADR giữ một lựa chọn cùng bối cảnh và đánh đổi ở thời điểm chọn. Tài liệu kiến trúc giữ trạng thái đang vận hành: module, database, luồng và nơi triển khai hiện tại. Nếu chỉ sửa sơ đồ sau refactor, lý do cũ mất đi; nếu chỉ giữ ADR, sơ đồ có thể lỗi thời. Nygard đề xuất ghi các quyết định nhỏ trong kho mã, giữ bản cũ khi quyết định bị thay thế. MADR cung cấp cấu trúc Markdown nhẹ cho bối cảnh, các lựa chọn, kết quả và cách xác nhận. Không cần điền mọi mục mới có một ADR hữu ích.

Không phải lần đổi tên biến nào cũng cần ADR. Ở đây, quyền sở hữu quy tắc hủy đơn và giữ tồn ảnh hưởng nhiều use case và hướng phụ thuộc, nên cần lưu lý do chung.

ADR-0001 — Chia module nghiệp vụ, giữ layer trong module

**Ngày:**2026-10-03. **Trạng thái:**Accepted trong case giả lập. **Phạm vi:**bố trí code và hợp đồng phụ thuộc của ứng dụng đặt/hủy đơn.

Bối cảnh và ràng buộc (Context)

Order sở hữu trạng thái và các dòng đơn; StockItem sở hữu 0 <= reserved <= on_hand. Hủy đơn đã SHIPPED bị từ chối; hủy lặp không sinh thêm sự kiện. Trả reservation phải qua inventory, không sửa bảng tồn từ ordering.

Ba cách tổ chức ở bài trước giữ cùng18file. Thêm lý do hủy chạm cùng5file ở cả ba cách. Mục tiêu hiện tại là tìm đúng owner và nhìn được dependency, chưa có phép đo thời gian tìm code hoặc tốc độ runtime. Chưa yêu cầu hai deploy hay database.

Ràng buộc chọn trước khi so:

  • Domain không phụ thuộc HTTP/SQL driver; application dùng domain và port.
  • Các thay đổi quy tắc hủy phải tìm được trong ordering; tồn thuộc inventory.
  • Không thêm distributed transaction hoặc service chỉ để chia folder.
  • Số aggregate hiện tại ít; không buộc thêm folder nếu chưa giải quyết khó khăn cụ thể.

Các lựa chọn

Lựa chọnTìm owner hủy đơnNhìn layerChi phí hiện tại
A: layer ngoài, domain theo typeĐi qua application/domain/interfaces/testsTập trung theo vai trò kỹ thuật18file; thay đổi5file phân tán qua các khu vực
B: module ngoài, layer trongBắt đầu ở orderingVẫn có domain/application/adapter18file; thay đổi5file cùng module
C: như B, domain theo aggregateNhư B; Order và event cùng cụmVẫn giữ use case/adapter ngoài aggregate18file;5file đổi, thêm tên cụm khi case còn nhỏ

Đây là đối chiếu vị trí của file, không là điểm hiệu năng hay điểm DDD. A vẫn có thể bảo vệ invariant đúng; C chưa giảm số file so với B trong case này.

Quyết định và hệ quả (Consequences)

Chọn B vì ranh giới ordering/inventory hiện có giúp định vị owner; layer bên trong vẫn giữ hướng phụ thuộc. Chưa chọn C vì một aggregate chính trong mỗi module chưa cho thấy cần thêm tầng folder. Một database và một deploy vẫn hợp lệ.

Hệ quả mong muốn: thay đổi lý do hủy nằm dưới ordering; inventory chỉ nhận hợp đồng trả reservation. Đánh đổi: lặp tên layer giữa module, cần wiring ở composition root, và hợp đồng giữa hai module phải được giữ rõ. Thay tên folder không cưỡng chế import; code vẫn có thể vi phạm nếu ordering truy cập adapter inventory trực tiếp.

Cách xác nhận và điều chưa kiểm (Confirmation)

Đã đối chiếu cây B trong bài tổ chức code: Order, CancelOrder, OrderStore, SqlOrderStore và OrdersHttp nằm đúng layer; luồng đặt hàng trong sequence giữ commit/rollback và201/409.

Khi có ứng dụng thật, thêm kiểm import dependency và test SHIPPED/blank reason/ hủy lặp, lưu trạng thái cùng sự kiện trong ranh giới transaction đã chọn. Hiện cây thư mục và hành vi trong bài trước là mô hình; chưa chạy các test ứng dụng ấy, chưa đo chi phí tìm code, không viết chúng thành kết quả đã đạt.

Khi nào xem lại?

Tình huống giả định: ordering có nhiều aggregate với enum/event trùng tên; ba thay đổi liên tiếp phải lần qua nhiều cụm mới tìm đúng owner. Thu thập các file thực sự đổi và đường đi tìm code. Nếu gom theo aggregate cải thiện việc đó mà vẫn giữ dependency, viết ADR-0002 cho C; đánh dấu0001 làSuperseded và link hai chiều.

Nếu inventory cần deploy độc lập, đó là ràng buộc mới về ownership, giao thức và lỗi giữa service, cần quyết định khác. Không suy từ B rằng hiện đã có microservice.

Rà soát ADR trước khi nhận

Người đọc phải truy từ câu hỏi tới ràng buộc, options, lý do chọn, hệ quả và evidence. Thử thay một ràng buộc: nếu mọi phương án vẫn được khen giống nhau, tiêu chí chưa giúp chọn. Một ADR chỉ ghi “chọn B vì best practice” không trả lời câu hỏi.

Khi thay quyết định, giữ lý do ở thời điểm cũ, tạo bản mới và cập nhật architecture hiện tại. Khi sửa lỗi chữ hoặc link, có thể sửa bản cũ với lịch sử thay đổi rõ. Không tự đổi một Proposed thành Accepted vì build Markdown xanh: trạng thái quyết định thuộc người có quyền trong dự án; ở bài này chỉ là một ví dụ đã điền.

Học tiếp: code organization, system views. Giữ ADR ngắn đủ để người sửa code đọc; link tới bằng chứng thay vì chép lại toàn bộ sơ đồ và benchmark.

Repository và ORM: biên nào che một quyết định thật?

Câu hỏi: thêm OrderStore giúp giữ luật hủy đơn hay chỉ thêm một bước gọi?

Cần biết trước: module/domain, transaction và Python. Lab dùng Python3.14, SQLAlchemy2.0.54, SQLite trong thư mục thử riêng. Đây là phép kiểm hành vi và số câu SQL, không đo tốc độ ORM hoặc concurrency PostgreSQL.

So cùng bài toán

Order SHIPPED không hủy được, lý do không trắng; CANCELLED hủy lại là no-op. Trạng thái và sự kiện hủy phải commit cùng transaction. Direct dùng ORM ngay trong use case; phương án có biên trả Order thuần và adapter lưu nó. Cả hai dùng cùng luật. Read projection đếm theo trạng thái dùng SQL set-based, không ép nạp từng aggregate.

Session quản lý transaction và identity map; context transaction commit khi thành công, rollback khi exception. Repository không tự có cơ chế đó: ở lab, caller giữ transaction cho cả save và event. Session.

Lưu bốn file và runner trong thư mục trống

File thứ nhất là domain thuần. Hủy lặp giữ lý do đầu tiên trong event đã lưu.

from __future__ import annotations

from dataclasses import dataclass


def cancel_status(status: str, reason: str) -> str:
    if status == "CANCELLED":
        return status
    if status == "SHIPPED" or not reason.strip():
        raise ValueError("không được hủy")
    return "CANCELLED"


@dataclass(slots=True)
class Order:
    id: int
    status: str

    def cancel(self, reason: str) -> None:
        self.status = cancel_status(self.status, reason)
from __future__ import annotations

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class OrderRow(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    status: Mapped[str] = mapped_column("status")


class CancelEvent(Base):
    __tablename__ = "cancel_events"
    order_id: Mapped[int] = mapped_column(primary_key=True)
    reason: Mapped[str]
from __future__ import annotations

from mapping import OrderRow
from sqlalchemy import func, select
from sqlalchemy.orm import Session


def summary(session: Session) -> dict[str, int]:
    query = select(OrderRow.status, func.count()).group_by(OrderRow.status)
    return {status: count for status, count in session.execute(query)}

Biên sau che cách nạp/lưu domain; caller vẫn biết transaction là một Session. Đây là demo nhỏ, chưa tách UnitOfWork port. Không nói use case đã độc lập mọi ORM.

from __future__ import annotations

from typing import Protocol

from mapping import OrderRow
from order import Order
from sqlalchemy.orm import Session


class OrderStore(Protocol):
    def get(self, order_id: int) -> Order: ...
    def save(self, order: Order) -> None: ...


class SqlOrderStore:
    def __init__(self, session: Session) -> None:
        self.session = session
        self.loaded: dict[int, OrderRow] = {}

    def get(self, order_id: int) -> Order:
        row = self.session.get(OrderRow, order_id)
        if row is None:
            raise LookupError(order_id)
        self.loaded[order_id] = row
        return Order(row.id, row.status)

    def save(self, order: Order) -> None:
        self.loaded[order.id].status = order.status

Runner so hai use case trên database mới, không dùng fake repository thay transaction. Mỗi transaction có một adapter mới; cache loaded chỉ sống trong transaction ấy.

from __future__ import annotations

import hashlib
import importlib.metadata
import sqlite3
import subprocess
import sys
from pathlib import Path

from mapping import Base, CancelEvent, OrderRow
from order import cancel_status
from queries import summary
from sqlalchemy import create_engine, event, func, select
from sqlalchemy.orm import Session
from store import OrderStore, SqlOrderStore


def direct(session: Session, order_id: int, reason: str) -> None:
    row = session.get(OrderRow, order_id)
    assert row is not None
    before = row.status
    row.status = cancel_status(row.status, reason)
    if before != row.status:
        session.add(CancelEvent(order_id=order_id, reason=reason.strip()))


def boundary(session: Session, store: OrderStore, order_id: int, reason: str) -> None:
    order = store.get(order_id)
    before = order.status
    order.cancel(reason)
    store.save(order)
    if before != order.status:
        session.add(CancelEvent(order_id=order_id, reason=reason.strip()))


def exercise() -> None:
    assert importlib.metadata.version("SQLAlchemy") == "2.0.54"
    for mode in ("direct", "repo"):
        engine = create_engine(f"sqlite:///{mode}.db")
        statements: list[str] = []
        event.listen(
            engine,
            "before_cursor_execute",
            lambda c, u, q, p, x, m, log=statements: log.append(q),
        )
        Base.metadata.create_all(engine)
        try:
            with Session(engine) as session, session.begin():
                session.add_all(
                    [
                        OrderRow(id=1, status="CONFIRMED"),
                        OrderRow(id=2, status="SHIPPED"),
                    ]
                )
            for order_id, reason in ((2, "lý do"), (1, " ")):
                try:
                    with Session(engine) as session, session.begin():
                        if mode == "direct":
                            direct(session, order_id, reason)
                        else:
                            boundary(session, SqlOrderStore(session), order_id, reason)
                except ValueError:
                    pass
                else:
                    raise AssertionError("invariant không được bảo vệ")
            try:
                with Session(engine) as session, session.begin():
                    if mode == "direct":
                        direct(session, 1, "first")
                    else:
                        boundary(session, SqlOrderStore(session), 1, "first")
                    session.flush()
                    raise RuntimeError("fault sau flush")
            except RuntimeError as error:
                assert str(error) == "fault sau flush"
            with Session(engine) as session:
                row = session.get(OrderRow, 1)
                assert row is not None and row.status == "CONFIRMED"
                assert (
                    session.scalar(select(func.count()).select_from(CancelEvent)) == 0
                )
            statements.clear()
            with Session(engine) as session, session.begin():
                if mode == "direct":
                    direct(session, 1, "first")
                else:
                    boundary(session, SqlOrderStore(session), 1, "first")
            assert len(statements) == 3, statements
            statements.clear()
            with Session(engine) as session, session.begin():
                if mode == "direct":
                    direct(session, 1, "second")
                else:
                    boundary(session, SqlOrderStore(session), 1, "second")
            assert len(statements) == 1
            with Session(engine) as session:
                assert (
                    session.scalar(select(func.count()).select_from(CancelEvent)) == 1
                )
                saved_event = session.get(CancelEvent, 1)
                assert saved_event is not None and saved_event.reason == "first"
                statements.clear()
                result = summary(session)
                expected = (
                    {"SHIPPED": 1}
                    if "--query" in sys.argv
                    else {"CANCELLED": 1, "SHIPPED": 1}
                )
                assert result == expected and len(statements) == 1
            sys.stdout.write(
                f"{mode}: invariant/rollback/noop=ok cancel_sql=3 repeat_sql=1 summary_sql=1\n"
            )
        finally:
            engine.dispose()
            Path(f"{mode}.db").unlink(missing_ok=True)


def main() -> None:
    exercise()
    if "--child" in sys.argv:
        return
    before = {
        p: hashlib.sha256(p.read_bytes()).hexdigest() for p in Path(".").glob("*.py")
    }
    mapping = Path("mapping.py")
    original = mapping.read_text()
    try:
        mapping.write_text(
            original.replace('mapped_column("status")', 'mapped_column("state")')
        )
        changed = [
            p.name
            for p, digest in before.items()
            if hashlib.sha256(p.read_bytes()).hexdigest() != digest
        ]
        assert changed == ["mapping.py"]
        subprocess.run(
            [sys.executable, "-B", "check.py", "--child"], check=True, timeout=30
        )
    finally:
        mapping.write_text(original)
    queries = Path("queries.py")
    original_query = queries.read_text()
    try:
        queries.write_text(
            original_query.replace(
                ".group_by(OrderRow.status)",
                '.where(OrderRow.status != "CANCELLED").group_by(OrderRow.status)',
            )
        )
        changed = [
            p.name
            for p, digest in before.items()
            if hashlib.sha256(p.read_bytes()).hexdigest() != digest
        ]
        assert changed == ["queries.py"]
        subprocess.run(
            [sys.executable, "-B", "check.py", "--child", "--query"],
            check=True,
            timeout=30,
        )
    finally:
        queries.write_text(original_query)
    sys.stdout.write(
        f"sqlite={sqlite3.sqlite_version} schema_change=1 query_change=1 both_paths=ok\n"
    )


if __name__ == "__main__":
    main()

Chạy và kiểm output

set -euo pipefail
uv run --no-project --with 'SQLAlchemy==2.0.54' python -B check.py
direct: invariant/rollback/noop=ok cancel_sql=3 repeat_sql=1 summary_sql=1
repo: invariant/rollback/noop=ok cancel_sql=3 repeat_sql=1 summary_sql=1
set -euo pipefail
test ! -e direct.db
test ! -e repo.db

Đọc phép so đúng phạm vi

Thay đổi kiểm trong labDirectCó repositoryĐiều đã được che
Cột vật lý status→state, attribute vẫn status1file mapping.py1file mapping.pyORM mapping đã che tên cột
Read projection bỏ CANCELLED1file queries.py1file queries.pyQuery owner giữ shape/filter đọc
Hủy đơn và event atomic3câu SQL3câu SQLTransaction do caller giữ, không nhờ tên repository

Các biến thể tạo database mới để kiểm mapping, chưa chạy migration dữ liệu cũ. Runner so hash file thật và chạy lại cả hai đường; không đo số file trong mọi project. Mapped có thể dùng tên attribute khác tên cột database. Mapping API.

Repository hữu ích khi hợp đồng OrderStore là thứ application muốn giữ ổn định, domain không phụ thuộc tracking/query của ORM, hoặc cách lưu root đổi thật. Đổi schema vật lý đơn giản không tự tạo lợi ích: ORM cũng làm được như lab. Adapter ở đây thêm mapping/cache và interface, một chi phí cần nhận rõ.

Repository rỗng như get(id) -> session.get(Row,id) và trả Query/Row của chính ORM không che query/tracking hay domain model. Dùng ORM trực tiếp ở use case nhỏ có thể đủ, miễn invariant và transaction vẫn được kiểm. Ngược lại, repository “chung mọi bảng” có thể ép query đọc qua vòng lặp, gây nhiều SQL; kiểm SQL và dữ liệu ở N+1 thay vì tin tên pattern.

Lab chưa kiểm concurrent cancel, optimistic version hoặc database ngoài SQLite. Authorization phải kiểm trước hành vi hủy; idempotent không cấp quyền. Một transaction chỉ chứa database này, không bao gồm notification bên ngoài. Học tiếp: ADR, MVCC.

Batch job: chạy lại an toàn sau lỗi giữa chừng

Câu hỏi: worker đã ghi hiệu ứng nhưng chưa nhận được ACK; chạy lại có cộng tiền lần nữa không?

Cần biết trước: SQL transaction, MVCC, deadlock/retry. Chép fixture cách A của lab database vào thư mục trống. Lab dùng Python3.14.4, PostgreSQL18.6, dữ liệu đơn hàng giả và hai connection thật; không dùng queue/cloud.

Chọn identity và checkpoint

Một event có event_id ổn định cùng amount cent. Cùng ID/cùng payload là replay; cách xử lý idempotent giữ cùng kết quả khi replay input đã thành công. cùng ID/amount khác là lỗi identity, không âm thầm coi là đã xử lý. Mỗi effect có PK event_id và tham chiếu job; tổng tiền lấy từ effect, không cộng một biến tổng rồi quên ghi identity. Constraints.

Trạng tháiÝ nghĩa trong labAi nhìn thấy?
pendingCó input, chưa checkpoint thành côngConnection mới thấy
runningWorker giữ row lock, đang xử lý trong transactionWorker thấy; chưa commit nên reader khác còn thấy pending
completedEffect và trạng thái cùng commitReplay thấy và trả already
failedInput amount âm, lỗi vĩnh viễn được ghi nhậnReplay trả failed, cần sửa/duyệt input theo policy

Checkpoint là commit cả effect lẫn completed, không phải log “xong”. Fault trước commit rollback về pending. Running không lưu riêng, nên không cần lease recovery trong demo này. Với job lâu có checkpoint nhiều transaction, phải thiết kế ownership, lease/fencing và phục hồi; không bê nguyên transaction dài của lab sang production.

Mã lab tự chứa

Lưu file dưới đây. FOR UPDATE khóa job trước khi xem terminal state. Worker sau chờ worker trước commit và đọc row mới ở READ COMMITTED. Row lock.

from __future__ import annotations

import os
import subprocess
import sys
import time
from collections.abc import Callable
from pathlib import Path

LAB_DIR = os.environ["LAB_DIR"]
COMMAND = [
    "psql",
    "-X",
    "-q",
    "-w",
    "-h",
    LAB_DIR,
    "-p",
    "5432",
    "-U",
    "lab",
    "-d",
    "postgres",
    "-At",
    "-v",
    "ON_ERROR_STOP=1",
    "-v",
    "VERBOSITY=verbose",
]


def environment(name: str = "batch-monitor") -> dict[str, str]:
    env = dict(os.environ)
    for key in ("PGSERVICE", "PGHOSTADDR", "PGPASSWORD", "PGOPTIONS"):
        env.pop(key, None)
    env.update(
        PGAPPNAME=name,
        PGSERVICEFILE="/dev/null",
        PGPASSFILE=str(Path(LAB_DIR) / "no-pass"),
        PGOPTIONS="-c statement_timeout=7000 -c lock_timeout=5000 -c client_min_messages=warning",
    )
    return env


def sql(statement: str) -> str:
    return subprocess.run(
        COMMAND,
        input=statement,
        text=True,
        capture_output=True,
        check=True,
        timeout=10,
        env=environment(),
    ).stdout.strip()


def reset() -> None:
    sql(
        "TRUNCATE wiki_lab.batch_effects,wiki_lab.batch_jobs; INSERT INTO wiki_lab.batch_jobs VALUES (1,100,'pending'),(2,-1,'pending');"
    )


def state() -> str:
    return sql(
        "SELECT status,(SELECT count(*) FROM wiki_lab.batch_effects) FROM wiki_lab.batch_jobs WHERE event_id=1;"
    )


class AckLost(RuntimeError):
    pass


def retry(fault: str, always: bool = False) -> tuple[str, int]:
    for attempt in range(3):
        try:
            current = fault if always or attempt == 0 else "none"
            sql_fault = "none" if current == "after_commit" else current
            result = sql(
                f"BEGIN; SELECT wiki_lab.apply_event(1,100,'{sql_fault}',0); COMMIT;"
            )
            if current == "after_commit":
                raise AckLost("commit xong nhưng client mất ack giả lập")
            return result, attempt + 1
        except subprocess.CalledProcessError as error:
            if "40001" not in error.stderr:
                raise
        except AckLost:
            pass
        if attempt < 2:
            time.sleep(0.01 * 2**attempt)
    raise RuntimeError("retry exhausted: 3")


def wait_for(predicate: Callable[[], bool]) -> None:
    deadline = time.monotonic() + 1.5
    while time.monotonic() < deadline:
        if predicate():
            return
        time.sleep(0.01)
    raise TimeoutError("không quan sát được barrier")


def race() -> None:
    reset()
    workers: list[subprocess.Popen[str]] = []
    try:
        workers.append(
            subprocess.Popen(
                [*COMMAND, "-c", "SELECT wiki_lab.apply_event(1,100,'none',3);"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                env=environment("batch-a"),
            )
        )
        wait_for(
            lambda: (
                sql(
                    "SELECT count(*) FROM pg_stat_activity WHERE application_name='batch-a' AND wait_event='PgSleep';"
                )
                == "1"
            )
        )
        workers.append(
            subprocess.Popen(
                [*COMMAND, "-c", "SELECT wiki_lab.apply_event(1,100,'none',0);"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                env=environment("batch-b"),
            )
        )
        wait_for(
            lambda: (
                sql(
                    "SELECT count(*) FROM pg_stat_activity b WHERE b.application_name='batch-b' AND EXISTS(SELECT 1 FROM pg_stat_activity a WHERE a.application_name='batch-a' AND a.pid=ANY(pg_blocking_pids(b.pid)));"
                )
                == "1"
            )
        )
        outputs = [worker.communicate(timeout=10)[0].strip() for worker in workers]
        assert all(worker.returncode == 0 for worker in workers)
        assert outputs == ["applied", "already"] and state() == "completed|1"
        assert sql("SELECT sum(amount) FROM wiki_lab.batch_effects;") == "100"
        sys.stdout.write(
            "race: blocked_B_by_A=observed applied/already effect_count=1 sum=100\n"
        )
    finally:
        for worker in workers:
            if worker.poll() is None:
                worker.terminate()
                try:
                    worker.communicate(timeout=2)
                except subprocess.TimeoutExpired:
                    worker.kill()
                    worker.communicate(timeout=2)


def main() -> None:
    assert Path(LAB_DIR, ".wiki-lab").is_file()
    assert sql("SHOW unix_socket_directories;") == LAB_DIR
    assert sql("SHOW server_version_num;") == "180006"
    sql("""
CREATE TABLE wiki_lab.batch_jobs (
 event_id integer PRIMARY KEY, amount integer NOT NULL,
 status text NOT NULL CHECK(status IN ('pending','running','completed','failed'))
);
CREATE TABLE wiki_lab.batch_effects (
 event_id integer PRIMARY KEY REFERENCES wiki_lab.batch_jobs(event_id),
 amount integer NOT NULL CHECK(amount>=0)
);
CREATE FUNCTION wiki_lab.apply_event(p_id integer,p_amount integer,p_fault text,p_delay double precision)
RETURNS text LANGUAGE plpgsql AS $body$
DECLARE item wiki_lab.batch_jobs%ROWTYPE;
BEGIN
 SELECT * INTO STRICT item FROM wiki_lab.batch_jobs WHERE event_id=p_id FOR UPDATE;
 IF item.amount<>p_amount THEN RAISE EXCEPTION 'identity payload mismatch' USING ERRCODE='22000'; END IF;
 IF item.status='completed' THEN RETURN 'already'; END IF;
 IF item.status='failed' THEN RETURN 'failed'; END IF;
 IF p_amount<0 THEN UPDATE wiki_lab.batch_jobs SET status='failed' WHERE event_id=p_id; RETURN 'failed'; END IF;
 UPDATE wiki_lab.batch_jobs SET status='running' WHERE event_id=p_id;
 PERFORM pg_sleep(p_delay);
 IF p_fault='before_write' THEN RAISE EXCEPTION 'fault before' USING ERRCODE='40001'; END IF;
 INSERT INTO wiki_lab.batch_effects VALUES(p_id,p_amount);
 IF p_fault='after_write' THEN RAISE EXCEPTION 'fault after' USING ERRCODE='40001'; END IF;
 UPDATE wiki_lab.batch_jobs SET status='completed' WHERE event_id=p_id;
 RETURN 'applied';
END;
$body$;
""")
    for fault in ("before_write", "after_write", "after_commit"):
        reset()
        if fault != "after_commit":
            try:
                sql(f"SELECT wiki_lab.apply_event(1,100,'{fault}',0);")
            except subprocess.CalledProcessError as error:
                assert "40001" in error.stderr and state() == "pending|0"
            else:
                raise AssertionError("fault không xảy ra")
        result, attempts = retry(fault)
        assert attempts == 2 and state() == "completed|1"
        assert result == ("already" if fault == "after_commit" else "applied")
        assert sql("SELECT wiki_lab.apply_event(1,100,'none',0);") == "already"
        sys.stdout.write(f"{fault}: attempts=2 replay=already effect_count=1\n")
    reset()
    try:
        retry("before_write", always=True)
    except RuntimeError as error:
        assert str(error) == "retry exhausted: 3" and state() == "pending|0"
    else:
        raise AssertionError("retry không dừng")
    assert sql("SELECT wiki_lab.apply_event(2,-1,'none',0);") == "failed"
    assert sql("SELECT wiki_lab.apply_event(2,-1,'none',0);") == "failed"
    try:
        sql("SELECT wiki_lab.apply_event(1,999,'none',0);")
    except subprocess.CalledProcessError as error:
        assert "22000" in error.stderr and state() == "pending|0"
    else:
        raise AssertionError("payload conflict không bị từ chối")
    sys.stdout.write(
        "exhausted=3 pending|0 permanent_failed=yes payload_conflict=22000\n"
    )
    race()
    race()
    sql(
        "DROP FUNCTION wiki_lab.apply_event(integer,integer,text,double precision); DROP TABLE wiki_lab.batch_effects,wiki_lab.batch_jobs;"
    )


if __name__ == "__main__":
    main()

Dựng, chạy và kết quả

set -euo pipefail
. ./lab-local.sh
lab_up
lab_seed
lab_whoami
lab_versions
set -euo pipefail
. ./lab.env
python3 batch.py
before_write: attempts=2 replay=already effect_count=1
after_write: attempts=2 replay=already effect_count=1
after_commit: attempts=2 replay=already effect_count=1
exhausted=3 pending|0 permanent_failed=yes payload_conflict=22000
race: blocked_B_by_A=observed applied/already effect_count=1 sum=100

Fault40001 được tiêm để kiểm retry path, chưa là một SSI conflict thật. Mất ACK là exception phía client sau commit, chưa cắt mạng. Hai worker là psql thật, barrier xác nhận A đã giữ lock và B bị A chặn, không suy từ sleep rằng đã có race. Race chạy hai lượt từ dữ liệu reset; unique effect và tổng100 kiểm hậu điều kiện.

Cleanup cả khi thất bại

set -euo pipefail
. ./lab-local.sh
if [ -f lab.env ]; then
  . ./lab.env
  old_dir=$LAB_DIR
  lab_clean
  [ ! -e "$old_dir" ]
fi
lab_clean
[ ! -e lab.env ]
echo 'cleanup ok'

Retry phải hữu hạn và đúng lỗi

Lab retry40001 hoặc ACK giả lập, tối đa3lượt, backoff0.01/0.02s. Lỗi22000 do payload đổi không retry; amount âm thành failed để kiểm/xử lý input. SQLSTATE giúp phân loại, nhưng production phải có policy cho từng lỗi thực tế, deadline tổng và jitter phù hợp. Exhausted giữ pending; một scheduler khác có thể thử lại theo ngân sách đã duyệt.

“Đã thấy job tồn tại” rồi ghi effect ngoài transaction là check-then-act, hai worker vẫn có thể cùng làm. Trong lab, khóa và effect/checkpoint cùng transaction mới giữ invariant. Unique constraint là lớp bảo vệ thêm, không thay thiết kế identity.

Hiệu ứng ngoài database (gửi email, charge API, file/cloud) không nằm trong commit này. Cần idempotency key của bên nhận hoặc outbox và consumer dedupe; không tuyên bố exactly-once cho toàn pipeline. Checkpoint từng item cũng khác “cả file atomic”.

Loại việc này hay gặp ở dịch vụ thu thập dữ liệu định kỳ từ nhiều nguồn: chạy lại sau lỗi không được tạo bản ghi trùng. JobCollect, sản phẩm khác của tác giả, tổng hợp tin tuyển dụng từ nhiều nền tảng theo mô tả trên trang công khai; bài này không mô tả cách JobCollect được xây dựng và lab không dùng nó.

Học tiếp: bulk import, transaction isolation, ADR.

SSO, quyền ứng dụng và quyền dữ liệu có ba hợp đồng

Câu hỏi: token hợp lệ cho biết ai đang gọi, nhưng ai quyết định người đó được đọc đơn hàng nào?

Cần biết trước: HTTP, JWT và transaction. Ví dụ là dịch vụ đơn hàng giả lập, Python 3.14.4/PyJWT 2.15.1/cryptography 50.0.2/PostgreSQL 18.6 native trên macOS arm64. Không đăng nhập IdP thật, chưa chạy discovery/JWKS rotation/revocation hoặc PKCE flow; lab chỉ kiểm phần resource server, policy và context DB.

Ba câu hỏi và các biên tin cậy

TầngCâu hỏiNguồn được tin
AuthenticationIssuer nào xác nhận subject và token này có dành cho API?Issuer/key/algorithm/audience cấu hình, chữ ký và claim đã kiểm
Application authorizationSubject có permission cho thao tác và resource này?Membership/role nội bộ cùng scope đã cấp cho access token
Data accessQuery có thể nhìn thấy hàng của tenant nào?Tenant đã map ở backend, role DB và policy trong transaction

SSO dùng phiên của IdP để người dùng vào nhiều ứng dụng; OAuth/OIDC không buộc mọi API phải dùng JWT hay cấm ứng dụng giữ session. ID token dành cho client/RP kiểm kết quả đăng nhập, audience thường là client_id; access token dành cho resource server. Đừng đưa ID token vào API chỉ vì chữ ký hợp lệ. OIDC Core.

Lab chọn access token theo hợp đồng JWT cụ thể: RS256 cố định, issuer/audience tường minh, exp/iat và các claim bắt buộc, typ=at+jwt. JWT access token của hệ khác cần đối chiếu hợp đồng issuer; opaque token có đường validation khác. Không dùng URL/key/algorithm do header token tự cung cấp làm cấu hình tin cậy. RFC 9068 §4.

Backend map (iss,sub) sang user nội bộ; email/display name không là khóa identity bền. Tenant/role lấy từ membership nội bộ, không từ query parameter, header tự đặt hoặc một token role chưa thống nhất contract. UI chỉ phản ánh quyền; API phải deny.

Dựng database lab đã cô lập

Từ lab database, chép lab-common.sh, lab-local.sh, seed-pg.sql, seed-mysql.sql vào thư mục trống. Giữ nguyên guard socket/owner/cleanup; cần cả hai binary theo fixture, dù bài này chỉ query PostgreSQL. Không chạy schema dưới đây trên database của bạn. Cài dependency trong môi trường thử riêng bằng uv run --python 3.14.4 --with PyJWT==2.15.1 --with cryptography==50.0.2.

CREATE ROLE wiki_api LOGIN NOSUPERUSER NOBYPASSRLS NOINHERIT;
CREATE SCHEMA identity_lab;
CREATE TABLE identity_lab.orders (
  id integer PRIMARY KEY,
  tenant text NOT NULL CHECK (tenant IN ('alpha', 'beta')),
  status text NOT NULL
);
INSERT INTO identity_lab.orders VALUES (1,'alpha','paid'), (2,'beta','pending');
GRANT USAGE ON SCHEMA identity_lab TO wiki_api;
GRANT SELECT ON identity_lab.orders TO wiki_api;
ALTER TABLE identity_lab.orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE identity_lab.orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_read ON identity_lab.orders FOR SELECT TO wiki_api
USING (tenant = nullif(current_setting('app.tenant', true), ''));

Đăng nhập DB bằng wiki_api không phải owner, superuser hoặc BYPASSRLS. Thiếu tenant context thì không có hàng; chỉ grant SELECT nên DB cũng không có quyền ghi. Policy này bảo vệ query quên filter tenant. Role có thể tự đổi GUC app.tenant, nên không bảo vệ khỏi SQL injection tùy ý hoặc backend bị chiếm; tenant context phải do đường code tin cậy đặt. SET ROLE không thay permission nghiệp vụ. PostgreSQL RLS.

. ./lab-local.sh
lab_up
lab_target
lab_psql -f setup.sql
printf 'identity fixture ready\n'

API giả với mapping và quyền ở backend

from __future__ import annotations

import os
import subprocess
from dataclasses import dataclass

import jwt
from cryptography.hazmat.primitives.asymmetric import rsa

ISSUER = "https://issuer.example.org"
AUDIENCE = "orders-api"
PRIVATE = rsa.generate_private_key(public_exponent=65537, key_size=2048)
PUBLIC = PRIVATE.public_key()


@dataclass(frozen=True, slots=True)
class User:
    tenant: str
    role: str


USERS = {
    (ISSUER, "user-a"): User("alpha", "reader"),
    (ISSUER, "user-b"): User("beta", "reader"),
    (ISSUER, "user-c"): User("alpha", "none"),
}
PERMISSIONS = {"reader": frozenset({"orders:read"}), "none": frozenset()}


def pg(socket: str, script: str, tenant: str = "alpha", key: int = 1) -> list[str]:
    assert tenant in {"alpha", "beta"}
    env = {
        k: v
        for k, v in os.environ.items()
        if k not in {"PGSERVICE", "PGHOSTADDR", "PGPASSWORD"}
    }
    env.update(
        PGSERVICEFILE="/dev/null",
        PGPASSFILE=f"{socket}/no-pgpass",
        PGOPTIONS="-c client_min_messages=warning",
    )
    out = subprocess.run(
        [
            "psql",
            "-X",
            "-q",
            "-w",
            "-At",
            "-h",
            socket,
            "-p",
            "5432",
            "-U",
            "wiki_api",
            "-d",
            "postgres",
            "-v",
            "ON_ERROR_STOP=1",
            "-v",
            f"tenant={tenant}",
            "-v",
            f"key={key}",
        ],
        input=script,
        capture_output=True,
        text=True,
        env=env,
        check=True,
        timeout=15,
    )
    return out.stdout.splitlines()


def read_order(socket: str, token: str, key: object) -> tuple[int, str]:
    try:
        if jwt.get_unverified_header(token).get("typ") != "at+jwt":
            raise jwt.InvalidTokenError("wrong token kind")
        claims = jwt.decode(
            token,
            PUBLIC,
            algorithms=["RS256"],
            issuer=ISSUER,
            audience=AUDIENCE,
            options={
                "require": [
                    "iss",
                    "aud",
                    "sub",
                    "exp",
                    "iat",
                    "jti",
                    "client_id",
                    "scope",
                ]
            },
        )
        if not isinstance(claims["scope"], str):
            raise jwt.InvalidTokenError("invalid scope")
    except jwt.InvalidTokenError:
        return 401, "invalid_token"
    user = USERS.get((claims["iss"], claims["sub"]))
    if user is None or "orders:read" not in PERMISSIONS[user.role]:
        return 403, "forbidden"
    if "orders:read" not in claims["scope"].split():
        return 403, "insufficient_scope"
    if type(key) is not int:
        return 400, "invalid_id"
    rows = pg(
        socket,
        """BEGIN;
        SELECT set_config('app.tenant', :'tenant', true);
        SELECT 'ROW:'||id||':'||tenant||':'||status FROM identity_lab.orders WHERE id=:'key'::integer;
        COMMIT;""",
        tenant=user.tenant,
        key=key,
    )
    data = [line.removeprefix("ROW:") for line in rows if line.startswith("ROW:")]
    return (200, data[0]) if data else (404, "not_found")

Hai lớp kiểm quyền đều ở backend: role cho phép đọc và token có scope đọc. Query chỉ filter ID để RLS chịu trách nhiệm tenant. ID không phải integer bị trả 400 ngay ở handler; bool cũng không được xem là ID. :'key'::integer quote giá trị theo cú pháp psql rồi ép kiểu SQL, thay vì phép thay thế thô :key. Tenant được quote tương tự. Response404 che hàng không thuộc tenant; lab không có HTTP transport, status/body là kết quả handler giả.

Token sai, quyền thiếu và connection được dùng lại

from __future__ import annotations

import sys
import time
from importlib.metadata import version

import jwt
from auth import AUDIENCE, ISSUER, PRIVATE, pg, read_order
from cryptography.hazmat.primitives.asymmetric import rsa

assert sys.version_info[:3] == (3, 14, 4)
assert version("PyJWT") == "2.15.1" and version("cryptography") == "50.0.2"
socket = sys.argv[1]
now = int(time.time())
payload: dict[str, object] = {
    "iss": ISSUER,
    "aud": AUDIENCE,
    "sub": "user-a",
    "iat": now,
    "exp": now + 300,
    "jti": "fake-request",
    "client_id": "orders-web",
    "scope": "orders:read",
}


def issue(changes: dict[str, object] | None = None, typ: str = "at+jwt") -> str:
    return jwt.encode(
        payload | (changes or {}), PRIVATE, algorithm="RS256", headers={"typ": typ}
    )


for label, token in [
    ("expired", issue({"iat": now - 100, "exp": now - 10})),
    ("audience", issue({"aud": "other-api"})),
    ("issuer", issue({"iss": "untrusted-issuer"})),
    ("id_token", issue({"aud": "orders-web"}, typ="JWT")),
    ("wrong_typ_same_aud", issue(typ="JWT")),
    ("future", issue({"nbf": now + 300})),
    (
        "wrong_key",
        jwt.encode(
            payload,
            rsa.generate_private_key(public_exponent=65537, key_size=2048),
            algorithm="RS256",
            headers={"typ": "at+jwt"},
        ),
    ),
    (
        "wrong_alg",
        jwt.encode(
            payload,
            b"fake-test-key-never-use-in-production",
            algorithm="HS256",
            headers={"typ": "at+jwt"},
        ),
    ),
    ("malformed", "invalid"),
]:
    assert read_order(socket, token, 1) == (401, "invalid_token"), label
    print(label, "401")
missing = payload.copy()
del missing["exp"]
assert (
    read_order(
        socket,
        jwt.encode(missing, PRIVATE, algorithm="RS256", headers={"typ": "at+jwt"}),
        1,
    )[0]
    == 401
)
assert read_order(socket, issue({"sub": "user-c"}), 1) == (403, "forbidden")
assert read_order(socket, issue({"sub": "unknown"}), 1) == (403, "forbidden")
assert read_order(socket, issue({"scope": "orders:write"}), 1) == (
    403,
    "insufficient_scope",
)
assert read_order(socket, issue(), 1) == (200, "1:alpha:paid")
assert read_order(socket, issue(), 2) == (404, "not_found")
assert read_order(socket, issue({"sub": "user-b"}), 2) == (200, "2:beta:pending")
assert read_order(socket, issue({"sub": "user-b"}), 1) == (404, "not_found")
print("missing claim401; no permission403; scope403; tenants200/404")

for invalid_key in [
    (
        "0; SELECT set_config('app.tenant','beta',true); "
        "SELECT 'ROW:'||id||':'||tenant||':'||status FROM identity_lab.orders WHERE id=2"
    ),
    True,
    1.5,
]:
    result = read_order(socket, issue(), invalid_key)
    assert result == (400, "invalid_id"), result
print("untrusted id400; tenant-switch injection rejected")

lines = pg(
    socket,
    """
SELECT 'VERSION:'||current_setting('server_version_num');
SELECT 'ROLE:'||current_user||':'||rolsuper||':'||rolbypassrls FROM pg_roles WHERE rolname=current_user;
SELECT 'PID:'||pg_backend_pid();
SELECT 'EMPTY:'||count(*) FROM identity_lab.orders;
BEGIN;
SELECT set_config('app.tenant','alpha',true);
SELECT 'A:'||count(*) FROM identity_lab.orders;
SELECT 'A_FOREIGN:'||count(*) FROM identity_lab.orders WHERE id=2;
COMMIT;
SELECT 'PID:'||pg_backend_pid();
SELECT 'CLEAR_COMMIT:'||count(*) FROM identity_lab.orders;
BEGIN;
SELECT set_config('app.tenant','beta',true);
SELECT 'B:'||count(*) FROM identity_lab.orders;
SELECT 'B_FOREIGN:'||count(*) FROM identity_lab.orders WHERE id=1;
ROLLBACK;
SELECT 'PID:'||pg_backend_pid();
SELECT 'CLEAR_ROLLBACK:'||count(*) FROM identity_lab.orders;
""",
)
pids = [line for line in lines if line.startswith("PID:")]
assert len(pids) == 3 and len(set(pids)) == 1
assert {
    "VERSION:180006",
    "ROLE:wiki_api:false:false",
    "EMPTY:0",
    "A:1",
    "A_FOREIGN:0",
    "CLEAR_COMMIT:0",
    "B:1",
    "B_FOREIGN:0",
    "CLEAR_ROLLBACK:0",
} <= set(lines), lines
print("RLS same connection A1/B1 foreign0; commit/rollback context0")
. ./lab.env
uv run --python 3.14.4 --with PyJWT==2.15.1 --with cryptography==50.0.2 python check.py "$LAB_DIR"

Expected: token sai401, quyền thiếu403, own tenant200/cross-tenant404; query trực tiếp bỏ WHEREtenant vẫn chỉ thấy một hàng. Ba PID phải cùng một connection, context rỗng sau commit và rollback. Đây là mô phỏng tái dùng connection, chưa kiểm PgBouncer hoặc driver pool. SET LOCAL hết ở cuối transaction; session SET có thể rò context sang request sau. Pooling contracts.

. ./lab-local.sh
lab_clean
lab_clean
test ! -f lab.env

Điều lab chưa bảo đảm

RSA key sinh riêng mỗi process, không lưu/in token/key. Production cần key lấy từ issuer đã tin, TLS/discovery, rotation/cache failure và giới hạn lifetime/revocation đã kiểm. Token đúng signature/expiry không chứng minh user vẫn còn membership; lab dùng mapping backend hiện tại, không đo propagation/identity lifecycle.

Policy DB không hiểu scope hoặc permission endpoint; role đọc trong tenant không được tự suy thành quyền hoàn tiền. API vẫn phải kiểm nghiệp vụ. Superuser/BYPASSRLS và một số table-owner path có thể vượt RLS; FORCE không thắng superuser. Custom GUC có thể đổi bởi SQL, nên dùng principal tin cậy ở biên context và kiểm injection riêng. Lab chỉ SELECT hai hàng, không có write policy, audit durable hoặc chuẩn bảo mật đã chứng nhận. Học tiếp: review AI code, ADR, MVCC.

Python: chọn async, thread hay process từ workload

Câu hỏi: chờ I/O và tính CPU có cùng hưởng lợi từ concurrency không, và pool startup tốn gì?

Cần biết trước: Python, async/await và process. Lab dùng CPython3.14.4, thư viện chuẩn, server HTTP chỉ nghe loopback với dữ liệu giả. Không cần database. Xác nhận build/GIL lúc chạy; không lấy kết quả build này để chứng nhận free-threaded.

Giữ lượng công việc và đầu ra giống nhau

I/O gồm8request mới tới server local, mỗi request chờ0.03s rồi trả số từ task ID. CPU gồm8tác vụ, mỗi tác vụ chạy600000vòng phép tính integer Python thuần. Thread/process giới hạn4worker; async I/O có semaphore4. CPU async không offload: coroutine chạy tính toán trước khi trả, không tự làm Python bytecode song song.

Timer tính toàn vòng create pool, execute, collect và shutdown, gồm IPC ở process. Server startup, expected-result và validation nằm ngoài timer. Chạy3lượt mỗi tổ hợp, luân phiên thứ tự; không ép cold cache. HTTP client blocking và async có implementation khác, vì vậy không quy toàn bộ chênh lệch cho scheduler.

Trên GIL build, nhiều thread không đồng thời chạy bytecode thuần trong một interpreter; I/O và extension có thể nhả GIL. Process có interpreter riêng nhưng phải chuyển input/ output, khởi tạo và quản lifecycle. GIL/build, executor.

Lưu concurrency.py trong thư mục trống

from __future__ import annotations

import asyncio
import json
import multiprocessing
import os
import platform
import statistics
import sys
import sysconfig
import threading
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
from datetime import UTC, datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import TypedDict
from urllib.request import urlopen

MODES = ("seq", "async", "thread", "process")
IDS = tuple(range(8))


class Sample(TypedDict):
    work: str
    mode: str
    repeat: int
    seconds: float


class Handler(BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        task_id = int(self.path.removeprefix("/"))
        time.sleep(0.2 if task_id == 999 else 0.03)
        body = str(task_id * 17).encode()
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        try:
            self.wfile.write(body)
        except BrokenPipeError, ConnectionResetError:
            pass

    def log_message(self, format: str, *args: object) -> None:
        pass


def io_work(item: tuple[int, int]) -> int:
    port, task_id = item
    with urlopen(f"http://127.0.0.1:{port}/{task_id}", timeout=2) as response:
        return int(response.read())


def cpu_work(task_id: int) -> int:
    return sum((i * 17 + task_id) % 997 for i in range(600000))


async def async_io(port: int, task_id: int, limit: asyncio.Semaphore) -> int:
    async with limit:
        reader, writer = await asyncio.open_connection("127.0.0.1", port)
        try:
            writer.write(f"GET /{task_id} HTTP/1.0\r\nHost: localhost\r\n\r\n".encode())
            await writer.drain()
            async with asyncio.timeout(2):
                data = await reader.read()
            headers, body = data.split(b"\r\n\r\n", 1)
            assert headers.startswith(b"HTTP/1.0 200")
            return int(body)
        finally:
            writer.close()
            await writer.wait_closed()


async def async_cpu(task_id: int) -> int:
    return cpu_work(task_id)


async def async_run(work: str, port: int) -> list[int]:
    limit = asyncio.Semaphore(4)
    async with asyncio.TaskGroup() as group:
        tasks = [
            group.create_task(
                async_io(port, i, limit) if work == "io" else async_cpu(i)
            )
            for i in IDS
        ]
    return [task.result() for task in tasks]


def run(work: str, mode: str, port: int) -> list[int]:
    if mode == "seq":
        return [io_work((port, i)) if work == "io" else cpu_work(i) for i in IDS]
    if mode == "async":
        return asyncio.run(async_run(work, port))
    pool = (
        ThreadPoolExecutor(max_workers=4)
        if mode == "thread"
        else ProcessPoolExecutor(
            max_workers=4, mp_context=multiprocessing.get_context("spawn")
        )
    )
    with pool:
        if work == "io":
            return list(pool.map(io_work, ((port, i) for i in IDS)))
        return list(pool.map(cpu_work, IDS))


def ping(payload: bytes) -> tuple[int, int]:
    return len(payload), os.getpid()


def finite_work() -> int:
    time.sleep(0.15)
    return 1


async def cancellation(port: int) -> None:
    tasks: list[asyncio.Task[int]] = []
    try:
        async with asyncio.timeout(0.02):
            async with asyncio.TaskGroup() as group:
                tasks = [
                    group.create_task(async_io(port, 999, asyncio.Semaphore(4)))
                    for _ in range(2)
                ]
    except TimeoutError:
        assert len(tasks) == 2 and all(
            task.done() and task.cancelled() for task in tasks
        )
    else:
        raise AssertionError("timeout không xảy ra")
    assert asyncio.all_tasks() == {asyncio.current_task()}


def main() -> None:
    assert platform.python_version() == "3.14.4"
    gil = sys._is_gil_enabled()
    assert gil and not sysconfig.get_config_var("Py_GIL_DISABLED")
    server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
    server.daemon_threads = False
    server.block_on_close = True
    thread = threading.Thread(target=server.serve_forever)
    thread.start()
    port = server.server_port
    samples: list[Sample] = []
    ipc_samples = []
    try:
        expected = {"io": [i * 17 for i in IDS], "cpu": [cpu_work(i) for i in IDS]}
        for work in ("io", "cpu"):
            for repeat in range(3):
                for mode in MODES[repeat:] + MODES[:repeat]:
                    started = time.perf_counter()
                    result = run(work, mode, port)
                    elapsed = time.perf_counter() - started
                    assert result == expected[work]
                    samples.append(
                        {
                            "work": work,
                            "mode": mode,
                            "repeat": repeat + 1,
                            "seconds": elapsed,
                        }
                    )
        for size in (16, 1048576):
            payload = b"x" * size
            for repeat in range(3):
                started = time.perf_counter()
                with ProcessPoolExecutor(
                    max_workers=4, mp_context=multiprocessing.get_context("spawn")
                ) as pool:
                    ipc_result = list(pool.map(ping, [payload] * 8))
                elapsed = time.perf_counter() - started
                assert all(length == size for length, _ in ipc_result)
                ipc_samples.append(
                    {
                        "bytes_per_task": size,
                        "repeat": repeat + 1,
                        "seconds": elapsed,
                        "workers_observed": len({pid for _, pid in ipc_result}),
                    }
                )
        asyncio.run(cancellation(port))
        with ProcessPoolExecutor(
            max_workers=1, mp_context=multiprocessing.get_context("spawn")
        ) as pool:
            future = pool.submit(finite_work)
            try:
                future.result(timeout=0.01)
            except TimeoutError:
                cancelled = future.cancel()
            else:
                raise AssertionError("future timeout không xảy ra")
        assert future.done() and (future.cancelled() or future.result() == 1)
        assert not multiprocessing.active_children()
        summaries = []
        for work in ("io", "cpu"):
            for mode in MODES:
                times = [
                    s["seconds"]
                    for s in samples
                    if s["work"] == work and s["mode"] == mode
                ]
                assert len(times) == 3
                summaries.append(
                    {
                        "work": work,
                        "mode": mode,
                        "mean_s": statistics.mean(times),
                        "min_s": min(times),
                        "max_s": max(times),
                        "stdev_s": statistics.stdev(times),
                    }
                )
        metadata = {
            "checked_at": datetime.now(UTC).isoformat(),
            "python": platform.python_version(),
            "platform": platform.platform(),
            "logical_cpus": os.cpu_count(),
            "gil_enabled": gil,
            "gil_disabled_build": sysconfig.get_config_var("Py_GIL_DISABLED"),
            "start_method": "spawn",
            "tasks": 8,
            "workers_max": 4,
            "cpu_iterations": 600000,
            "io_delay_s": 0.03,
            "cache": "no eviction; rotated mode order",
            "timer": "create/execute/collect/shutdown including IPC",
            "future_cancel_succeeded": cancelled,
        }
        payload_json = {
            "metadata": metadata,
            "samples": samples,
            "summaries": summaries,
            "ipc_samples": ipc_samples,
        }
        Path("concurrency-results.json").write_text(
            json.dumps(payload_json, indent=2) + "\n"
        )
        sys.stdout.write("CONC_RESULT " + json.dumps(payload_json) + "\n")
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=2)
        assert not thread.is_alive() and not multiprocessing.active_children()
    sys.stdout.write(
        "samples=24 outputs=equal timeout/cancel=ok server/tasks/processes=closed\n"
    )


if __name__ == "__main__":
    main()
set -euo pipefail
python3 -B concurrency.py
samples=24 outputs=equal timeout/cancel=ok server/tasks/processes=closed
set -euo pipefail
test -f concurrency-results.json

Kết quả và startup/IPC

Đo2026-10-03 lúc07:59:28UTC, macOS27.0.1arm64/10logicalCPU, Python3.14.4, GILenabled=true, Py_GIL_DISABLED=0, process dùngspawn. Thời gian giây, mỗi dòng3sample; không evict cache, thứ tự cách chạy luân phiên.

WorkloadCáchMean (s)Min (s)Max (s)Stddev (s)
ioseq0.3199600.3167770.3239470.003652
ioasync0.0887060.0851600.0928300.003868
iothread0.0778330.0710860.0813320.005844
ioprocess0.1686880.1569170.1817910.012491
cpuseq0.1665120.1574630.1830310.014328
cpuasync0.1543480.1525830.1578630.003044
cputhread0.1523410.1520200.1526380.000310
cpuprocess0.1182920.1168020.1204410.001907

I/O thread/async thấp hơn seq trong lần đo này, còn process trả thêm startup. CPU process có mean thấp hơn ba cách còn lại ở lượng việc này; vẫn gồm create/ IPC/shutdown. CPU thread/async gần thời gian seq, không chứng minh bytecode chạy parallel: CPU async không có điểm await, và build đang bật GIL. Biến thiên và khác biệt overhead có thể làm mean nhích; cần profile nếu muốn quy nguyên nhân.

Ping16B có ba thời gian0.073312/0.073714/0.071822s;1MiB là 0.073671/0.073873/0.075469s. Ping nhỏ quan sát2–4PID thực sự nhận việc, ping lớn4PID; max_workers4 không có nghĩa mọi worker nhận cùng số task. Ping16B/1MiB đo create pool + input serialization + IPC + length computation + collect/shutdown; không phải chi phí IPC thuần. Khoảng thời gian chồng nhau; không trừ hai mean để gán một chi phí transfer chắc chắn. JSON giữ24sample,8summary và6ping sample để đọc lại từng lượt; lần chạy lại có thể khác số. Tất cả kết quả workload bằng expected, timeout/cancel/cleanup đạt.

Timeout không tự giết công việc đang chạy

TaskGroup gom task và await cleanup. asyncio.timeout hủy ở điểm coroutine có thể nhường quyền; finally đóng connection và CancelledError được truyền tiếp. CPU thuần không yield có thể giữ loop qua deadline; đổi thành asyncdef không giải quyết. Task/cancellation.

Future timeout chỉ dừng chờ; cancel có thể thất bại nếu đã running. Lab ghi kết quả cancel, chờ công việc hữu hạn0.15s hoàn thành khi đóng pool, rồi kiểm active_children rỗng. Một công việc treo vô hạn cần policy khác; không gọi timeout là kill. Server vẫn có thể xử lý request client đã hủy; fixture hữu hạn và server_close chờ handlers.

Free-threaded là build/runtime khác; extension có thể làm GIL bật lại. Kiểm sys._is_gil_enabled() bên cạnh build flag rồi benchmark trên build ấy. sys runtime. Chọn từ thời gian và lượng công việc thật, chi phí quản pool và giới hạn tài nguyên; không chọn process chỉ vì có chữ CPU trong tên task.

Node.js: event loop bị nghẽn trông như thế nào?

Câu hỏi: một request CPU có làm request I/O chậm theo, và chuyển sang worker phải trả chi phí gì?

Cần biết trước: JavaScript Promise, HTTP và process. Lab dùng Node.js 24.18.0, chỉ built-in modules, dữ liệu giả và HTTP loopback. Client chạy ở process riêng với server; đồng hồ client không bị CPU server chặn. Chưa đo production hoặc mạng xa.

Chốt phép so

Mỗi lượt cùng 4 CPU job, mỗi job 60.000.000 vòng tính integer; hai job gửi đồng thời, hai wave. Trong mỗi wave gửi 8 request I/O, endpoint chờ 5 ms. Cả hai cách phải trả đúng mọi kết quả. Inline chạy CPU trên loop server; phương án kia có pool 2 worker tái sử dụng, không queue chờ trong pool. Khi cả hai bận, request CPU thứ ba trả 503.

Chạy 3 lượt mỗi cách, đổi thứ tự inline/worker; mỗi lượt server mới. Worker/process startup được ghi riêng, không cộng vào request latency. Không gọi đây là pool warm production hoặc benchmark throughput tối đa. Vòng CPU async/Promise vẫn có thể chặn loop nếu chạy JavaScript đồng bộ.

Worker phù hợp CPU; I/O bất đồng bộ thường không cần chuyển vào worker. Pool tránh khởi tạo một worker/request nhưng thêm messaging và lifecycle. worker_threads.

Lưu ba file trong thư mục trống

import { parentPort } from 'node:worker_threads';

parentPort.on('message', ({ id, n }) => {
  let value = 0;
  for (let i = 0; i < n; i++) value += (i + id) % 997;
  parentPort.postMessage(value);
});

Server giữ worker capacity 2 và timer I/O của chính nó. Không có job chạy vô hạn. Job số 93 dành riêng cho phép thử timeout, chạy 600.000.000 vòng để quan sát rõ worker vẫn bận sau khi client ngắt; job này nằm ngoài mẫu benchmark. Shutdown đóng endpoint và worker thuộc fixture; không tác động service khác.

import assert from 'node:assert/strict';
import http from 'node:http';
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
import { Worker } from 'node:worker_threads';

assert.equal(process.version, 'v24.18.0');
const mode = process.argv[2];
assert.ok(['inline', 'worker'].includes(mode));
const slots = [];
const startup = performance.now();
if (mode === 'worker') {
  for (let i = 0; i < 2; i++) {
    const worker = new Worker(new URL('./worker.mjs', import.meta.url));
    const slot = { worker, job: null, gone: false };
    worker.on('message', (value) => {
      const job = slot.job;
      slot.job = null;
      if (job) job.resolve(value);
    });
    const fail = (error) => {
      slot.gone = true;
      if (slot.job) slot.job.reject(error);
      slot.job = null;
    };
    worker.on('error', fail);
    worker.on('exit', () => fail(new Error('worker exit')));
    slots.push(slot);
  }
  await Promise.all(slots.map(({ worker }) => new Promise((resolve, reject) => {
    worker.once('online', resolve);
    worker.once('error', reject);
  })));
}
const workerStartupMs = performance.now() - startup;
const delay = monitorEventLoopDelay({ resolution: 10 });
delay.enable();
const timers = new Map();
let stopping = false;
const active = () => slots.filter((slot) => slot.job !== null).length;
const cpu = (id, n) => {
  if (mode === 'inline') {
    let value = 0;
    for (let i = 0; i < n; i++) value += (i + id) % 997;
    return Promise.resolve(value);
  }
  const slot = slots.find((item) => !item.job && !item.gone);
  if (!slot) throw new Error('busy');
  return new Promise((resolve, reject) => {
    slot.job = { resolve, reject };
    slot.worker.postMessage({ id, n });
  });
};
const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1');
  const send = (status, body) => {
    if (!res.destroyed) {
      res.writeHead(status, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(body));
    }
  };
  if (stopping) return send(503, { error: 'stopping' });
  if (url.pathname === '/state') return send(200, { active: active() });
  if (url.pathname === '/metrics') return send(200, {
    meanMs: delay.mean / 1e6, maxMs: delay.max / 1e6,
    p99Ms: delay.percentile(99) / 1e6, samples: delay.count, active: active(),
  });
  if (url.pathname === '/io') {
    await new Promise((resolve) => {
      const timer = setTimeout(() => { timers.delete(timer); resolve(); }, 5);
      timers.set(timer, resolve);
    });
    return send(200, { ok: true });
  }
  if (url.pathname !== '/cpu') return send(404, { error: 'unknown route' });
  const id = Number(url.searchParams.get('id'));
  if (!Number.isInteger(id) || id < 0 || id > 99) return send(400, { error: 'id' });
  try {
    send(200, { value: await cpu(id, id === 93 ? 600000000 : 60000000) });
  } catch (error) {
    send(error.message === 'busy' ? 503 : 500, { error: error.message });
  }
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
console.log(JSON.stringify({ ready: true, port: address.port, workerStartupMs }));

process.once('SIGTERM', async () => {
  stopping = true;
  const closed = new Promise((resolve) => server.close(resolve));
  server.closeAllConnections();
  for (const [timer, resolve] of timers) { clearTimeout(timer); resolve(); }
  timers.clear();
  await Promise.all(slots.map((slot) => slot.worker.terminate()));
  await closed;
  delay.disable();
  assert.equal(server.listening, false);
  assert.ok(slots.every(({ worker }) => worker.threadId === -1));
  console.log(JSON.stringify({ stopped: true, listening: false, workers: slots.length, timers: timers.size }));
});

Client giữ timeout hữu hạn, đối chiếu output, đo wall time từng request và tắt child qua SIGTERM. Dùng một Agent riêng mỗi lượt; không tái dùng socket của service khác.

import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { writeFile } from 'node:fs/promises';
import http from 'node:http';
import { availableParallelism } from 'node:os';
import { performance } from 'node:perf_hooks';
import readline from 'node:readline';
import { setTimeout as sleep } from 'node:timers/promises';

assert.equal(process.version, 'v24.18.0');
const expected = (id) => {
  const sum = (n) => Math.floor(n / 997) * 997 * 996 / 2 + (n % 997) * (n % 997 - 1) / 2;
  return sum(60000000 + id) - sum(id);
};
async function run(mode, repeat) {
  const started = performance.now();
  const child = spawn(process.execPath, ['server.mjs', mode], { stdio: ['ignore', 'pipe', 'pipe'] });
  let stderr = '';
  child.stderr.setEncoding('utf8').on('data', (data) => { stderr += data; });
  const lines = readline.createInterface({ input: child.stdout });
  const messages = [];
  lines.on('line', (line) => messages.push(JSON.parse(line)));
  const exited = new Promise((resolve) => child.once('close', (code) => resolve(code)));
  const agent = new http.Agent({ keepAlive: true, maxSockets: 20 });
  let port;
  const get = (path, timeout = 5000) => new Promise((resolve, reject) => {
    const begun = performance.now();
    const req = http.get({ hostname: '127.0.0.1', port, path, agent }, (res) => {
      let body = '';
      res.setEncoding('utf8').on('data', (data) => { body += data; });
      res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(body), ms: performance.now() - begun }));
      res.on('error', reject);
    });
    req.setTimeout(timeout, () => req.destroy(new Error('deadline')));
    req.on('error', reject);
  });
  const wait = async (predicate) => {
    const deadline = performance.now() + 5000;
    while (performance.now() < deadline) {
      if (await predicate()) return;
      assert.equal(child.exitCode, null, stderr);
      await sleep(5);
    }
    throw new Error('barrier timeout');
  };
  try {
    await wait(() => messages.some((message) => message.ready));
    const ready = messages.find((message) => message.ready);
    port = ready.port;
    const startupMs = performance.now() - started;
    await sleep(30);
    const cpuLatencies = [];
    const ioLatencies = [];
    for (let wave = 0; wave < 2; wave++) {
      const ids = [wave * 2, wave * 2 + 1];
      const jobs = ids.map((id) => get(`/cpu?id=${id}`));
      await sleep(10);
      const probes = Array.from({ length: 8 }, () => get('/io'));
      const cpuResults = await Promise.all(jobs);
      const ioResults = await Promise.all(probes);
      cpuResults.forEach((result, i) => {
        assert.equal(result.status, 200);
        assert.equal(result.body.value, expected(ids[i]));
        cpuLatencies.push(result.ms);
      });
      ioResults.forEach((result) => {
        assert.equal(result.status, 200);
        assert.equal(result.body.ok, true);
        ioLatencies.push(result.ms);
      });
    }
    await sleep(20);
    const metrics = (await get('/metrics')).body;
    assert.ok(metrics.samples > 0 && metrics.maxMs > 0);
    assert.equal(metrics.active, 0);
    if (mode === 'worker') {
      const occupied = [get('/cpu?id=90'), get('/cpu?id=91')];
      await wait(async () => (await get('/state')).body.active === 2);
      assert.equal((await get('/cpu?id=92')).status, 503);
      const occupiedResults = await Promise.all(occupied);
      assert.ok(occupiedResults.every((result) => result.status === 200));
      const timed = get('/cpu?id=93', 50).then(
        () => new Error('unexpected completion'), (error) => error,
      );
      await wait(async () => (await get('/state')).body.active === 1);
      assert.match((await timed).message, /deadline/);
      assert.equal((await get('/state')).body.active, 1);
      await wait(async () => (await get('/state')).body.active === 0);
    }
    return { mode, repeat, startupMs, workerStartupMs: ready.workerStartupMs, cpuLatencies, ioLatencies, loop: metrics, overload: mode === 'worker' ? 503 : 'not tested', timeout: mode === 'worker' ? 'client deadline; finite worker finished' : 'not tested' };
  } finally {
    agent.destroy();
    child.kill('SIGTERM');
    const timer = setTimeout(() => child.kill('SIGKILL'), 5000);
    const code = await exited;
    clearTimeout(timer);
    lines.close();
    assert.equal(code, 0, stderr);
    assert.ok(messages.some((message) => message.stopped && !message.listening && message.timers === 0));
    assert.equal(stderr, '');
  }
}
const samples = [];
for (let repeat = 1; repeat <= 3; repeat++) {
  for (const mode of repeat % 2 ? ['inline', 'worker'] : ['worker', 'inline']) samples.push(await run(mode, repeat));
}
const payload = { metadata: { measuredAt: new Date().toISOString(), node: process.version, arch: process.arch, platform: process.platform, cpus: availableParallelism(), cpuIterations: 60000000, cpuJobs: 4, ioRequests: 16, ioDelayMs: 5, workerCapacity: 2, loopResolutionMs: 10, repeats: 3, startupExcludedFromRequestLatency: true }, samples };
await writeFile('eventloop-results.json', JSON.stringify(payload, null, 2) + '\n');
console.log('NODE_RESULT ' + JSON.stringify(payload));
console.log('runs=6 outputs=equal capacity=2 overload=503 timeout=checked shutdown=closed');
set -euo pipefail
node --check worker.mjs
node --check server.mjs
node --check study.mjs
node study.mjs
runs=6 outputs=equal capacity=2 overload=503 timeout=checked shutdown=closed
set -euo pipefail
test -f eventloop-results.json

Đọc request latency cùng loop delay

Đo lúc 08:13:58 UTC ngày 2026-10-03, Node.js 24.18.0, macOS arm64, 10 logical CPU. Đây là wall time đo được trên máy này; không phải cam kết latency cho máy khác. JSON eventloop-results.json giữ từng request và window của mỗi lượt. Bảng dùng ms, mean CPU từ 4 request và mean/max I/O từ 16 request mỗi lượt:

CáchLượtStartup msCPU mean msI/O mean msI/O max msLoop max msMẫu loop
inline139.15064.81080.17182.66947.9079
worker156.24345.6798.7589.87312.19012
worker248.75443.0058.65711.90212.03412
inline236.69262.64977.54580.32646.4989
inline337.67062.04076.17079.10945.8429
worker349.75744.1658.04910.76512.32912

I/O mean giảm rõ trong cả ba lượt khi CPU sang worker. Worker startup riêng là 18.367, 11.416 và 11.538 ms; startup process cộng worker nằm ở cột Startup, được loại khỏi request latency. Loop max và request max đo hai đại lượng khác nhau: I/O có thể phải chờ nhiều công việc CPU liên tiếp trước khi callback được phục vụ. Worker CPU mean thấp hơn ở workload này; chưa đo chi phí truyền payload lớn, nhiều worker hoặc CPU contention với service khác nên không kết luận worker luôn nhanh hơn.

monitorEventLoopDelay trả nanosecond, lab đổi sang ms. Resolution 10 ms là nhịp lấy mẫu, không phải latency request và không là CPU%. P99 histogram với ít sample không có độ tin cậy của một production SLO. perf_hooks.

Timeout client không dừng CPU đã gửi vào worker. Lab quan sát slot bận trước và sau socket timeout 50 ms, rồi chờ công việc hữu hạn hoàn tất. req.setTimeout đo thời gian socket không hoạt động; không phải deadline tổng cho mọi dạng HTTP streaming. Không trả slot về idle sớm rồi giao task khác cho worker còn bận. Pool không có queue, 503 là tín hiệu quá tải để caller giảm concurrency/retry có ngân sách, không retry vô hạn.

Shutdown gọi server.close/closeAllConnections với fixture đã kiểm riêng, terminate worker và await child close sau khi các stream đã đóng. Đây không là production drain policy: request đang chạy có thể bị ngắt nếu shutdown cưỡng chế. HTTPclose. Học tiếp: Python concurrency, idempotent retry.

N+1: đo SQL và dữ liệu khi nạp bằng EF Core

Câu hỏi bài này trả lời: một màn hình chỉ có vài đơn hàng nhưng phát ra nhiều SQL; sửa bằng Include, split hay projection, và kiểm thế nào để không đánh đổi dữ liệu đúng lấy số query thấp?

Cần biết trước: C#, LINQ, quan hệ một–nhiều và lab database. Bài đọc PostgreSQL 18.6 trong fixture local; .NET SDK 10.0.401/runtime 10.0.12, EF Core và provider Npgsql 10.0.0 là môi trường thử trên macOS arm64. Nhánh Docker/Linux chưa kiểm. Ghim package để đo một tổ hợp cụ thể, không coi đây là đề xuất phiên bản mới nhất cho production.

Cùng một màn hình, cùng một seed

Màn hình trả ID đơn, tên khách và các dòng SKU/số lượng. Dùng ba bảng wiki_lab.orders, customers, order_items: mỗi đơn có đúng ba dòng. Chọn N đơn đầu theo ID; sắp dòng theo ID trước khi so kết quả.

N+1 ở đây được viết tường minh: một query lấy đơn, rồi mỗi đơn gọi một query khách và một query dòng. Vì vậy cần kiểm 1 + 2N, không mặc định N+1 luôn có đúng N+1 câu. Không bật proxy hay lazy loading. Cả bốn cách dùng context mới và no tracking; cache entity hoặc navigation fixup của context cũ không được che query.

Tạo thư mục trống và chép bốn file cách A của bài lab: lab-common.sh, lab-local.sh, seed-pg.sql, seed-mysql.sql. Các khối bash chạy ở thư mục này. lab_seed reset schema nên không dùng một lab còn chứa phép thử cần giữ.

. ./lab-local.sh
lab_up
lab_seed
lab_whoami

Project nhỏ, không migration hay server HTTP

Lưu hai file sau vào thư mục eflab. Không gọi EnsureDeleted, EnsureCreated hoặc SaveChanges: chương trình chỉ đọc fixture.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
    <PackageReference Include="Npgsql" Version="10.0.0" />
  </ItemGroup>
</Project>
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;
using Npgsql;

if (args.Length != 2 || !int.TryParse(args[1], out var n) || n < 1 || n > 20)
    throw new ArgumentException("Dùng: mode N, với 1 <= N <= 20");
var mode = args[0];
if (!new[] { "nplus1", "include", "split", "projection" }.Contains(mode))
    throw new ArgumentException("Mode không hợp lệ");
var dir = Environment.GetEnvironmentVariable("LAB_DIR")
    ?? throw new InvalidOperationException("Thiếu LAB_DIR");
if (!Path.IsPathRooted(dir) || !File.Exists(Path.Combine(dir, ".wiki-lab")))
    throw new InvalidOperationException("Thiếu dấu xác nhận lab");
Environment.SetEnvironmentVariable("PGPASSWORD", null);
Environment.SetEnvironmentVariable("PGPASSFILE", Path.Combine(dir, "no-pgpass"));
var connection = new NpgsqlConnectionStringBuilder
{
    Host = dir, Username = "lab", Database = "postgres", Pooling = false,
    Passfile = Path.Combine(dir, "no-pgpass"), Timeout = 10
}.ConnectionString;
await using (var probe = new NpgsqlConnection(connection))
{
    await probe.OpenAsync();
    await using var check = new NpgsqlCommand(
        "SELECT value FROM wiki_lab.lab_info WHERE key='lab'", probe);
    if ((string?)await check.ExecuteScalarAsync() != "wiki-lab")
        throw new InvalidOperationException("Kết nối không phải lab");
}
using var log = new StreamWriter($"sql-{mode}-{n}.log");
var options = new DbContextOptionsBuilder<LabDb>()
    .UseNpgsql(connection)
    .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)
    .LogTo(log.WriteLine, new[] { RelationalEventId.CommandExecuted },
        LogLevel.Information, DbContextLoggerOptions.SingleLine)
    .Options;
await using var db = new LabDb(options);
db.ChangeTracker.LazyLoadingEnabled = false;
var roots = db.Orders.OrderBy(o => o.Id).Take(n);
List<OrderDto> result;
if (mode == "nplus1")
{
    result = new();
    foreach (var order in await roots.ToListAsync())
    {
        var customer = await db.Customers.SingleAsync(c => c.Id == order.CustomerId);
        var items = await db.Items.Where(i => i.OrderId == order.Id).OrderBy(i => i.Id)
            .Select(i => new ItemDto(i.Sku, i.Qty)).ToListAsync();
        result.Add(new OrderDto(order.Id, customer.Name, items));
    }
}
else if (mode == "projection")
{
    result = await roots.AsSingleQuery().Select(o => new OrderDto(o.Id, o.Customer.Name,
        o.Items.OrderBy(i => i.Id).Select(i => new ItemDto(i.Sku, i.Qty)).ToList()))
        .ToListAsync();
}
else
{
    var query = roots.Include(o => o.Customer).Include(o => o.Items);
    var orders = await (mode == "split" ? query.AsSplitQuery() : query.AsSingleQuery())
        .ToListAsync();
    result = orders.Select(o => new OrderDto(o.Id, o.Customer.Name,
        o.Items.OrderBy(i => i.Id).Select(i => new ItemDto(i.Sku, i.Qty)).ToList()))
        .ToList();
}
if (db.ChangeTracker.Entries().Any())
    throw new InvalidOperationException("Đã track entity ngoài dự kiến");
await File.WriteAllTextAsync($"result-{mode}-{n}.json", JsonSerializer.Serialize(result));
Console.WriteLine($"{mode} N={n} orders={result.Count} items={result.Sum(o => o.Items.Count)} tracked=0");

record ItemDto(string Sku, int Qty);
record OrderDto(int Id, string Customer, List<ItemDto> Items);

sealed class LabDb(DbContextOptions<LabDb> options) : DbContext(options)
{
    public DbSet<OrderRow> Orders => Set<OrderRow>();
    public DbSet<CustomerRow> Customers => Set<CustomerRow>();
    public DbSet<ItemRow> Items => Set<ItemRow>();
    protected override void OnModelCreating(ModelBuilder model)
    {
        model.Entity<OrderRow>(b =>
        {
            b.ToTable("orders", "wiki_lab");
            b.HasKey(o => o.Id);
            b.Property(o => o.Id).HasColumnName("id");
            b.Property(o => o.CustomerId).HasColumnName("customer_id");
            b.Property(o => o.Status).HasColumnName("status");
            b.Property(o => o.TotalCents).HasColumnName("total_cents");
            b.HasOne(o => o.Customer).WithMany().HasForeignKey(o => o.CustomerId);
            b.HasMany(o => o.Items).WithOne().HasForeignKey(i => i.OrderId);
        });
        model.Entity<CustomerRow>(b =>
        {
            b.ToTable("customers", "wiki_lab");
            b.HasKey(c => c.Id);
            b.Property(c => c.Id).HasColumnName("id");
            b.Property(c => c.Name).HasColumnName("name");
            b.Property(c => c.Country).HasColumnName("country");
        });
        model.Entity<ItemRow>(b =>
        {
            b.ToTable("order_items", "wiki_lab");
            b.HasKey(i => i.Id);
            b.Property(i => i.Id).HasColumnName("id");
            b.Property(i => i.OrderId).HasColumnName("order_id");
            b.Property(i => i.Sku).HasColumnName("sku");
            b.Property(i => i.Qty).HasColumnName("qty");
            b.Property(i => i.PriceCents).HasColumnName("price_cents");
        });
    }
}
sealed class OrderRow
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public string Status { get; set; } = "";
    public int TotalCents { get; set; }
    public CustomerRow Customer { get; set; } = null!;
    public List<ItemRow> Items { get; set; } = new();
}
sealed class CustomerRow
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public string Country { get; set; } = "";
}
sealed class ItemRow
{
    public int Id { get; set; }
    public int OrderId { get; set; }
    public string Sku { get; set; } = "";
    public int Qty { get; set; }
    public int PriceCents { get; set; }
}

Mapper chỉ có cột cần cho ví dụ; Include còn nạp status/total/country/price, trong khi DTO projection không cần chúng. Log chỉ ghi sự kiện CommandExecuted thành công, không bật sensitive-data logging. Probe lab dùng Npgsql trực tiếp trước khi mở log EF; seed, restore/build và probe không thuộc số query màn hình.

Kiểm dữ liệu và đếm lại từ log

Lưu checker bên ngoài eflab, cạnh các file lab. Expected DTO được tính từ công thức seed, không dùng kết quả N+1 làm chân lý cho ba cách kia.

import json
from pathlib import Path

for n in (3, 7):
    expected = [
        {"Id": g, "Customer": f"customer-{g * g % 2000 + 1}",
         "Items": [{"Sku": f"sku-{g * i * 31 % 500}", "Qty": i} for i in (1, 2, 3)]}
        for g in range(1, n + 1)
    ]
    results = []
    for mode, count in (("nplus1", 1 + 2 * n), ("include", 1), ("split", 2), ("projection", 1)):
        data = json.loads(Path(f"result-{mode}-{n}.json").read_text())
        sql = Path(f"sql-{mode}-{n}.log").read_text()
        actual = sql.count("Executed DbCommand")
        assert actual == count, (mode, n, actual, count)
        assert data == expected, (mode, n, "DTO khác công thức seed")
        assert sum(len(o["Items"]) for o in data) == n * 3
        results.append(data)
        print(f"{mode} N={n} queries={actual} orders={len(data)} items={n * 3}")
    assert all(data == results[0] for data in results)
    single = Path(f"sql-include-{n}.log").read_text()
    projected = Path(f"sql-projection-{n}.log").read_text()
    assert single.count("JOIN") >= 2
    for column in ("status", "total_cents", "country", "price_cents"):
        assert column in single and column not in projected, (n, column)
print("DTO equal; seed equal")
dotnet restore eflab/eflab.csproj --source https://api.nuget.org/v3/index.json
dotnet build eflab/eflab.csproj --no-restore -c Release
. ./lab-local.sh
. ./lab.env
lab_target
for n in 3 7; do
  for mode in nplus1 include split projection; do
    dotnet run --project eflab/eflab.csproj --no-build -c Release -- "$mode" "$n" >/dev/null
  done
done
python3 check-ef.py

Expected cần kiểm lại trên máy người đọc (không phải benchmark tốc độ):

nplus1 N=3 queries=7 orders=3 items=9
include N=3 queries=1 orders=3 items=9
split N=3 queries=2 orders=3 items=9
projection N=3 queries=1 orders=3 items=9
nplus1 N=7 queries=15 orders=7 items=21
include N=7 queries=1 orders=7 items=21
split N=7 queries=2 orders=7 items=21
projection N=7 queries=1 orders=7 items=21
DTO equal; seed equal

Mở sql-include-7.log và sql-split-7.log: single ghép khách/dòng vào cùng SELECT; split có SELECT gốc và SELECT collection. Projection vẫn một câu ở provider/version và LINQ này. Một Select khác có thể sinh SQL khác; ToQueryString() giúp xem hình dạng SQL nhưng không chứng minh số câu đã thực thi.

Chạy lại sau reset, giữ code nhưng nạp lại cùng seed:

. ./lab-local.sh
. ./lab.env
lab_seed
for n in 3 7; do
  for mode in nplus1 include split projection; do
    dotnet run --project eflab/eflab.csproj --no-build -c Release -- "$mode" "$n" >/dev/null
  done
done
python3 check-ef.py

Số query chưa phải chi phí tải dữ liệu

Trong seed này, single JOIN cho N đơn × 3 dòng = 3N hàng SQL, dù DTO chỉ có N đơn. Cột đơn/khách lặp trên từng hàng. Split đọc N hàng gốc rồi 3N hàng collection; N+1 đọc N hàng gốc, N hàng khách và 3N hàng dòng qua nhiều câu. Đó là suy luận từ quan hệ và SQL đã xem, không phải phép đo bytes hoặc bộ nhớ. Projection bớt cột nhưng vẫn cần dữ liệu các dòng.

Một câu truyền hàng lớn có thể tốn hơn hai câu nhỏ. Ngược lại, split thêm round trip và buffering; nhiều câu còn có thể thấy dữ liệu ở các thời điểm khác nhau nếu có writer. Lab không có writer, nên DTO bằng nhau ở đây không chứng minh consistency khi có cập nhật đồng thời. Muốn snapshot chung phải chọn transaction/isolation phù hợp với engine và chịu chi phí của nó. Tài liệu single/split giải thích các trade-off này.

Case chỉ Include một collection và một reference; không có tích Cartesian giữa hai collection ngang cấp. Nếu thêm payments cùng cấp với items, số hàng JOIN có thể là items × payments. Đừng gọi mọi dữ liệu lặp là cùng một kiểu Cartesian explosion.

No tracking giảm công việc ChangeTracker; nó không tự loại query trong loop. Projection chỉ chứa scalar/DTO nên không track entity; projection chứa nguyên entity có thể vẫn track. Tài liệu tracking nêu khác biệt này. Lab kiểm tracker rỗng nhưng không đo allocation/peak RSS hay thời gian API, không kết luận projection nhanh hơn một tỷ lệ cố định.

Lỗi thường gặp và cách chọn

Dấu hiệuCần kiểmThay đổi thử
Query tăng cùng NLog theo request; explicit/lazy load trong loopEager loading hoặc DTO projection, so lại dữ liệu
Một query nhưng truyền nhiềuSELECT có cột lớn/lặp, cardinality collectionBớt cột hoặc thử split, đo rows/bytes
Split khác dữ liệu singleWriter, isolation và thứ tự pagingKiểm snapshot, dùng thứ tự xác định theo ID
Query count thấp bất ngờContext cũ, Find/cache, log sai khoảngContext mới, log đúng sự kiện sau chuẩn bị
Restore/build lỗi.NET target, package/provider tương thích, mạng NuGetGiữ version đã ghim, kiểm lỗi trước khi đo

Không đếm câu bằng số lần gọi hàm LINQ: LINQ xây biểu thức, truy vấn thường chạy khi enumerate. SingleAsync trong loop phát query thật ở ví dụ này. Efficient Querying là nguồn đọc thêm về nạp dữ liệu và chọn cột.

Reset, cleanup và bài tập

lab_seed reset schema; lab_clean dừng hai server và xóa dữ liệu riêng theo dấu lab. Log/JSON cùng thư mục thực hành chỉ chứa dữ liệu giả. Nếu bước build/query lỗi, vẫn chạy cleanup; đừng sửa Host sang database thật để thử cho qua.

. ./lab-local.sh
lab_clean
echo cleaned

Ba bài tập chưa chạy trong lượt kiểm của bài: thử N=1/20 và đếm lại; thêm collection payments để quan sát tích hàng; chạy writer giữa hai câu split rồi kiểm isolation. Giữ cùng yêu cầu DTO khi so các phương án.

Học tiếp: EXPLAIN ANALYZE, composite index và deadlock/retry. Package/provider tham chiếu Npgsql EF 10 và NuGet 10.0.0, đọc ngày 2026-10-03.

Pattern xuất hiện khi hợp đồng bên ngoài thay đổi

Câu hỏi: thêm một lớp Adapter bảo vệ phần nào của đơn hàng, và phải trả thêm bao nhiêu bước gọi?

Cần biết trước: hàm Python, type hint và biên module. Lab dùng Python 3.14.4, stdlib và hai SDK giả lập, không gọi dịch vụ vận chuyển thật. Đơn vị tiền là cent nguyên; khối lượng là gram nguyên dương. Không có thuế, tiền tệ khác, network retry hoặc việc tạo shipment.

Bắt đầu bằng một provider

Ban đầu checkout gọi shipping_cost(grams) và cộng phí vào subtotal. Một hàm và SDK cụ thể đủ dùng. Yêu cầu mới: cùng checkout chọn được provider thứ hai, nhận kilogram kiểu Decimal và trả dictionary. Việc đổi đơn vị, tên method và dạng kết quả thuộc biên SDK; nghiệp vụ cộng phí không cần biết những khác biệt này.

Ta đặt hợp đồng quote(grams) -> int, rồi hai Adapter chuyển sang SDK tương ứng. Đây là composition: Adapter giữ việc chuyển đổi ở một biên rồi gọi SDK, không kế thừa implementation SDK để thay nghiệp vụ của nó. Protocol mô tả hợp đồng để type checker kiểm structural typing; nó không tự kiểm response hoặc bảo đảm đúng đơn vị lúc chạy. Python Protocol.

Lưu các file trong một thư mục lab trống

from typing import Protocol


class Shipping(Protocol):
    def quote(self, grams: int) -> int: ...


def validate(subtotal: int, grams: int) -> None:
    if subtotal < 0 or grams <= 0:
        raise ValueError("invalid order")
from decimal import Decimal


class SDKv1:
    def shipping_cost(self, grams: int) -> int:
        return 200 + grams // 10


class SDKv2:
    def estimate(self, kilograms: Decimal) -> dict[str, int]:
        return {"cost_cents": 200 + int(kilograms * 100)}


class FirstShipping:
    def quote(self, grams: int) -> int:
        return SDKv1().shipping_cost(grams)


class SecondShipping:
    def quote(self, grams: int) -> int:
        result = SDKv2().estimate(Decimal(grams) / 1000)
        return result["cost_cents"]
from adapter import SDKv1
from contract import validate


def total(subtotal: int, grams: int) -> int:
    validate(subtotal, grams)
    return subtotal + SDKv1().shipping_cost(grams)
from contract import Shipping, validate


def total(subtotal: int, grams: int, shipping: Shipping) -> int:
    validate(subtotal, grams)
    return subtotal + shipping.quote(grams)

simple.py là bản trước khi có provider thứ hai; checkout.py là bản sau refactor. Chỗ lắp ghép chọn FirstShipping() hoặc SecondShipping(). Không thêm factory, DI container hoặc superclass vì hai lựa chọn chưa cần chúng.

Kiểm cùng hành vi và thay hợp đồng trên file thật

import unittest

import checkout
import simple
from adapter import FirstShipping, SecondShipping


class ContractTests(unittest.TestCase):
    def test_prices(self) -> None:
        for subtotal, grams, expected in [
            (1000, 1000, 1300),
            (0, 2500, 450),
            (99, 1, 299),
        ]:
            with self.subTest(subtotal=subtotal, grams=grams):
                self.assertEqual(simple.total(subtotal, grams), expected)
                for shipping in (FirstShipping(), SecondShipping()):
                    self.assertEqual(
                        checkout.total(subtotal, grams, shipping), expected
                    )

    def test_invalid(self) -> None:
        for subtotal, grams in [(-1, 1000), (1000, 0), (0, -1)]:
            with self.subTest(subtotal=subtotal, grams=grams):
                with self.assertRaisesRegex(ValueError, "invalid order"):
                    simple.total(subtotal, grams)
                for shipping in (FirstShipping(), SecondShipping()):
                    with self.assertRaisesRegex(ValueError, "invalid order"):
                        checkout.total(subtotal, grams, shipping)


if __name__ == "__main__":
    unittest.main()
python3.14 check.py

Yêu cầu tiếp theo: SDKv2 đổi field từ cost_cents sang charge_cents, vẫn giữ cùng phí. Lab đổi cả fake SDK và phần đọc response trong adapter.py, chạy process mới để không dính module cache, rồi khôi phục file. Hash xác nhận checkout, hợp đồng và bản đơn giản không đổi; không lấy việc liệt kê file dự kiến thay cho phép thử.

import hashlib
import subprocess
import sys
from pathlib import Path

assert sys.version_info[:3] == (3, 14, 4)
files = [
    Path(name) for name in ("adapter.py", "checkout.py", "contract.py", "simple.py")
]
before = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in files}
adapter = Path("adapter.py")
original = adapter.read_text()
assert original.count('"cost_cents"') == 2
try:
    adapter.write_text(original.replace('"cost_cents"', '"charge_cents"'))
    after = {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in files}
    changed = sorted(name for name in before if before[name] != after[name])
    assert changed == ["adapter.py"]
    subprocess.run([sys.executable, "check.py"], check=True)
    print("changed=adapter.py; both providers preserved")
finally:
    adapter.write_text(original)
assert {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in files} == before
print("restored")
python3.14 change.py

Tính cả giá của abstraction

Thiết kếBước gọi để tính phíHợp đồng thêmĐiều đã kiểm
Một SDK trực tiếptotal→SDK0 interfaceBa input hợp lệ, ba input sai
Hai provider qua Adaptertotal→Adapter→SDK1 Protocol, 2 lớp Adapter, 1 tham số injectedHai provider cùng kết quả, đổi field chỉ adapter.py

Adapter thêm một bước gọi và chỗ lắp ghép. Hợp đồng vẫn phải thay nếu nghiệp vụ đổi từ trả phí sang giữ chỗ hoặc tạo shipment; timeout/idempotency/error mapping chưa có trong lab. Hai SDK cùng nằm trong một module chỉ để ví dụ gọn, không đo mức cô lập dependency ở package hoặc migration SDK thật.

Nếu chỉ có SDKv1 và không có nhu cầu thay hợp đồng, giữ simple.total là hợp lý. Một helper chuyển đổi dùng chung cũng có thể tạo biên tương tự; tên pattern không làm code tốt hơn và số lớp không phải bằng chứng lợi ích. Đừng tạo lớp chỉ để chuyển tiếp khi bạn kiểm soát được cả hai bên và có thể sửa trực tiếp hợp đồng.

Học tiếp: Repository/ORM kiểm một biên DB, ADR ghi lý do chấp nhận chi phí, và debug có giả thuyết kiểm điều kiện khiến quyết định này sai.

Encoding văn bản: BOM, UTF-8 nghiêm ngặt, CP932 và độ dài tính bằng byte

Câu hỏi bài này trả lời: nhận một chuỗi byte không ghi sẵn encoding thì nhận diện thế nào cho đúng, CP932 khác Shift_JIS ở đâu, byte 0x5C nằm trong ký tự đa byte làm hỏng những việc gì, và vì sao “dài bao nhiêu ký tự” khác “dài bao nhiêu byte”?

Cần biết trước: Python cơ bản (bytes và str). Lab dùng thư viện chuẩn của Python 3.14.4 trên macOS arm64 với chuỗi giả tự sinh, không có tệp thật. Bài lấy tiếng Nhật làm ví dụ vì CP932 là trường hợp khó điển hình; kết luận về byte và ký tự áp dụng cho mọi encoding đa byte.

Byte không tự mang tên encoding

Một str là dãy ký tự (điểm mã Unicode); thứ nằm trong tệp hay trên đường truyền là bytes, và encoding là phép ánh xạ giữa hai thứ đó. Khi người gửi không nói encoding, nhận diện chỉ là đoán, trừ khi có chữ ký ở đầu dữ liệu: BOM (byte order mark). Chuẩn Encoding của WHATWG dùng ba chữ ký khi dò: EF BB BF là UTF-8, FE FF là UTF-16 big-endian, FF FE là UTF-16 little-endian; RFC 3629 ghi rằng với UTF-8, BOM luôn là đúng dãy EF BB BF và chỉ còn tác dụng làm chữ ký (vì UTF-8 không có vấn đề thứ tự byte); tài liệu codecs của Python gọi nó là chữ ký Unicode và có codec utf-8-sig để bỏ nó khi đọc.

Không có BOM thì phải dựa vào cấu trúc byte. Bài dùng thứ tự sau, dựng lại cho dữ liệu tiếng Nhật có thể là UTF-8 hoặc CP932 (bản Shift_JIS của Windows):

  1. Có BOM thì tin BOM.
  2. Chỉ có byte 0x00–0x7F (ASCII thuần) thì hợp lệ ở mọi encoding trong bài, nên không nhận diện được: dùng encoding mặc định đã thỏa thuận.
  3. Giải mã UTF-8 nghiêm ngặt; qua được thì coi là UTF-8.
  4. Không qua UTF-8 mà giải mã được bằng CP932 thì coi là CP932; không qua cả hai thì báo không xác định.

Hai lab dưới đây cho thấy từng bước chạy ra sao và vì sao UTF-8 phải được thử trước CP932. Tạo thư mục trống rồi lưu các tệp.

Lab: nhận diện và UTF-8 nghiêm ngặt

RFC 3629 mô tả UTF-8 dùng 1 đến 4 byte cho dải U+0000..U+10FFFF, cấm mã thay thế U+D800..U+DFFF, nêu mối nguy của dạng dài thừa (ví dụ C0 80 bị giải mã ngây thơ thành U+0000) và liệt kê các byte C0, C1, F5 đến FF không bao giờ xuất hiện. Bộ giải mã UTF-8 mặc định của Python (errors="strict") từ chối các dạng đó; lab kiểm bảy dãy sai và chín ca nhận diện.

import codecs


def valid(data: bytes, encoding: str) -> bool:
    try:
        data.decode(encoding)
    except UnicodeDecodeError:
        return False
    return True


def detect(data: bytes) -> tuple[str, str]:
    """Thứ tự: BOM, ASCII thuần, UTF-8 nghiêm ngặt, CP932, cuối cùng là không xác định."""
    if data.startswith(codecs.BOM_UTF8):
        return "utf-8-sig", "BOM EF BB BF"
    if data.startswith(codecs.BOM_UTF16_LE):
        return "utf-16-le", "BOM FF FE"
    if data.startswith(codecs.BOM_UTF16_BE):
        return "utf-16-be", "BOM FE FF"
    if all(byte < 0x80 for byte in data):
        return "ascii", "chỉ có byte 0x00-0x7F, hợp lệ ở mọi encoding trong bài"
    utf8, cp932 = valid(data, "utf-8"), valid(data, "cp932")
    if utf8 and cp932:
        return "utf-8", "mơ hồ: hợp lệ cả UTF-8 lẫn CP932, chọn UTF-8 theo chính sách"
    if utf8:
        return "utf-8", "hợp lệ UTF-8 nghiêm ngặt, không hợp lệ CP932"
    if cp932:
        return "cp932", "không hợp lệ UTF-8, hợp lệ CP932"
    return "không xác định", "không hợp lệ cả UTF-8 lẫn CP932"


CASES = [
    ("BOM UTF-8 rồi ABC", codecs.BOM_UTF8 + b"ABC", "utf-8-sig"),
    ("BOM UTF-16 LE rồi あ", codecs.BOM_UTF16_LE + "あ".encode("utf-16-le"), "utf-16-le"),
    ("UTF-8 日本語のテキスト", "日本語のテキスト".encode("utf-8"), "utf-8"),
    ("CP932 日本語のテキスト", "日本語のテキスト".encode("cp932"), "cp932"),
    ("ASCII abc", b"abc", "ascii"),
    ("CP932 ① (NEC mở rộng)", "①".encode("cp932"), "cp932"),
    ("UTF-8 あい", "あい".encode("utf-8"), "utf-8"),
    ("CP932 あい", "あい".encode("cp932"), "cp932"),
    ("byte rác 81 20", b"\x81\x20", "không xác định"),
]

INVALID_UTF8 = [
    (b"\xc0\x80", "C0 80: dạng dài thừa của U+0000, byte C0 bị cấm"),
    (b"\xe0\x80\x80", "E0 80 80: dạng dài thừa 3 byte của U+0000"),
    (b"\xed\xa0\x80", "ED A0 80: mã thay thế U+D800 bị cấm"),
    (b"\xf4\x90\x80\x80", "F4 90 80 80: vượt quá U+10FFFF"),
    (b"\xf5\x80\x80\x80", "F5 80 80 80: byte F5 không bao giờ xuất hiện"),
    (b"\xe3\x81", "E3 81: bị cắt giữa ký tự"),
    (b"\x80", "80: byte tiếp nối đứng một mình"),
]

for label, data, expected in CASES:
    encoding, reason = detect(data)
    print(f"{label:24} {data.hex(' '):28} -> {encoding} ({reason})")
    assert encoding == expected, (label, encoding)
assert "mơ hồ" in detect("あい".encode("utf-8"))[1]
assert not valid("①".encode("cp932"), "shift_jis")
for data, why in INVALID_UTF8:
    assert not valid(data, "utf-8"), why
    print(f"UTF-8 nghiêm ngặt từ chối {why}")
assert valid("©".encode("utf-8"), "utf-8") and valid("😀".encode("utf-8"), "utf-8")
assert codecs.BOM_UTF32_LE.startswith(codecs.BOM_UTF16_LE)
utf32 = detect(codecs.BOM_UTF32_LE + "あ".encode("utf-32-le"))[0]
print("giới hạn: dữ liệu UTF-32 LE có BOM bắt đầu bằng FF FE nên bị nhận là", utf32)
assert utf32 == "utf-16-le"
print("nhận diện đúng chín ca, UTF-8 nghiêm ngặt từ chối bảy dãy byte sai")
python3 -B detect.py
BOM UTF-8 rồi ABC        ef bb bf 41 42 43            -> utf-8-sig (BOM EF BB BF)
BOM UTF-16 LE rồi あ      ff fe 42 30                  -> utf-16-le (BOM FF FE)
UTF-8 日本語のテキスト           e6 97 a5 e6 9c ac e8 aa 9e e3 81 ae e3 83 86 e3 82 ad e3 82 b9 e3 83 88 -> utf-8 (hợp lệ UTF-8 nghiêm ngặt, không hợp lệ CP932)
CP932 日本語のテキスト           93 fa 96 7b 8c ea 82 cc 83 65 83 4c 83 58 83 67 -> cp932 (không hợp lệ UTF-8, hợp lệ CP932)
ASCII abc                61 62 63                     -> ascii (chỉ có byte 0x00-0x7F, hợp lệ ở mọi encoding trong bài)
CP932 ① (NEC mở rộng)    87 40                        -> cp932 (không hợp lệ UTF-8, hợp lệ CP932)
UTF-8 あい                 e3 81 82 e3 81 84            -> utf-8 (mơ hồ: hợp lệ cả UTF-8 lẫn CP932, chọn UTF-8 theo chính sách)
CP932 あい                 82 a0 82 a2                  -> cp932 (không hợp lệ UTF-8, hợp lệ CP932)
byte rác 81 20           81 20                        -> không xác định (không hợp lệ cả UTF-8 lẫn CP932)
UTF-8 nghiêm ngặt từ chối C0 80: dạng dài thừa của U+0000, byte C0 bị cấm
UTF-8 nghiêm ngặt từ chối E0 80 80: dạng dài thừa 3 byte của U+0000
UTF-8 nghiêm ngặt từ chối ED A0 80: mã thay thế U+D800 bị cấm
UTF-8 nghiêm ngặt từ chối F4 90 80 80: vượt quá U+10FFFF
UTF-8 nghiêm ngặt từ chối F5 80 80 80: byte F5 không bao giờ xuất hiện
UTF-8 nghiêm ngặt từ chối E3 81: bị cắt giữa ký tự
UTF-8 nghiêm ngặt từ chối 80: byte tiếp nối đứng một mình
giới hạn: dữ liệu UTF-32 LE có BOM bắt đầu bằng FF FE nên bị nhận là utf-16-le
nhận diện đúng chín ca, UTF-8 nghiêm ngặt từ chối bảy dãy byte sai

Đọc kết quả:

  • BOM là tín hiệu duy nhất chắc chắn. Hai ca BOM được nhận ngay ở bước đầu, không cần đoán. Giới hạn: BOM UTF-32 LE bắt đầu bằng FF FE nên cách dò ba chữ ký của WHATWG (và hàm trên) nhận nhầm thành UTF-16 LE.
  • ASCII thuần không nhận diện được. abc hợp lệ ở mọi encoding trong bài, nên hàm chỉ trả ascii; chọn encoding mặc định cho nó là thỏa thuận của hệ thống, không phải kết quả đo.
  • Có ca mơ hồ thật. あい bằng UTF-8 (E3 81 82 E3 81 84) cũng giải mã không lỗi bằng CP932, ra chữ vô nghĩa. Hàm chọn UTF-8 theo chính sách và đánh dấu “mơ hồ”; với chuỗi ngắn không thuật toán nào chắc chắn, cần metadata (header, đặc tả tệp) khi có.
  • UTF-8 nghiêm ngặt từ chối bảy dạng sai gồm dạng dài thừa, mã thay thế, vượt giới hạn, byte không bao giờ xuất hiện, ký tự bị cắt và byte tiếp nối đứng một mình. Đây là lý do “hợp lệ UTF-8” mang nhiều thông tin.
  • Một ca CP932 hợp lệ không phải là bằng chứng. ① (ký tự mở rộng NEC) chỉ giải mã được bằng CP932, không bằng shift_jis chuẩn của Python (assert trong lab); phần tiếp theo nói rõ hơn.

Lab: vì sao phải thử UTF-8 trước CP932

Mức “tín hiệu mạnh hay yếu” của hai phép kiểm đo được. Lab sinh chuỗi ngẫu nhiên từ hai bộ ký tự, mã hóa bằng một encoding và hỏi encoding kia có giải mã được không. Bộ “thường dùng” gồm hiragana, katakana và kanji ở các hàng đầu của bảng (byte đầu 0x88–0x9F) theo tỉ lệ 2:1:1; bộ “toàn bảng” lấy đều 9.604 ký tự hai byte mà cp932 của Python giải mã được. Đây là chuỗi ngẫu nhiên, không phải tiếng Nhật thật: tần suất của câu chữ thật làm tỉ lệ khác đi.

import random

TRIALS = 20_000
LENGTHS = (1, 2, 4, 8, 16, 32)


def double_byte_chars(leads: list[int]) -> list[str]:
    chars = []
    for lead in leads:
        for trail in range(0x40, 0xFD):
            if trail == 0x7F:
                continue
            try:
                chars.append(bytes([lead, trail]).decode("cp932"))
            except UnicodeDecodeError:
                pass
    return chars


HIRAGANA = [chr(code) for code in range(0x3041, 0x3094)]
KATAKANA = [chr(code) for code in range(0x30A1, 0x30F7)]
KANJI = double_byte_chars(list(range(0x88, 0xA0)))
EVERYTHING = double_byte_chars(list(range(0x81, 0xA0)) + list(range(0xE0, 0xFD)))


def common_text(rng: random.Random, length: int) -> str:
    pools = rng.choices([HIRAGANA, KATAKANA, KANJI], weights=[2, 1, 1], k=length)
    return "".join(rng.choice(pool) for pool in pools)


def any_text(rng: random.Random, length: int) -> str:
    return "".join(rng.choice(EVERYTHING) for _ in range(length))


def valid(data: bytes, encoding: str) -> bool:
    try:
        data.decode(encoding)
    except UnicodeDecodeError:
        return False
    return True


def rates(make, length: int, seed: int) -> tuple[float, float]:
    rng = random.Random(seed)
    cp932_ok_as_utf8 = utf8_ok_as_cp932 = 0
    for _ in range(TRIALS):
        text = make(rng, length)
        cp932_ok_as_utf8 += valid(text.encode("cp932"), "utf-8")
        utf8_ok_as_cp932 += valid(text.encode("utf-8"), "cp932")
    return cp932_ok_as_utf8 / TRIALS, utf8_ok_as_cp932 / TRIALS


print(f"hiragana {len(HIRAGANA)}, katakana {len(KATAKANA)}, kanji cấp thấp {len(KANJI)}, toàn bảng hai byte {len(EVERYTHING)}")
table = {}
for name, make in (("thường dùng", common_text), ("toàn bảng", any_text)):
    for length in LENGTHS:
        a, b = rates(make, length, seed=length)
        table[name, length] = (a, b)
        print(f"{name:11} n={length:2}: văn bản CP932 vẫn hợp lệ UTF-8 {a:7.3%} | văn bản UTF-8 vẫn hợp lệ CP932 {b:7.3%}")
assert all(table["thường dùng", n][0] == 0 for n in LENGTHS)
assert max(a for (name, _), (a, _) in table.items() if name == "toàn bảng") < 0.10
assert all(table["thường dùng", n][1] > 0.2 for n in (1, 2, 4, 8))
assert table["thường dùng", 32][1] < table["thường dùng", 8][1]
print("hợp lệ UTF-8 là tín hiệu mạnh, hợp lệ CP932 là tín hiệu yếu")
python3 -B rates.py
hiragana 83, katakana 86, kanji cấp thấp 4375, toàn bảng hai byte 9604
thường dùng n= 1: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 48.385%
thường dùng n= 2: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 58.265%
thường dùng n= 4: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 43.565%
thường dùng n= 8: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 28.945%
thường dùng n=16: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 11.500%
thường dùng n=32: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932  2.090%
toàn bảng   n= 1: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 50.180%
toàn bảng   n= 2: văn bản CP932 vẫn hợp lệ UTF-8  2.055% | văn bản UTF-8 vẫn hợp lệ CP932 53.330%
toàn bảng   n= 4: văn bản CP932 vẫn hợp lệ UTF-8  0.060% | văn bản UTF-8 vẫn hợp lệ CP932 40.740%
toàn bảng   n= 8: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 25.585%
toàn bảng   n=16: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932 10.125%
toàn bảng   n=32: văn bản CP932 vẫn hợp lệ UTF-8  0.000% | văn bản UTF-8 vẫn hợp lệ CP932  1.455%
hợp lệ UTF-8 là tín hiệu mạnh, hợp lệ CP932 là tín hiệu yếu

Đọc kết quả:

  • Văn bản CP932 gần như không bao giờ qua phép kiểm UTF-8. Với bộ thường dùng là 0% ở mọi độ dài, vì byte đầu 0x82, 0x83 và 0x88–0x9F là byte tiếp nối trong UTF-8 và không thể mở đầu một ký tự. Với bộ toàn bảng tối đa khoảng 2% (ở 2 ký tự) rồi về 0 khi dài hơn. Nên “hợp lệ UTF-8” là tín hiệu mạnh.
  • Văn bản UTF-8 hay qua được phép kiểm CP932. Ở 1 đến 8 ký tự có từ khoảng 26% đến 58% chuỗi giải mã không lỗi bằng CP932 (ra mojibake), còn khoảng 10 đến 11,5% ở 16 ký tự và 1,5 đến 2,1% ở 32 ký tự. Nên “hợp lệ CP932” là tín hiệu yếu, nhất là với chuỗi ngắn. Ngoài ra, decoder cp932 của Python còn nhận cả byte đơn 0x80 (xem lab sau), nên còn lỏng hơn mức cần thiết.
  • Hệ quả cho thứ tự. Thử CP932 trước sẽ nhận nhầm một phần lớn văn bản UTF-8 ngắn là CP932; thử UTF-8 trước thì chuỗi CP932 thật gần như luôn bị loại, đúng như thứ tự bốn bước ở đầu bài. Cái giá là ca mơ hồ ở lab trước: chuỗi UTF-8 và CP932 đều hợp lệ thì phải có chính sách rõ ràng.

CP932 không phải Shift_JIS

Tài liệu MySQL nói cp932 khác sjis ở chỗ cp932 hỗ trợ ký tự đặc biệt của NEC, ký tự mở rộng NEC chọn từ IBM và ký tự IBM chọn lọc; một số ký tự cp932 có hai điểm mã cùng đổi sang một điểm mã Unicode, nên khi đổi ngược phải chọn một theo quy tắc Microsoft khuyến nghị. Chuẩn Encoding của WHATWG gom các nhãn shift_jis, sjis, windows-31j, ms_kanji, ms932 và vài nhãn khác vào cùng một encoding, và nêu Windows-31J (CP932) trong mô tả của encoding đó. Tài liệu codecs của Python lại tách hai codec: cp932 (bí danh 932, ms932, mskanji, ms-kanji, windows-31j) và shift_jis (bí danh csshiftjis, shiftjis, sjis, s_jis). Vậy chữ “Shift_JIS” trong một nhãn có thể trỏ tới một trong hai bảng, và lab cho thấy hậu quả.

Lab: ký tự mở rộng, byte 0x5C và nơi nó làm hỏng

Phần đầu so cp932 và shift_jis của Python. Phần hai quét mọi cặp byte để kiểm cấu trúc hai byte và đếm ký tự có byte sau là 0x5C (dấu gạch chéo ngược trong ASCII), rồi cho hai tác hại: tách đường dẫn theo byte, và thoát ký tự theo byte. Phần thoát ký tự dùng một bộ phân tích chuỗi đồ chơi (không có database): nó chỉ phân tích dấu nháy và dấu gạch chéo ngược để cho thấy điều gì xảy ra khi bộ thoát và bộ đọc hiểu encoding khác nhau. Mục đích là hiểu vì sao phải dùng truy vấn tham số hóa và charset đúng, không phải để tấn công.

LEADS = list(range(0x81, 0xA0)) + list(range(0xE0, 0xFD))


def show_hex(data: bytes) -> str:
    return data.hex(" ")


def try_encode(text: str, encoding: str) -> str:
    try:
        return show_hex(text.encode(encoding))
    except UnicodeEncodeError:
        return "không mã hóa được"


def try_decode(data: bytes, encoding: str) -> str:
    try:
        return ", ".join(f"U+{ord(c):04X}" for c in data.decode(encoding))
    except UnicodeDecodeError:
        return "không giải mã được"


def decodes(data: bytes, encoding: str) -> bool:
    try:
        data.decode(encoding)
    except UnicodeDecodeError:
        return False
    return True


print("== CP932 khác Shift_JIS")
print("① (U+2460): cp932", try_encode("①", "cp932"), "| shift_jis", try_encode("①", "shift_jis"))
print("byte 81 60: cp932", try_decode(b"\x81\x60", "cp932"), "| shift_jis", try_decode(b"\x81\x60", "shift_jis"))
roundtrip = "〜".encode("cp932").decode("cp932")
print(f"U+301C qua cp932 một vòng thành U+{ord(roundtrip):04X}")
assert try_encode("①", "shift_jis") == "không mã hóa được" and try_encode("①", "cp932") == "87 40"
assert b"\x81\x60".decode("cp932") != b"\x81\x60".decode("shift_jis")
assert roundtrip == "~"
assert b"\x80".decode("cp932") == "\x80"
print("decoder cp932 của Python còn nhận byte đơn 80 (thành U+0080)")

print("== Byte 0x5C làm byte sau")
pairs = {}
for lead in range(256):
    for trail in range(256):
        try:
            text = bytes([lead, trail]).decode("cp932")
        except UnicodeDecodeError:
            continue
        if len(text) == 1 and not decodes(bytes([lead]), "cp932"):
            pairs[lead, trail] = text
leads_seen = sorted({lead for lead, _ in pairs})
trails_seen = sorted({trail for _, trail in pairs})
print(f"{len(pairs)} ký tự hai byte; byte đầu {leads_seen[0]:#04x}-{leads_seen[-1]:#04x}, byte sau {trails_seen[0]:#04x}-{trails_seen[-1]:#04x}")
assert set(leads_seen) <= set(LEADS)
assert all(0x40 <= t <= 0x7E or 0x80 <= t <= 0xFC for t in trails_seen)
assert 0x5C in trails_seen and 0x41 in trails_seen
five_c = {}
for lead in LEADS:
    try:
        five_c[lead] = bytes([lead, 0x5C]).decode("cp932")
    except UnicodeDecodeError:
        pass
examples = " ".join(f"{c}={show_hex(c.encode('cp932'))}" for c in "表ソ十申")
print(f"{len(five_c)} ký tự cp932 có byte sau là 0x5C, ví dụ: {examples}")
assert len(five_c) == 52 and all(c in five_c.values() for c in "表ソ十申")
path = "データ\\表計算\\ソース.txt"
raw = path.encode("cp932")
naive = raw.split(b"\\")
correct = path.split("\\")
good = sum(1 for part in naive if decodes(part, "cp932"))
print(f"cắt đường dẫn theo byte 5C: {len(naive)} mảnh, {good} mảnh giải mã được; cắt sau khi giải mã: {len(correct)} mảnh")
assert len(naive) > len(correct)


def is_lead(byte: int) -> bool:
    return byte in range(0x81, 0xA0) or byte in range(0xE0, 0xFD)


def literal_end(statement: bytes, charset: str) -> int | None:
    """Vị trí dấu nháy đóng của chuỗi mở ở byte 0; dấu gạch chéo ngược thoát ký tự kế tiếp."""
    i = 1
    while i < len(statement):
        byte = statement[i]
        if charset == "cp932" and is_lead(byte):
            i += 2
        elif byte == 0x5C:
            i += 2
        elif byte == 0x27:
            return i
        else:
            i += 1
    return None


def naive_escape(data: bytes) -> bytes:
    return data.replace(b"\\", b"\\\\").replace(b"'", b"\\'")


def safe_escape(data: bytes, charset: str) -> bytes:
    text = data.decode(charset)
    return text.replace("\\", "\\\\").replace("'", "\\'").encode(charset)


def wrap(escaped: bytes) -> bytes:
    return b"'" + escaped + b"'"


print("== Escape theo byte so với theo ký tự (mô phỏng, không có database)")
attack = b"\x95' OR 1=1 --"
legit = "表' OR 1=1 --".encode("cp932")
for label, data in (("đầu vào sai 95 27", attack), ("văn bản hợp lệ 表'", legit)):
    statement = wrap(naive_escape(data))
    end = literal_end(statement, "cp932")
    tail = statement[end + 1 :].decode("cp932", errors="replace") if end is not None else ""
    print(f"{label}: escape theo byte, chuỗi đóng sớm ở byte {end}/{len(statement) - 1}, phần thoát ra ngoài: {tail!r}")
    assert end is not None and end < len(statement) - 1
    assert literal_end(statement, "utf-8") == len(statement) - 1
safe = wrap(safe_escape(legit, "cp932"))
print("escape theo ký tự cho 表':", "chuỗi đóng đúng cuối" if literal_end(safe, "cp932") == len(safe) - 1 else "đóng sớm")
assert literal_end(safe, "cp932") == len(safe) - 1
try:
    safe_escape(attack, "cp932")
except UnicodeDecodeError:
    print("escape theo ký tự từ chối đầu vào 95 27 vì không hợp lệ CP932")
else:
    raise AssertionError("lẽ ra phải từ chối")
print("CP932 và 0x5C: cắt, thoát và đường dẫn phải làm trên ký tự")
python3 -B cp932.py
== CP932 khác Shift_JIS
① (U+2460): cp932 87 40 | shift_jis không mã hóa được
byte 81 60: cp932 U+FF5E | shift_jis U+301C
U+301C qua cp932 một vòng thành U+FF5E
decoder cp932 của Python còn nhận byte đơn 80 (thành U+0080)
== Byte 0x5C làm byte sau
9604 ký tự hai byte; byte đầu 0x81-0xfc, byte sau 0x40-0xfc
52 ký tự cp932 có byte sau là 0x5C, ví dụ: 表=95 5c ソ=83 5c 十=8f 5c 申=90 5c
cắt đường dẫn theo byte 5C: 5 mảnh, 3 mảnh giải mã được; cắt sau khi giải mã: 3 mảnh
== Escape theo byte so với theo ký tự (mô phỏng, không có database)
đầu vào sai 95 27: escape theo byte, chuỗi đóng sớm ở byte 3/14, phần thoát ra ngoài: " OR 1=1 --'"
văn bản hợp lệ 表': escape theo byte, chuỗi đóng sớm ở byte 5/16, phần thoát ra ngoài: " OR 1=1 --'"
escape theo ký tự cho 表': chuỗi đóng đúng cuối
escape theo ký tự từ chối đầu vào 95 27 vì không hợp lệ CP932
CP932 và 0x5C: cắt, thoát và đường dẫn phải làm trên ký tự

Đọc kết quả:

  • Hai bảng cho kết quả khác nhau. ① mã hóa được bằng cp932 (87 40) nhưng không bằng shift_jis của Python; byte 81 60 giải mã thành U+FF5E ở cp932 và U+301C ở shift_jis; và U+301C đi qua cp932 một vòng trở thành U+FF5E, tức một ký tự đổi thành ký tự khác (khớp mô tả “hai điểm mã cùng đổi sang một điểm mã” của tài liệu MySQL). Dữ liệu từ Windows nên đọc bằng cp932, và phải biết thư viện của bạn dùng bảng nào khi gặp nhãn Shift_JIS.
  • Byte sau của ký tự hai byte nằm trong 0x40–0x7E hoặc 0x80–0xFC, nghĩa là trùng dải ASCII (cả chữ A lẫn dấu \). Lab quét 65.536 cặp byte và thấy 9.604 ký tự hai byte với byte đầu trong 0x81–0x9F và 0xE0–0xFC. Trong đó 52 ký tự có byte sau là 0x5C, ví dụ 表 (95 5C), ソ (83 5C), 十 (8F 5C), 申 (90 5C). Vì vậy một byte 0x5C chưa chắc là dấu gạch chéo ngược: nó có thể là nửa sau của một ký tự.
  • Tách theo byte làm hỏng đường dẫn. Đường dẫn データ\表計算\ソース.txt có hai dấu \ thật nhưng cắt ở mọi byte 5C cho 5 mảnh (chỉ 3 mảnh giải mã được); cắt sau khi giải mã cho đúng 3 mảnh.
  • Thoát theo byte làm hở dấu nháy. Bộ thoát theo byte thêm \ trước dấu nháy; nếu ngay trước đó là byte đầu của ký tự hai byte, \ vừa thêm bị ghép làm byte sau của ký tự đó và dấu nháy ở lại không được thoát. Chuỗi đóng sớm cho cả đầu vào sai (95 27) lẫn văn bản hợp lệ (表'); phần còn lại của đầu vào lộ ra ngoài chuỗi. Bộ đọc hiểu UTF-8 không bị ảnh hưởng vì byte sau trong UTF-8 không bao giờ là 0x5C. Thoát theo ký tự (giải mã nghiêm ngặt rồi mới thoát) cho kết quả đúng cho 表' và từ chối đầu vào sai.
  • Điều này không thay cho truy vấn tham số hóa. Tài liệu C API của MySQL nói việc thoát ký tự phụ thuộc charset đang dùng của kết nối, và khuyên đổi charset bằng mysql_set_character_set() thay vì câu lệnh SET NAMES, vì chỉ hàm trước làm mysql_real_escape_string() biết charset mới. Truy vấn tham số hóa tách dữ liệu khỏi câu lệnh nên không cần thoát; bài không mô phỏng nó và không đo database thật.

Độ dài: điểm mã, byte và đơn vị UTF-16

len() của Python đếm điểm mã Unicode. Số byte phụ thuộc encoding, và đơn vị UTF-16 là cách đếm của một số ngôn ngữ khác (bài không kiểm ngôn ngữ nào). Lab in bảng cho vài chuỗi có chủ đích, rồi thử hai việc hay gặp trong thực tế: giới hạn cột tính bằng byte, và đổi encoding dữ liệu cũ.

import random
import unicodedata

SAMPLES = [
    ("ABC", "ABC"),
    ("アイウ nửa chiều rộng", "アイウ"),
    ("デ có dấu đục", "デ"),
    ("デ toàn chiều rộng", "デ"),
    ("アイウ toàn chiều rộng", "アイウ"),
    ("日本語", "日本語"),
    ("① NEC", "①"),
    ("é dựng sẵn (NFC)", unicodedata.normalize("NFC", "é")),
    ("é tách dấu (NFD)", unicodedata.normalize("NFD", "é")),
    ("😀", "😀"),
    ("👨‍👩‍👧 gia đình", "👨‍👩‍👧"),
]


def cp932_bytes(text: str) -> str:
    try:
        return str(len(text.encode("cp932")))
    except UnicodeEncodeError:
        return "không"


print(f"{'chuỗi':26} {'điểm mã':>8} {'UTF-8':>6} {'UTF-16':>7} {'CP932':>6}")
for label, text in SAMPLES:
    utf16 = len(text.encode("utf-16-le")) // 2
    print(f"{label:26} {len(text):8} {len(text.encode('utf-8')):6} {utf16:7} {cp932_bytes(text):>6}")
lookup = dict(SAMPLES)
assert len("アイウ") == 3 and len("アイウ".encode("cp932")) == 3 and len("アイウ".encode("utf-8")) == 9
assert len("😀") == 1 and len("😀".encode("utf-8")) == 4 and len("😀".encode("utf-16-le")) == 4
assert len("デ") == 2 and unicodedata.normalize("NFKC", "デ") == "デ"
assert len(lookup["é dựng sẵn (NFC)"]) == 1 and len(lookup["é tách dấu (NFD)"]) == 2
try:
    "😀".encode("cp932")
except UnicodeEncodeError:
    pass
else:
    raise AssertionError("CP932 không có emoji")
print("NFKC: デ (2 điểm mã) thành デ (1 điểm mã)")

print("== Cột 10 byte")
LIMIT = 10
text = "日本語テキスト"
for encoding in ("cp932", "utf-8"):
    print(f"{encoding}: {len(text)} ký tự, {len(text.encode(encoding))} byte; len(text) <= {LIMIT} là {len(text) <= LIMIT}")
assert len(text) <= LIMIT < len(text.encode("cp932"))


def truncate(text: str, limit: int, encoding: str) -> str:
    out, used = [], 0
    for char in text:
        size = len(char.encode(encoding))
        if used + size > limit:
            break
        out.append(char)
        used += size
    return "".join(out)


cut = text.encode("utf-8")[:LIMIT]
try:
    cut.decode("utf-8")
except UnicodeDecodeError as err:
    print(f"cắt thẳng {LIMIT} byte UTF-8 rồi giải mã: lỗi ({err.reason})")
safe = truncate(text, LIMIT, "utf-8")
print(f"cắt theo ký tự: {safe!r} ({len(safe.encode('utf-8'))} byte)")
assert safe == "日本語" and len(safe.encode("utf-8")) <= LIMIT
assert truncate(text, LIMIT, "cp932") == "日本語テキ" and len(truncate(text, LIMIT, "cp932").encode("cp932")) == LIMIT

print("== Đổi CP932 sang UTF-8 làm dữ liệu dài thêm")
rng = random.Random(21)
HALF = [chr(code) for code in range(0xFF66, 0xFF9F)]
FULL = [chr(code) for code in range(0x3041, 0x3094)] + [chr(code) for code in range(0x30A1, 0x30F7)]
ASCII = [chr(code) for code in range(0x41, 0x5B)]
fits = grew = 0
worst = 0.0
for _ in range(10_000):
    pools = rng.choices([HALF, FULL, ASCII], weights=[1, 2, 1], k=rng.randint(4, 14))
    value = "".join(rng.choice(pool) for pool in pools)
    old, new = len(value.encode("cp932")), len(value.encode("utf-8"))
    if old <= 20:
        fits += 1
        grew += new > 20
        worst = max(worst, new / old)
print(f"{fits} chuỗi vừa cột 20 byte CP932; {grew} chuỗi ({grew / fits:.1%}) vượt 20 byte sau khi đổi sang UTF-8; tối đa gấp {worst:.2f} lần")
assert grew > 0 and worst <= 3.0
print("đếm ký tự khác đếm byte; đổi encoding đổi cả độ dài")
python3 -B lengths.py
chuỗi                       điểm mã  UTF-8  UTF-16  CP932
ABC                               3      3       3      3
アイウ nửa chiều rộng                3      9       3      3
デ có dấu đục                     2      6       2      2
デ toàn chiều rộng                 1      3       1      2
アイウ toàn chiều rộng               3      9       3      6
日本語                               3      9       3      6
① NEC                             1      3       1      2
é dựng sẵn (NFC)                  1      2       1  không
é tách dấu (NFD)                  2      3       2  không
😀                                 1      4       2  không
👨‍👩‍👧 gia đình                    5     18       8  không
NFKC: デ (2 điểm mã) thành デ (1 điểm mã)
== Cột 10 byte
cp932: 7 ký tự, 14 byte; len(text) <= 10 là True
utf-8: 7 ký tự, 21 byte; len(text) <= 10 là True
cắt thẳng 10 byte UTF-8 rồi giải mã: lỗi (unexpected end of data)
cắt theo ký tự: '日本語' (9 byte)
== Đổi CP932 sang UTF-8 làm dữ liệu dài thêm
9135 chuỗi vừa cột 20 byte CP932; 4784 chuỗi (52.4%) vượt 20 byte sau khi đổi sang UTF-8; tối đa gấp 3.00 lần
đếm ký tự khác đếm byte; đổi encoding đổi cả độ dài

Đọc kết quả:

  • Điểm mã, byte và đơn vị UTF-16 là ba số khác nhau. アイウ (katakana nửa chiều rộng) có 3 điểm mã, 3 byte CP932 nhưng 9 byte UTF-8; アイウ toàn chiều rộng có 3 điểm mã, 6 byte CP932 và 9 byte UTF-8. Ký tự 😀 là 1 điểm mã, 4 byte UTF-8, 2 đơn vị UTF-16 và không có trong CP932.
  • Điều mắt thấy là một ký tự chưa chắc là một điểm mã. デ (chữ nửa chiều rộng kèm dấu đục) là 2 điểm mã; chuẩn hóa NFKC (theo tài liệu Python: phân rã tương thích rồi ghép chính tắc) đưa nó thành デ, 1 điểm mã. é có thể là 1 điểm mã (NFC) hoặc 2 (NFD). Biểu tượng gia đình 👨‍👩‍👧 là 5 điểm mã và 18 byte UTF-8. Bài không đo ranh giới “ký tự hiển thị” (grapheme cluster).
  • Kiểm độ dài sai đơn vị là lỗi hay gặp. Chuỗi 7 ký tự qua phép kiểm len(text) <= 10 nhưng chiếm 14 byte CP932 hoặc 21 byte UTF-8, vượt một cột 10 byte. Cắt thẳng 10 byte UTF-8 cắt đôi một ký tự (giải mã lỗi “unexpected end of data”); cắt theo ký tự cho 日本語 (9 byte) mà không phá ký tự.
  • Đổi encoding làm dữ liệu dài thêm. Trong 9.135 chuỗi giả (hỗn hợp theo tỉ lệ 1:2:1 giữa katakana nửa chiều rộng, kana toàn chiều rộng và chữ ASCII) vừa cột 20 byte ở CP932, có 4.784 chuỗi (52,4%) vượt 20 byte khi đổi sang UTF-8, tối đa gấp 3 lần (chuỗi toàn nửa chiều rộng). Tỉ lệ này phụ thuộc thành phần hỗn hợp; chuỗi toàn ASCII không đổi độ dài.

Chọn gì trong từng tình huống

Tình huốngQuyết địnhCăn cứ trong bài
Nhận tệp mà người gửi không nói encodingBOM, rồi UTF-8 nghiêm ngặt, rồi encoding mặc định đã thỏa thuận (ở đây CP932)Hợp lệ UTF-8 là tín hiệu mạnh; hợp lệ CP932 là tín hiệu yếu
Có metadata (header, đặc tả tệp)Tin metadata; chỉ nhận diện khi không cóChuỗi ngắn như あい hợp lệ cả hai encoding
ASCII thuần hoặc chuỗi rất ngắnĐừng nhận diện; dùng encoding mặc định đã thỏa thuậnASCII hợp lệ ở mọi encoding; 26–58% chuỗi UTF-8 ngắn vẫn qua phép kiểm CP932
Gặp nhãn “Shift_JIS”Hỏi bảng nào; dữ liệu từ Windows thường cần CP932① và byte 81 60 khác nhau giữa hai bảng
Cắt, tách, thoát chuỗi CP932 ở mức byteGiải mã trước, xử lý theo ký tự, mã hóa lại; không split/replace trên byte52 ký tự có byte sau là 0x5C
Ghép SQL từ chuỗi có thể là CP932Dùng truy vấn tham số hóa; nếu phải thoát thì bằng hàm theo charset của kết nốiMô phỏng thoát theo byte; tài liệu C API của MySQL
Đặt giới hạn độ dài cộtNêu rõ đơn vị (ký tự hay byte, encoding nào), kiểm đúng đơn vị và cắt theo ký tựlen() 7 so với 14 và 21 byte
Đổi encoding dữ liệu đã cóTính lại độ dài byte trước khi đổi; katakana nửa chiều rộng tăng gấp 352,4% chuỗi giả vượt cột 20 byte

Giới hạn

  • Hành vi codec là của Python 3.14.4 trên macOS arm64 (ví dụ cp932 nhận byte đơn 0x80; U+301C đi vòng thành U+FF5E). iconv, ICU, trình duyệt hay ngôn ngữ khác có thể khác; WHATWG gom nhãn shift_jis cùng windows-31j vào một encoding nhưng bài không kiểm trình duyệt.
  • “Văn bản” trong lab tỉ lệ là chuỗi ngẫu nhiên từ bộ ký tự, không phải tiếng Nhật thật; tần suất thật của câu chữ làm xác suất hợp lệ khác đi. Con số 28,9% ở 8 ký tự là của mô hình này.
  • Bài không đo UTF-16 hay UTF-32 không BOM, EUC-JP, ISO-2022-JP, Windows-1252, thư viện nhận diện thống kê (như chardet, ICU) hay hàm nhận diện của PHP, và không đo ranh giới grapheme.
  • Phần thoát chuỗi là bộ phân tích đồ chơi; hành vi database thật phụ thuộc charset kết nối và phiên bản. Khoảng byte đầu và byte sau mà lab giả định cho bộ phân tích là cấu trúc Shift_JIS mà quét thực tế của cp932 Python khớp, không phải trích từ đặc tả.
  • BOM: cách dò ba chữ ký không phân biệt UTF-16 LE với UTF-32 LE (lab nêu ca này).
  • Lab không ghi tệp ngoài thư mục bạn đã tạo; xóa thư mục đó là dọn xong.

Học tiếp và nguồn

Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc Python 3.14.4 trên macOS arm64, không có nghiệm thu Linux hay thư viện khác.

Debug có giả thuyết: giữ phép thử đủ sức bác bỏ mình

Câu hỏi: bằng chứng nào khiến ta bỏ một giả thuyết trước khi sửa code?

Cần biết trước: transaction và lab deadlock. Ví dụ tái dựng lỗi của lab đó, không báo một sự cố production. Môi trường đã chọn: MySQL 26.7.0/InnoDB, Python 3.14.4, macOS arm64, socket và dữ liệu giả riêng. Phần SQL/controller thuộc bài deadlock; bài này sở hữu nhật ký điều tra và phép thử regression. Tiêu chí hoàn tất giữ quy tắc nghiệm thu; chọn context giữ quy tắc input.

Chốt lỗi trước khi đặt giả thuyết

Hai yêu cầu A/B đều cần tăng balance hai hàng từ 100 lên 102 sau hai commit, mỗi request đúng một lần. Trong lịch lỗi, A giữ hàng 1 rồi đòi hàng 2; B làm ngược lại. Actual: một request gặp 1213/40001, transaction đó rollback; winner commit cho balance 101/request 1. Retry toàn transaction bằng cùng mã mới đưa đến 102/request 2.

Regression muốn bảo vệ lịch hai hàng của fixture sau khi đổi mọi đường ghi sang cùng thứ tự: B có thể chờ, nhưng cả hai commit mà không có 1213 trong lịch này. Đây không phải hợp đồng “hệ thống không bao giờ deadlock”. FK, unique constraint, transaction khác hoặc đường code cũ vẫn có thể tạo vòng khác.

Thử tái hiện hai lần từ reset, dùng marker để biết đã giữ khóa và performance_schema.data_lock_waits để thấy cạnh A→B trước khi B đóng vòng. Không dùng sleep tùy ý hoặc gõ nhanh hai terminal làm bằng chứng đồng thời. Deadlock handling.

Dự đoán trước khi xem kết quả

Giả thuyếtDự đoán phân biệt đượcPhép thử giữ nguyên phần còn lạiĐiều bác bỏ
H1: thứ tự khóa ngược tạo vòngCùng thứ tự 1→2 loại vòng hai hàngGiữ bảng/dữ liệu/hai session; chỉ đổi lịch lấy khóaCùng thứ tự vẫn có cùng vòng trên hai hàng
H2: thiếu index là nguyên nhân cần thiếtDeadlock này đòi truy cập không có PRIMARY indexKiểm metadata index; giữ lịch lỗi, không thêm indexPRIMARY đã có nhưng 1213 vẫn xuất hiện
H3: lock timeout quá ngắn bị nhầm thành deadlockTimeout dài sẽ làm lỗi biến mất hoặc mã lỗi khác 1213Đặt SESSION timeout 60s cho cả hai; giữ lịch ngượcDetector vẫn sinh 1213/40001, không phải 1205

H2 bị bác bỏ chỉ cho claim “thiếu index là điều kiện cần của lỗi này”; không suy index không ảnh hưởng phạm vi khóa ở query khác. Không tắt detector hoặc tăng global timeout trên máy thật để thử. Một biến thay đổi mỗi thí nghiệm.

Dựng phép thử từ file owner

Trong thư mục trống, chép bốn file cách A của lab database: lab-common.sh, lab-local.sh, seed-pg.sql, seed-mysql.sql; chép setup.sql và deadlock.py từ bài deadlock. Không sửa các file đó. Wrapper dưới dùng runpy để chạy bộ kiểm gốc trước mỗi phép thử; controller không phải thư viện ứng dụng. Fixture gốc còn chạy PostgreSQL phục vụ lab chung, dù phép debug này chỉ dùng MySQL.

set -euo pipefail
. ./lab-local.sh
lab_up
lab_seed
lab_whoami
lab_mysql < setup.sql
from __future__ import annotations

import contextlib
import io
import runpy
import sys
from typing import Any

assert sys.version_info[:3] == (3, 14, 4)
baseline = io.StringIO()
with contextlib.redirect_stdout(baseline):
    owner = runpy.run_path("deadlock.py")
query = owner["query"]
balances = owner["balances"]
assert query("SELECT @@version") == "26.7.0"
assert "timeout ERROR 1205: waiter còn thấy 107" in baseline.getvalue()
assert (
    "retry toàn transaction và gửi trùng: balance=102, requests=2"
    in baseline.getvalue()
)
assert "cùng thứ tự 1 rồi 2: có chờ, hai commit, không deadlock" in baseline.getvalue()


def capture(function: Any, *args: Any) -> str:
    output = io.StringIO()
    with contextlib.redirect_stdout(output):
        function(*args)
    return output.getvalue()


mode = sys.argv[1]
assert mode in ("hypotheses", "broken", "fixed", "full")
if mode == "hypotheses":
    primary = query(
        "SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='wiki_lab' AND table_name='accounts' AND index_name='PRIMARY' AND column_name='id'"
    )
    assert primary == "1"
    assert "ERROR 1213 (40001)" in capture(owner["cycle"], 3)
    print("H2 primary_index=present reverse_order=1213 hypothesis=rejected")
    scope = owner["cycle"].__globals__
    original = scope["Session"]

    def long_timeout(inspect_error: bool = False) -> Any:
        session = original(inspect_error=inspect_error)
        session.mark("SET SESSION innodb_lock_wait_timeout=60;", "timeout_set")
        session.send("SELECT @@innodb_lock_wait_timeout;")
        assert session.line() == "60"
        return session

    scope["Session"] = long_timeout
    try:
        assert "ERROR 1213 (40001)" in capture(owner["cycle"], 4)
    finally:
        scope["Session"] = original
    print("H3 session_timeout=60 reverse_order=1213 hypothesis=rejected")
    ordered = capture(owner["ordered"])
    assert "có chờ, hai commit, không deadlock" in ordered
    print("H1 ordered=wait_then_commit balance=102 requests=2 hypothesis=supported")
elif mode in ("broken", "fixed"):
    output = (
        capture(owner["cycle"], 5) if mode == "broken" else capture(owner["ordered"])
    )
    assert "ERROR 1213" not in output, "regression: reverse order produced 1213"
    assert (
        query("SELECT GROUP_CONCAT(balance ORDER BY id) FROM wiki_lab.accounts")
        == "102,102"
    )
    assert query("SELECT COUNT(*) FROM wiki_lab.requests") == "2"
    print("regression=green two_commits=2 balances=102,102")
else:
    print(
        "adjacent owner_suite=passed wait=normal timeout=1205 rollback=explicit replay=deduplicated"
    )
owner["reset"]()
balances(100, 0)

Chạy giả thuyết trước. Wrapper tăng timeout bằng Session factory chỉ trong lab, đọc lại giá trị 60 ở từng connection rồi khôi phục factory trong finally. Không sửa server global hoặc tạo một bản controller có lịch khác với owner.

set -euo pipefail
python3 debug.py hypotheses
H2 primary_index=present reverse_order=1213 hypothesis=rejected
H3 session_timeout=60 reverse_order=1213 hypothesis=rejected
H1 ordered=wait_then_commit balance=102 requests=2 hypothesis=supported

Giữ assertion không có 1213 và chạy lịch ngược: test phải đỏ vì đúng triệu chứng, không phải vì lỗi kết nối, thiếu file hoặc cú pháp. Sau đó chọn lịch cùng thứ tự, giữ assertion và input như cũ. Đây là sửa lịch thao tác của fixture, không sửa mã ứng dụng đang vận hành. Khi áp dụng thật, sửa mọi caller có thứ tự trái nhau. Controller lịch lỗi dùng client --force để đọc trạng thái sau lỗi, lịch sửa thì không cần tiếp tục sau lỗi. Tùy chọn này không đổi thứ tự SQL lấy khóa; hai controller có khác phần quan sát/retry, nên bài không đo chi phí runtime giữa chúng.

set -euo pipefail
python3 debug.py broken
set -euo pipefail
python3 debug.py fixed
python3 debug.py full
regression=green two_commits=2 balances=102,102
adjacent owner_suite=passed wait=normal timeout=1205 rollback=explicit replay=deduplicated
set -euo pipefail
. ./lab-local.sh
lab_clean
test ! -e lab.env

Nhật ký đủ để người khác tiếp tục

MụcRecord của ví dụ
Context tối thiểuVersion, socket lab, hai bảng/PK, lịch A1/B1/A2/B2, expected/actual
EvidenceCạnh chờ trước B2, mã 1213, báo cáo InnoDB; victim không còn thay đổi đầu transaction
Đã bác bỏThiếu PRIMARY; timeout ngắn là nguyên nhân 1213 trong lịch này
Đã xác nhậnHai đường khóa ngược; cùng thứ tự cho chờ bình thường và hai commit ở fixture
Fix cụ thểThống nhất 1→2, giữ transaction ngắn; retry toàn transaction vẫn cần cho lỗi khác
RegressionLịch cũ đỏ đúng 1213, lịch mới xanh với 102/102 và 2 request; 1205/replay vẫn đúng
Chưa chứng minhKhông có mọi loại deadlock; production latency; nhiều unique/FK/API ngoài DB

Không chép SQL/định danh khách hàng thật vào journal. Báo cáo deadlock có thể chứa query, account hoặc địa chỉ vận hành; khử định danh trước khi chia sẻ. Không lấy “test xanh” thay cho expected/actual và phần chưa kiểm. InnoDB rollback semantics.

Học tiếp: batch retry, checkpoint để tiếp tục sau mất context.

Đọc benchmark: phép đo nào chống lưng cho kết luận?

Câu hỏi: một bảng import nhanh hơn có đủ để hứa thời gian cho 10 triệu hàng không?

Cần biết trước: lab import CSV và mean/min/max. Bài dùng số đo của lab đó, lấy lần chạy 2026-10-03 07:21:54 UTC. Bài import là nơi giữ toàn bộ fixture, bảng và cách tái lập; ở đây chỉ trích bốn dòng để đọc kết luận. Không chạy lại benchmark mới và không có số đo 10 triệu hàng.

Đọc provenance trước thứ hạng

Thành phầnBằng chứng của phép đo nàyĐiều chưa biết
MáymacOS/Darwin arm64, 10 logical CPUModel chip, RAM, loại ổ đĩa, tải nền chưa lưu trong raw metadata; không tự điền
RuntimePython 3.14.4, PostgreSQL 18.6 native/socket localKhông container, MySQL hoặc remote network
DatasetCSV sinh seed17, 1000/5000/10000 hàng, batch400; input hash và export theo ID khớpKhông kích thước/ phân bố production hoặc file10M
CorrectnessMột transaction/file; PK/FK/CHECK/index, fsync/full_page_writes/synchronous_commit bậtKhông tắt durability để so tốc độ; chưa đo external side effect
TimerCSV đọc/serialize, khởi tạo psql, protocol và commitReset/seed/validation ngoài timer; không phải riêng thời gian SQL server
Cache/lượtWarm-up, không eviction; ba lượt/method/size, xoay thứ tự; 27 sampleChưa cold cache/reboot, chưa nhiều máy hoặc confidence interval đáng tin

Nói “đã warm-up” không chứng minh mọi page đều trong RAM. Xóa bảng không phải xóa OS cache; restart một client không phải cold DB. Khi người nhận cần startup latency, giữ startup trong timer; nếu loại nó, phải báo riêng và giải thích boundary. Clock elapsed dùng perf_counter, chỉ lấy hiệu hai mốc; UTC là provenance chứ không phải clock đo duration. Python clock.

Mean nhỏ hơn, nhưng mẫu có chồng nhau không?

Thời gian dưới tính bằng giây, làm tròn sáu chữ số; stdev là sample standard deviation của ba lượt. Nó không phải khoảng tin cậy, sai số timer hoặc p99 latency. Python statistics.

HàngMethodMean sMin sMax sSample stdev s
1000batch0.0145480.0139350.0157690.001058
1000copy0.0141620.0134960.0146760.000605
10000batch0.0492660.0472730.0521860.002584
10000copy0.0374930.0374100.0376200.000112

Ở 1000 hàng, range batch/COPY chồng nhau; mean COPY thấp hơn không đủ để kết luận mọi lượt luôn thắng. Ở 10000 hàng, ba sample COPY thấp hơn ba sample batch trong lần đo này. Range không chồng nhau vẫn chưa chứng minh thắng ở máy khác, workload khác hoặc cùng máy ngày khác. Không tính p99 từ ba sample rồi đặt SLO production.

Row→batch→COPY thay cả serialization, cách gửi SQL/protocol và client processing. Giữ input và transaction giúp so tổng đường đi, chưa cô lập riêng network RTT, parser hay lock contention. Muốn gán nguyên nhân, thêm đo từng tầng hoặc chỉ đổi một biến với cùng semantics; dùng debug có giả thuyết.

Ngoại suy 10M: phép tính đúng vẫn có thể trả lời sai câu hỏi

Ví dụ giả định, chưa đo: nhân mean COPY ở 10000 hàng với tỷ lệ 1000 để tưởng tượng 10000000 hàng: 0.037493 × 1000 = 37.493 giây (dùng mean đã làm tròn). Đây là phép nhân minh họa, không phải thời gian đã chạy, hồi quy hoặc cam kết.

Nó nhân cả startup cố định lên 1000 lần, trong khi một file lớn chỉ startup một lần; đồng thời bỏ qua WAL/checkpoint, cache capacity, index growth, CSV memory và tải đồng thời có thể làm chi phí mỗi hàng tăng. Hai sai lệch có thể trái chiều, nên không biết ngoại suy đang cao hay thấp. Một đường fit trông đẹp trong mẫu nhỏ không kiểm được regime chưa chạy; cần sample lớn hơn, residual/holdout và workload đại diện.

Không dùng tốc độ trung bình của import một transaction để sizing online requests. Latency, lỗi, lock và deadline của request khác boundary. Xem scale database để chọn phép đo đúng nút thắt.

Viết headline mà dữ liệu chịu được

ClaimVì sao
Sai: “COPY luôn nhanh nhất và import10M chỉ mất37.493s”Đổi ngoại suy thành đo thật, xóa overlap và mọi giới hạn môi trường
Đúng: “Trong ba lượt local PG18.6 với10000 hàng, COPY mean0.037493s, batch mean0.049266s”Ghi workload, repeat, runtime và statistic; phải kèm timer/cache/correctness ở trên

Checklist trước khi tin hoặc chia sẻ

  1. Output có đúng toàn dữ liệu và lỗi không bị bỏ qua? Constraint/durability có tương đương?
  2. Dataset/seed/hash, schema/index, runtime và máy có đủ để tái lập? Metadata thiếu phải ghi thiếu.
  3. Timer bắt đầu/kết thúc ở đâu; startup, parse, network, commit, validation phần nào được tính?
  4. Warm/cold được tạo bằng cách nào, thứ tự chạy có thiên lệch, bao nhiêu lượt và biến thiên?
  5. Mỗi kết luận đang dùng số đo, giả định, dự báo hay suy đoán nguyên nhân? Có sample ở quy mô claim?
  6. Có raw samples để tính lại, điều kiện dừng và phép thử bác bỏ? Kết quả lỗi/timeout có cùng được báo?

Tái lập bằng lab import ở đầu bài sẽ tạo bulk-results.json mới; giữ kết quả mới với môi trường của bạn, không thay raw gốc bằng lượt tình cờ nhanh hơn. Bài này chứng minh cách đọc một phép đo hữu hạn; lựa chọn production vẫn cần đo workload của bạn.

Giới hạn nén: entropy, đếm chuỗi và chọn codec theo phép đo

Câu hỏi bài này trả lời: dữ liệu nén được đến mức nào, vì sao không codec nào nén được mọi đầu vào, và các codec trong thư viện chuẩn của Python (zlib, bz2, lzma, zstd) đánh đổi kích thước và tốc độ ra sao trên dữ liệu cụ thể?

Cần biết trước: Python cơ bản và logarit cơ số 2. Lab dùng thư viện chuẩn của Python 3.14.4 (zlib 1.2.12, bz2, lzma và compression.zstd với libzstd 1.5.7) trên macOS arm64, với chuỗi giả tự sinh và mã nguồn của chính thư viện chuẩn. Tài liệu Python ghi compression.zstd là module tùy chọn; bản Python thiếu nó thì bỏ các dòng zstd. Thời gian là của một máy: chỉ thứ tự và tỉ lệ gấp bội đáng đọc, không phải con số tuyệt đối.

Hai giới hạn khác nhau

Giới hạn đếm. Có 2^n chuỗi dài đúng n bit nhưng chỉ có 1 + 2 + … + 2^(n−1) = 2^n − 1 chuỗi ngắn hơn. Một cách nén không mất dữ liệu phải gán cho mỗi đầu vào một đầu ra riêng, nên không thể rút ngắn mọi chuỗi n bit: ít nhất một chuỗi không ngắn đi. Mạnh hơn, chỉ có 2^(n−k+1) − 1 chuỗi dài tối đa n−k bit, nên tỉ lệ chuỗi n bit có thể rút bớt ít nhất k bit nhỏ hơn 2^−(k−1): dưới 0,8% với k = 8 (một byte), dưới 0,004% với k = 16. Đây là phép đếm, không phụ thuộc cách viết codec.

Giới hạn entropy (Shannon 1948). Với nguồn phát các ký hiệu độc lập, ký hiệu i có xác suất p_i, entropy là H = −Σ p_i · log₂ p_i bit mỗi ký hiệu. Bài báo của Shannon chứng minh (định lý 9) rằng có thể mã hóa để số bit trung bình tiến tới H với sai khác nhỏ tùy ý, và không thể thấp hơn. Ví dụ trong chính bài báo: nguồn bốn chữ cái A, B, C, D với xác suất 1/2, 1/4, 1/8, 1/8 có H = 7/4 bit mỗi ký hiệu, và mã 0, 10, 110, 111 đạt đúng trung bình 7/4. Entropy phụ thuộc mô hình của nguồn: Shannon ghi rằng một máy sinh các chữ số của π cho ra một dãy xác định, không có yếu tố ngẫu nhiên, và tiếng Anh thường có độ dư thừa khoảng 50% khi chỉ tính cấu trúc thống kê trong khoảng tám chữ cái. Vì vậy entropy bậc 0 của byte (chỉ đếm tần suất từng byte) là giới hạn cho nguồn có các byte độc lập; dữ liệu có cấu trúc đi xuống thấp hơn nhiều.

zlib và gzip dùng định dạng DEFLATE, mà RFC 1951 mô tả là kết hợp thuật toán LZ77 với mã Huffman, và có khối lưu nguyên (BTYPE = 00) giới hạn 65.535 byte cho dữ liệu không nén được. Bài không mô tả cấu tạo bên trong của bz2, lzma và zstd.

Lab: các hàm dùng chung

Tạo thư mục trống rồi lưu corpus.py. Mẫu văn bản là mã nguồn Python của 23 module thư viện chuẩn (xác định trong một bản Python), entropy() là entropy bậc 0 của chính mẫu, và size_with() kiểm mỗi lần nén đều giải nén ra đúng dữ liệu gốc.

import bz2
import importlib
import inspect
import lzma
import math
import zlib
from collections import Counter

from compression import zstd

MODULES = [
    "argparse", "ast", "collections", "dataclasses", "enum", "functools", "inspect",
    "json.decoder", "logging", "os", "pathlib", "re._parser", "shutil", "socket",
    "subprocess", "tarfile", "typing", "zipfile", "http.client", "urllib.request",
    "email.message", "unittest.case", "random",
]

CODECS = {
    "zlib-9": (lambda data: zlib.compress(data, 9), zlib.decompress),
    "bz2-9": (lambda data: bz2.compress(data, 9), bz2.decompress),
    "lzma-6": (lambda data: lzma.compress(data, preset=6), lzma.decompress),
    "zstd-3": (lambda data: zstd.compress(data, level=3), zstd.decompress),
    "zstd-19": (lambda data: zstd.compress(data, level=19), zstd.decompress),
}


def text_sample() -> bytes:
    """Mã nguồn Python của vài module thư viện chuẩn: văn bản thật, xác định trong một bản Python."""
    return b"".join(inspect.getsource(importlib.import_module(name)).encode() for name in MODULES)


def entropy(data: bytes) -> float:
    """Entropy bậc 0 (bit mỗi byte) của chính mẫu này."""
    counts = Counter(data)
    total = len(data)
    return -sum(c / total * math.log2(c / total) for c in counts.values())


def size_with(name: str, data: bytes) -> int:
    compress, decompress = CODECS[name]
    packed = compress(data)
    assert decompress(packed) == data
    return len(packed)
python3 -B -c "import corpus; print('OK', len(corpus.text_sample()))"

Lab: đếm chuỗi và dữ liệu ngẫu nhiên

Phần đầu liệt kê thật các chuỗi ngắn hơn n bit (với n đến 16) và so với 2^n, rồi tính cận tỉ lệ cho chuỗi 8.000 bit. Phần hai nén 2.000 chuỗi ngẫu nhiên dài 1.000 byte bằng năm cấu hình và đếm xem chuỗi nào nhỏ đi; lab dừng nếu có một chuỗi nhỏ đi.

import itertools
import random
import statistics
from fractions import Fraction

from corpus import CODECS, size_with

print("== Đếm chuỗi")
for n in (1, 2, 3, 8, 16):
    strings = 2**n
    shorter = sum(len(list(itertools.product("01", repeat=length))) for length in range(n))
    print(f"n={n:2}: {strings} chuỗi dài {n} bit nhưng chỉ có {shorter} chuỗi ngắn hơn: thiếu {strings - shorter}")
    assert shorter == strings - 1

BITS = 8000
print(f"tỉ lệ tối đa chuỗi {BITS} bit có thể nén bớt ít nhất k bit (cận 2^-(k-1)):")
for k in (1, 8, 16, 80):
    bound = Fraction(2 ** (BITS - k + 1) - 1, 2**BITS)
    print(f"  k={k:2}: tối đa {float(bound):.3e}")
    assert bound < Fraction(1, 2 ** (k - 1)) or k == 1
    assert bound < 1

print("== Chuỗi ngẫu nhiên 1000 byte")
rng = random.Random(9)
samples = [rng.randbytes(1000) for _ in range(2000)]
for name in CODECS:
    sizes = [size_with(name, data) for data in samples]
    shrunk = sum(size < 1000 for size in sizes)
    print(f"{name}: {shrunk}/2000 chuỗi nhỏ đi; kích thước trung bình {statistics.fmean(sizes):.1f}, nhỏ nhất {min(sizes)}")
    assert shrunk == 0
print("không codec nào làm nhỏ chuỗi ngẫu nhiên; đếm chuỗi khớp số đo")
python3 -B counting.py
== Đếm chuỗi
n= 1: 2 chuỗi dài 1 bit nhưng chỉ có 1 chuỗi ngắn hơn: thiếu 1
n= 2: 4 chuỗi dài 2 bit nhưng chỉ có 3 chuỗi ngắn hơn: thiếu 1
n= 3: 8 chuỗi dài 3 bit nhưng chỉ có 7 chuỗi ngắn hơn: thiếu 1
n= 8: 256 chuỗi dài 8 bit nhưng chỉ có 255 chuỗi ngắn hơn: thiếu 1
n=16: 65536 chuỗi dài 16 bit nhưng chỉ có 65535 chuỗi ngắn hơn: thiếu 1
tỉ lệ tối đa chuỗi 8000 bit có thể nén bớt ít nhất k bit (cận 2^-(k-1)):
  k= 1: tối đa 1.000e+00
  k= 8: tối đa 7.812e-03
  k=16: tối đa 3.052e-05
  k=80: tối đa 1.654e-24
== Chuỗi ngẫu nhiên 1000 byte
zlib-9: 0/2000 chuỗi nhỏ đi; kích thước trung bình 1011.0, nhỏ nhất 1011
bz2-9: 0/2000 chuỗi nhỏ đi; kích thước trung bình 1292.3, nhỏ nhất 1249
lzma-6: 0/2000 chuỗi nhỏ đi; kích thước trung bình 1060.0, nhỏ nhất 1060
zstd-3: 0/2000 chuỗi nhỏ đi; kích thước trung bình 1010.0, nhỏ nhất 1010
zstd-19: 0/2000 chuỗi nhỏ đi; kích thước trung bình 1010.0, nhỏ nhất 1010
không codec nào làm nhỏ chuỗi ngẫu nhiên; đếm chuỗi khớp số đo

Đọc kết quả:

  • Luôn thiếu đúng một chuỗi. Ở mọi n có 2^n chuỗi nhưng chỉ 2^n − 1 chuỗi ngắn hơn, nên một cách nén không mất dữ liệu không thể rút ngắn tất cả. Cận cho chuỗi 8.000 bit: tỉ lệ chuỗi nén bớt được ít nhất một byte nhỏ hơn 0,78%, ít nhất hai byte nhỏ hơn 0,003%, ít nhất mười byte nhỏ hơn 10⁻²³.
  • Đầu vào ngẫu nhiên chỉ có thể phình ra. Không chuỗi nào trong 2.000 chuỗi ngẫu nhiên 1.000 byte nhỏ đi ở cả năm cấu hình. Chi phí cố định khác nhau nhiều: thêm khoảng 10 byte (zstd), 11 byte (zlib), 60 byte (lzma) và khoảng 292 byte (bz2, gần 30% với chuỗi ngắn như vậy). Con số 11 byte của zlib khớp với một khối lưu nguyên (5 byte đầu khối) cộng phần đầu và phần cuối của định dạng zlib.

Lab: entropy của nguồn và các codec

Lab sinh bốn nguồn độc lập, mỗi nguồn 1.000.000 ký hiệu (mỗi ký hiệu một byte): nguồn dyadic bốn ký hiệu của ví dụ Shannon, nguồn nhị phân lệch 0,9/0,1, nguồn đều 16 ký hiệu và nguồn đều 256 ký hiệu. Với mỗi nguồn, lab in entropy lý thuyết H, entropy của chính mẫu và số bit mỗi ký hiệu mà từng cấu hình đạt được. Lab dừng nếu có cấu hình nào đạt thấp hơn 99,5% entropy của mẫu, hoặc nếu mã 0/10/110/111 không cho đúng trung bình bằng H.

import math
import random

from corpus import CODECS, entropy, size_with

N = 1_000_000
SOURCES = {
    "dyadic 4 ký hiệu (1/2, 1/4, 1/8, 1/8)": ([0, 1, 2, 3], [0.5, 0.25, 0.125, 0.125]),
    "nhị phân lệch 0,9/0,1": ([0, 1], [0.9, 0.1]),
    "đều 16 ký hiệu": (list(range(16)), [1 / 16] * 16),
    "đều 256 ký hiệu": (list(range(256)), [1 / 256] * 256),
}

dyadic = SOURCES["dyadic 4 ký hiệu (1/2, 1/4, 1/8, 1/8)"][1]
code_lengths = [1, 2, 3, 3]
average = sum(p * length for p, length in zip(dyadic, code_lengths))
h = -sum(p * math.log2(p) for p in dyadic)
print(f"mã 0/10/110/111 cho trung bình {average} bit mỗi ký hiệu, entropy H = {h}")
assert average == h == 1.75

rng = random.Random(5)
for name, (symbols, weights) in SOURCES.items():
    data = bytes(rng.choices(symbols, weights=weights, k=N))
    h = -sum(p * math.log2(p) for p in weights)
    h_sample = entropy(data)
    row = {codec: 8 * size_with(codec, data) / N for codec in CODECS}
    cells = " ".join(f"{codec} {value:.4f}" for codec, value in row.items())
    print(f"{name}: H = {h:.4f}, entropy của mẫu {h_sample:.4f} | {cells}")
    assert all(value >= 0.995 * h_sample for value in row.values())
    best_codec = min(row, key=row.__getitem__)
    print(f"    tốt nhất {best_codec} {row[best_codec]:.4f} bit mỗi ký hiệu = {row[best_codec] / h:.3f} lần H")
print("không codec nào xuống dưới entropy của nguồn")
python3 -B entropy.py
mã 0/10/110/111 cho trung bình 1.75 bit mỗi ký hiệu, entropy H = 1.75
dyadic 4 ký hiệu (1/2, 1/4, 1/8, 1/8): H = 1.7500, entropy của mẫu 1.7492 | zlib-9 2.1045 bz2-9 2.0604 lzma-6 1.9605 zstd-3 2.2887 zstd-19 1.7579
    tốt nhất zstd-19 1.7579 bit mỗi ký hiệu = 1.005 lần H
nhị phân lệch 0,9/0,1: H = 0.4690, entropy của mẫu 0.4687 | zlib-9 0.6522 bz2-9 0.5746 lzma-6 0.5767 zstd-3 0.9991 zstd-19 0.5817
    tốt nhất bz2-9 0.5746 bit mỗi ký hiệu = 1.225 lần H
đều 16 ký hiệu: H = 4.0000, entropy của mẫu 4.0000 | zlib-9 4.5590 bz2-9 4.0689 lzma-6 4.1589 zstd-3 4.1475 zstd-19 4.0095
    tốt nhất zstd-19 4.0095 bit mỗi ký hiệu = 1.002 lần H
đều 256 ký hiệu: H = 8.0000, entropy của mẫu 7.9998 | zlib-9 8.0025 bz2-9 8.0393 lzma-6 8.0009 zstd-3 8.0003 zstd-19 8.0003
    tốt nhất zstd-3 8.0003 bit mỗi ký hiệu = 1.000 lần H
không codec nào xuống dưới entropy của nguồn

Đọc kết quả:

  • Không codec nào xuống dưới entropy. Ở cả bốn nguồn, mọi cấu hình đều đạt từ entropy của mẫu trở lên, đúng phần ngược của định lý 9. Entropy của mẫu lệch khỏi H lý thuyết dưới 0,1% vì mẫu dài một triệu ký hiệu.
  • Tiến tới entropy có điều kiện. Ở nguồn đều 256 ký hiệu (không có gì để nén) tốt nhất là 1,000 lần H; ở nguồn đều 16 ký hiệu và nguồn dyadic, zstd mức 19 chỉ cao hơn H khoảng 0,2% và 0,5%. Cùng nguồn dyadic mà zlib mức 9 đạt 2,10 bit, tức cao hơn H khoảng 20%: bộ tìm chuỗi lặp của LZ77 tìm ra các khớp ngẫu nhiên trong dãy chỉ có bốn ký hiệu, và mỗi khớp tốn nhiều bit hơn mã từng ký hiệu (diễn giải từ số đo, bài không đọc mã nguồn zlib).
  • Nguồn lệch bộc lộ giới hạn của từng codec. Với nguồn nhị phân 0,9/0,1 (H = 0,469), không cấu hình nào dưới 0,574 bit, tức cao hơn 22,5%. zstd mức 3 đạt 0,999 bit mỗi ký hiệu: khớp với giới hạn “không dùng ít hơn 1 bit cho mỗi ký hiệu” của mã Huffman trên nguồn hai ký hiệu (bài không kiểm cấu tạo bên trong của zstd). Ví dụ thứ hai trong bài Shannon nói về đúng tình huống này (nguồn hai ký hiệu có một ký hiệu rất hiếm) và mô tả mã hóa theo đoạn giữa hai lần ký hiệu hiếm xuất hiện, thay vì mã từng ký hiệu.

Lab: loại dữ liệu

Bốn loại dữ liệu: 1 MB byte ngẫu nhiên, 300 KB là chuỗi abc lặp lại, mã nguồn Python (khoảng 1,5 MB) và chính mã nguồn đó sau khi nén bằng lzma. Lab in số bit mỗi byte của từng cấu hình và dừng nếu: dữ liệu ngẫu nhiên hay dữ liệu đã nén không phình trong khoảng 0 đến 1%; chuỗi lặp không nhỏ hơn 0,2% kích thước gốc; mã nguồn không đạt dưới entropy bậc 0.

import lzma
import random

from corpus import CODECS, entropy, size_with, text_sample

rng = random.Random(3)
DATA = {
    "ngẫu nhiên 1 MB": rng.randbytes(1_000_000),
    "'abc' lặp 300 KB": b"abc" * 100_000,
    "mã nguồn Python": text_sample(),
}
DATA["mã nguồn nén lzma"] = lzma.compress(DATA["mã nguồn Python"], preset=6)

sizes: dict[str, dict[str, int]] = {}
for label, data in DATA.items():
    h0 = entropy(data)
    sizes[label] = {codec: size_with(codec, data) for codec in CODECS}
    cells = " ".join(f"{codec} {8 * size / len(data):.3f}" for codec, size in sizes[label].items())
    print(f"{label:20} {len(data):>9} byte, H0 {h0:5.3f} bit/byte | bit/byte: {cells}")

for label in ("ngẫu nhiên 1 MB", "mã nguồn nén lzma"):
    n = len(DATA[label])
    assert all(n < size < 1.01 * n for size in sizes[label].values()), label
assert all(size < 0.002 * 300_000 for size in sizes["'abc' lặp 300 KB"].values())
text_h0 = entropy(DATA["mã nguồn Python"])
assert all(8 * size / len(DATA["mã nguồn Python"]) < text_h0 for size in sizes["mã nguồn Python"].values())
print("ngẫu nhiên và đã nén thì phình nhẹ; dữ liệu có cấu trúc xuống dưới entropy bậc 0")
python3 -B kinds.py
ngẫu nhiên 1 MB        1000000 byte, H0 8.000 bit/byte | bit/byte: zlib-9 8.003 bz2-9 8.039 lzma-6 8.001 zstd-3 8.000 zstd-19 8.000
'abc' lặp 300 KB        300000 byte, H0 1.585 bit/byte | bit/byte: zlib-9 0.008 bz2-9 0.001 lzma-6 0.005 zstd-3 0.001 zstd-19 0.001
mã nguồn Python        1557776 byte, H0 4.444 bit/byte | bit/byte: zlib-9 1.927 bz2-9 1.591 lzma-6 1.590 zstd-3 2.029 zstd-19 1.611
mã nguồn nén lzma       309516 byte, H0 7.999 bit/byte | bit/byte: zlib-9 8.003 bz2-9 8.047 lzma-6 8.002 zstd-3 8.000 zstd-19 8.000
ngẫu nhiên và đã nén thì phình nhẹ; dữ liệu có cấu trúc xuống dưới entropy bậc 0

Đọc kết quả:

  • Dữ liệu ngẫu nhiên và dữ liệu đã nén không nén thêm được. Byte ngẫu nhiên và bản lzma của mã nguồn đều có entropy bậc 0 gần 8 bit mỗi byte, và mọi cấu hình đều đưa ra 8,000 đến 8,047 bit mỗi byte, tức phình thêm 0,003% đến 0,6%. Nén hai lần chỉ tốn thêm thời gian và làm tệp phình thêm tới 0,6%. Ảnh JPEG, video và tệp đã mã hóa thuộc loại này (lập luận, bài không đo các định dạng đó).
  • Entropy bậc 0 không phải giới hạn của dữ liệu có cấu trúc. Chuỗi abc lặp có entropy bậc 0 là 1,585 bit mỗi byte nhưng chỉ còn khoảng 0,001 đến 0,008 bit mỗi byte sau khi nén (dưới 320 byte cho 300 KB), vì nó sinh ra từ một luật một dòng chứ không phải từ các byte độc lập; đây là ý của ví dụ chữ số π trong bài Shannon. Mã nguồn Python có entropy bậc 0 là 4,44 bit mỗi byte nhưng nén xuống 1,59 đến 2,03 bit mỗi byte, vì có nhiều cấu trúc lặp (diễn giải: tên hàm, thụt lề, cú pháp).

Lab: kích thước và tốc độ

Lab nén mẫu mã nguồn Python 1,5 MB bằng mười một cấu hình (zlib mức 1, 6, 9; bz2 mức 1, 9; lzma preset 0, 6; zstd mức 1, 3, 9, 19), lấy thời gian nhỏ nhất trong 5 lần đo, và dừng nếu: có lần giải nén không ra đúng dữ liệu gốc; mức cao hơn của cùng codec cho kết quả lớn hơn; lzma preset 6 hoặc zstd mức 19 không nhỏ hơn zlib mức 9; zstd mức 3 không nhanh hơn lzma preset 6 ít nhất 5 lần khi nén; hoặc có cài đặt zlib nằm trên biên không bị thống trị theo (kích thước, thời gian nén).

import bz2
import lzma
import time
import zlib

from compression import zstd
from corpus import text_sample

SETTINGS = [
    ("zlib-1", lambda d: zlib.compress(d, 1), zlib.decompress),
    ("zlib-6", lambda d: zlib.compress(d, 6), zlib.decompress),
    ("zlib-9", lambda d: zlib.compress(d, 9), zlib.decompress),
    ("bz2-1", lambda d: bz2.compress(d, 1), bz2.decompress),
    ("bz2-9", lambda d: bz2.compress(d, 9), bz2.decompress),
    ("lzma-0", lambda d: lzma.compress(d, preset=0), lzma.decompress),
    ("lzma-6", lambda d: lzma.compress(d, preset=6), lzma.decompress),
    ("zstd-1", lambda d: zstd.compress(d, level=1), zstd.decompress),
    ("zstd-3", lambda d: zstd.compress(d, level=3), zstd.decompress),
    ("zstd-9", lambda d: zstd.compress(d, level=9), zstd.decompress),
    ("zstd-19", lambda d: zstd.compress(d, level=19), zstd.decompress),
]
REPEATS = 5


def best_time(fn, argument) -> float:
    best = float("inf")
    for _ in range(REPEATS):
        start = time.perf_counter()
        fn(argument)
        best = min(best, time.perf_counter() - start)
    return best


data = text_sample()
megabytes = len(data) / 1e6
rows = {}
print(f"mẫu: {len(data)} byte mã nguồn Python")
print(f"{'cài đặt':8} {'kích thước':>10} {'bit/byte':>9} {'nén MB/s':>9} {'giải nén MB/s':>14}")
for name, compress, decompress in SETTINGS:
    packed = compress(data)
    assert decompress(packed) == data
    t_comp = best_time(compress, data)
    t_decomp = best_time(decompress, packed)
    rows[name] = (len(packed), t_comp, t_decomp)
    print(f"{name:8} {len(packed):10} {8 * len(packed) / len(data):9.3f} {megabytes / t_comp:9.1f} {megabytes / t_decomp:14.1f}")

size = {name: row[0] for name, row in rows.items()}
t_comp = {name: row[1] for name, row in rows.items()}
t_decomp = {name: row[2] for name, row in rows.items()}
assert size["zlib-1"] >= size["zlib-6"] >= size["zlib-9"]
assert size["bz2-1"] >= size["bz2-9"] and size["lzma-0"] >= size["lzma-6"]
assert size["zstd-1"] >= size["zstd-3"] >= size["zstd-9"] >= size["zstd-19"]
assert size["lzma-6"] < size["zlib-9"] and size["zstd-19"] < size["zlib-9"]
assert t_comp["zlib-1"] < t_comp["zlib-9"] and t_comp["zstd-3"] * 5 < t_comp["lzma-6"]
assert t_decomp["zstd-3"] < t_decomp["lzma-6"] and t_decomp["zlib-6"] < t_decomp["bz2-9"]
frontier = [
    name
    for name in rows
    if not any(
        size[other] <= size[name] and t_comp[other] <= t_comp[name] and (size[other], t_comp[other]) != (size[name], t_comp[name])
        for other in rows
    )
]
print("không bị thống trị theo (kích thước, thời gian nén):", ", ".join(frontier))
assert not any(name.startswith("zlib") for name in frontier)
print("không cài đặt zlib nào nằm trên biên (kích thước, thời gian nén) của mẫu này")
print("mức cao hơn nén nhỏ hơn và chậm hơn; kết quả khớp thứ tự dự kiến")
python3 -B speed.py
mẫu: 1557776 byte mã nguồn Python
cài đặt  kích thước  bit/byte  nén MB/s  giải nén MB/s
zlib-1       469425     2.411     201.9         1437.7
zlib-6       378645     1.945      59.2         1581.0
zlib-9       375214     1.927      12.9         1625.4
bz2-1        342625     1.760      29.6           84.5
bz2-9        309819     1.591      29.2           80.8
lzma-0       393032     2.018      53.3          120.3
lzma-6       309516     1.590       6.8          163.3
zstd-1       428838     2.202     620.5         2079.9
zstd-3       395038     2.029     451.6         1981.7
zstd-9       348564     1.790     110.8         2255.2
zstd-19      313753     1.611       7.5         2232.4
không cài đặt zlib nào nằm trên biên (kích thước, thời gian nén) của mẫu này
mức cao hơn nén nhỏ hơn và chậm hơn; kết quả khớp thứ tự dự kiến

Đọc kết quả (tốc độ là của một máy, chỉ nên đọc thứ tự và tỉ lệ gấp bội):

  • Mức cao hơn nén nhỏ hơn và chậm hơn. Trong cùng một codec, kích thước giảm dần theo mức và tốc độ nén giảm theo: zlib từ 2,41 xuống 1,93 bit mỗi byte khi nén chậm đi khoảng 16 lần (mức 1 sang mức 9), zstd từ 2,20 xuống 1,61 bit mỗi byte khi nén chậm đi khoảng 80 lần (mức 1 sang mức 19).
  • Ba nhóm theo kích thước. Nhỏ nhất (khoảng 1,59 đến 1,61 bit mỗi byte) là lzma preset 6, bz2 mức 9 và zstd mức 19; nhóm giữa (1,76 đến 1,95) có bz2 mức 1, zstd mức 9, zlib mức 6 và 9; các cài đặt còn lại (2,0 đến 2,4) gồm lzma preset 0, zstd mức 1 và 3, zlib mức 1, trong đó zstd mức 1 và 3 là hai cài đặt nén nhanh nhất (621 và 452 MB/s).
  • zlib không nằm trên biên trên mẫu này. Với mọi mức zlib đều có một cài đặt của zstd vừa nhỏ hơn vừa nén nhanh hơn: zstd mức 9 vừa nhỏ hơn (348.564 so với 375.214 và 378.645 byte) vừa nhanh hơn zlib mức 6 và 9, còn zstd mức 1 vừa nhỏ hơn vừa nhanh gấp 3 lần zlib mức 1. Điều này không loại zlib khỏi việc dùng: nó có mặt ở gzip, ZIP và HTTP, và bài không đo tính tương thích.
  • Nén chậm không có nghĩa giải nén chậm. zstd mức 19 nén chậm ngang lzma preset 6 (khoảng 7 MB/s) nhưng giải nén nhanh hơn khoảng 14 lần (2.232 so với 163 MB/s) với kích thước chỉ lớn hơn khoảng 1,4% (313.753 so với 309.516 byte). bz2 giải nén chậm nhất (khoảng 81 đến 85 MB/s) dù ở mức 9 nén nhanh gấp khoảng 4 lần lzma preset 6 với kích thước gần bằng nhau.
  • Bộ nhớ không được đo. Tài liệu Python ghi lzma ở preset cao đòi nhiều bộ nhớ (preset 9 có thể tới 800 MiB cho bộ nén) và khuyên dùng preset mặc định; tài liệu compression.zstd ghi mức trên 20 là “ultra” và đòi nhiều bộ nhớ hơn. Lab không đo mức dùng bộ nhớ.

Chọn codec theo phép đo

Tình huốngQuyết địnhCăn cứ trong bài
Dữ liệu ngẫu nhiên, đã mã hóa hoặc đã nén (JPEG, video, .xz)Đừng nén lần nữaPhình 0,003% đến 0,6%; chuỗi ngắn phình hơn (bz2: gần 30% ở 1.000 byte)
Nén một lần, đọc nhiều lần, cần nhỏ nhấtlzma preset 6, bz2 mức 9 hoặc zstd mức 19; chọn theo giải nén và bộ nhớCả ba cỡ 1,59 đến 1,61 bit mỗi byte; giải nén 163, 81 và 2.232 MB/s
Nén thường xuyên, cần nhanhzstd mức 1 đến 3450 đến 620 MB/s nén, 2,0 đến 2,2 bit mỗi byte
Cần cỡ nhỏ vừa phải với tốc độ vẫn caozstd mức 91,79 bit mỗi byte, 111 MB/s nén
Cần tương thích gzip hoặc ZIPzlib (mức 6)Là DEFLATE (RFC 1951); trên mẫu này bị zstd thống trị nhưng tương thích rộng
Muốn biết dữ liệu của bạn nén được tới đâuĐo trên chính dữ liệu đó, không suy từ entropy bậc 0Chuỗi lặp và mã nguồn xuống rất thấp dưới entropy bậc 0
Dữ liệu nhỏ vài trăm byteĐo riêng; chi phí cố định của từng codec chiếm phần lớnChi phí 10 đến 292 byte trên chuỗi ngẫu nhiên 1.000 byte

Giới hạn

  • Số đo thuộc Python 3.14.4 (zlib 1.2.12, libzstd 1.5.7) trên một máy macOS arm64; thư viện khác phiên bản, CPU khác hay nhiều luồng sẽ cho tốc độ khác. Bài đã chạy zstd vì có compression.zstd trong bản Python này, nhưng không đo Brotli, LZ4, Snappy, 7z hay các công cụ dòng lệnh tương ứng, và không suy kết quả cho chúng.
  • Chỉ một mẫu văn bản thật (mã nguồn Python, khoảng 1,5 MB) cho các so sánh kích thước và tốc độ. Văn bản tiếng Việt, JSON, log, CSV, ảnh và nhị phân khác có thể xếp hạng các codec khác; biên “không bị thống trị” cũng chỉ là của mẫu này và nhạy với nhiễu thời gian ở các cặp gần nhau (bài chỉ khẳng định về zlib, chênh lệch lớn).
  • Bài không đo mức dùng bộ nhớ, nén theo luồng, chế độ từ điển, nén song song, độ trễ của khối nhỏ, hay chi phí của gzip/ZIP bao quanh.
  • Entropy trong lab là của nguồn độc lập đã biết hoặc entropy bậc 0 của mẫu; bài không tính entropy bậc cao hay độ phức tạp thuật toán của dữ liệu.
  • Giải thích về cách zlib và zstd lệch khỏi entropy ở nguồn dyadic và nhị phân là diễn giải từ số đo; bài không đọc mã nguồn của các thư viện.
  • Lab không ghi tệp ngoài thư mục bạn đã tạo; xóa thư mục đó là dọn xong.

Học tiếp và nguồn

  • Đọc benchmark: số đo và ngoại suy: cách đọc số đo thời gian và tỉ lệ gấp bội mà không suy quá phép đo.
  • LSM-tree so với B-tree: nơi nén theo khối (SSTable) gặp đánh đổi ghi và đọc.
  • Claude E. Shannon, A Mathematical Theory of Communication, Bell System Technical Journal, 1948 (bản in lại có sửa lỗi, đọc các mục 6 đến 10): công thức entropy, entropy của nguồn, định lý 9 và các ví dụ.
  • IETF, RFC 1951: DEFLATE Compressed Data Format Specification: LZ77 kết hợp Huffman và khối lưu nguyên.
  • Python 3.14, zlib: mức nén 0 đến 9 và -1 (mặc định, tương đương mức 6).
  • Python 3.14, bz2: compresslevel từ 1 đến 9, mặc định 9.
  • Python 3.14, lzma: preset từ 0 đến 9, mặc định 6, đánh đổi bộ nhớ.
  • Python 3.14, compression.zstd: module tùy chọn, mức mặc định 3, mức trên 20 là “ultra”.

Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc Python 3.14.4 trên macOS arm64, không có nghiệm thu Linux hay thư viện khác.

Quy trình Git: GitFlow, GitHub flow, trunk-based và bảo vệ nhánh bằng hook

Câu hỏi bài này trả lời: cùng một yêu cầu phát hành (hai tính năng vào bản 1.0, một tính năng đang làm dở không được lộ ra, rồi một lỗi phải sửa nóng ở production) đi qua GitFlow, GitHub flow và trunk-based khác nhau ở đâu; bước nào dễ bị quên và quên thì hậu quả hiện ra lúc nào; và phần nào của “bảo vệ nhánh” một hook pre-receive kiểm được, phần nào phải để nền tảng như GitHub?

Cần biết trước: Git cơ bản (branch, merge, tag, push, cherry-pick) và đọc được Python. Lab dùng Git 2.54.0 (Apple Git-157) và Python 3.14.4 trên macOS arm64; code dùng git init -b, git switch và GIT_CONFIG_GLOBAL nên cần một bản Git đủ mới, nhưng bài chỉ chạy thật trên 2.54.0. Lab tự dựng repo tạm có remote bare và xóa khi xong; không chạm repo của bạn, không cần mạng. Chưa chạy trên Linux hay Windows, và không chạy trên GitHub nên không mô phỏng được pull request, số lần duyệt hay thiết lập bảo vệ nhánh của nền tảng. Bài không khuyên chọn một luồng: nó đo cái giá của từng luồng trên một kịch bản cụ thể.

Ba luồng, ba cách giữ công việc dở dang ra khỏi bản phát hành

Cả ba luồng trả lời cùng hai câu hỏi: công việc chưa xong nằm ở đâu để không lọt vào bản phát hành, và sửa nóng ở production đi đường nào. Bảng tóm lại theo nguồn đã đọc; hàng cuối là cách lab hiện thực từng luồng, không phải quy định của nguồn.

ĐiểmGitFlow (Driessen)GitHub flowTrunk-based
Nhánh sống lâumaster và develop (“two main branches with an infinite lifetime”); lab gọi nhánh chính là mainchỉ nhánh mặc địnhchỉ trunk
Nhánh phụfeature, release, hotfix, mỗi loại có nhánh gốc và nhánh đích gộp cố địnhnhánh cho từng thay đổi, vào nhánh mặc định qua pull requestnhánh ngắn (“a couple of days”), hoặc commit thẳng; nhánh release cắt muộn khi cần
Công việc dở dangnằm ở nhánh feature, chưa gộp vào developnằm ở nhánh, chưa mergemerge sớm vào trunk, ẩn sau cờ tính năng
Sửa nóngnhánh hotfix từ tag, gộp vào develop và master (đang có nhánh release thì gộp vào nhánh release thay cho develop)một thay đổi nữa vào nhánh mặc địnhsửa trên trunk trước, rồi cherry-pick sang nhánh release
Trong labdevelop, feature/*, release/*, hotfix/*, merge --no-fffeature/* và fix/*, merge --no-ff thay pull request, bản phát hành là tag trên mainnhánh ngắn merge --ff-only, cờ là dòng c-flag trong file, nhánh release/1.0 cắt muộn

Docs của GitHub mô tả GitHub flow là “a lightweight, branch-based workflow”: tạo nhánh, sửa, mở pull request, được duyệt rồi merge vào nhánh mặc định. Driessen tự giới hạn phạm vi của mô hình: ghi chú năm 2020 nói mô hình ra đời cho một loại phần mềm khác, và “If your team is doing continuous delivery of software, I would suggest to adopt a much simpler workflow (like GitHub flow) instead of trying to shoehorn git-flow into your team”, còn nếu phần mềm “explicitly versioned” hoặc phải hỗ trợ nhiều phiên bản đang chạy ngoài thực tế thì git-flow vẫn có thể phù hợp. DORA mô tả ba thực hành của trunk-based development: “Have three or fewer active branches in the application’s code repository. Merge branches to trunk at least once a day. Don’t have code freezes and don’t have integration phases.” và gắn chúng với hiệu năng phân phối cao hơn. Bài này không phân xử giữa các luồng.

Lab: cùng một yêu cầu, năm lần chạy

Yêu cầu chung: bản 1.0 có tính năng A và B; tính năng C đã có một commit dở lúc cắt bản và không được lộ ra ở 1.0; sau khi phát hành có lỗi ở dòng core của app.txt và phải ra bản sửa 1.0.1; sau đó C xong và ra bản 1.1 gồm A, B, C và bản sửa. Tạo thư mục trống rồi lưu gitkit.py: nó dựng repo tạm gồm remote bare và một bản làm việc, đặt giờ commit tăng dần để lịch sử giống nhau ở mỗi lần chạy, và xóa thư mục khi thoát. Lưu flows.py: nó thực hiện kịch bản bằng các lệnh git của từng luồng rồi in số đo đọc lại từ remote và từ các tag, không điền tay. GitFlow và trunk-based có thêm một lần chạy “quên bước”: GitFlow quên merge ngược hotfix vào develop, trunk-based quên cherry-pick vào release/1.0.

import os
import shutil
import subprocess
import tempfile
from pathlib import Path

ENV = {
    **os.environ,
    "GIT_CONFIG_GLOBAL": os.devnull,
    "GIT_CONFIG_SYSTEM": os.devnull,
    "GIT_AUTHOR_NAME": "Dev",
    "GIT_AUTHOR_EMAIL": "dev@example.org",
    "GIT_COMMITTER_NAME": "Dev",
    "GIT_COMMITTER_EMAIL": "dev@example.org",
}


class Sandbox:
    """Thư mục tạm gồm remote bare và một bản làm việc; tự xóa khi thoát."""

    def __enter__(self):
        self.root = Path(tempfile.mkdtemp(prefix="gitlab-"))
        self.remote = self.root / "remote.git"
        self.work = self.root / "work"
        self.work.mkdir()
        self.clock = 1_700_000_000  # giờ tăng dần để lịch sử giống nhau ở mỗi lần chạy
        subprocess.run(["git", "init", "-q", "--bare", "-b", "main", self.remote], env=ENV, check=True)
        self.git("init", "-q", "-b", "main")
        self.git("remote", "add", "origin", str(self.remote))
        return self

    def __exit__(self, *exc):
        shutil.rmtree(self.root, ignore_errors=True)

    def run(self, *args, remote=False):
        self.clock += 1
        env = {**ENV, "GIT_AUTHOR_DATE": f"{self.clock} +0000", "GIT_COMMITTER_DATE": f"{self.clock} +0000"}
        cmd = ["git", *(["--git-dir", str(self.remote)] if remote else []), *args]
        return subprocess.run(cmd, cwd=self.work, env=env, capture_output=True, text=True)

    def git(self, *args, remote=False) -> str:
        done = self.run(*args, remote=remote)
        if done.returncode:
            raise RuntimeError(f"git {' '.join(args)}: {done.stderr.strip()}")
        return done.stdout.strip()

    def edit(self, message, add="", replace=None, name="app.txt") -> str:
        path = self.work / name
        text = path.read_text() if path.exists() else ""
        if replace:
            text = text.replace(*replace)
        path.write_text(text + add)
        self.git("add", "-A")
        self.git("commit", "-q", "-m", message)
        return self.git("rev-parse", "HEAD")

    def push(self, *refspecs):
        self.git("push", "-q", "origin", *refspecs)

    def heads(self) -> set[str]:
        return set(self.git("for-each-ref", "--format=%(refname:short)", "refs/heads", remote=True).split())

    def lines_at(self, rev, remote=False) -> list[str]:
        return self.git("show", f"{rev}:app.txt", remote=remote).splitlines()
from gitkit import Sandbox

FIXED = "core: v1-fixed"
FINAL: dict[str, frozenset] = {}


def yn(flag: bool) -> str:
    return "có" if flag else "không"


def c_in(lines: list[str]) -> str:
    if "c-code: half-done" not in lines:
        return "không"
    return "có, cờ tắt" if "c-flag: off" in lines else "có, cờ bật"


def report(box, title, alive, extra=()):
    """In số đo của một lần chạy; mọi giá trị đọc từ repo, không điền tay."""
    FINAL[title] = frozenset(box.lines_at("v1.1"))
    print(f"== {title}")
    print(f"nhánh khác main khi cắt release 1.0: {', '.join(sorted(alive)) or '(không có)'}")
    print(f"v1.0 chứa mã của C: {c_in(box.lines_at('v1.0'))}")
    carriers = sorted(h for h in box.heads() if FIXED in box.lines_at(h, remote=True))
    print(f"đỉnh nhánh có sửa nóng trên remote: {', '.join(carriers)}")
    fixed_101, fixed_11 = (FIXED in box.lines_at(tag) for tag in ("v1.0.1", "v1.1"))
    print(f"v1.0.1 có sửa nóng: {yn(fixed_101)}; v1.1 có sửa nóng: {yn(fixed_11)}")
    for line in extra:
        print(line)


def gitflow(forget_back_merge: bool) -> None:
    with Sandbox() as box:
        box.edit("base", add="core: v1\n")
        box.git("branch", "develop")
        box.push("main", "develop")
        for name in ("a", "b"):
            box.git("switch", "-q", "-c", f"feature/{name}", "develop")
            box.edit(f"feature {name}", add=f"{name}: on\n")
            box.git("switch", "-q", "develop")
            box.git("merge", "-q", "--no-ff", "-m", f"merge feature/{name}", f"feature/{name}")
            box.git("branch", "-q", "-d", f"feature/{name}")
        box.git("switch", "-q", "-c", "feature/c", "develop")
        box.edit("c1", add="c-code: half-done\n")
        box.push("feature/c")
        box.git("switch", "-q", "-c", "release/1.0", "develop")
        box.push("release/1.0")
        alive = box.heads() - {"main"}
        box.git("switch", "-q", "main")
        box.git("merge", "-q", "--no-ff", "-m", "release 1.0", "release/1.0")
        box.git("tag", "v1.0")
        box.git("branch", "-q", "-d", "release/1.0")
        box.git("push", "-q", "origin", "--delete", "release/1.0")
        box.git("switch", "-q", "-c", "hotfix/1.0.1", "v1.0")
        box.edit("fix core", replace=("core: v1", FIXED))
        box.git("switch", "-q", "main")
        box.git("merge", "-q", "--no-ff", "-m", "hotfix 1.0.1", "hotfix/1.0.1")
        box.git("tag", "v1.0.1")
        if not forget_back_merge:
            box.git("switch", "-q", "develop")
            box.git("merge", "-q", "--no-ff", "-m", "back-merge hotfix", "hotfix/1.0.1")
        box.git("branch", "-q", "-D", "hotfix/1.0.1")
        missing = sum(line.startswith("+") for line in box.git("cherry", "develop", "main").splitlines())
        box.git("switch", "-q", "feature/c")
        box.edit("c2", replace=("c-code: half-done", "c-code: done"), add="c-flag: on\n")
        box.git("switch", "-q", "develop")
        box.git("merge", "-q", "--no-ff", "-m", "merge feature/c", "feature/c")
        box.git("switch", "-q", "-c", "release/1.1", "develop")
        before = FIXED in box.lines_at("release/1.1")
        box.git("switch", "-q", "main")
        box.git("merge", "-q", "--no-ff", "-m", "release 1.1", "release/1.1")
        box.git("tag", "v1.1")
        box.push("main", "develop", "--tags")
        title = "GitFlow, quên merge ngược hotfix vào develop" if forget_back_merge else "GitFlow, đủ bước"
        report(box, title, alive, [
            f"thay đổi trên main chưa có ở develop (git cherry): {missing}",
            f"đỉnh release/1.1 trước khi merge vào main có sửa nóng: {yn(before)}",
        ])  # fmt: skip


def github_flow() -> None:
    with Sandbox() as box:
        box.edit("base", add="core: v1\n")
        box.push("main")
        for name in ("a", "b"):
            box.git("switch", "-q", "-c", f"feature/{name}", "main")
            box.edit(f"feature {name}", add=f"{name}: on\n")
            box.git("switch", "-q", "main")
            box.git("merge", "-q", "--no-ff", "-m", f"pull request feature/{name}", f"feature/{name}")
            box.git("branch", "-q", "-d", f"feature/{name}")
        box.git("switch", "-q", "-c", "feature/c", "main")
        box.edit("c1", add="c-code: half-done\n")
        box.push("feature/c")
        box.git("tag", "v1.0", "main")
        alive = box.heads() - {"main"}
        box.git("switch", "-q", "-c", "fix/core", "main")
        box.edit("fix core", replace=("core: v1", FIXED))
        box.git("switch", "-q", "main")
        box.git("merge", "-q", "--no-ff", "-m", "pull request fix/core", "fix/core")
        box.git("tag", "v1.0.1")
        box.git("branch", "-q", "-d", "fix/core")
        box.git("switch", "-q", "feature/c")
        box.edit("c2", replace=("c-code: half-done", "c-code: done"), add="c-flag: on\n")
        box.git("merge", "-q", "-m", "update feature/c from main", "main")
        box.git("switch", "-q", "main")
        box.git("merge", "-q", "--no-ff", "-m", "pull request feature/c", "feature/c")
        box.git("tag", "v1.1")
        box.push("main", "--tags")
        report(box, "GitHub Flow", alive)


def trunk(forget_cherry_pick: bool) -> None:
    with Sandbox() as box:
        box.edit("base", add="core: v1\n")
        box.push("main")

        def short_branch(name, message, **change):
            box.git("switch", "-q", "-c", name, "main")
            sha = box.edit(message, **change)
            box.git("switch", "-q", "main")
            box.git("merge", "-q", "--ff-only", name)
            box.git("branch", "-q", "-d", name)
            return sha

        short_branch("a", "feature a", add="a: on\n")
        short_branch("b", "feature b", add="b: on\n")
        short_branch("c-1", "c1 sau cờ", add="c-code: half-done\nc-flag: off\n")
        box.git("switch", "-q", "-c", "release/1.0", "main")
        box.git("tag", "v1.0")
        box.push("main", "release/1.0")
        alive = box.heads() - {"main"}
        box.git("switch", "-q", "main")
        fix = short_branch("fix", "fix core", replace=("core: v1", FIXED))
        box.git("switch", "-q", "release/1.0")
        if not forget_cherry_pick:
            box.git("cherry-pick", "-x", fix)
        box.git("tag", "v1.0.1")
        box.git("switch", "-q", "main")
        short_branch("c-2", "c2 hoàn thiện C", replace=("c-code: half-done", "c-code: done"))
        box.edit("bật cờ C", replace=("c-flag: off", "c-flag: on"))
        box.git("tag", "v1.1")
        box.push("main", "release/1.0", "--tags")
        title = "Trunk-based, quên cherry-pick vào release" if forget_cherry_pick else "Trunk-based, đủ bước"
        report(box, title, alive)


gitflow(False)
gitflow(True)
github_flow()
trunk(False)
trunk(True)
print(f"nội dung v1.1 của cả năm lần chạy: {'giống nhau' if len(set(FINAL.values())) == 1 else 'KHÁC NHAU'}")
python3 -B flows.py
== GitFlow, đủ bước
nhánh khác main khi cắt release 1.0: develop, feature/c, release/1.0
v1.0 chứa mã của C: không
đỉnh nhánh có sửa nóng trên remote: develop, main
v1.0.1 có sửa nóng: có; v1.1 có sửa nóng: có
thay đổi trên main chưa có ở develop (git cherry): 0
đỉnh release/1.1 trước khi merge vào main có sửa nóng: có
== GitFlow, quên merge ngược hotfix vào develop
nhánh khác main khi cắt release 1.0: develop, feature/c, release/1.0
v1.0 chứa mã của C: không
đỉnh nhánh có sửa nóng trên remote: main
v1.0.1 có sửa nóng: có; v1.1 có sửa nóng: có
thay đổi trên main chưa có ở develop (git cherry): 1
đỉnh release/1.1 trước khi merge vào main có sửa nóng: không
== GitHub Flow
nhánh khác main khi cắt release 1.0: feature/c
v1.0 chứa mã của C: không
đỉnh nhánh có sửa nóng trên remote: main
v1.0.1 có sửa nóng: có; v1.1 có sửa nóng: có
== Trunk-based, đủ bước
nhánh khác main khi cắt release 1.0: release/1.0
v1.0 chứa mã của C: có, cờ tắt
đỉnh nhánh có sửa nóng trên remote: main, release/1.0
v1.0.1 có sửa nóng: có; v1.1 có sửa nóng: có
== Trunk-based, quên cherry-pick vào release
nhánh khác main khi cắt release 1.0: release/1.0
v1.0 chứa mã của C: có, cờ tắt
đỉnh nhánh có sửa nóng trên remote: main
v1.0.1 có sửa nóng: không; v1.1 có sửa nóng: có
nội dung v1.1 của cả năm lần chạy: giống nhau

Đọc kết quả:

  • Số nhánh phải giữ khi cắt bản. Lúc cắt release 1.0, GitFlow giữ ba nhánh khác main (develop, feature/c, release/1.0), GitHub flow giữ một (feature/c, vì bản 1.0 chỉ là tag trên main) và trunk-based giữ một (release/1.0, cắt muộn từ trunk). Đây là hệ quả của cách lab dựng từng luồng, không phải hằng số: DORA nêu “three or fewer active branches” nhưng trang không nói có tính trunk hay không, nên bài không kết luận GitFlow vượt ngưỡng.
  • C có nằm trong v1.0 hay không. Hai luồng đầu cô lập C bằng nhánh chưa merge nên v1.0 không có mã C. Trunk-based đã merge commit dở của C vào trunk với cờ tắt, nên mã C nằm trong v1.0 nhưng không chạy; trunkbaseddevelopment.com mô tả đúng việc này là đưa việc chưa xong vào một cờ “that ships dark (off in the release)”. Cái giá của cờ là mã chưa xong đi cùng bản phát hành; cái giá của nhánh là C lệch dần khỏi nhánh chính cho đến lúc merge, và lab không đo độ lệch đó.
  • Bản sửa nóng nằm ở đâu. Đỉnh nhánh có sửa trên remote là develop và main ở GitFlow, main ở GitHub flow, main và release/1.0 ở trunk-based. GitFlow đòi nhớ hai đích, trunk-based đòi nhớ một bước cherry-pick, GitHub flow chỉ có một nhánh. Ở GitHub flow bản sửa đi một mình chỉ vì C chưa merge; nếu C đã merge vào main trước khi sửa, bản phát hành từ main mang theo cả C (lập luận, lab không chạy ca này).
  • Quên một bước. GitFlow quên merge ngược: git cherry báo 1 thay đổi của main chưa có ở develop, đỉnh release/1.1 trước khi merge vào main không có bản sửa, nhưng v1.1 trên main vẫn có vì phép gộp ba chiều giữ thay đổi đã có ở main khi nhánh release không đụng dòng đó. Hậu quả không biến mất mà đổi chỗ: bản dựng và kiểm thử từ develop hoặc release/1.1 còn lỗi trong khi bản phát hành thì không, và nếu develop sửa đúng dòng đó thì lần gộp sẽ xung đột (lab không chạy ca này). Trunk-based quên cherry-pick: v1.0.1 không có bản sửa, còn v1.1 có vì cắt từ trunk. Hậu quả lộ ngay ở chính bản vá, không chờ đến bản sau.
  • Quá trình khác, kết quả cuối giống nhau. Nội dung v1.1 của cả năm lần chạy giống hệt, kể cả hai lần quên bước. Khác biệt giữa các luồng là số nhánh phải theo dõi, nơi công việc dở dang nằm, và lúc nào một bước bị quên mới lộ ra.

Sửa nóng: sửa ở đâu trước

Hai nguồn đưa hai luật khác nhau cho cùng việc. Driessen: nhánh hotfix “May branch off from: master” và “Must merge back into: develop and master”, kèm một ngoại lệ: “when a release branch currently exists, the hotfix changes need to be merged into that release branch, instead of develop”. Đích gộp phụ thuộc trạng thái repo lúc đó, và là thứ người làm phải nhớ. trunkbaseddevelopment.com đưa luật một chiều: tái hiện lỗi trên trunk, sửa ở đó với test, rồi cherry-pick sang nhánh release; và ở mục “Cherry-picks from the trunk to branch ONLY” giải thích lý do: sửa trên nhánh release rồi mong cherry-pick ngược về trunk dễ bị quên, mà “Forgetting means a regression in production some weeks later”. Trang cũng nêu ngoại lệ có chủ ý: khi không tái hiện được lỗi trên trunk thì phải làm ngược lại và “you have introduced risk of regression”.

Lab trên đo phía còn lại của luật đó: quên cherry-pick từ trunk sang release thì bản vá thiếu bản sửa ngay. Chiều nguy hiểm (sửa thẳng trên release và quên đưa về trunk) là chiều mà hook ở phần sau từ chối khi đẩy lên.

Lab: bảo vệ nhánh bằng pre-receive

GitHub đặt tên cho các thiết lập bảo vệ nhánh. Trang About protected branches liệt kê: Require pull request reviews before merging, Require status checks before merging, Require conversation resolution before merging, Require signed commits, Require linear history, Require merge queue, Require deployments to succeed before merging, Lock branch, Do not allow bypassing the above settings, Restrict who can push to matching branches, Allow force pushes và Allow deletions; kèm tùy chọn “dismiss stale pull request approvals when commits are pushed that affect the diff in the pull request”. Khi vi phạm, nền tảng từ chối bằng lỗi dạng GH006: Protected branch update failed for refs/heads/main. Local không có nền tảng đó, nhưng Git có hook phía server làm được một phần: git-receive-pack gọi pre-receive “just before starting to update refs”, hook nhận trên stdin mỗi dòng <old-oid> SP <new-oid> SP <ref-name>, với old toàn số 0 khi tạo ref mới; thoát khác 0 thì “none of the refs will be updated”; stdout và stderr được chuyển về máy đẩy nên người dùng thấy lý do. Pro Git ghi hook này dùng để đảm bảo không ref nào bị ghi đè kiểu non-fast-forward hoặc để kiểm soát quyền theo ref. Objects vừa đẩy nằm trong thư mục “quarantine” cho đến khi hook xong, nhưng lệnh git do hook gọi đọc được chúng.

Cái hook thấy chỉ là ref và commit, nên chỉ những quy tắc suy được từ hai thứ đó mới kiểm được:

Quy tắcHook pre-receiveThiết lập liên quan trên GitHub (tên theo docs)
Không ghi đè lịch sử main và release/*kiểm được: git merge-base --is-ancestor old newAllow force pushes
Không xóa nhánh được bảo vệkiểm được: giá trị mới toàn số 0Allow deletions
Tên nhánh mới theo quy ướckiểm được: so tên ref khi tạokhông thấy trong danh sách của docs
release/* chỉ nhận thay đổi đã có trên mainkiểm được: git cherry cộng cấm merge commitkhông thấy trong danh sách của docs
Phải qua pull request và đủ số lần duyệtkhông: dữ liệu duyệt nằm ở nền tảng, hook không thấyRequire pull request reviews before merging
Hủy lần duyệt cũ khi có commit mớikhôngdismiss stale pull request approvals
Chỉ một số người hoặc vai trò được đẩymột phần: Pro Git ghi hook biết người đẩy khi đi qua SSH, qua biến môi trườngRestrict who can push to matching branches

Lưu hook.py (đây là nội dung của hook, bài chép nó vào hooks/pre-receive của remote bare) và protect.py. Hook từ chối khi: tạo nhánh có tên ngoài quy ước; xóa hoặc ghi đè main và release/*; đẩy lên release/* một merge commit; hoặc đẩy lên release/* bất kỳ commit nào mà git cherry refs/heads/main <new> đánh dấu +. Theo git-cherry, phép so “is based on the diff, after removing whitespace and line numbers” và lệnh in - cho commit “that have an equivalent” ở nhánh đối chiếu, + cho commit không có. git-patch-id nói hai patch cùng patch ID “are almost guaranteed to be the same thing”. protect.py chạy 20 ca đẩy thật vào remote bare có hook, mỗi ca khẳng định cả kết quả lẫn lý do từ chối.

#!/usr/bin/env python3
"""pre-receive: bảo vệ main và release/* bằng những gì hook kiểm được từ ref và commit."""

import re
import subprocess
import sys

ZERO = "0" * 40
PROTECTED = re.compile(r"refs/heads/(main|release/.+)")
RELEASE = re.compile(r"refs/heads/release/.+")
NEW_NAME = re.compile(r"refs/heads/(main|(feature|fix|hotfix|release)/[a-z0-9][a-z0-9._-]*)")


def git(*args) -> subprocess.CompletedProcess:
    return subprocess.run(["git", *args], capture_output=True, text=True)


def problems(old: str, new: str, ref: str) -> list[str]:
    if old == ZERO and ref.startswith("refs/heads/") and not NEW_NAME.fullmatch(ref):
        return ["tên nhánh mới không theo quy ước (main, feature/, fix/, hotfix/, release/)"]
    if not PROTECTED.fullmatch(ref):
        return []
    if new == ZERO:
        return ["không được xóa nhánh được bảo vệ"]
    if old != ZERO and git("merge-base", "--is-ancestor", old, new).returncode != 0:
        return ["không được ghi đè lịch sử (non-fast-forward)"]
    if RELEASE.fullmatch(ref):
        if git("rev-list", "--merges", "-n", "1", f"refs/heads/main..{new}").stdout.strip():
            return ["release/* không nhận merge commit"]
        extra = [line for line in git("cherry", "refs/heads/main", new).stdout.splitlines() if line.startswith("+")]
        if extra:
            return [f"{len(extra)} commit trên release/* chưa có bản tương đương trên main"]
    return []


failed = False
for line in sys.stdin:
    old, new, ref = line.split()
    for reason in problems(old, new, ref):
        print(f"chặn: {ref}: {reason}", file=sys.stderr)
        failed = True
sys.exit(1 if failed else 0)
import shutil
from pathlib import Path

from gitkit import Sandbox

results = []


def attempt(box, label, refspec, expect_ok, needle="", *flags):
    done = box.run("push", *flags, "origin", refspec)
    accepted = done.returncode == 0
    reason = next((line.split("chặn: ", 1)[1].strip() for line in done.stderr.splitlines() if "chặn: " in line), "")
    print(f"{label:58} {'nhận' if accepted else 'từ chối'}  {reason}")
    assert accepted == expect_ok, (label, done.stderr)
    assert needle in reason, (label, reason)
    results.append(accepted)


with Sandbox() as box:
    hook = box.remote / "hooks" / "pre-receive"
    shutil.copy(Path("hook.py"), hook)
    hook.chmod(0o755)
    base = box.edit("base", add="core: v1\n")
    m1 = box.edit("m1", add="a: on\n", name="a.txt")
    m2 = box.edit("m2 sửa core", replace=("core: v1", "core: v1-fixed"))
    box.push("main")
    box.git("switch", "-q", "-c", "feature/x", m1)
    f1 = box.edit("f1", add="x: on\n", name="x.txt")
    attempt(box, "tạo nhánh tên sai Feature_X", f"{m1}:refs/heads/Feature_X", False, "tên nhánh")
    attempt(box, "tạo nhánh feature/x", "feature/x", True)
    box.git("switch", "-q", "main")
    m3 = box.edit("m3", add="d: on\n", name="d.txt")
    m4 = box.edit("m4 thêm e1", add="e: 1\n", name="e.txt")
    m5 = box.edit("m5 thêm e2", add="e: 2\n", name="e.txt")
    attempt(box, "đẩy main tiến lên ba commit (fast-forward)", "main", True)
    box.git("switch", "-q", "--detach", base)
    attempt(box, "ghi đè main về commit cũ (--force)", "HEAD:refs/heads/main", False, "non-fast-forward", "--force")
    attempt(box, "xóa main", ":refs/heads/main", False, "xóa")
    attempt(box, "tạo release/1.0 tại commit cũ của main", f"{m1}:refs/heads/release/1.0", True)

    def on_release():
        box.git("fetch", "-q", "origin")
        box.git("switch", "-q", "--detach", "origin/release/1.0")

    to_release = "HEAD:refs/heads/release/1.0"
    no_twin = "chưa có bản tương đương"
    on_release()
    box.edit("sửa thẳng trên release", add="y: hotfix\n", name="y.txt")
    attempt(box, "commit viết thẳng trên release/1.0", to_release, False, no_twin)
    on_release()
    box.git("cherry-pick", "-x", m2)
    attempt(box, "cherry-pick -x commit m2 của main", to_release, True)
    on_release()
    box.git("cherry-pick", m3)
    attempt(box, "cherry-pick m3, không có -x", to_release, True)
    on_release()
    assert box.run("cherry-pick", m5).returncode != 0  # m5 cần m4: xung đột
    (box.work / "e.txt").write_text("e: 2\n")
    box.git("add", "e.txt")
    box.git("-c", "core.editor=true", "cherry-pick", "--continue")
    attempt(box, "cherry-pick m5 một mình, sửa tay xung đột", to_release, False, no_twin)
    on_release()
    box.git("cherry-pick", "-x", m4, m5)
    attempt(box, "cherry-pick -x m4 rồi m5 theo thứ tự", to_release, True)
    on_release()
    box.edit(f"khác\n\n(cherry picked from commit {m2})", add="z: evil\n", name="z.txt")
    attempt(box, "commit chỉ ghi dòng cherry picked giả", to_release, False, no_twin)
    on_release()
    box.git("cherry-pick", "-x", f1)
    attempt(box, "cherry-pick -x commit của nhánh feature/x", to_release, False, no_twin)
    on_release()
    box.git("merge", "-q", "--no-ff", "-m", "merge feature/x", "feature/x")
    attempt(box, "merge feature/x vào release/1.0", to_release, False, "merge commit")
    attempt(box, "tạo release/2.0 tại commit cũ của main", f"{m1}:refs/heads/release/2.0", True)
    box.git("fetch", "-q", "origin")
    box.git("switch", "-q", "--detach", "origin/release/2.0")
    box.git("merge", "-q", "--no-ff", "--no-commit", "origin/main")
    (box.work / "hidden.txt").write_text("giấu trong merge commit\n")
    box.git("add", "hidden.txt")
    box.git("commit", "-q", "-m", "merge main")
    attempt(box, "merge main kèm thay đổi giấu trong merge commit", "HEAD:refs/heads/release/2.0", False, "merge commit")
    on_release()
    box.git("reset", "-q", "--hard", base)
    attempt(box, "ghi đè release/1.0 về commit cũ (--force)", to_release, False, "non-fast-forward", "--force")
    attempt(box, "xóa release/1.0", ":refs/heads/release/1.0", False, "xóa")
    box.git("switch", "-q", "feature/x")
    box.git("reset", "-q", "--hard", m1)
    attempt(box, "ghi đè feature/x (--force)", "feature/x", True, "", "--force")
    attempt(box, "xóa feature/x", ":refs/heads/feature/x", True)

print(f"{sum(results)} lần nhận, {len(results) - sum(results)} lần từ chối")
python3 -B protect.py
tạo nhánh tên sai Feature_X                                từ chối  refs/heads/Feature_X: tên nhánh mới không theo quy ước (main, feature/, fix/, hotfix/, release/)
tạo nhánh feature/x                                        nhận
đẩy main tiến lên ba commit (fast-forward)                 nhận
ghi đè main về commit cũ (--force)                         từ chối  refs/heads/main: không được ghi đè lịch sử (non-fast-forward)
xóa main                                                   từ chối  refs/heads/main: không được xóa nhánh được bảo vệ
tạo release/1.0 tại commit cũ của main                     nhận
commit viết thẳng trên release/1.0                         từ chối  refs/heads/release/1.0: 1 commit trên release/* chưa có bản tương đương trên main
cherry-pick -x commit m2 của main                          nhận
cherry-pick m3, không có -x                                nhận
cherry-pick m5 một mình, sửa tay xung đột                  từ chối  refs/heads/release/1.0: 1 commit trên release/* chưa có bản tương đương trên main
cherry-pick -x m4 rồi m5 theo thứ tự                       nhận
commit chỉ ghi dòng cherry picked giả                      từ chối  refs/heads/release/1.0: 1 commit trên release/* chưa có bản tương đương trên main
cherry-pick -x commit của nhánh feature/x                  từ chối  refs/heads/release/1.0: 1 commit trên release/* chưa có bản tương đương trên main
merge feature/x vào release/1.0                            từ chối  refs/heads/release/1.0: release/* không nhận merge commit
tạo release/2.0 tại commit cũ của main                     nhận
merge main kèm thay đổi giấu trong merge commit            từ chối  refs/heads/release/2.0: release/* không nhận merge commit
ghi đè release/1.0 về commit cũ (--force)                  từ chối  refs/heads/release/1.0: không được ghi đè lịch sử (non-fast-forward)
xóa release/1.0                                            từ chối  refs/heads/release/1.0: không được xóa nhánh được bảo vệ
ghi đè feature/x (--force)                                 nhận
xóa feature/x                                              nhận
9 lần nhận, 11 lần từ chối

Đọc kết quả:

  • Bảo vệ theo mẫu tên. main và release/* bị từ chối khi ghi đè hay xóa; feature/x thì ghi đè và xóa được nhận. Tạo nhánh tên Feature_X bị từ chối vì ngoài quy ước.
  • release/* kiểm bằng nội dung, không bằng chữ. Cherry-pick có -x, cherry-pick không có -x đều được nhận; một commit chỉ ghi dòng “(cherry picked from commit …)” trỏ tới commit thật của main nhưng mang thay đổi khác thì bị từ chối, và cherry-pick một commit chỉ có trên nhánh feature/x cũng bị từ chối vì main chưa có bản tương đương. Tài liệu git-cherry-pick ghi -x thêm dòng đó “only for cherry picks without conflicts”, nên dòng chữ không phải bằng chứng đáng tin để hook dựa vào.
  • Backport có xung đột bị từ chối. m5 cần m4 có trước; cherry-pick m5 một mình và sửa tay xung đột cho ra patch khác nên không có bản tương đương, hook từ chối. Cherry-pick m4 rồi m5 theo thứ tự thì được nhận. Hook đóng cửa khi nghi ngờ; một quy trình ngoại lệ có người chịu trách nhiệm cho các ca thật sự cần sửa tay là việc của nhóm, bài không viết.
  • Merge commit bị cấm hẳn. Tác giả đã thử tắt quy tắc này (ngoài lab): ca “merge main kèm thay đổi giấu trong merge commit” được nhận, tức git cherry không bắt được thay đổi nằm trong chính merge commit. Chỉ quy tắc cấm merge chặn được nó.
  • Hook không biết gì ngoài ref và commit. Nó không biết ai đẩy (trừ khi transport cho biết), không biết pull request nào đã được duyệt, không biết CI đã xanh chưa. Những điều kiện đó là việc của nền tảng, và bài không mô phỏng chúng.

Chọn luồng theo ràng buộc

Ràng buộc của bạnHướng nghiêngCăn cứ trong bài
Một phiên bản chạy, phát hành liên tụcGitHub flow hoặc trunk-basedGhi chú 2020 của Driessen; ba thực hành DORA
Phát hành theo lịch, hoặc nhiều phiên bản chạy cùng lúcGitFlow, hoặc trunk-based với nhánh release cắt muộnGhi chú 2020 của Driessen; “Branch for release”: nhóm phát hành hằng tháng vẫn cần bản vá giữa các bản
Có tính năng dở dang lúc cắt bảnNhánh chưa merge, hoặc cờ tính năng nếu chấp nhận mã “ship tối”Lab: v1.0 không có C ở hai luồng đầu, có C với cờ tắt ở trunk-based
Có sửa nóngSửa trên nhánh chính trước rồi mang sang nhánh release; để hook chặn chiều ngược lạiTrang trunkbaseddevelopment.com; lab hook
Cần bắt buộc review hoặc trạng thái CI trước khi mergeThiết lập của nền tảng, không phải hookBảng quy tắc; danh sách thiết lập của GitHub
Quy ước tên nhánh hoặc cấm ghi đè lịch sửHook hoặc thiết lập nền tảng đều làm đượcLab hook

Giới hạn

  • Một kịch bản duy nhất, một lần chạy cho mỗi luồng. Số nhánh khi cắt bản và nơi bản sửa nằm là hệ quả của cách lab dựng từng luồng (GitHub flow ở đây phát hành bằng tag trên main, trunk-based cắt release/1.0 muộn và merge --ff-only); nhóm khác làm khác (squash merge, rebase, nhánh release cho GitHub flow) sẽ ra số khác. Trang GitHub flow của docs mô tả vòng nhánh, pull request và merge chứ không nêu nhịp phát hành, nên “bản phát hành là tag trên main” là giả định của lab.
  • Không đo: xung đột hợp nhất, chi phí giữ nhánh dài theo kịp nhánh chính, thời gian CI, độ trễ review, vòng đời cờ tính năng (nợ cờ), quy mô nhóm, monorepo hay nhiều repo, GitLab flow và nhánh theo môi trường (bài chưa đọc nguồn sơ cấp cho các mục này).
  • Hook là thiết kế của bài, chưa chạy trên máy chủ Git thật hay trên nền tảng. Nó giả định kho SHA-1 (ZERO dài 40), nhánh chính tên main và Python 3 có trên máy chủ. git cherry so patch ID, mà git-patch-id mô tả là “reasonably unique”, nên hai commit cùng patch ID được coi là cùng một thay đổi chứ không phải được chứng minh là cùng một thay đổi.
  • Không mô phỏng pull request, số lần duyệt, hủy lần duyệt cũ, quyền đẩy theo vai trò hay thiết lập bảo vệ của GitHub; chỉ liệt kê tên thiết lập theo docs.
  • Chưa chạy trên Linux hay Windows (hook dùng dòng #!/usr/bin/env python3 và chmod); cần một bản Git đủ mới cho git init -b, git switch và GIT_CONFIG_GLOBAL.
  • Lab không ghi tệp ngoài thư mục tạm của chính nó; thư mục đó bị xóa khi mỗi lần chạy kết thúc.

Học tiếp và nguồn

Nguồn trực tuyến đọc ngày 2026-10-04; số đo thuộc Python 3.14.4 và Git 2.54.0 (Apple Git-157) trên macOS arm64, không có nghiệm thu Linux hay Windows.