- Trong Linux 7.0, chế độ preemption PREEMPT_NONE vốn là mặc định truyền thống cho máy chủ đã bị loại bỏ, dẫn tới một đợt hồi quy hiệu năng nghiêm trọng khiến thông lượng PostgreSQL trên cùng phần cứng giảm còn một nửa
- Kỹ sư AWS chạy pgbench trên máy Graviton4 96 vCPU và ghi nhận rằng trên Linux 7.0 so với Linux 6.x, số giao dịch mỗi giây giảm từ 98.565 xuống 50.751, với 55% CPU bị tiêu tốn trong một hàm spinlock duy nhất
- Spinlock bảo vệ việc truy cập shared buffer pool của PostgreSQL kết hợp với minor page fault của các trang bộ nhớ 4KB, khiến khi trình lập lịch preempt trong lúc đang giữ khóa, toàn bộ backend đang chờ đều quay vòng lãng phí CPU
- Khi bật Huge Pages (2MB hoặc 1GB), số page fault tiềm năng giảm từ 31 triệu xuống chỉ còn vài chục nghìn đến vài trăm, qua đó loại bỏ hiện tượng hồi quy
- Phía kernel đề xuất áp dụng Restartable Sequences (rseq), nhưng cộng đồng PostgreSQL cho rằng việc hiệu năng suy giảm chỉ vì nâng cấp kernel tự thân đã đi ngược nguyên tắc "không làm hỏng userspace"
Hiện tượng vấn đề
- Kỹ sư AWS Salvatore Dipietro chạy pgbench trên bộ xử lý Graviton4 96 vCPU, thực hiện bài kiểm tra tải song song cao với scale factor 8.470 (bảng khoảng 847 triệu hàng), 1.024 client và 96 thread
- Thông lượng giảm gần một nửa, từ 98.565 TPS trên Linux 6.x xuống 50.751 TPS trên Linux 7.0
- Kết quả profiling bằng
perf cho thấy 55,60% thời gian CPU bị tiêu tốn trong hàm s_lock
- Đường gọi:
StartReadBuffer → GetVictimBuffer → StrategyGetBuffer → s_lock
Preemption là gì
- Preemption là quyết định của bộ lập lịch hệ điều hành dừng một thread đang chạy và chuyển CPU cho thread khác
- Trước Linux 7.0 có ba lựa chọn
- PREEMPT_NONE: gần như không dừng thread cho tới khi nó tự nguyện nhường CPU (syscall, chặn I/O, sleep). Đây là mặc định truyền thống cho máy chủ, có ít context switch và thông lượng cao
- PREEMPT_FULL: có thể dừng thread đang chạy tại gần như mọi điểm an toàn. Giảm độ trễ phản hồi nhưng làm tăng overhead của context switch. Đây là mặc định truyền thống cho máy tính để bàn
- PREEMPT_LAZY: phương án dung hòa được giới thiệu trong Linux 6.12, chờ các ranh giới tự nhiên nhưng vẫn cho phép preempt khi cần. Được thiết kế để xấp xỉ đặc tính thông lượng của PREEMPT_NONE
- Trong Linux 7.0, PREEMPT_NONE bị loại bỏ trên các kiến trúc CPU hiện đại, chỉ còn PREEMPT_FULL và PREEMPT_LAZY
- Dù PREEMPT_LAZY hoạt động như phương án thay thế cho đa số phần mềm máy chủ, với PostgreSQL lại xuất hiện khác biệt mang tính chí mạng
Quản lý bộ nhớ của PostgreSQL
- PostgreSQL dùng data page kích thước cố định (mặc định 8KB) làm đơn vị lưu trữ cơ bản; hàng của bảng, nút chỉ mục B-tree, siêu dữ liệu... đều được lưu trong các trang này
- Để giảm số lần đọc đĩa, hệ thống cache các data page vừa đọc vào một vùng bộ nhớ dùng chung lớn gọi là shared buffer pool
- Khi client kết nối, một backend process chuyên dụng được tạo ra; nếu trang cần đọc chưa có trong buffer pool thì hệ thống phải đọc từ đĩa rồi tìm một buffer trống hoặc có thể bị đẩy ra
- Hàm phụ trách việc chọn buffer này là
StrategyGetBuffer
Spinlock của PostgreSQL
- Spinlock là cơ chế khóa mà thay vì ngủ chờ, tiến trình liên tục lặp để kiểm tra xem khóa đã được nhả hay chưa
- Với vùng tới hạn rất ngắn, quay vòng như vậy hiệu quả hơn chi phí ngủ rồi đánh thức thread
- Giả định cốt lõi là: thread giữ khóa sẽ nhả khóa rất nhanh
StrategyGetBuffer dùng một spinlock toàn cục duy nhất để bảo vệ quá trình chọn buffer
- Trong môi trường 96 vCPU và 1.024 client, mọi backend đều cạnh tranh cùng một khóa
Bộ nhớ ảo và TLB
- Mọi tiến trình đều dùng địa chỉ bộ nhớ ảo, và phần cứng sẽ chuyển nó thành địa chỉ vật lý thông qua page table có cấu trúc cây nhiều tầng
- Vì việc duyệt page table mỗi lần đều chậm, CPU duy trì TLB (Translation Lookaside Buffer) để cache các kết quả chuyển đổi gần đây
- Nếu TLB hit thì truy cập nhanh, còn TLB miss sẽ cần page table walk nên tốn thời gian
- Linux dùng nguyên tắc lazy allocation, nghĩa là khi cấp phát bộ nhớ ảo thì trang vật lý thật chỉ được ánh xạ tại thời điểm truy cập đầu tiên
- Lần truy cập đầu sẽ phát sinh minor page fault: kernel cấp phát trang vật lý, lưu ánh xạ, và thao tác này chậm hơn đọc/ghi thông thường ở mức vài micro giây
Vấn đề của trang 4KB
- Trong benchmark,
shared_buffers được đặt ở mức 120GB; với trang bộ nhớ 4KB tương ứng khoảng 31 triệu trang bộ nhớ, tức 31 triệu page fault tiềm năng khi truy cập lần đầu
- Trong benchmark chạy lâu với shared buffer pool 120GB, các vùng bộ nhớ mới liên tục đi vào working set nên page fault không chỉ xảy ra lúc khởi động mà còn tiếp diễn liên tục
- Nếu truy cập shared memory bên trong
StrategyGetBuffer trong khi đang giữ spinlock mà vùng đó chưa được ánh xạ, sẽ phát sinh minor page fault
- Với PREEMPT_NONE (trước Linux 7.0): ngay cả khi backend A đi vào page fault handler, nó ít có khả năng bị schedule out trước khi xử lý xong fault vì tránh được các điểm reschedule tự nguyện. Thời gian chờ có dài hơn dự kiến nhưng thiệt hại vẫn bị giới hạn
- Với PREEMPT_LAZY (từ Linux 7.0): bộ lập lịch có thể preempt backend A bên trong page fault handler và chuyển sang tiến trình khác. Ngay cả khi fault đã được xử lý xong, vẫn phát sinh thêm thời gian chờ
t cho tới khi scheduler trả lại quyền điều khiển
- Khoảng chờ thêm này không chỉ là
t đơn thuần mà bị khuếch đại thành số backend đang quay vòng hiện tại × t dưới dạng CPU bị lãng phí
- Trên hệ thống 96 vCPU với hàng trăm backend, hiệu ứng nhân này trở nên chí mạng, và kết quả là 56% CPU bị tiêu tốn trong
s_lock
Khắc phục bằng Huge Pages
- Với
shared_buffers 120GB, khi thay đổi kích thước trang bộ nhớ, số page fault tiềm năng giảm mạnh
- Trang 4KB: ~31.000.000 page fault tiềm năng
- Huge Pages 2MB: ~61.440
- Huge Pages 1GB: ~120
- Kích thước trang lớn hơn không chỉ giảm số page fault mà còn giảm áp lực lên TLB: cùng một lượng bộ nhớ được bao phủ bởi ít entry TLB hơn nhiều, từ đó giảm TLB miss và page table walk
- Nhờ
StrategyGetBuffer không còn gây fault trong lúc giữ khóa, tiến trình giữ khóa hoàn tất nhanh, các backend khác chỉ phải chờ ở mức micro giây thay vì mili giây. Hiện tượng hồi quy được loại bỏ
- Trong PostgreSQL, cấu hình huge pages được điều khiển bằng tham số
huge_pages
- Hỗ trợ ba giá trị:
off, on, try (mặc định)
try sẽ dùng huge pages nếu có thể, còn nếu không sẽ âm thầm fallback về 4KB, nên có nguy cơ không nhận ra cấu hình sai
- Nếu đặt thành
on, PostgreSQL sẽ không khởi động được khi không dùng được huge pages, nhờ đó có thể phát hiện vấn đề ngay lập tức
- Đánh đổi: huge pages dùng cơ chế cấp phát và dành trước, nên ngay cả khi PostgreSQL không dùng hết thì phần bộ nhớ đó cũng không thể được phần còn lại của hệ thống sử dụng. Nếu chỉ dùng một phần trang thì phần còn lại bị lãng phí. Tuy vậy, trong môi trường production dùng
shared_buffers lớn, đây thường vẫn là một đánh đổi đáng chấp nhận
Diễn biến tiếp theo
- Peter Zijlstra, kỹ sư kernel của Intel, người thiết kế thay đổi về preemption, đã đề xuất PostgreSQL áp dụng Restartable Sequences (rseq)
rseq là tính năng của kernel Linux cho phép mã userspace phát hiện việc bị preempt hoặc migration trong vùng tới hạn và khởi động lại vùng đó
- Nếu áp dụng
rseq vào đường spinlock của PostgreSQL, có thể tránh được kịch bản tiến trình giữ khóa bị preempt làm trì hoãn toàn bộ backend đang chờ
- Phản ứng của cộng đồng PostgreSQL là tiêu cực
- Họ khó chấp nhận việc phải dùng thêm một tính năng kernel riêng chỉ để lấy lại hiệu năng vốn có miễn phí trước Linux 7.0
- Quan điểm của họ là điều này đi ngược nguyên tắc lâu đời của kernel: "không làm hỏng userspace" (phần mềm hoạt động bình thường trước khi nâng cấp kernel thì sau nâng cấp vẫn phải tiếp tục hoạt động bình thường)
Chưa có bình luận nào.