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)
- Setup Linux đầy đủ: Ubuntu Desktop, shell, công cụ lập trình, container, cloud, tiếng Việt và xử lý lỗi Wayland.
- Setup macOS đầy đủ: Apple Silicon/Intel, Homebrew, shell, toolchain, container VM và bảo trì workstation.
- Cài đặt môi trường Windows: Hướng dẫn thiết lập Chocolatey, OpenSSH Server, Ansible và công cụ dòng lệnh trên Windows.
2. AI Agent Skills
- Tổng quan AI Agent Skills: khái niệm và cách chọn skill theo vấn đề.
- Danh mục skill theo nhóm: năng lực, thời điểm dùng và đầu ra cần kiểm.
- Sử dụng và viết skill: giao việc, mẫu SKILL.md, kiểm chứng và xử lý lỗi.
3. Bắt Đầu Theo Vấn Đề
- Query chậm: bắt đầu với EXPLAIN ANALYZE, chọn composite index, rồi nối metric với query chẩn đoán. Trước khi đổi hạ tầng, xem quyết định scale.
- Lỗi khó tái hiện: dùng debug có giả thuyết, viết regression test và đọc benchmark để phân biệt số đã đo với ngoại suy.
- Đổi kiến trúc backend: xác định cách gom code, ghi ADR, rồi kiểm biên provider hoặc quyền ứng dụng và dữ liệu.
- Giao việc cho AI: chọn context cho thay đổi, đặt tiêu chí hoàn tất, review code và lưu checkpoint. Khi state lớn hơn, cân nhắc file hay database cho bộ nhớ.
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ình | Kiểm tra |
|---|---|---|
| JetBrains Toolbox | Tả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ép | Mở IDE, chọn SDK/interpreter và kiểm tra terminal tích hợp |
| Postman | sudo snap install postman hoặc archive Linux từ nhà cung cấp | Gử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-ce | Kết nối database thử nghiệm, chạy truy vấn chỉ đọc |
| Navicat | Tải AppImage chính thức, cần giấy phép phù hợp | Mở app và kiểm tra connection thử nghiệm |
| Beekeeper Studio / SQLTools | Chọn client phù hợp với workflow file .sql và Git; SQLTools cần driver extension tương ứng | Lư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ứng | Thử nghiệm có kiểm soát | Nghiệ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-9149 | Copy 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 file | Thử 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.
Link file từ terminal mở sai ứng dụng hoặc sai dòng
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óm | Kiểm tra | Kế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 |
| Shell | zsh --version, zsh -n ~/.zshrc, terminal mới | Không lỗi startup, font/icon đúng |
| CLI | git --version, jq --version, rg --version, just --version | Có executable và chạy được |
| SSH/Git | ssh-add -l, kết nối dịch vụ, cấu hình danh tính | Chọn đúng public key và tài khoản |
| Container | docker version, docker compose version, hello-world | Kết nối được daemon/context dự định |
| Python | uv --version, interpreter và test của dự án | Đúng runtime, dependency khớp lockfile |
| Node/PHP/Go/Rust | Version command của tool đã chọn | Đúng phiên bản dự án và toolchain |
| Cloud/IaC | Version command; đăng nhập profile nếu dùng | Đúng CLI, profile và engine; chưa tự apply |
| Kubernetes | Client version, context, cluster local nếu cài | Đúng context, node sẵn sàng ở lab |
| GUI/input | Gõ tiếng Việt, copy/paste, drag/drop, video | Hoạ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ếu | Cách hiểu |
|---|---|---|
| OVERLOAD | MemAvailable < 12% hoặc memory PSI full avg10 > 40 | Thiếu RAM hoặc stall bộ nhớ kéo dài |
| CRITICAL | MemAvailable < 6% hoặc memory PSI full avg10 > 60 | Cần kiểm lại sau mỗi bước giảm tải |
| Editor hard limit | MemAvailable < 3% sau thời gian chờ editor | Chỉ dùng cho bước nâng mức can thiệp cuối |
| Swap/zRAM | Tỷ lệ tổng swap đã dùng | Telemetry; 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:
- Đóng nhóm trình duyệt Chrome, Edge/Teams bằng
SIGKILLđể thu hồi RAM nhanh. - Chờ
RECLAIM_WAIT=2giây, đọc lại telemetry; chỉ chuyển bước tiếp nếu còn CRITICAL. - Nếu cho phép đóng editor, gửi
SIGTERMcho Cursor và VS Code như hai phương án cuối có cùng mức ưu tiên. ChờEDITOR_TERM_GRACE=6giây. - Chỉ nâng lên
SIGKILLeditor khi RAM sau thời gian chờ vẫn dướiEDITOR_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ếu | Mục đích và điều cần kiểm |
|---|---|
vm.swappiness=10 | Thay cân bằng reclaim swap/file-backed page; đo lại với zRAM và workload thực tế |
vm.vfs_cache_pressure=50 | Thay mức ưu tiên reclaim dentry/inode; giữ cache hơn có thể tốn RAM hơn |
vm.dirty_ratio=15 | Ngưỡng dirty memory khiến tiến trình ghi tham gia writeback |
vm.dirty_background_ratio=5 | Ngưỡng bắt đầu background writeback |
vm.min_free_kbytes=131072 | Mứ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áy | Dòng cấu hình |
|---|---|
| Apple Silicon | eval "$(/opt/homebrew/bin/brew shellenv)" |
| Intel | eval "$(/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, gh | Quản lý mã nguồn và thao tác với repository trên GitHub |
just | Chạy recipe chuẩn của dự án |
jq | Đọc, lọc và biến đổi JSON |
tree, eza | Xem cây thư mục và danh sách file |
ripgrep, fzf | Tì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 |
ncdu | Tìm thư mục chiếm nhiều dung lượng |
gnupg | Mã hóa, kiểm chữ ký hoặc ký commit khi dự án yêu cầu |
uv, pre-commit | Quả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
| File | Nội dung nên đặt |
|---|---|
~/.zprofile | Homebrew shellenv và cấu hình cho login shell |
~/.zshrc | Prompt, plugin, alias và completion cho shell tương tác |
~/.zsh_history | Lị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óm | Cài đặt | Cấu hình và kiểm tra |
|---|---|---|
| Go | brew install go | go version; thêm $(go env GOPATH)/bin vào PATH khi cài Go CLI |
| Java | brew install openjdk@17 nếu dự án dùng JDK 17 | java -version; cấu hình JAVA_HOME theo caveat của formula |
| PHP | brew install php composer | php -v, composer --version; kiểm tra php --ini trước khi cài extension |
| Dart | brew install dart-lang/dart/dart | dart --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ếu | Công việc |
|---|---|
| DataGrip, Navicat | Truy vấn và quản lý nhiều loại database |
| RedisInsight | Quan sát Redis, key và hiệu năng |
| Postman | Gửi request, tổ chức collection kiểm API |
| Charles | Quan sát HTTP(S) cho ứng dụng được phép kiểm thử |
| VS Code, Zed, Neovim | Chỉnh mã nguồn, terminal, LSP và chạy task |
| IDE chuyên biệt | Java, Go, Python, PHP hoặc Rust theo stack |
| Figma, OBS | Thiết kế giao diện và ghi hình demo |
| GitHub Desktop | Thao 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
autosshgiá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.cloudflaredphụ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-runtrước tác vụ sync vì sync có thể xóa file ở đích.aws-vaultlà 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 Brewfile | Mục đích |
|---|---|
terraform-docs, hcledit, tfupdate | Sinh docs, chỉnh HCL và cập nhật constraint |
tflint, checkov, trivy | Kiểm lint, cấu hình và rủi ro bảo mật |
tfsec, terrascan | Hỗ trợ pipeline cũ khi dự án còn dùng |
infracost | Ước tính chi phí thay đổi IaC |
dotenvx | Quả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_RECIPE | Xem lệnh do dự án của bạn cung cấp trước khi chạy |
make, gmake | Chạy Makefile; gmake là GNU Make cài bằng Homebrew |
git diff, git difftool | Chọn diff văn bản hoặc GUI theo nhu cầu |
tree -a -L 2 | Xem cây thư mục, kể cả file ẩn, giới hạn độ sâu |
lsof -nP -iTCP -sTCP:LISTEN | Xem tiến trình đang mở port TCP |
podman port --all | Xem 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 SPHardwareDataType | Xem phần cứng; rà soát thông tin định danh trước khi chia sẻ output |
sysctl -n hw.memsize, df -h | Xem RAM và dung lượng filesystem |
brew list, brew services list | Kiểm kê gói và service Homebrew |
podman machine list, podman ps -a | Xem VM và container, sau đó kiểm image/volume/network theo nhu cầu |
podman system df, ncdu | Kiể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 outdatedrồibrew upgradecho 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ệu | Cần giữ | Có thể dựng lại nếu đủ manifest |
|---|---|---|
| Shell và cấu hình | Dotfiles đã rà soát, Brewfile, cấu hình terminal/editor | Completion cache |
| Source và tài liệu | Repository, file chưa commit, dataset và tài liệu riêng | Build output, dependency cache |
| Runtime | File pin version và lockfile | Venv, package cache, toolchain không còn dùng |
| Cloud/IaC | Cấu hình riêng, state/backend, chính sách quyền | CLI binary |
| Container/database | Dump database, dữ liệu volume và cấu hình Compose | Image có thể pull/build lại |
| IDE/AI | Settings cần dùng, transcript cần giữ, note | Cache 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ứng | Kiểm tra và xử lý |
|---|---|
Không tìm thấy brew | Kiểm prefix đúng kiến trúc, shellenv trong .zprofile, mở shell mới |
| Có hai bản runtime | Dùng command -v, kiểm PATH và manager của từng dự án |
| Lỗi CLT sau nâng macOS | Kiểm xcode-select -p; cài/cập nhật CLT tương thích |
| Homebrew báo lock | Kiể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ống | Tìm biến path hoặc PATH ghi đè; mở /bin/zsh -f để chẩn đoán |
| Completion Just sai | Kiểm plugin tùy chỉnh và _just sinh cũ; nạp lại completion |
| Sudo hỏi lại khi update cask | Theo dõi prompt ở terminal; keeper không chia sẻ mọi dạng ticket subprocess |
| Podman không kết nối | Xem machine/connection; start hoặc stop/start trước khi nghĩ đến reset |
| Build image khác kiến trúc | Kiểm manifest image, chọn ARM64/multi-arch hoặc chấp nhận chi phí giả lập |
| Port database bị chiếm | Xem 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 đầy | Dù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ượng | Kiểm tra |
|---|---|
Connection refused | Get-Service sshd phải là Running; kiểm rule tường lửa cổng 22 |
Connection timed out | Kiể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 ssh | Cài OpenSSH Client, rồi mở PowerShell mới để nạp lại PATH |
Dịch vụ sshd không khởi động | Xem 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
- Danh mục skill theo nhóm: chọn năng lực theo bài toán và biết đầu ra cần kiểm.
- Sử dụng và viết skill: giao việc, kiểm kết quả, tạo skill nhỏ và bảo trì.
Phân biệt các thành phần
| Thành phần | Vai trò | Ví dụ |
|---|---|---|
| Prompt | Giao việc cụ thể trong lượt hiện tại | Điều tra lỗi timeout của API này |
| Skill | Quy trình và kiến thức dùng lại theo loại việc | Tái hiện lỗi, tìm nguyên nhân, kiểm regression |
| Quy tắc dự án | Ràng buộc áp dụng trong dự án | Không sửa dữ liệu thật khi chạy test |
| Tool / MCP | Khả 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 |
| Harness | Môi trường điều phối và kiểm soát agent | Giớ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
| Skill | Khi dùng | Đầu ra cần kiểm |
|---|---|---|
solutions-architect-orchestrator | Làm rõ bài toán và chọn hướng triển khai | Scope, 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 plane | Task, kế hoạch, tiến độ và verifier; cần adapter phù hợp dự án |
harness-engineering | Thiết kế môi trường làm việc cho agent | Instructions, quyền, trạng thái và vòng đời kiểm chứng |
session-state | Tiếp tục công việc qua nhiều phiên | Checkpoint chứa bước hiện tại và bước kế tiếp |
definition-of-done | Chốt điều kiện hoàn tất trước triển khai | Tiêu chí quan sát được, verifier và evidence |
clean-state | Kết thúc phiên hoặc bàn giao | Kiểm tra đạt, tiến độ lưu, không còn artifact thừa |
loop-engineering | Thiết kế vòng lặp agent có điều kiện dừng | Giớ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-foresight | Rà soát tri thức mới và độ tươi tài liệu | Nguồn đã kiểm, tổng hợp và quyết định cập nhật |
prompt-engineering | Thiế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
| Skill | Khi dùng | Đầu ra cần kiểm |
|---|---|---|
monorepo-navigation | Tìm nơi sửa trong repository lớn | Phạm vi file, dependency và lệnh kiểm liên quan |
domain-driven-hexagon | Tách domain, use case và adapter | Biên module và chiều phụ thuộc |
ubiquitous-language | Thuật ngữ nghiệp vụ bị dùng không nhất quán | Glossary và các điểm còn mơ hồ |
design-an-interface | So sánh nhiều thiết kế API/module | Phương án, trade-off và ví dụ sử dụng |
improve-codebase-architecture | Tìm cơ hội giảm coupling | Refactor có lý do và phạm vi rõ |
request-refactor-plan | Chia refactor lớn thành bước nhỏ | Kế hoạch kiểm được từng bước |
prototype | Kiểm giả thuyết trước khi xây đầy đủ | Bản thử và kết luận về giả thuyết |
diagnose | Lỗi khó hoặc regression hiệu năng | Tái hiện, nguyên nhân và kiểm sau sửa |
enterprise-rbac-architecture | Thiết kế RBAC/ABAC, SSO và multi-tenant | Ma trận quyền, ranh giới tenant và kiểm audit |
Phát triển phần mềm và kiểm thử
| Skill | Khi dùng | Đầu ra cần kiểm |
|---|---|---|
python-development | Phát triển Python, type safety và async | Mã nguồn, typecheck và test phù hợp |
rust-commentless | Dự á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-backend | Backend Node.js, stream hoặc memory leak | Profile, backpressure và graceful shutdown |
laravel-enterprise-arch | Tổ chức backend Laravel lớn | Controller gọn, use case rõ và truy vấn hợp lý |
code-quality-testing | Kiểm lint, format, type và test | Kết quả lệnh thật, lỗi còn tồn tại |
tdd | Xây hành vi bằng red–green–refactor | Test thất bại trước sửa và đạt sau sửa |
algorithmic-problem-solving | Giải thuật toán hoặc bài toán rời rạc | Invariant, độ phức tạp và ví dụ chạy được |
jupyter-notebook | Phân tích dữ liệu và kiểm giả thuyết | Notebook chạy lại được, kết quả có ngữ cảnh |
taste-engineering | Review tính dễ đọc, code và giao diện | Giảm phần thừa, giữ hành vi và tính dễ dùng |
Hạ tầng, automation và dữ liệu
| Skill | Khi dùng | Đầu ra cần kiểm |
|---|---|---|
infra-iac | Thiết kế IaC multi-cloud | Module, plan và phạm vi tài nguyên thay đổi |
infra-devops | Container, CI/CD và observability | Pipeline, deployment và cách phát hiện lỗi |
ansible-automation | Provision hoặc cấu hình server | Role/playbook, inventory và tính idempotent |
container-services | Quản lý dịch vụ Compose trên Docker/Podman | Profile, volume, network và healthcheck |
infra-shell-just | Harden thân script shell | Quoting, trap và exit code chính xác |
just-task-runner | Tổ chức recipe và dependency | Recipe rõ tên, tham số và luồng chạy |
execution-time-card | Chuẩn hóa thời gian hiển thị của runner | Một mẫu render, đơn vị và độ rộng nhất quán |
database-management | Schema, migration, query đa hệ database | Thiết kế và phương án rollback/kiểm dữ liệu |
postgres-deep-dive | Truy vấn chậm, MVCC, VACUUM, lock | Execution plan, số đo và tác động vận hành |
event-driven-systems | Streaming hoặc workflow phân tán | Event contract, retry và xử lý trùng lặp |
Bảo mật và vệ sinh artifact
| Skill | Khi dùng | Đầu ra cần kiểm |
|---|---|---|
skill-security-scan | Review skill/MCP trước khi tin tưởng | Finding, nguồn gốc và phán quyết từng phát hiện |
git-guardrails-claude-code | Chặn lệnh Git nguy hiểm trong Claude Code | Hook được kiểm bằng trường hợp cho phép và bị chặn |
playwright-artifact-hygiene | Kiểm trình duyệt bằng Playwright | Screenshot, trace và log đúng thư mục dự án |
Tài liệu, kiến thức và đào tạo
| Skill | Khi dùng | Đầu ra cần kiểm |
|---|---|---|
changelog-documentation | Ghi nhận thay đổi và chưng cất bài học | WHAT/WHY và kết quả verify |
design-md-patterns | Viết tài liệu hoặc registry Markdown | GFM đọc tốt và liên kết hợp lệ |
markdown-table-formatting | Chuẩn hóa bảng Markdown | Cột thẳng, escaped pipe và code fence được giữ |
write-a-skill | Đóng gói quy trình dùng lại | Trigger, hướng dẫn, ví dụ và kiểm kết quả |
notebooklm | Hỏi đáp trên tập tài liệu đã chọn | Câu trả lời đối chiếu được với nguồn |
obsidian-vault | Quản lý ghi chú Obsidian | Note, wikilink và index nhất quán |
teach | Học một khái niệm qua thực hành | Giải thích, bài tập và phản hồi |
scaffold-exercises | Tạo bộ khung bài tập | Đề, lời giải và cấu trúc hợp lệ |
sql-course | Biên soạn khóa SQL cho developer | Nội dung theo mục tiêu học và ví dụ kiểm được |
Viết và xử lý đầu việc
| Skill | Khi dùng | Đầu ra cần kiểm |
|---|---|---|
writing-fragments | Thu thập ý tưởng trước khi viết | Mảnh ý, câu chuyện và luận điểm thô |
writing-beats | Ghép bài viết theo từng nhịp | Một đoạn có mục đích và hướng chuyển tiếp |
job-application-email | Viết thư ứng tuyển hoặc cover letter | Nội dung bám JD và kinh nghiệm có thật |
triage | Phân loại bug/feature và chuẩn bị bàn giao | Issue 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
- Chốt kết quả mong muốn, quyền thực thi và vùng được sửa.
- Chọn một skill chính; bổ sung chuyên môn khi task thật sự cần.
- Đọc điều kiện môi trường, script và nguồn của skill trước khi chạy.
- Làm từng phần nhỏ; kiểm kết quả quan sát được sau mỗi phần.
- 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ượng | Kiểm tra | Cách xử lý |
|---|---|---|
| Không được nhận diện | Vị trí cài và metadata theo ứng dụng | Sử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ạt | Description và yêu cầu có khớp không | Nêu loại việc cụ thể hoặc chọn skill trực tiếp |
| Agent làm sai phạm vi | Trigger quá rộng hoặc hướng dẫn xung đột | Thu hẹp phạm vi, thêm tình huống kiểm |
| Script thất bại | Dependency, đường dẫn, quyền và exit code | Sửa nguyên nhân, chạy lại verifier |
| Test đạt nhưng chưa đúng yêu cầu | Tiêu chí có bỏ sót ý định không | Bổ 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ếu | Rà 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ì
checkouttrả503kèm{"error": "upstream_timeout", "service": "shipping"}ngay sau đó. - Thực tế:
checkoutchờ đế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ảnh | Quyết định mà mảnh cho phép | Dán hay trỏ |
|---|---|---|
| 1. Mong đợi | Trả 503 hay kết quả dự phòng; hạn bao lâu | Dán: không có trong repo |
| 2. Phạm vi | Sửa client hay helper dùng chung | Dán: là quyết định của bạn |
| 3. Test lỗi và log | Lỗi nằm ở bước nào, chậm bao lâu | Dán: là kết quả của một lần chạy |
4. shipping.py | Sửa ở đâu | Trỏ đường dẫn nếu agent đọc được |
5. checkout.py | Dùng lỗi nào để ra 503 | Trỏ đường dẫn |
| 6. Diff | Nguyên nhân nên kiểm trước | Trỏ (lịch sử git) hoặc dán |
7. inventory.py | Quy ước timeout và cách bọc lỗi | Trỏ đường dẫn |
| 8. Cách kiểm | Khi nào dừng, báo gì nếu không chạy được | Dá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ỏi | Quyết định trong case |
|---|---|---|
| Relevance | File 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). |
| Recency | Thô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. |
| Representation | Dạ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. |
| Minimization | Bỏ 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.pycó 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,
timeoutcủaurllibá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ỗi | Hậ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ác | Lọc theo request id hoặc khung giờ; ẩn dữ liệu nhạy cảm |
| Mô tả quy ước bằng lời | Kiểu exception, tên hằng số bị hiểu khác | Dán đoạn code đúng trong cùng repo |
| Không nêu phần cấm đổi | Sửa lan sang helper hoặc cấu hình dùng chung | Ghi rõ phạm vi được sửa và phạm vi không đổi |
| Đưa nghi ngờ như kết luận | Sửa theo giả thuyết sai | Gắn nhãn giả thuyết; để test quyết định |
| Thiếu cách kiểm | “Đã sửa xong” không kiểm được | Cho 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ác | Sử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
- Tiêu chí hoàn tất có thể kiểm chứng: biến mảnh “cách kiểm” thành tiêu chí và verifier có thể thất bại.
- Sử dụng và viết skill: đưa quy trình lặp lại vào skill để khỏi dán lại mỗi lần.
- AI Agent Skills: phân biệt prompt, skill, quy tắc dự án, tool và harness.
Nguồn tham khảo
- Anthropic, Effective context engineering for AI agents, 2025-09-29.
- N. F. Liu và cộng sự, Lost in the Middle: How Language Models Use Long Contexts, arXiv:2307.03172 (v3, 2023-11-20), TACL.
- Chroma, Context Rot, 2025-07-14.
- Claude Code, Best practices, mục “Provide specific context in your prompts”.
- Python,
urllib.requestvàsocket.
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ại | test -f users.csv | Nội dung đúng, đủ dòng, đọc lại được |
| Test chạy được | Một lệnh kiểm kết thúc với exit code 0 | Rằ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ầu | Verifier đọ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ẫu | Cá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_atcó dạng2026-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ầu | Verifier | Kỳ vọng | Thực tế | Bằng chứng |
|---|---|---|---|---|
R1 Dòng đầu là id,name,created_at | verify_behavior.py, ca normal | Hàng đầu là id, name, created_at | Khớp | ok normal |
| R2 Một dòng mỗi người dùng, đúng thứ tự | Ca normal | Hai dòng dữ liệu theo thứ tự đầu vào | Khớp | ok normal |
R3 created_at dạng …T…Z | Ca normal | 2026-01-31T09:05:00Z | Khớp | ok normal |
| R4 Dấu phẩy, nháy kép, xuống dòng không phá cột | Ca special_chars | Một dòng dữ liệu, tên nguyên vẹn | Hai dòng dữ liệu, tên bị tách thành Le, "Van" và Tran | FAIL special_chars, exit code 1 |
| R5 Danh sách rỗng chỉ có dòng tiêu đề | Ca empty | Một hàng: tiêu đề | Khớp | ok empty |
| R6 Tiếng Việt có dấu giữ nguyên | Ca unicode | Nguyễn Thị Bình | Khớp | ok 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ại | Ví dụ | Dùng làm bằng chứng “xong”? |
|---|---|---|
| Kiểm chỉ đọc hoặc cô lập | Chạ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ật | Deploy, chạy migration trên database thật, gửi email, xóa dữ liệu, push | Khô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ỗi | Hậ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 sai | Viết kết quả quan sát được |
| Verifier chỉ kiểm file tồn tại hoặc exit code | Pass 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ểm | Lỗi và kỳ vọng sai cùng nhau | Viết kỳ vọng thành dữ liệu độc lập |
| Không có ca rỗng và ca đặc biệt | Lỗi ca biên lọt qua | Liệt kê ca biên từ yêu cầu trước khi viết code |
| Agent tự sửa verifier cho dễ pass | Verifier bị nới lỏng | Xem 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ó output | Không kiểm lại được | Đòi lệnh, exit code và output |
Học tiếp
- Chọn context khi sửa code: mảnh “cách kiểm” trong bộ input chính là một tiêu chí như bài này.
- Sử dụng và viết skill: đưa quy trình lặp lại, như báo cáo hoàn tất, vào skill.
- Review code do AI sinh ra: khi diff đã có bằng chứng, reviewer còn phải hỏi gì để đi tới verdict.
Nguồn tham khảo
- Claude Code, Best practices, mục “Give Claude a way to verify its work” và “Avoid common failure patterns”.
- J. Young (Anthropic), Effective harnesses for long-running agents, 2025-11-26.
- METR, Recent Frontier Models Are Reward Hacking, 2025-06-05.
- S. K. Lahiri, Intent Formalization: A Grand Challenge for Reliable Coding in the Age of AI Agents, arXiv:2603.17150 (v1, 2026-03-17).
- RFC 4180 (Informational, 2005), RFC 3339 (Proposed Standard, 2002) và tài liệu module
csvcủa Python.
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.pydòng 7,if amount > order.total. - Điều kiện tái hiện: đơn tổng 100; gọi
refund60 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.refundedthà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êmorder.refundedkhông vượtorder.totalsau 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.pydòng 4 (tham sốactor) và cả thân hàm: không dòng nào đọcactor. - Điều kiện tái hiện: gọi
refund(order, 10, CUSTOMER)vớirole="customer"; hàm chạy và ghiorder.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ào | Ai hoặc cái gì làm | Bắt được | Không bắt được |
|---|---|---|---|---|
| Kiểm tự động | Mọi diff | CI: format, lint, type, test, secret scan | Lỗi cú pháp, quy ước, hồi quy đã có test | Hành vi chưa có test: cả hai lỗi của ví dụ đều lọt qua |
| Review logic | Mọi diff | Ngườ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âu | Diff 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 khai | Người có chuyên môn của vùng đó | Rủi ro bảo mật, thiết kế, đồng thời | Thứ 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:
- Diff chạm vùng ở bảng trên mà reviewer hiện tại không đủ chuyên môn.
- 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.
- 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.
- 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 checklist | Bằ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à output | Không | Khô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ông | Có: 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ối | Không | Có |
| 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ọi | Có | 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ếu | Báo cần soi | Bá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
pythonthay chopython3và lệnhgrepcần Git Bash hoặc tương đương.
Lỗi thường gặp
| Lỗi | Hậu quả | Cách tránh |
|---|---|---|
| Coi test xanh là đủ để duyệt | Lỗi ngoài phạm vi test lọt qua | Hỏi test có đỏ khi code sai không; viết probe |
| Review do chính agent đã viết code, trong cùng ngữ cảnh | Thiên vị code của mình | Ngữ cảnh mới, chỉ có diff và tiêu chí; vẫn xác minh finding |
| Checklist toàn ô yes/no | Tick đủ mà không có bằng chứng | Mỗ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ện | Tá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ức | Lỗi quay lại mà không ai bắt | Chuyể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ôn | Rủi ro bảo mật lọt qua | Trigger chuyển người; ghi rõ phần đã review |
Học tiếp
- Tiêu chí hoàn tất có thể kiểm chứng: viết tiêu chí và verifier để diff có bằng chứng ngay từ đầu.
- Chọn context khi sửa code: đưa cho agent đủ ngữ cảnh để diff ít lỗi hơn ngay từ lần sinh.
- Dựng harness để AI agent làm việc đáng tin: đặt các cổng kiểm và cổng người ở đúng chỗ trong vòng đời task.
Nguồn tham khảo
- Google, The Standard of Code Review và What to look for in a code review, Engineering Practices (đọc ngày 2026-10-02).
- OWASP, Top 10:2025, A01 Broken Access Control (đọc ngày 2026-10-02).
- Claude Code, Best practices, các mục “Add an adversarial review step” và “Run multiple Claude sessions” (đọc ngày 2026-10-02).
- N. Perry và cộng sự, Do Users Write More Insecure Code with AI Assistants?, CCS 2023, arXiv:2211.03622 (v3, 2023-12-18).
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ần | Nội dung hữu ích | Lỗ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 đạt | Chỉ ghi “đã sửa gần xong” |
| Scope | File được sửa, phần ngoài phạm vi | Mở rộng sang refactor/deploy |
| Quyết định | Lựa chọn hiện tại và lý do | Lưu mọi phương án đã bỏ |
| Diff | File/hành vi đã đổi và cách đối chiếu | Cho rằng tên file chứng minh nội dung |
| Evidence | Lệnh, exit, log, phiên bản và snapshot file/test | Chỉ ghi “tests pass” |
| Blocker | Điều thiếu và ai/cái gì gỡ được | Biến chưa kiểm thành pass |
| Next step | Một việc cụ thể để tiếp tục | Nhiề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ần | File trong lab | Vai trò |
|---|---|---|
| Instructions | INSTRUCTIONS.md | Người/agent đọc mục tiêu và ranh giới |
| Scope/criteria | task.json và fixture S25 | Chỉ sửa exporter; expected có trước code |
| Verification | verify_behavior.py | So CSV đọc lại với giá trị độc lập |
| State | checkpoint.json, verify.log | Trạng thái hiện tại, snapshot, next step |
| Lifecycle | run.py | START 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:
| Store | Lượt | Giây |
|---|---|---|
| json | 1 | 0.064589 |
| sqlite | 1 | 0.001089 |
| sqlite | 2 | 0.001057 |
| json | 2 | 0.065312 |
| json | 3 | 0.065446 |
| sqlite | 3 | 0.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ầu | File đủ khi | Cân nhắc database khi |
|---|---|---|
| Đọc/tra ID | Ít record, một writer, parse/cache đủ rẻ, người review diff | Lookup 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ể rebuild | Index/join/FTS giúp workload đã đo; vẫn cần contract search |
| Nhiều writer | Tất cả hợp tác lock/CAS, file nhỏ, serialize chấp nhận được | Transaction nhiều record, uniqueness/FK và query đồng thời; SQLite vẫn một writer |
| Provenance | File nguồn + SHA/revision, lỗi stale có hành động | Cũng phải giữ nguồn/hash/version và rebuild; DB không tự sửa stale |
| Recovery/portability | Snapshot/export/diff rõ, biết giới hạn crash durability | Backup 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ứng | Nguyên nhân gốc | Cơ 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ỗ kia | Cấu hình nằm ở nhiều nơi và được sửa tay | Mộ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 quan | Không có biên: được ghi ở đâu, được đụng file nào | Phạm vi bằng cấu trúc: mỗi task một vùng ghi riêng, vùng gốc chỉ đọc | Thử 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ự khai | Cổ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ứng | Ngườ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ớp | Trả lời câu hỏi | Cơ chế điển hình | Nếu thiếu |
|---|---|---|---|
| Chỉ dẫn | Là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 file | Agent tự đoán, hoặc bị nhồi quá nhiều chỉ dẫn |
| Tri thức theo nhu cầu | Cầ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ấy | Nhồ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ói | Việc loại này làm thế nào? | Skill theo từng loại việc | Mỗ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 file | Luậ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 repo | Phiê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ằng | Với PreToolUse |
|---|---|
| exit 2 | Chặn lời gọi tool; stderr được đưa lại cho Claude làm phản hồi |
| exit 0 | Khô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ả 1 | Lỗ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.pycó 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.pychặ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ý doassert_blocks.pycó 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:
- 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.
- 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.
- 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ớ.
| Pha | Câu hỏi | Vật chứng đọc lại được | Ai hoặc cái gì quyết |
|---|---|---|---|
| Chốt phạm vi | Làm gì, không làm gì, giả định nào? | Ghi chú phạm vi và giả định | Người xác nhận |
| Lập kế hoạch | Chia 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ất | Ngườ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 |
| Review | Cò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 thu | Mọ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ường | Máy chạy, người đọc |
| Bàn giao | Người sau cần biết gì? | Tóm tắt, tài liệu đã cập nhật, trạng thái sạch | Ngườ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 addtừ 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 diffgiữ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ệm | Hậu quả | Cách giảm |
|---|---|---|
| Chỉ chạy một lần | Model có tính ngẫu nhiên nên một lần chỉ là giai thoại | Chạ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 án | Cắ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ộ đề đo | Thước đo thành mục tiêu và hết đáng tin | Giữ một bộ đề chưa dùng để chỉnh; thay đề định kỳ |
| Đổi nhiều thành phần cùng lúc | Không biết thành phần nào có tác dụng | Mỗ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ứng | Chấ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.
- 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.
- Viết chỉ dẫn ngắn như mục lục, trỏ tới nguồn sâu hơn.
- 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.
- Thêm tiến độ và checkpoint để task sống qua nhiều phiên.
- Cô lập vùng ghi khi bắt đầu chạy song song.
- Đ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.pychỉ 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ỗi | Hậu quả | Cách tránh |
|---|---|---|
| Tệp chỉ dẫn dài như bách khoa | Chiếm chỗ của task, thành không chỉ dẫn, mục nát | Giữ như mục lục, trỏ tới nguồn sâu hơn |
| Hook chỉ ghi log mà tưởng là chặn | Vi phạm vẫn xảy ra, chỉ có dấu vết | Phép thử vi phạm cho từng hook chặn |
| Hook thoát exit 1 hoặc không khởi động được | Gate tắt âm thầm | Dùng exit 2; chạy phép thử ngay sau khi cấu hình |
So đường dẫn bằng / trên Windows | Bỏ lọt mọi lời gọi | Chuẩ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 sinh | Lần sinh sau ghi đè, hoặc hai nơi lệch nhau | Sửa nguồn; CI sinh lại và so diff |
| Các worktree dùng chung cổng hoặc database | Vùng khác thư mục vẫn đạp lên nhau | Cổng và namespace riêng cho từng vùng |
| Đo một lần rồi kết luận | Chỉ là giai thoại | Chạy lặp, đổi một thành phần mỗi lần |
| Giữ thành phần harness thừa | Chi 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
- AI Agent Skills: phân biệt prompt, skill, quy tắc dự án, tool và harness.
- Chọn context khi sửa code: lớp tri thức theo nhu cầu, áp dụng cho một task cụ thể.
- Tiêu chí hoàn tất có thể kiểm chứng: viết tiêu chí và verifier cho lớp bằng chứng.
- Sử dụng và viết skill: đóng gói quy trình lặp lại thành skill.
Nguồn tham khảo
- J. Young (Anthropic), Effective harnesses for long-running agents, 2025-11-26.
- R. Lopopolo (OpenAI), Harness engineering: leveraging Codex in an agent-first world, 2026-02.
- Anthropic (Erik S. và Barry Zhang), Building effective agents, 2024-12.
- Claude Code, Hooks reference và Hooks guide, đọc ngày 2026-10-02.
- Git, git-worktree.
- AGENTS.md.
- Model Context Protocol: Introduction.
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ệc | Câu hỏi | Đối tượng | Kết quả |
|---|---|---|---|
| Test | Hành vi này có chạy đúng không? | Một hành vi | Đạt hoặc không đạt |
| Review | Thay đổi này có nên vào không? | Một thay đổi | Duyệ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ểm | Danh 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ồn | Gồm | Ai tạo ra | Điểm yếu |
|---|---|---|---|
| Bản ghi | Kế hoạch, ticket, bảng tiến độ, nhật ký | Người hoặc agent tự cập nhật | Không ai chạy nó nên có thể đi sau hoặc đi trước thực tế |
| Hiện vật | Mã, test, tài liệu, cấu hình trong kho mã, kết quả CI | Ngườ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ành | Bản đang phục vụ người dùng, hạ tầng đang chạy | Quá trình triển khai | Khó đ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:
- Hai nguồn lệch nhau là một phát hiện, chưa phải lỗi của ai.
- 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.
- 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ệch | Mức độ | Bằng chứng | Người xử lý | Hạn |
|---|---|---|---|---|---|
Bản b43 đang chạy nhưng chưa được duyệt | Hiện vật và môi trường | Chặn | Lệnh đo và kết quả, kèm thời điểm | Tên hoặc vai trò | Ngày cụ thể |
| Tiến độ ghi 80%, đếm được 75% | Bản ghi và số đếm | Sửa trong sprint | Kết quả audit.py | Tê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
- Viết tiêu chí hoàn tất mà AI có thể kiểm chứng: viết tiêu chí và kiểm chính verifier.
- Dựng harness để AI agent làm việc đáng tin: cổng duyệt, vòng đời task, cô lập vùng ghi.
- Review code do AI sinh ra: review từng thay đổi, finding mẫu, verdict có lý do.
- Chọn context khi sửa code: giao việc có phạm vi trước khi có gì để audit.
Nguồn tham khảo
- Effective harnesses for long-running agents, Anthropic Engineering.
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
initializenê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 đổi | Nộ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 |
resultType | Mọ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à cacheScope | Bắ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ỗi | Base 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:
idlà chuỗi hoặc số nguyên; “Unlike base JSON-RPC, the ID MUST NOT be null”. - Response thành công: cùng
idvới request và córesultchứaresultType. - Response lỗi: có
errorvớicodenguyên vàmessage, tùy chọndata; cùngidvớ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ừ
stdinvà ghi lênstdout, 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 rastderr(“MAY write UTF-8 strings to stderr for any logging purposes”). Đóngstdinlà 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 _meta | Bắt buộc | Ý nghĩa |
|---|---|---|
io.modelcontextprotocol/protocolVersion | có | Bản giao thức của yêu cầu này |
io.modelcontextprotocol/clientCapabilities | có | Năng lực của client liên quan tới yêu cầu |
io.modelcontextprotocol/clientInfo | không | Tên và phiên bản client (nên gửi) |
io.modelcontextprotocol/logLevel | không | Mứ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-01nhận-32022kèmdata.supported; thử lại với2026-07-28thì 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/discovervàtools/listcó đủ trường bắt buộc.resultTypelàcomplete,server/discovernêu bản, năng lực vàserverInfo,tools/listcóttlMsvà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àaddvới tham số chuỗi đều trả JSON-RPC thành công (resultTypelàcomplete) vớiisError: truevà lời giải thích trongcontent: đây là lỗi thực thi công cụ. Tool không tồn tại vàargumentslà mảng thì là lỗi giao thức-32602. - Mười hai ca lỗi giao thức ra đúng mã.
-32700cho JSON hỏng;-32600cho mảng, giá trị không phải object,methodkhông phải chuỗi vàidnull;-32601cho method lạ vàinitialize;-32602cho thiếu_meta, thiếuclientCapabilities, tool lạ vàargumentssai dạng;-32022cho bản không hỗ trợ. Vớiinitialize, 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ó
idkhô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
stdinthì server thoát với mã 0, và nhật ký chỉ nằm ởstderr(23 dòng) trong khistdoutcó 21 dòng, đều là JSON-RPC. - Một dòng
printthừa phá khung. Khi server in nhật ký rastdout, dòng đầu tiên client đọc không phải JSON vàjson.loadsbáoJSONDecodeError; đú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ên | Ca trong lab | Căn cứ |
|---|---|---|---|
-32700 | Parse error | dòng không phải JSON | JSON-RPC: “Invalid JSON was received by the server”; id là null vì không đọc được |
-32600 | Invalid Request | mảng; giá trị không phải object; method không phải chuỗi; id null | JSON-RPC; MCP: id không được null; stdio: mỗi dòng một thông điệp |
-32601 | Method not found | method 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 |
-32602 | Invalid params | thiếu _meta hoặc clientCapabilities; tool lạ; arguments sai dạng | MCP: thiếu trường _meta bắt buộc; trang Tools: ví dụ “Unknown tool” dùng -32602 |
-32022 | UnsupportedProtocolVersion | bản 1900-01-01 và 2099-01-01 | MCP: lỗi kèm data.supported và data.requested |
-32021 | MissingRequiredClientCapability | không kiểm: công cụ của lab không đòi năng lực nào của client | MCP: kèm data.requiredCapabilities |
-32020 | HeaderMismatch | không kiểm: chỉ liên quan Streamable HTTP | bảng mã lỗi của MCP |
-32603 | Internal error | khô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: true | lỗi thực thi công cụ | chia cho 0; tham số sai kiểu | trang 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ệc | Ai chịu trách nhiệm | Căn cứ trong đặc tả |
|---|---|---|
| Quyết định có chạy một công cụ hay không | Host 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ền | Ngườ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ực | HTTP có khung Authorization; stdio lấy thông tin xác thực từ môi trường | stdio “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 định | mô tả hành vi như annotations “should be considered untrusted, unless obtained from a trusted server” |
Tin vào serverInfo và clientInfo | Khô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 ra | Server | “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án | Client | mụ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 (headerMCP-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ềinitializekhi 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,-32021và-32020chưa kiểm. Lab không có đường dẫn lỗi nội bộ, không thửidkiể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àTimer60 giây tự kết thúc server nếu treo.
Học tiếp và nguồn
- Harness tối thiểu cho một task nhỏ: kernel nhỏ và ranh giới quyền, nơi cổng cho phép hoặc từ chối công cụ nên nằm.
- Dựng harness để AI agent làm việc đáng tin: cổng người duyệt và chốt chặn thật.
- Model Context Protocol, Specification (bản 2026-07-28): tổng quan và nguyên tắc an toàn.
- MCP, Base Protocol: thông điệp,
resultType, mã lỗi,_meta, statelessness và xác thực. - MCP, Versioning and Compatibility và Versioning: đàm phán bản qua từng yêu cầu,
-32022, ma trận tương thích, bản hiện hành. - MCP, stdio: khung thông điệp,
stderr, tắt server, dò tương thích. - MCP, Discovery và Tools:
server/discover,tools/list,tools/call, hai loại lỗi, lưu ý bảo mật. - MCP, Key Changes: thay đổi so với bản 2025-11-25.
- JSON-RPC Working Group, JSON-RPC 2.0 Specification: request, response, notification và bảng mã lỗi.
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:
- 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.
- 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.
- Đủ 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.
- Reset nhanh: dựng lại dữ liệu từ đầu trong vài giây.
- 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.
- Dọn được: một lệnh xóa sạch mọi thứ lab tạo ra.
Chọn phiên bản
| Engine | Phiên bản ghim | Lý do |
|---|---|---|
| PostgreSQL | 18 (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 |
| MySQL | 26.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ạm | B. Container bằng Compose |
|---|---|---|
| Cần có | initdb, pg_ctl, psql, mysqld, mysqladmin, mysql trong PATH | Docker hoặc Podman có Compose |
| Cổng mạng | Không mở cổng nào: cả hai server chỉ nhận kết nối qua socket Unix | Không công bố cổng: client chạy bên trong container |
| Dữ liệu | Thư mục tạm riêng, mặc định chỉ chủ sở hữu truy cập được | Volume đặt tên riêng của project wikilab |
| Dọn | lab_clean | docker 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ảng | Số hàng | Nội dung |
|---|---|---|
customers | 2.000 | id, name, country (5 giá trị luân phiên), created_at |
orders | 100.000 | id, customer_id, status (70% paid, 20% shipped, 7% cancelled, 3% pending), total_cents, created_at |
order_items | 300.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_idtí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_addressesrỗ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-networkingtắt kết nối TCP/IP và--mysqlx=OFFtắ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 -dtạ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êmunix_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-insecuretạ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ậylab_upchỉ 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-lablà điều kiện đểlab_cleanđược phép xóa thư mục. NếuLAB_DIRrỗng hoặc trỏ nhầm chỗ, không có dấu thì không xóa gì. lab.envlư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_upgọ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=trustvàrootkhô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ứng | Nguyên nhân thường gặp | Cách xử lý |
|---|---|---|
thiếu initdb trong PATH | Công cụ cài ở thư mục không nằm trong PATH | Thê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 lab | Giữ 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 aborted | Quên nâng cte_max_recursion_depth khi sửa seed MySQL | Giữ 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.env | Chưa lab_up, hoặc đứng sai thư mục | cd vào thư mục lab, . ./lab-local.sh, rồi lab_up |
| Lo lệnh chạy nhầm vào database thật | Quên mình đang trỏ vào đâu | Chạ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 18 | Gắ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_checkvàlab_whoami: bằng chứng chạy được thay cho cảm giác.
Nguồn tham khảo
- PostgreSQL, Versioning Policy (đọc ngày 2026-10-03).
- PostgreSQL 18, Connection Settings (
listen_addresses,unix_socket_directories,unix_socket_permissions) và pg_ctl (-l,-w,-m,status), đọc ngày 2026-10-03. - Docker Official Images, tài liệu image postgres và image mysql: danh sách thẻ,
PGDATAvà volume của bản 18, biến môi trường bắt buộc (đọc ngày 2026-10-03). - MySQL 8.4 Reference Manual: Initializing the Data Directory, Server Command Options và WITH (Common Table Expressions). Tôi chỉ đọc được bản 8.4 qua công cụ tóm tắt; tài liệu bản 26.7 chưa đọc.
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ỏi | Gauge lab | Query đối chiếu | Bước tiếp theo |
|---|---|---|---|
| Query giả còn chạy bao lâu? | wiki_observe_slow_seconds, seconds | Tuổ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, sessions | wait_event_type='Lock', pg_blocking_pids | Xác định transaction giữ khóa và phạm vi ghi |
| Replica còn nợ bao nhiêu WAL? | wiki_observe_replay_bytes, bytes | Primary current LSN trừ replica replay LSN | Kiểm transport/replay; bytes không tự đổi thành seconds |
| Có dữ liệu để tin biểu đồ? | up{job="wiki_pg"}, 0/1 | Prometheus target, exporter pg_up và scrape error | Sử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:
| Ca | Gauge | DB query | Lệch cửa sổ s |
|---|---|---|---|
| slow | 1.475271 | 1.575232 | 0.104 |
| lock | 1.000000 | 1.000000 | 0.177 |
| lag | 264.000000 | 264.000000 | 0.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ệc | Câu hỏi | Ai trả lời |
|---|---|---|
| Nhận diện | Chuỗ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ại | Mỗ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 định | Vớ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 MITlà lựa chọn giữaLGPL-2.1-onlyvà biểu thứcBSD-3-Clause AND MIT, vìANDưu tiên hơnOR. Ngoặc đổi thứ tự. ORlà chọn,ANDlà cùng lúc. Đặc tả dùngORkhi “given a choice between” các giấy phép vàANDkhi “required to simultaneously comply with two or more licenses”. Cả hai giao hoán.- Chữ hoa chữ thường: toán tử
AND,OR,WITHnê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ênMIT,MitvàmItlà 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
ANDvà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èmDocumentRef-…:đứ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:
ANDlấy nhóm khó nhất của các vế, vì phải tuân thủ cả hai.ORlấ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.WITHgiữ 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.0hạstrong-copyleftxuốngweak-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ànhunknown, bị chặn ở mọi kịch bản.ANDvới một mã lạ chounknown;ORvớ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-onlyhoặc-or-latertươ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óm | Mã trong chính sách mẫu | Nội bộ | Phân phối |
|---|---|---|---|
permissive | MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, Zlib, Unlicense, CC0-1.0, 0BSD | allow | allow |
weak-copyleft | LGPL 2.1 và 3.0 (cả -only và -or-later), MPL-2.0, EPL-2.0 | allow | review |
strong-copyleft | GPL 2.0 và 3.0 (cả -only và -or-later) | allow | review |
network-copyleft | AGPL-3.0 (cả -only và -or-later) | review | review |
unknown | mọi mã khác, LicenseRef-…, chuỗi sai cú pháp | block | block |
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#InternalDistributionnó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ậystrong-copyleftlà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àoreview. - 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ênnetwork-copyleftlàreviewcả ở 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-copyleftlà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ênallowở đâ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ảng | Deprecated as of | Bài đọc thành |
|---|---|---|---|
GPL-2.0 | GNU General Public License v2.0 only | 3.0 | GPL-2.0-only |
GPL-2.0+ | GNU General Public License v2.0 or later | 2.0rc2 | GPL-2.0-or-later |
GPL-3.0 | GNU General Public License v3.0 only | 3.0 | GPL-3.0-only |
GPL-3.0+ | GNU General Public License v3.0 or later | 2.0rc2 | GPL-3.0-or-later |
LGPL-2.1 | GNU Lesser General Public License v2.1 only | 3.0 | LGPL-2.1-only |
AGPL-3.0 | GNU Affero General Public License v3.0 | 3.0 | AGPL-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 MITthànhLGPL-2.1-only OR (BSD-3-Clause AND MIT): chọnLGPL-2.1-onlylà đủ. Đọc(LGPL-2.1-only OR BSD-3-Clause) AND MITthìMITluô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 Zlibcho thấy+dính vào mã, không phải toán tử đứng riêng. ANDvàORkhác nhau ở chỗ có lựa chọn.MIT AND GPL-3.0-onlylàstrong-copyleft;MIT OR GPL-3.0-onlylàpermissivevới nhánhMITđược ghi lại. TrongGPL-3.0-or-later AND (LGPL-2.1-or-later OR MIT), chọnMITở 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.0có trong chính sách nênGPL-2.0-only WITH Classpath-exception-2.0xuốngweak-copyleft;Bison-exception-2.2chưa có trong chính sách nênGPL-2.0-or-later WITH Bison-exception-2.2giữstrong-copyleftvà 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-copyleftlàallowở cột nội bộ vàreviewở cột phân phối;network-copyleftlàreviewở cả hai;unknownbị chặn ở cả hai.MIT OR Foo-1.0qua được vì nhánhMITđủ, cònMIT AND Foo-1.0bị 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ểm | SPDX 2.3 | Debian copyright-format 1.0 |
|---|---|---|
| Toán tử | AND, OR, WITH viết hoa; nên so khớp phân biệt hoa thường | and, 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ên | mã 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òngtau-bad,upsilon-emptyvàphi-slashbị 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ànhMIT. mitchữ thường hợp lệ.chi-lowerđược nhận làpermissivevì 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ênMIT or Apache-2.0bị 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 (weakba,strongnăm), tổng chín góireview. - Nhánh
ORđược ghi lại.epsilon-uivàsigma-mixedcó nhánh GPL nhưng chọnMITlà đủ, 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
GPLsai 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ánhMIT; nó bỏ sót 7 gói chính sách cần xem (rho-mplthuộcweak-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óANDvà 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ườnglicense: thiếu metadata cũng là một ca cần người xem. unknownlà phần lớn việc bảo trì. Với bảng 22 mã, 31 trong 166 formula (18,7%) rơi vàounknownvà 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):unknownnghĩ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ằngLGPL-) 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
GPLbỏ 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ứaGPLđượ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ứaGPL: 24 là mã lạ và 5 làweak-copyleftkhô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
allowcòn ở cột phân phối chỉ 85, hiệu số 50reviewđều làweak-copylefthoặcstrong-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ống | Quyết định | Că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 con | Mẫ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 OR | Chọ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 AND | Lấ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 WITH | Chỉ đổi nhóm khi ngoại lệ có trong chính sách và có người đọc | Classpath-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 metadata | Bả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ách | Homebrew: 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ống | Chặn, không đoán | tau-bad, upsilon-empty, phi-slash trong mẫu |
| Nguồn là cask hoặc kênh không có metadata | Liệ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ầu | Chuyển sang kịch bản phân phối | GNU 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ừ Debian | Không đưa thẳng vào bộ đọc SPDX; ánh xạ có kiểm hoặc đọc tay | Toá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-laterlà 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
licenselà 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ểmbrew infocó ghi cache của Homebrew hay không; đã đặtHOMEBREW_NO_AUTO_UPDATE=1và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ết | Vì sao thoát 0 | Dấu vết sai còn lại | Bản sửa |
|---|---|---|---|
lệnh || true | lệnh lỗi nằm trong danh sách ||; trạng thái cả danh sách là true | dòng “xong” vẫn in, bước sau chạy tiếp | bỏ || true; lab thoát 1 (mã của cp) |
pipeline không pipefail | trạng thái pipeline là của lệnh cuối | dữ liệu đầu vào cụt, bước sau nhận thiếu | set -o pipefail; lab thoát 3 (mã lệnh đầu) |
curl không --fail | HTTP 401 vẫn là một phản hồi nhận được nên curl thoát 0 | tệ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ào | Ca trong lab | Căn cứ |
|---|---|---|---|
| 126 | tìm thấy lệnh nhưng không thực thi được | ./noexec.sh thiếu quyền thực thi | Bash: “If a command is found but is not executable, the return status is 126”; POSIX: “the exit status shall be 126” |
| 127 | không tìm thấy lệnh | khong-co-lenh | Bash: “a status of 127”; POSIX: “the exit status shall be 127” |
| 128+N | tiến trình chết vì tín hiệu số N | SIGTERM (N là 15) cho 143 | Bash: “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ĩa | Bộ lập lịch nên | Ca trong lab |
|---|---|---|---|
| 0 | xong, tệp đích đã ghi | đi tiếp | HTTP 200 |
| 10 | lỗi tạm thời | thử lại có giới hạn | HTTP 408, 429, 500, 503; quá thời gian (curl 28); cổng đóng (curl 7) |
| 11 | xác thực hoặc ủy quyền | dừng và báo người sửa cấu hình | HTTP 401, 403 |
| 1 | lỗi khác | dừng và để người đọc log | HTTP 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.pylà API thử bằng thư viện chuẩn:/seedđòi headerAuthorizationđúng,/flakytrả 503 hai lần rồi 200,/slowchậm 3 giây,/status/NNNtrả đúng mã NNN.fetch.shtải một URL và đổi kết quả thành mã thoát của hợp đồng. Nó không dùngset -evì 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ồimvđể chỉ lần thành công mới tạo hoặc thay tệp đích; cótrapdọn tệp tạm.jobwrap.pychạy một lệnh tối đa ba lần, chỉ thử lại khi mã nằm trongRETRYABLE(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.pychạy mọi ca vàasserttừ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.
|| truevẫn in “xong”; pipeline khôngpipefailthoát 0 dù lệnh đầu trả 3; curl không--failghi JSON báo lỗi vàoseed.json. Bản sửa thoát 1, 3 và 22 và không để lại tệp. --failcho 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.jsonchứa JSON báo lỗi, bước sau sẽ đọc nó như dữ liệu.fetch.shthoá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.
/flakyra các lần 10, 10, 0 rồi thoát 0;503mã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ảng | Mã không khớp luật nào | Căn cứ |
|---|---|---|
Kubernetes podFailurePolicy | xử 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 evaluateOnExit | job đượ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,exittrong subshell hay các cách khác. - Lab truyền token qua tham số
--headercho 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.pykết thúc, kể cả khiasserthỏng.
Học tiếp và nguồn
- Batch job: chạy lại an toàn sau lỗi giữa chừng: identity, checkpoint và retry ở tầng giao dịch, điều kiện để thử lại không nhân đôi kết quả.
- Kiểm kê giấy phép phần mềm: một ví dụ khác dùng mã thoát khác 0 để chặn pipeline khi báo cáo không đạt.
- GNU, Bash Reference Manual: Pipelines, Exit Status và The Set Builtin: trạng thái pipeline, mã 126, 127, 128+N và hành vi của
set -e. - The Open Group, POSIX.1-2024: Shell Command Language: mục 2.8.2, mã thoát của lệnh.
- curl, manpage:
--fail,--fail-with-body,--max-time, biếnhttp_codecủa--write-outvà bảng mã thoát. - Python, subprocess:
returncodeâm khi tiến trình con chết vì tín hiệu. - Kubernetes, Jobs và Job API:
podFailurePolicy,onExitCodes, các hành động. - AWS Batch, Automated job retries, EvaluateOnExit và RetryStrategy:
attempts,evaluateOnExit,onExitCode.
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 Scanlà cách đọc bảng.Filternằm ở dòng dưới: mỗi hàng đọc lên được kiểm điều kiện.- Hai số
costlà 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. rowslà 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.widthlà 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:
- Tên node: đọc toàn bảng hay đi qua index?
rowstrong phần cost: planner dự kiến bao nhiêu hàng đầu ra?actual ... rows ... loops: đo được bao nhiêu hàng mỗi vòng, và node được gọi bao nhiêu vòng?Rows Removed by Filter: đã đọc rồi bỏ bao nhiêu hàng?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,DELETEhay 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.
ANALYZElấ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ọilab_up,lab_seedvà dùng tênwiki_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 xaactual 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.
- Lab database dùng chung: dữ liệu, reset, cách xác nhận đúng lab và dọn tài nguyên.
- N+1 với EF Core: đếm nhiều câu SQL ở tầng ứng dụng trước khi tối ưu từng plan.
- Tiêu chí hoàn tất có thể kiểm chứng: viết kiểm hành vi và ghi giới hạn của bằng chứng.
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ình | Thứ 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
- 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. - 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.
- Đổ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. - Thử INCLUDE rồi VACUUM trong lab, kiểm
Heap Fetchestrướ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
- Đọc EXPLAIN ANALYZE: rows là đầu ra mỗi vòng; buffer và thời gian của node cha gồm con.
- Lab database dùng chung: công thức dữ liệu, reset và dấu nhận diện.
- PostgreSQL 18, Multicolumn Indexes: khoảng scan và skip scan.
- PostgreSQL 18, Indexes and ORDER BY: thứ tự, hướng scan và LIMIT.
- PostgreSQL 18, Index-Only Scans and Covering Indexes: INCLUDE và visibility map.
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 con | Hàng cha có tồn tại không | Khóa chính hoặc unique của bảng cha: luôn có |
DELETE hoặc đổi khóa ở bảng cha | Còn hàng con trỏ tới không, hoặc xóa, đặt NULL hàng con theo ON DELETE | Cộ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 cha | Có chặn không |
|---|---|
DELETE hàng cha | Có |
SELECT ... FOR UPDATE hàng cha | Có |
UPDATE không đổi cột khóa | Không (nó chỉ cần FOR NO KEY UPDATE) |
| Một khóa ngoại khác kiểm cùng hàng cha | Khô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
- 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. - 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).
- Coi
ON DELETE CASCADElà một câuDELETEtrê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. - Khi
DELETEhoặc cascade chậm mà plan nhanh, đọc các dòngTrigger for constrainttrongEXPLAIN (ANALYZE)trước khi tìm nguyên nhân ở chỗ khác, và nhớ rằng trigger hoãn không hiện ở đó. - 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,
DEFERRABLEvà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 ANALYZEthực sự chạy câu lệnh. Lab đặt mọi thứ trongBEGIN ... 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
- Đọc EXPLAIN ANALYZE: cách đọc node, loops và dòng trigger.
- Composite index: thứ tự cột và skip scan của PostgreSQL 18.
- Lab database dùng chung: dữ liệu, reset và dấu nhận diện của lab.
- PostgreSQL 18, Constraints, phần Foreign Keys: hành vi
ON DELETEvà việc không tự tạo index cột tham chiếu. - PostgreSQL 18, Explicit Locking, khóa hàng: bốn mức khóa hàng và bảng xung đột.
- PostgreSQL 18, Using EXPLAIN: thời gian trigger và
Index Searches. - PostgreSQL 18, Multicolumn Indexes: phạm vi scan và skip scan.
- MySQL 26.7, FOREIGN KEY Constraints: yêu cầu index, index tự tạo, bảng phân vùng.
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ước | A | B | Barrier |
|---|---|---|---|
| 1 | BEGIN và đọc qty=10 | — | Kết quả SELECT của A |
| 2 | — | BEGIN, UPDATE qty=20, chưa COMMIT | Marker sau UPDATE |
| 3 | Đọc khi B chưa commit | — | Kết quả của A |
| 4 | — | COMMIT | Marker sau COMMIT |
| 5 | Đọc lại trong transaction cũ | — | Kết quả của A |
| 6 | ROLLBACK, 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
BEGINvà “đã 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 |
|---|---|
| visible | COUNT của client mới, khác snapshot reader A |
| physical_live/dead | Tuple được pgstattuple phân loại khi quét heap |
| free | Free space trong heap, byte |
| heap/index/total | Byte từ pg_relation_size, pg_indexes_size, pg_total_relation_size |
| estimated_dead | n_dead_tup, số ước lượng của pg_stat_user_tables |
| vacuum/analyze | Counter 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ước | Live/dead vật lý | Heap | Free | Index | Total | Vacuum/analyze |
|---|---|---|---|---|---|---|
| Before | 4000/0 | 819200 | 800 | 155648 | 1007616 | 0/1 |
| Changed | 3000/5000 | 1638400 | 1600 | 286720 | 1966080 | 1/1 |
| Analyzed | 3000/5000 | 1638400 | 1600 | 286720 | 1966080 | 1/2 |
| Vacuum held | 3000/5000 | 1638400 | 1600 | 286720 | 1966080 | 2/2 |
| Vacuum released | 3000/0 | 1638400 | 1021100 | 286720 | 1966080 | 3/2 |
| Refill | 4000/0 | 1638400 | 817200 | 303104 | 1982464 | 3/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ỏi | Quan sát | Giới hạn |
|---|---|---|
| Replica có kết nối? | Sender pg_stat_replication, receiver pg_stat_wal_receiver | Streaming không chứng minh một transaction đã visible |
| WAL đã tới replica? | pg_last_wal_receive_lsn() so barrier sau commit | Nhậ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_lag | Metric 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ầu | Lựa chọn | Chi 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 theo | Tă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 định | Mang barrier của cùng primary, đợi replica replay tới đó với timeout; hết hạn fallback/đáp lỗi | Routing phải kiểm đúng replica/cluster/timeline; không suy LSN của cluster khác |
| Cần commit chờ standby áp dụng | Cấu hình synchronous standby và synchronous_commit=remote_apply | Chờ 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 án | Công việc client | Công việc gửi database |
|---|---|---|
| row | Python đọc CSV, chuyển số và dựng SQL | N câu INSERT trong một session/transaction |
| batch | Cùng chuyển số, gom tối đa400hàng | Một INSERT nhiều VALUES/lô, cùng transaction |
| copy | Client psql đọc file CSV | Mộ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àng | Cách | Mean (s) | Min (s) | Max (s) | Stddev (s) | Hàng/s |
|---|---|---|---|---|---|---|
| 1000 | row | 0.028945 | 0.027562 | 0.029821 | 0.001212 | 34548 |
| 1000 | batch | 0.014548 | 0.013935 | 0.015769 | 0.001058 | 68738 |
| 1000 | copy | 0.014162 | 0.013496 | 0.014676 | 0.000605 | 70613 |
| 5000 | row | 0.099265 | 0.097083 | 0.100486 | 0.001894 | 50370 |
| 5000 | batch | 0.029153 | 0.028890 | 0.029435 | 0.000273 | 171511 |
| 5000 | copy | 0.024375 | 0.024322 | 0.024432 | 0.000055 | 205125 |
| 10000 | row | 0.191379 | 0.189333 | 0.194709 | 0.002908 | 52252 |
| 10000 | batch | 0.049266 | 0.047273 | 0.052186 | 0.002584 | 202981 |
| 10000 | copy | 0.037493 | 0.037410 | 0.037620 | 0.000112 | 266716 |
Ở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ước | Session A | Session B | Khóa và trạng thái |
|---|---|---|---|
| A1 | BEGIN, ghi mã A, UPDATE hàng 1 | Chưa bắt đầu | A giữ khóa hàng 1, chưa commit |
| B1 | Dừng tại đây | BEGIN, ghi mã B, UPDATE hàng 2 | B giữ khóa hàng 2, chưa commit |
| A2 | UPDATE hàng 2, câu lệnh chưa trả về | Dừng tại đây | A chờ B; mới có một cạnh chờ |
| B2 | Vẫn chờ | UPDATE hàng 1 | B 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ợp | Bằng chứng lab | Trạng thái cần xử lý |
|---|---|---|
| Chờ có thể giải | B đợi A ở bản sửa, A commit rồi B tiếp tục | Chưa phải lỗi; giới hạn thời gian chờ theo yêu cầu |
| Deadlock | 1213, báo cáo vòng, một victim | InnoDB rollback toàn transaction; retry toàn đơn vị công việc |
| Lock timeout | 1205 khi owner vẫn giữ hàng 1 | Vớ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
- Đổi cả hai session sang cùng thứ tự và xác nhận có cạnh chờ nhưng không có
1213. - 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.
- Đổ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
- Lab database: namespace, socket, reset và cleanup.
- Đọc EXPLAIN ANALYZE: query tìm nhiều hàng có thể làm phạm vi công việc lớn; kết quả PostgreSQL không thay phép đo lock MySQL.
- MySQL 26.7, How to Minimize and Handle Deadlocks: báo cáo, thứ tự thao tác và retry.
- MySQL 26.7, InnoDB Error Handling: phạm vi rollback theo loại lỗi.
- MySQL 26.7, data_lock_waits: quan hệ bên chờ và bên giữ khóa.
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 TABLEhoặcCREATE INDEXtrong 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ình | Chặn gì |
|---|---|---|
AccessShareLock | SELECT | Chỉ bị ACCESS EXCLUSIVE chặn |
RowExclusiveLock | INSERT, UPDATE, DELETE, MERGE | Bị chặn bởi SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
ShareUpdateExclusiveLock | VACUUM, ANALYZE, CREATE INDEX CONCURRENTLY, một số dạng ALTER TABLE | Khô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 |
ShareLock | CREATE INDEX (không CONCURRENTLY) | Chặn ghi, cho đọc |
ShareRowExclusiveLock | ADD FOREIGN KEY, CREATE TRIGGER | Chặn ghi, cho đọc |
AccessExclusiveLock | Nhiều dạng ALTER TABLE, DROP TABLE, TRUNCATE, REINDEX, VACUUM FULL | Chặ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 COLUMNkhông default và với default hằng đều lấyAccessExclusiveLock, vàrewrittenlà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 defaultvolatile(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 (đổiintegersangbigint) córewrittenlàt, trong lúcACCESS EXCLUSIVEvẫn được giữ. - Cùng
AccessExclusiveLock, một bản quét bảng một bản không.ADD CHECKcó quét vàADD CHECK ... NOT VALIDđều lấyACCESS EXCLUSIVE; khác biệt theo tài liệu làNOT VALIDbỏ 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ướcVALIDATEsau đó chỉ lấyShareUpdateExclusiveLock, mức không chặn đọc và ghi. ADD FOREIGN KEYlà ngoại lệ so với các ràng buộc khác: tài liệu ghi nó chỉ cầnShareRowExclusiveLockthay 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ảnNOT VALIDcũng lấy mức đó nhưng bỏ qua bước quét nên chỉ cần giữ ngắn;VALIDATEhạ xuốngShareUpdateExclusiveLockở bảng con và chỉ cònRowShareLockở 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ạy | Máy kiểm được không | Cá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ần | NOT 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ông | Ngườ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ần | Kiể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ông | Ngườ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 COLUMNvà đổi tên cột (tương thích với ứng dụng), lỗi giữa chừng củaCREATE INDEX CONCURRENTLY(chỉ có lời tài liệu),SET NOT NULLvới ràng buộc kiểm đã có, và hệ quả của việc các dạngALTER TABLEghi 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_timeouttoà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 VALIDvàVALIDATE CONSTRAINT. - PostgreSQL 18, CREATE INDEX:
CONCURRENTLY, hai lần quét, chờ giao dịch cũ và indexinvalid. - 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ả định | Tín hiệu cần xác nhận | Ưu tiên thử | Dấu hiệu đã chọn sai |
|---|---|---|---|
| Đọc nhiều | Mộ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 replica | Query 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ều | WAL/I/O/lock hoặc index maintenance chiếm chi phí, không chỉ tổng R/W ratio | Giữ transaction ngắn, cùng thứ tự khóa, batch đúng atomicity; đo index/durability trước partition/shard | Cù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 connection | Nhiều connection idle/churn, pool wait và số backend/CPU tăng cùng burst | Bound/reuse pool, admission/deadline; kiểm session contract trước chọn pool mode | SQL/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ọn | Vận hành thêm | Consistency/failure cần giữ | Bằng chứng khiến dừng hoặc đổi hướng |
|---|---|---|---|
| Query/index | Statistics, index build/maintenance và theo dõi plan | Kết quả query/constraint không đổi; index thêm chi phí ghi | Plan/buffers không giảm ở workload mục tiêu, write budget xấu đi |
| Bounded pool | Queue, timeout, reset/session và tổng backend | Không trả connection đang transaction lỗi; tenant state đúng | Queue age vượt deadline dù DB chưa bận: kiểm hold time/lỗi lease |
| Replica | Routing, WAL retention/apply, health, failover rehearsal | Stale read/read-after-write/RPO theo hợp đồng | Apply tụt xa hoặc query yêu cầu dữ liệu mới; route đó trở lại primary |
| Partition | Key/range, retention, pruning, DDL/constraint | Unique/FK/query semantics được giữ | Query không pruning hoặc hot partition vẫn chiếm tải |
| Sharding | Router/key, rebalancing, backup/restore nhiều shard, migration | Cross-shard transaction/JOIN, hotspot, partial failure | Hot 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ách | Tìm thấy (probe kỳ vọng) | Không thấy (probe kỳ vọng) |
|---|---|---|
| Chaining | 1 + α/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:
- 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. - 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ố trongdictobject.c: tải dùng được của bảngnô 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ơnGROWTH_RATE = số mục × 3(bảng nhỏ nhất có 8 ô). - 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 đặtrandom) thì giá trị băm củastrvàbytesđược seed ngẫu nhiên; đặt một số nguyên thì seed cố định; đặt0tắ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 để xemdictthật chịu ra sao. - 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 quasys.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ính12345, vàhash(12345 + P)cũng vậy vớiP = 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ủaP(hash đều bằng 0) và thấy dựngdicttừ 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 chostrvà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ựngdictvề 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,dictkhông gọi__eq__lần nào trong lúc chèn. Với hash trùng, khóa thứkphải so vớik−1khóa trước: tổng đúngn(n−1)/2lần (124.750, 499.500 rồi 1.999.000: gấp đôinthì công việc gấp bốn), và tra một khóa vắng tốnnlầ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ếndictthà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ống | Quyết định | Căn cứ trong bài |
|---|---|---|
| Tra cứu theo khóa, không cần thứ tự khóa | Dù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ào | Giớ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ình | Băm tuple các trường tham gia __eq__; kiểm số lần __eq__ khi nạp dữ liệu mẫu | Hash 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ải | Dùng cấu trúc có thứ tự (cây cân bằng, chỉ mục B-tree) thay vì hash table | dict 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 1 | Linear 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ức | Cô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
dictvì chuỗi dò củadictkhá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ủadict. - 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ệchash()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_infovàsys.getsizeof. - Python, What’s New in 3.7: thứ tự chèn của
dicttrở 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_SHIFTvà 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ượng | Công thức | Ghi chú |
|---|---|---|
Dương tính giả sau khi thêm n khóa | f = (1 − e^(−kn/m))^k | Giả định k hàm băm độc lập và rải đều |
k làm f nhỏ nhất | k = 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ả:
kcó điểm tối ưu. Với 8 bit mỗi khóa,k= 1 cho 11,8% dương tính giả; tăngklàm giảm tớik= 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ượng | Công thức theo bài báo | p = 1/2 | p = 1/4 |
|---|---|---|---|
Tỉ lệ nút có từ tầng i trở lên | p^(i−1) | 50%, 25%, 12,5% | 25%, 6,25%, 1,56% |
| Con trỏ trung bình mỗi nút | 1/(1−p) | 2 | 1,33 |
| Cận trên số so sánh trung bình | L(n)/p + 1/(1−p) + 1, L(n) = log_(1/p) n | 2·log₂n + 3 | 2·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ámp^(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 đôinthêm khoảng 1,9 so sánh, đúng hệ số 2 củaL(n)/p. Vớilog₂ 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
plà 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ùngp= 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ống | Quyết định | Că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ớn | Bloom filter; tính m và k từ dương tính giả chấp nhận được | Khô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ật | Dương tính giả luôn khác 0 |
| Cần xóa khóa khỏi tập | Biến thể có bộ đếm hoặc cấu trúc khác; không xóa bit | Lab 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ọng | Skip list | Luôn trả đúng; số so sánh đo ≈ 2·log₂n |
| Cần chặn trên cho từng thao tác | Cây cân bằng thay vì skip list | Skip 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 được | Skip list với p = 1/4 | 1,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 được | Giả đị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
klầ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ớimcỡ 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
- Hash table: va chạm, load factor và vì sao O(1) chỉ là kỳ vọng: hàm băm, va chạm và khi nào giả định “băm đều” sụp đổ.
- Đọc benchmark: số đo và ngoại suy: cách đọc số đo mà không suy quá phép đo.
- William Pugh, Skip Lists: A Probabilistic Alternative to Balanced Trees, Communications of the ACM 33(6), 1990 (bản công khai trên trang một môn học):
random_level,L(n), cận chi phí, bảng chọnp. - Andrei Broder và Michael Mitzenmacher, Network Applications of Bloom Filters: A Survey, Internet Mathematics 1(4), 2004: công thức dương tính giả,
ktối ưu, cận dưới kích thước, counting Bloom filter. - Burton H. Bloom, Space/Time Trade-offs in Hash Coding with Allowable Errors, Communications of the ACM 13(7), 1970 (bài báo gốc, liên kết DOI là con trỏ tới bản của nhà xuất bản; bài này chưa đọc được bản công khai): nguồn gốc của Bloom filter.
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)/2lầ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ầngi+1ở lần gộp thứjtrongrlầ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ình1 + (r−1)/2lần. Tăngrlà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 khirtăng (10,2 ởr= 4, 12,4 ởr= 10). Bài báo gốc đếmr_i + 1trang 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%(leveledr= 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 choGet()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ống | Quyết định | Căn cứ trong bài |
|---|---|---|
| Ghi liên tục, tra cứu ít hơn nhiều | LSM-tree | Khô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ừa | B-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ượng | LSM tiered | WA 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ều | LSM leveled | 2 đế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ại | Bloom 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ạn | Bà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
- Bloom filter và skip list: ngẫu nhiên có chủ đích, sai số đo được: skip list làm memtable và Bloom filter làm bộ lọc của từng bảng.
- Composite index: thứ tự cột và chi phí ghi: B-tree trong database quan hệ.
- Hash table: va chạm, load factor và vì sao O(1) chỉ là kỳ vọng: hàm băm dùng để dựng Bloom filter.
- Patrick O’Neil và cộng sự, The Log-Structured Merge-Tree (LSM-Tree), Acta Informatica, 1996 (bản trên trang của tác giả, đọc 18 trang đầu): thành phần
C0,C1, gộp cuốn chiếu, công thức chi phí insert của B-tree và LSM-tree, kích thước tối ưu của các thành phần. - Google, LevelDB: implementation notes, phiên bản 1.23: nhật ký và memtable, các tầng, compaction bỏ giá trị bị ghi đè và dấu xóa.
- Google, LevelDB: index.md, phiên bản 1.23: mục Filters, Bloom filter 10 bit mỗi khóa.
- RocksDB wiki, MemTable: memtable mặc định dựa trên skip list, memtable đầy thì thành bất biến và được xả ra SST.
- RocksDB wiki, Leveled Compaction: tầng 0 chứa tệp vừa xả, hệ số kích thước giữa tầng, cách chọn tệp để gộp.
- RocksDB wiki, Universal Compaction: đánh đổi WA thấp hơn lấy read amplification và space amplification.
- RocksDB wiki, RocksDB Bloom Filter: filter cho từng tệp SST, 9,9 bit mỗi khóa cho dương tính giả 1% (công thức của bài trước cho 9,59 bit; bài không kiểm nguyên nhân chênh lệch).
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ệm | Câu hỏi | Ví dụ trong bài |
|---|---|---|
| Layer | Thà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 |
| Module | Nhóm code sở hữu vấn đề nghiệp vụ nào? | ordering và inventory |
| Aggregate | Nhó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 đổi | A — layer/type | B — module/layer | C — module/aggregate |
|---|---|---|---|
| Order.py | domain/entities/Order.py | modules/ordering/domain/Order.py | modules/ordering/domain/OrderAggregate/Order.py |
| OrderCancelled.py | domain/events/OrderCancelled.py | modules/ordering/domain/OrderCancelled.py | modules/ordering/domain/OrderAggregate/OrderCancelled.py |
| CancelOrder.py | application/CancelOrder.py | modules/ordering/application/CancelOrder.py | modules/ordering/application/CancelOrder.py |
| OrdersHttp.py | interfaces/OrdersHttp.py | modules/ordering/interfaces/OrdersHttp.py | modules/ordering/interfaces/OrdersHttp.py |
| TestCancelOrder.py | tests/TestCancelOrder.py | modules/ordering/tests/TestCancelOrder.py | modules/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 test | Kết quả mong đợi | Điều test bảo vệ |
|---|---|---|
| CONFIRMED, lý do hợp lệ | CANCELLED, một event chứa lý do đã trim | Quy tắc và payload |
| SHIPPED, lý do hợp lệ | Từ chối, trạng thái giữ nguyên, không event | Không hủy sau gửi |
| CONFIRMED, lý do trắng | Từ chối, trạng thái giữ nguyên | Lý do bắt buộc |
| CANCELLED, yêu cầu lặp | Không event mới, giữ lý do đầu | Không phát sinh trả tồn lặp từ root |
| Hai caller cùng nạp CONFIRMED | Một commit hợp lệ hoặc phát hiện xung đột | Concurrency ở 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ảnh | Cá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 layer | A 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õ | B | Hợ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 root | C bên trong B | Xá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.
| View | Câu hỏi chính | Điều chủ động bỏ bớt |
|---|---|---|
| Context | Ai 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 |
| Sequence | Một request diễn ra theo thứ tự nào, lỗi dừng ở đâu? | Mọi use case và mọi query |
| Deployment | Instance nằm ở đâu trong một môi trường cụ thể? | Kiến trúc nghiệp vụ thay thế |
| ERD | Bả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ếu | Kết quả của case | Dấu hiệu lệch cần sửa |
|---|---|---|
| Actor | Customer/Operator có cùng vai trò ở context/container | Một view tự thêm admin hoặc đối tác |
| Runtime | ordering/inventory cùng OrderApp | Sequence vẽ HTTP giữa hai module |
| Protocol | HTTPS ở tuyến client, HTTP private sau edge, SQL/TLS tới StoreSQL | Deployment biến SQL thành REST hoặc thiếu hop TLS |
| Transaction | Reservation và Order cùng commit đặt hàng | Trả 201 trước commit hoặc nhánh lỗi vẫn commit |
| Storage | Năm bảng ở StoreSQL, ownership schema rõ | Outbox bỗng thành Kafka hay database ngoài |
| Cardinality | ERD cho DRAFT không dòng, confirm có invariant riêng | Vẽ bắt buộc 1..N rồi ví dụ có DRAFT rỗng |
| Trust | Client untrusted, data zone không public | Container 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
- 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.
- 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.
- 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ụ:
- C4, System context, Container và Deployment: câu hỏi và phạm vi từng mức.
- C4, Notation: nhãn, loại phần tử và legend; ký pháp không bị giới hạn ở một công cụ.
- Tổ chức code theo layer/module/aggregate: domain ownership của case và giới hạn bảo vệ bằng folder.
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ọn | Tìm owner hủy đơn | Nhìn layer | Chi phí hiện tại |
|---|---|---|---|
| A: layer ngoài, domain theo type | Đi qua application/domain/interfaces/tests | Tập trung theo vai trò kỹ thuật | 18file; thay đổi5file phân tán qua các khu vực |
| B: module ngoài, layer trong | Bắt đầu ở ordering | Vẫn có domain/application/adapter | 18file; thay đổi5file cùng module |
| C: như B, domain theo aggregate | Như B; Order và event cùng cụm | Vẫn giữ use case/adapter ngoài aggregate | 18file;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 lab | Direct | Có repository | Điều đã được che |
|---|---|---|---|
| Cột vật lý status→state, attribute vẫn status | 1file mapping.py | 1file mapping.py | ORM mapping đã che tên cột |
| Read projection bỏ CANCELLED | 1file queries.py | 1file queries.py | Query owner giữ shape/filter đọc |
| Hủy đơn và event atomic | 3câu SQL | 3câu SQL | Transaction 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 lab | Ai nhìn thấy? |
|---|---|---|
| pending | Có input, chưa checkpoint thành công | Connection mới thấy |
| running | Worker giữ row lock, đang xử lý trong transaction | Worker thấy; chưa commit nên reader khác còn thấy pending |
| completed | Effect và trạng thái cùng commit | Replay thấy và trả already |
| failed | Input amount âm, lỗi vĩnh viễn được ghi nhận | Replay 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ầng | Câu hỏi | Nguồn được tin |
|---|---|---|
| Authentication | Issuer 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 authorization | Subject có permission cho thao tác và resource này? | Membership/role nội bộ cùng scope đã cấp cho access token |
| Data access | Query 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.
| Workload | Cách | Mean (s) | Min (s) | Max (s) | Stddev (s) |
|---|---|---|---|---|---|
| io | seq | 0.319960 | 0.316777 | 0.323947 | 0.003652 |
| io | async | 0.088706 | 0.085160 | 0.092830 | 0.003868 |
| io | thread | 0.077833 | 0.071086 | 0.081332 | 0.005844 |
| io | process | 0.168688 | 0.156917 | 0.181791 | 0.012491 |
| cpu | seq | 0.166512 | 0.157463 | 0.183031 | 0.014328 |
| cpu | async | 0.154348 | 0.152583 | 0.157863 | 0.003044 |
| cpu | thread | 0.152341 | 0.152020 | 0.152638 | 0.000310 |
| cpu | process | 0.118292 | 0.116802 | 0.120441 | 0.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ách | Lượt | Startup ms | CPU mean ms | I/O mean ms | I/O max ms | Loop max ms | Mẫu loop |
|---|---|---|---|---|---|---|---|
| inline | 1 | 39.150 | 64.810 | 80.171 | 82.669 | 47.907 | 9 |
| worker | 1 | 56.243 | 45.679 | 8.758 | 9.873 | 12.190 | 12 |
| worker | 2 | 48.754 | 43.005 | 8.657 | 11.902 | 12.034 | 12 |
| inline | 2 | 36.692 | 62.649 | 77.545 | 80.326 | 46.498 | 9 |
| inline | 3 | 37.670 | 62.040 | 76.170 | 79.109 | 45.842 | 9 |
| worker | 3 | 49.757 | 44.165 | 8.049 | 10.765 | 12.329 | 12 |
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ệu | Cần kiểm | Thay đổi thử |
|---|---|---|
| Query tăng cùng N | Log theo request; explicit/lazy load trong loop | Eager loading hoặc DTO projection, so lại dữ liệu |
| Một query nhưng truyền nhiều | SELECT có cột lớn/lặp, cardinality collection | Bớt cột hoặc thử split, đo rows/bytes |
| Split khác dữ liệu single | Writer, isolation và thứ tự paging | Kiể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ảng | Context 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 NuGet | Giữ 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ếp | total→SDK | 0 interface | Ba input hợp lệ, ba input sai |
| Hai provider qua Adapter | total→Adapter→SDK | 1 Protocol, 2 lớp Adapter, 1 tham số injected | Hai 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):
- Có BOM thì tin BOM.
- 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. - Giải mã UTF-8 nghiêm ngặt; qua được thì coi là UTF-8.
- 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 FEnê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.
abchợ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ằngshift_jischuẩ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,0x83và0x88–0x9Flà 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
cp932của Python còn nhận cả byte đơn0x80(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ằngcp932(87 40) nhưng không bằngshift_jiscủa Python; byte81 60giải mã thành U+FF5E ởcp932và U+301C ởshift_jis; và U+301C đi quacp932mộ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ằngcp932, 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–0x7Ehoặc0x80–0xFC, nghĩa là trùng dải ASCII (cả chữAlẫn dấu\). Lab quét 65.536 cặp byte và thấy 9.604 ký tự hai byte với byte đầu trong0x81–0x9Fvà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 byte0x5Cchư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
データ\表計算\ソース.txtcó hai dấu\thật nhưng cắt ở mọi byte5Ccho 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ệnhSET NAMES, vì chỉ hàm trước làmmysql_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) <= 10như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ống | Quyết định | Căn cứ trong bài |
|---|---|---|
| Nhận tệp mà người gửi không nói encoding | BOM, 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ận | ASCII 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 byte | Giải mã trước, xử lý theo ký tự, mã hóa lại; không split/replace trên byte | 52 ký tự có byte sau là 0x5C |
| Ghép SQL từ chuỗi có thể là CP932 | Dù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ối | Mô phỏng thoát theo byte; tài liệu C API của MySQL |
| Đặt giới hạn độ dài cột | Nê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 3 | 52,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ụ
cp932nhận byte đơn0x80; 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ãnshift_jiscùngwindows-31jvà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
cp932Python 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
- Hash table: va chạm, load factor và vì sao O(1) chỉ là kỳ vọng:
hash()của chuỗi và byte phụ thuộc cùng chuỗi byte này. - Đọc benchmark: số đo và ngoại suy: cách đọc các tỉ lệ mô phỏng ở trên.
- WHATWG, Encoding Standard: các chữ ký BOM, danh sách nhãn của
shift_jisvà khuyến nghị dùng UTF-8. - IETF, RFC 3629: UTF-8, a transformation format of ISO 10646: dải điểm mã, mã thay thế, dạng dài thừa và BOM.
- Python 3.14, codecs: hằng số BOM,
utf-8-sigvà bảng bí danh củacp932vàshift_jis. - Python 3.14, unicodedata: các dạng chuẩn hóa NFC, NFD, NFKC và NFKD.
- MySQL 26.7, The cp932 Character Set: khác biệt giữa
cp932vàsjis. - MySQL 26.7 C API, mysql_real_escape_string(): thoát ký tự phụ thuộc charset của kết nối.
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ết | Dự đoán phân biệt được | Phé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òng | Cùng thứ tự 1→2 loại vòng hai hàng | Giữ bảng/dữ liệu/hai session; chỉ đổi lịch lấy khóa | Cù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ết | Deadlock này đòi truy cập không có PRIMARY index | Kiểm metadata index; giữ lịch lỗi, không thêm index | PRIMARY đã có nhưng 1213 vẫn xuất hiện |
| H3: lock timeout quá ngắn bị nhầm thành deadlock | Timeout 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ược | Detector 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ục | Record của ví dụ |
|---|---|
| Context tối thiểu | Version, socket lab, hai bảng/PK, lịch A1/B1/A2/B2, expected/actual |
| Evidence | Cạ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ận | Hai đườ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 |
| Regression | Lị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 minh | Khô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ần | Bằng chứng của phép đo này | Điều chưa biết |
|---|---|---|
| Máy | macOS/Darwin arm64, 10 logical CPU | Model chip, RAM, loại ổ đĩa, tải nền chưa lưu trong raw metadata; không tự điền |
| Runtime | Python 3.14.4, PostgreSQL 18.6 native/socket local | Không container, MySQL hoặc remote network |
| Dataset | CSV sinh seed17, 1000/5000/10000 hàng, batch400; input hash và export theo ID khớp | Không kích thước/ phân bố production hoặc file10M |
| Correctness | Một transaction/file; PK/FK/CHECK/index, fsync/full_page_writes/synchronous_commit bật | Không tắt durability để so tốc độ; chưa đo external side effect |
| Timer | CSV đọc/serialize, khởi tạo psql, protocol và commit | Reset/seed/validation ngoài timer; không phải riêng thời gian SQL server |
| Cache/lượt | Warm-up, không eviction; ba lượt/method/size, xoay thứ tự; 27 sample | Chư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àng | Method | Mean s | Min s | Max s | Sample stdev s |
|---|---|---|---|---|---|
| 1000 | batch | 0.014548 | 0.013935 | 0.015769 | 0.001058 |
| 1000 | copy | 0.014162 | 0.013496 | 0.014676 | 0.000605 |
| 10000 | batch | 0.049266 | 0.047273 | 0.052186 | 0.002584 |
| 10000 | copy | 0.037493 | 0.037410 | 0.037620 | 0.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
| Claim | Vì 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ẻ
- Output có đúng toàn dữ liệu và lỗi không bị bỏ qua? Constraint/durability có tương đương?
- Dataset/seed/hash, schema/index, runtime và máy có đủ để tái lập? Metadata thiếu phải ghi thiếu.
- Timer bắt đầu/kết thúc ở đâu; startup, parse, network, commit, validation phần nào được tính?
- 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?
- 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?
- 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
ncó2^nchuỗi nhưng chỉ2^n − 1chuỗ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ơn10⁻²³. - Đầ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ủazlibkhớ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
Hlý 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,zstdmức 19 chỉ cao hơnHkhoảng 0,2% và 0,5%. Cùng nguồn dyadic màzlibmức 9 đạt 2,10 bit, tức cao hơnHkhoả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ồnzlib). - 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%.zstdmứ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ủazstd). 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
lzmacủ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
abclặ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:
zlibtừ 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),zstdtừ 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à
lzmapreset 6,bz2mức 9 vàzstdmức 19; nhóm giữa (1,76 đến 1,95) cóbz2mức 1,zstdmức 9,zlibmức 6 và 9; các cài đặt còn lại (2,0 đến 2,4) gồmlzmapreset 0,zstdmức 1 và 3,zlibmức 1, trong đózstdmức 1 và 3 là hai cài đặt nén nhanh nhất (621 và 452 MB/s). zlibkhông nằm trên biên trên mẫu này. Với mọi mứczlibđều có một cài đặt củazstdvừa nhỏ hơn vừa nén nhanh hơn:zstdmức 9 vừa nhỏ hơn (348.564 so với 375.214 và 378.645 byte) vừa nhanh hơnzlibmức 6 và 9, cònzstdmức 1 vừa nhỏ hơn vừa nhanh gấp 3 lầnzlibmức 1. Điều này không loạizlibkhỏ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.
zstdmức 19 nén chậm nganglzmapreset 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).bz2giả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ầnlzmapreset 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ệucompression.zstdghi 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ống | Quyết định | Că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ữa | Phì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ất | lzma 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 nhanh | zstd mức 1 đến 3 | 450 đế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 cao | zstd mức 9 | 1,79 bit mỗi byte, 111 MB/s nén |
| Cần tương thích gzip hoặc ZIP | zlib (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 0 | Chuỗ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ớn | Chi 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 (
zlib1.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ạyzstdvì cócompression.zstdtrong 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
zlibvàzstdlệ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:
compressleveltừ 1 đến 9, mặc định 9. - Python 3.14, lzma:
presettừ 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-receivekiể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ểm | GitFlow (Driessen) | GitHub flow | Trunk-based |
|---|---|---|---|
| Nhánh sống lâu | master và develop (“two main branches with an infinite lifetime”); lab gọi nhánh chính là main | chỉ nhánh mặc định | chỉ trunk |
| Nhánh phụ | feature, release, hotfix, mỗi loại có nhánh gốc và nhánh đích gộp cố định | nhánh cho từng thay đổi, vào nhánh mặc định qua pull request | nhá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ở dang | nằm ở nhánh feature, chưa gộp vào develop | nằm ở nhánh, chưa merge | merge sớm vào trunk, ẩn sau cờ tính năng |
| Sửa nóng | nhá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 định | sửa trên trunk trước, rồi cherry-pick sang nhánh release |
| Trong lab | develop, feature/*, release/*, hotfix/*, merge --no-ff | feature/* và fix/*, merge --no-ff thay pull request, bản phát hành là tag trên main | nhá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ênmain) 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à
developvàmainở GitFlow,mainở GitHub flow,mainvà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àomaintrước khi sửa, bản phát hành từmainmang 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 cherrybáo 1 thay đổi củamainchưa có ởdevelop, đỉnhrelease/1.1trước khi merge vàomainkhông có bản sửa, nhưngv1.1trênmainvẫn có vì phép gộp ba chiều giữ thay đổi đã có ởmainkhi 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ừdevelophoặcrelease/1.1còn lỗi trong khi bản phát hành thì không, và nếudevelopsử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.1không có bản sửa, cònv1.1có 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.1củ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ắc | Hook pre-receive | Thiế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 new | Allow force pushes |
| Không xóa nhánh được bảo vệ | kiểm được: giá trị mới toàn số 0 | Allow deletions |
| Tên nhánh mới theo quy ước | kiểm được: so tên ref khi tạo | không thấy trong danh sách của docs |
release/* chỉ nhận thay đổi đã có trên main | kiểm được: git cherry cộng cấm merge commit | không thấy trong danh sách của docs |
| Phải qua pull request và đủ số lần duyệt | không: dữ liệu duyệt nằm ở nền tảng, hook không thấy | Require pull request reviews before merging |
| Hủy lần duyệt cũ khi có commit mới | không | dismiss stale pull request approvals |
| Chỉ một số người hoặc vai trò được đẩy | một phần: Pro Git ghi hook biết người đẩy khi đi qua SSH, qua biến môi trường | Restrict 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.
mainvàrelease/*bị từ chối khi ghi đè hay xóa;feature/xthì ghi đè và xóa được nhận. Tạo nhánh tênFeature_Xbị 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ủamainnhưng mang thay đổi khác thì bị từ chối, và cherry-pick một commit chỉ có trên nhánhfeature/xcũng bị từ chối vìmainchưa có bản tương đương. Tài liệu git-cherry-pick ghi-xthê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.
m5cầnm4có trước; cherry-pickm5mộ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-pickm4rồim5theo 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 cherrykhô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ạn | Hướng nghiêng | Căn cứ trong bài |
|---|---|---|
| Một phiên bản chạy, phát hành liên tục | GitHub flow hoặc trunk-based | Ghi 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úc | GitFlow, hoặc trunk-based với nhánh release cắt muộn | Ghi 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ản | Nhá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óng | Sửa trên nhánh chính trước rồi mang sang nhánh release; để hook chặn chiều ngược lại | Trang trunkbaseddevelopment.com; lab hook |
| Cần bắt buộc review hoặc trạng thái CI trước khi merge | Thiết lập của nền tảng, không phải hook | Bả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 được | Lab 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ắtrelease/1.0muộ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ênmain” 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 (
ZEROdài 40), nhánh chính tênmainvà Python 3 có trên máy chủ.git cherryso 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 python3vàchmod); cần một bản Git đủ mới chogit init -b,git switchvà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
- Review code do AI sinh ra: phần review mà hook không làm được và pull request thường đảm nhiệm.
- ADR: giữ lý do của quyết định kiến trúc: chọn luồng Git là một quyết định đáng ghi lại cùng ràng buộc đã dẫn tới nó.
- Pro Git, Branching Workflows (mục 3.4): nhánh sống lâu và “topic branch” là nhánh “short-lived”.
- Pro Git, Git Hooks: hook phía server,
pre-receivevàupdate. - Vincent Driessen, A successful Git branching model (2010, ghi chú 2020): quy tắc nhánh
hotfix,release,featurevà phạm vi áp dụng. - GitHub Docs, GitHub flow: vòng nhánh, pull request và merge.
- GitHub Docs, About protected branches: danh sách thiết lập bảo vệ nhánh.
- Trunk Based Development, Short-lived feature branches và Branch for release: thời gian sống của nhánh, nhánh release cắt muộn, sửa trên trunk rồi cherry-pick.
- DORA, Trunk-based development: ba thực hành và mô tả nhánh ngắn.
- Git, githooks và git-receive-pack:
pre-receive, định dạng stdin, môi trường quarantine. - Git, git-cherry, git-cherry-pick, git-patch-id và git-merge-base: so sánh commit theo diff, tùy chọn
-x, patch ID,--is-ancestor.
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.