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.