Hình dung bàn làm việc của bạn là RAM, còn swap là cái kho dưới tầng hầm. Chừng nào mọi hồ sơ đang cần còn nằm gọn trên mặt bàn, bạn làm việc trơn tru. Nhưng khi bàn chật và một số hồ sơ bị đẩy xuống hầm, mỗi lần cần đến chúng bạn phải bỏ việc, đi thang bộ xuống hầm, lục, rồi leo lên — và cái giá ấy không phải "chậm hơn một chút", nó là một trật tự thời gian khác hẳn. Điều oái oăm là nhìn từ ngoài bạn vẫn đang ngồi làm việc: không ai thấy bạn đã dành phần lớn thời gian chạy lên chạy xuống cầu thang. Swap là cái tầng hầm đó, và bài này đo cả cái giá của chuyến đi lẫn cái bẫy "trông vẫn bình thường".
Câu hỏi "có nên tắt swap không" được tranh cãi nhiều hơn là được đo. Ta đo.
Cách đo
Container giới hạn 512 MB bộ nhớ, chạy một chương trình cấp một vùng nhớ rồi duyệt qua toàn bộ vùng đó nhiều vòng. Đổi hai thứ: kích thước vùng nhớ, và có cho phép swap hay không.
docker run --rm -m 512m --memory-swap 512m ... # khong swap
docker run --rm -m 512m --memory-swap 3g ... # 2,5 GB swap
Kết quả
| Vùng nhớ | Không swap | Có swap 2,5 GB |
|---|---|---|
| 200 MB | 0,01 µs/trang | 0,01 µs/trang |
| 480 MB | 0,01 µs/trang | 0,01 µs/trang |
| 600 MB | bị giết (137) | 4,82 – 10,65 µs/trang |
| 900 MB | bị giết (137) | 8,48 – 10,12 µs/trang |
| 1.400 MB | bị giết (137) | 9,91 rồi 41,21 µs/trang |
Mã thoát 137 là 128 + 9, nghĩa là bị SIGKILL — OOM killer.
Điều đáng nhớ: không có bậc trung gian
Vùng 480 MB nằm gọn trong giới hạn 512 MB: 0,01 µs mỗi trang. Vùng 600 MB vượt ra ngoài chút xíu: 4,82 µs. Gần 500 lần, chỉ vì vượt giới hạn 17%.
Tôi muốn nhấn mạnh chỗ này, vì trực giác thường hình dung swap là một cái dốc thoai thoải: vượt một ít thì chậm một ít. Thực tế nó là một vách đá. Lý do đơn giản — mỗi trang bị đẩy ra swap phải đọc lại từ ổ đĩa khi cần, và một lần đọc ổ đĩa đắt gấp hàng nghìn lần một lần truy cập RAM (phần 12 đã đo: 2,2 µs so với 34,6 µs cho 4 KB, và swap còn đắt hơn vì hoàn toàn ngẫu nhiên). Nói cách khác: nếu chương trình của bạn đang chạm tới swap, nó không đang "hơi chậm". Nó đang chậm ở một bậc độ lớn khác.
Dòng 1.400 MB cho thấy chuyện còn xấu thêm: vòng 1 mất 9,91 µs mỗi trang, vòng 2 lên 41,21 µs. Càng nhiều dữ liệu phải luân chuyển, tỷ lệ trang nằm trong RAM càng thấp, và vòng lặp bắt đầu ăn thịt chính nó.
Cái bẫy thật của swap
So hai cột với nhau.
Không swap — tiến trình bị giết ngay. Xấu, nhưng: bạn biết ngay, hệ thống giám sát báo ngay, container được khởi động lại, dịch vụ hồi phục trong vài giây.
Có swap — tiến trình sống. Kiểm tra sức khoẻ vẫn trả về 200. systemctl status vẫn hiện active (running). Bảng điều khiển vẫn xanh. Chỉ có điều mỗi yêu cầu mất vài giây thay vì vài mili giây, hàng đợi dồn lại, và các dịch vụ gọi tới nó bắt đầu hết thời gian chờ. Không có ngưỡng cảnh báo nào bắt được trạng thái đó, vì mọi chỉ số nhị phân đều báo "ổn".
Đó là lý do thực sự khiến Kubernetes mặc định yêu cầu tắt swap trên node: một pod chết được lên lịch lại; một pod chậm 500 lần thì kéo cả cụm xuống theo và không cơ chế tự chữa nào nhận ra.
Vậy tắt swap luôn?
Không. Swap có một việc mà nó làm rất tốt, và đó là việc khác hẳn. Trên máy chủ nào cũng có bộ nhớ đã cấp nhưng không bao giờ chạm tới: dữ liệu khởi tạo của thư viện, mã của tính năng chưa ai dùng, vùng đệm cấp sẵn. Swap đẩy những trang đó ra đĩa và trả RAM lại cho bộ đệm trang — đúng nơi RAM sinh lợi. Đó là những hồ sơ bạn chắc chắn không đụng tới, cất xuống hầm là hợp lý.
Việc đó xảy ra êm và không ai thấy. Trong bảng trên, dòng 480 MB có swap chạy đúng bằng tốc độ không swap: swap có mặt nhưng không được dùng tới. Phân biệt hai chế độ bằng vm.swappiness:
cat /proc/sys/vm/swappiness # mac dinh 60
sysctl -w vm.swappiness=10 # chi swap khi that su can
sysctl -w vm.swappiness=1 # gan nhu chi khi sap OOM
sysctl -w vm.swappiness=0 # chi khi khong con cach nao khac
swappiness không phải phần trăm và không phải "bao nhiêu thì swap". Nó là trọng số nhân khi nhân cân nhắc nên thu hồi trang tệp hay trang ẩn danh. Giá trị thấp nghĩa là ưu tiên vứt bộ đệm trang trước.
Khuyến nghị thực dụng: giữ swap, đặt swappiness thấp (1–10), và đặt cảnh báo trên chính lượng swap đang dùng.
# canh bao khi swap dung vuot 5%
free -m | awk '/Swap:/ {if ($2>0 && $3/$2 > 0.05) print "SWAP:", $3, "/", $2, "MB"}'
Chỉ số tốt hơn: áp lực bộ nhớ
Nhân từ 4.20 có PSI — Pressure Stall Information — đo trực tiếp thời gian tiến trình bị đình trệ vì thiếu bộ nhớ:
cat /proc/pressure/memory
some avg10=0.00 avg60=0.00 avg300=0.00 total=0
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
some là phần trăm thời gian có ít nhất một tiến trình bị đình trệ; full là phần trăm thời gian mọi tiến trình đều đình trệ. full khác 0 nghĩa là máy đang không làm được gì hữu ích. Đây là chỉ số cảnh báo đúng cho trạng thái "sống mà như chết" ở trên, vì nó đo cái đang thực sự xảy ra chứ không đo một con số tuyệt đối. Cùng có ở mức cgroup: /sys/fs/cgroup/<ten>/memory.pressure.
Mẫu số chung: chết sạch dễ chữa hơn sống thoi thóp
Cái bẫy sâu nhất của bài này không phải vách đá 500 lần, mà là chuyện đứng sau nó: một hệ thống hỏng mà vẫn sống nguy hiểm hơn một hệ thống chết hẳn, vì mọi cơ chế tự chữa đều dựa trên tín hiệu nhị phân sống/chết — và cái đang thoi thóp thì báo "sống". Fail-fast dễ vận hành hơn fail-slow. Nguyên tắc này lặp ở mọi tầng.
Trong thế giới Java, đây đúng là GC thrashing: heap gần đầy, JVM dành 90% thời gian gom rác mà thu lại được vài phần trăm — tiến trình vẫn chạy, health check /actuator/health vẫn 200, nhưng thông lượng sụp. Không cờ nào báo "đang hấp hối"; phải nhìn GC time % hay PSI mới thấy. Cùng lý do người ta đặt -XX:+ExitOnOutOfMemoryError — thà chết sạch để được khởi động lại còn hơn lê lết.
Trong Go, cùng hình dạng khi goroutine dồn ứ vì một downstream chậm: tiến trình sống, cổng vẫn nghe, nhưng mọi request xếp hàng sau một pool cạn — liveness probe qua, readiness thì không nếu bạn cấu hình đúng. Đây là lý do circuit breaker tồn tại: chủ động cắt phựt (fail fast) thay vì để timeout kéo dài dồn tải.
Còn ở cơ sở dữ liệu, đây là connection pool saturation: DB còn sống, SELECT 1 vẫn trả lời, nhưng mọi truy vấn thật chờ một kết nối rảnh — dịch vụ trên nó timeout hàng loạt trong khi mọi health check báo xanh. statement_timeout và giới hạn hàng đợi kết nối chính là cách biến "chờ vô hạn" (fail slow) thành "lỗi ngay" (fail fast) để cơ chế trên phát hiện được.
Mẫu số chung: một thành phần hỏng-mà-còn-sống lọt qua mọi kiểm tra nhị phân và kéo cả hệ xuống theo mà không kích hoạt được cơ chế tự chữa nào — trong khi một thành phần chết hẳn được phát hiện và thay thế trong vài giây; nên với hệ thống tự động, fail-fast gần như luôn an toàn hơn fail-slow. Nên câu hỏi đúng khi thiết kế độ bền không phải "làm sao để nó đừng bao giờ chết" mà là "khi nó không phục vụ nổi nữa, nó chết sạch hay lê lết — và cảnh báo của tôi đo trạng thái nhị phân sống/chết, hay đo áp lực thực sự (độ trễ, thời gian đình trệ, độ sâu hàng đợi)".
Muốn tự soi máy mình có đang âm thầm swap không:
free -m | awk '/Swap:/{printf "swap: %d / %d MB (%.1f%%)\n", $3, $2, ($2>0? $3*100/$2 : 0)}'
echo "swappiness: $(cat /proc/sys/vm/swappiness)"
echo "--- ap luc bo nho ---"; cat /proc/pressure/memory 2>/dev/null || echo "nhan khong ho tro PSI"
echo "--- 10 tien trinh swap nhieu nhat ---"
for p in $(ls /proc | grep -E '^[0-9]+$'); do
s=$(awk '/^VmSwap:/{print $2}' /proc/$p/status 2>/dev/null)
[ "${s:-0}" -gt 0 ] && printf "%8d KB %s\n" "$s" "$(tr -d '\0' < /proc/$p/cmdline | cut -c1-50)"
done 2>/dev/null | sort -rn | head -10
Cột VmSwap khác 0 với một tiến trình đang phục vụ yêu cầu là dấu hiệu cần xử lý — không phải vì con số lớn, mà vì mỗi lần chạm vào những trang đó sẽ trả cái giá 500 lần ở bảng trên.
Phần sau: OOM killer — đo cách nhân chọn nạn nhân, và vì sao nó thường chọn sai.