Bộ đệm có ích khi nó đúng. Bài này đo bốn cách giữ nó đúng khi dữ liệu gốc thay đổi — và cả bốn đều có chỗ hỏng.
Bốn chiến lược, 8 luồng vừa đọc vừa ghi
| Chiến lược | Số lần bộ đệm lệch / 120 lần kiểm |
|---|---|
| Ghi đè bộ đệm bằng giá trị mới | 103 |
| Xoá bộ đệm trước khi cập nhật | 0 |
| Xoá bộ đệm sau khi cập nhật | 3 |
| Xoá hai lần (trước và sau 50 ms) | 0 |
Dòng đầu là kết quả tệ nhất và cũng là chiến lược trực giác nhất.
Ghi đè bộ đệm là tệ nhất
103 trên 120 lần kiểm — bộ đệm nói một đằng, cơ sở dữ liệu nói một nẻo.
Lý do là một tình huống đua rất khó tránh:
A ghi CSDL = 1
B ghi CSDL = 2
B ghi bộ đệm = 2
A ghi bộ đệm = 1 <- A cham hon, nhung ghi sau
Bộ đệm nói 1, cơ sở dữ liệu nói 2, và nó ở đó cho tới khi hết TTL.
Thứ tự ghi vào cơ sở dữ liệu và thứ tự ghi vào bộ đệm là hai chuỗi độc lập; không có gì bắt chúng khớp nhau. Với hai hệ thống riêng biệt không có giao dịch chung, đây là vấn đề không giải được — chỉ giảm thiểu được.
Đó là lý do xoá tốt hơn ghi: xoá không có "giá trị nào thắng", nó chỉ nói "không biết, hỏi lại nguồn".
Nhưng bảng trên đổi khi có người đọc song song
Phép đo đầu tiên chỉ có luồng vừa đọc vừa ghi. Tôi đo lại với bốn luồng đọc liên tục tự nạp lại bộ đệm khi trượt — tức là mô hình thật.
xoá trước lần 1 bộ đệm=623681 CSDL=104088 LỆCH
lần 2 KHỚP lần 3 KHỚP
xoá sau 3/3 KHỚP
xoá hai lần 3/3 KHỚP
Xoá trước khi cập nhật để lại dữ liệu cũ vĩnh viễn trong một trên ba lần chạy.
Chuỗi sự kiện:
người ghi: DEL bộ đệm
người đọc: GET -> trượt
người đọc: SELECT -> nhận giá trị CŨ (cập nhật chưa chạy)
người ghi: UPDATE CSDL = mới
người đọc: SET bộ đệm = CŨ
Bộ đệm giữ giá trị cũ, và không có lần xoá nào nữa để sửa. Nó ở đó cho tới khi hết TTL — có thể hàng giờ.
Phép đo đầu tiên không bắt được điều này vì nó không có người đọc chen vào đúng khe đó. Đây là lý do phải đo với mô hình đọc thật, không chỉ với luồng ghi.
Kết luận: xoá sau khi cập nhật
db.execute("update sp set ... where id = %s", id)
r.delete(f"sp:{id}")
Thứ tự này thu hẹp khe hở xuống mức nhỏ nhất: giữa UPDATE và DEL, người đọc thấy giá trị cũ trong bộ đệm — nhưng lần xoá sẽ tới và sửa nó.
Ba lần lệch đo được ở bảng đầu chính là khe đó, và chúng tự khỏi ở lần đọc tiếp theo. Đó là khác biệt cốt lõi so với "xoá trước": lệch tạm thời so với lệch vĩnh viễn.
Xoá hai lần: an toàn hơn, chậm 5 lần
r.delete(key)
db.execute("update ...")
time.sleep(0.05) # hoac hen gio o hang doi
r.delete(key)
0 lệch trong cả hai phép đo, nhưng tổng thời gian 1,0 giây so với 0,2 giây — chậm 5 lần vì mỗi lần ghi phải chờ.
Trong thực tế, lần xoá thứ hai không nên chặn: đẩy nó vào hàng đợi với độ trễ. Lúc đó chi phí là một bản tin thêm, không phải 50 ms mỗi lần ghi.
Dùng nó khi một lần lệch cũng không chấp nhận được — số dư tài khoản, tồn kho. Với dữ liệu hiển thị thì "xoá sau" là đủ.
Cách thứ năm: để cơ sở dữ liệu tự báo
Cả bốn cách trên đều yêu cầu mã ứng dụng nhớ xoá bộ đệm. Một chỗ quên là một khoá lệch mãi.
Cách bền hơn là đọc nhật ký thay đổi của cơ sở dữ liệu — logical replication của PostgreSQL, binlog của MySQL — rồi xoá khoá tương ứng. Ưu điểm:
- Không phụ thuộc vào việc lập trình viên nhớ.
- Bắt được cả thay đổi từ script chạy tay, từ migration, từ công cụ quản trị.
- Thứ tự đúng theo thứ tự commit thật.
Nhược điểm: thêm một thành phần phải vận hành, và độ trễ vài chục tới vài trăm mili giây.
Đây là cách các hệ thống lớn thực sự làm, và nó là lựa chọn đúng khi số chỗ ghi dữ liệu nhiều hơn số người nhớ được quy ước.
Điều không có cách nào tránh
Không có cách nào làm bộ đệm và cơ sở dữ liệu luôn khớp mà không có giao dịch phân tán — và giao dịch phân tán đắt hơn nhiều so với việc chấp nhận vài trăm mili giây lệch.
Nên câu hỏi thiết kế đúng không phải "làm sao luôn khớp" mà là "lệch bao lâu thì chấp nhận được", rồi chọn chiến lược rẻ nhất đạt mức đó.
Và bất kể chọn gì: luôn đặt TTL. TTL là thứ giới hạn thiệt hại của mọi lần xoá bị bỏ lỡ, và mọi cách ở trên đều có trường hợp bỏ lỡ.
Thử ba mươi giây
Tìm khoá bộ đệm đã lệch:
redis-cli --scan --pattern 'sp:*' --count 500 | head -200 | while read k; do
id="${k#sp:}"
bo_dem=$(redis-cli get "$k")
csdl=$(psql -h <host> -U <user> -tAc "select mo_ta from sp where id=$id" | xargs)
[ "$bo_dem" != "$csdl" ] && echo "LECH $k: bo dem='$bo_dem' csdl='$csdl'"
done
Chạy nó một lần trên hệ thống thật thường cho kết quả bất ngờ. Và nếu bạn tìm thấy khoá lệch mà không có TTL, đó là dữ liệu sai sẽ không bao giờ tự sửa.
Phần sau: theo dõi Redis — những chỉ số đáng cảnh báo và những chỉ số gây hiểu nhầm.