13 điểm bởi GN⁺ 6 ngày trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Tổng hợp nguyên nhân và cách khắc phục các vấn đề cố hữu khi vận hành job queue trên Postgres: dead tuple tích tụ, table bloat kéo theo, và suy giảm hiệu năng
  • Bảng hàng đợi thường có hầu hết các dòng luân chuyển ngắn theo chu kỳ chèn-đọc-xóa, nên kích thước gần như ổn định nhưng tổng thông lượng tích lũy rất lớn
  • Theo cấu trúc MVCC của Postgres, các dòng đã xóa không bị loại bỏ ngay mà còn lại dưới dạng dead tuple và cần được dọn dẹp; việc này do autovacuum đảm nhiệm
  • Nếu có giao dịch chạy lâu hoặc các truy vấn phân tích chạy chồng lấn làm cố định MVCC horizon, autovacuum sẽ không thể dọn dead tuple, khiến hiệu năng hàng đợi suy giảm
  • Tính năng Traffic Control của PlanetScale (thuộc tiện ích mở rộng Insights) được đưa ra như một cách thực tế để giải quyết vấn đề này bằng giới hạn tài nguyên theo từng lớp truy vấn

Đặc tính của workload hàng đợi

  • Điểm đặc trưng của bảng hàng đợi là phần lớn các dòng đều tạm thời (transient) — được chèn vào, được đọc một lần rồi bị xóa
  • Kích thước bảng gần như cố định nhưng tổng thông lượng tích lũy rất lớn
  • Lợi ích chính của việc đặt job queue trong Postgres là có thể đồng bộ trạng thái job với logic DB khác trong cùng một giao dịch
    • Nếu job thất bại thì toàn bộ giao dịch sẽ rollback
    • Nếu dùng dịch vụ hàng đợi bên ngoài thì việc đồng bộ với trạng thái giao dịch của ứng dụng sẽ phức tạp hơn

Ví dụ về bảng hàng đợi và cách worker hoạt động

  • Schema cơ bản được đưa ra trong bài
CREATE TABLE jobs (  
  id BIGSERIAL PRIMARY KEY,  
  run_at TIMESTAMPTZ DEFAULT now(),  
  status TEXT DEFAULT 'pending',  
  payload JSONB  
);  
CREATE INDEX idx_jobs_fetch ON jobs (run_at) WHERE status = 'pending';  
  • Worker mở giao dịch và khóa pending job cũ nhất bằng FOR UPDATE SKIP LOCKED để tránh xử lý trùng
  • Nếu công việc thành công thì DELETE rồi COMMIT, nếu thất bại thì rollback để dòng đó lại hiện ra cho worker khác
  • Giao dịch này cần được giữ ngắn nhất có thể — vì càng mở lâu càng cản trở vacuum (ví dụ trong bài là worker ở mức dưới mili giây)

Bản thân hiệu năng không phải vấn đề

  • Đã có tài liệu chứng minh Postgres có thể xử lý job queue quy mô lớn, nên bản thân năng lực không phải vấn đề
  • Vấn đề thực sự là sự cùng tồn tại với các workload khác cạnh tranh trong cùng DB
  • Sức khỏe của bảng hàng đợi không chỉ phụ thuộc vào cấu hình của chính nó mà còn phụ thuộc vào hành vi của mọi giao dịch chạy trên cùng một instance Postgres
  • Bài viết tập trung vào lưu lượng truy vấn cạnh tranh trên primary (ảnh hưởng từ replica hay replication slot được tách riêng)

Vấn đề thực sự: dọn dẹp dead tuple

  • Postgres dùng MVCC để giữ nhiều phiên bản của cùng một dòng — dòng bị xóa không bị loại bỏ ngay mà được đánh dấu xóa rồi trở nên vô hình với giao dịch mới
  • Những dòng còn lại như vậy là dead tuple và sẽ được dọn bằng vacuum
  • Dead tuple không xuất hiện trong kết quả SELECT nhưng vẫn phát sinh chi phí
    • Sequential scan: bộ thực thi đọc dead tuple từ heap page, kiểm tra tính khả kiến rồi loại bỏ
    • Index scan (kiểu dùng trong hàng đợi với ORDER BY run_at LIMIT 1): chỉ mục B-tree tích lũy tham chiếu đến dead tuple, nên còn phải quét cả các mục trỏ tới những dòng không còn nhìn thấy nữa
  • Mỗi dead index entry đều gây thêm I/O, ứng dụng không thấy nhưng chi phí tăng mạnh theo số lượng dead tuple
  • Chu kỳ dọn dẹp do autovacuum_naptime quyết định (mặc định 1 phút), còn việc có chạy hay không phụ thuộc vào autovacuum_vacuum_thresholdautovacuum_vacuum_scale_factor

Cơ chế bên trong của dead tuple

  • Có 3 metadata của dòng là cốt lõi
    • ctid: vị trí vật lý của tuple trong heap (page, offset)
    • xmin: ID giao dịch (XID) đã chèn dòng này
    • xmax: ID giao dịch đã xóa/khóa dòng này, 0 nghĩa là chưa có đánh dấu xóa
  • Ngay cả khi truy vấn chỉ lấy 3 pending job, vẫn có thể xảy ra tình huống bộ thực thi phải quét hết 6 dead tuple đã bị xóa trước đó rồi mới trả về 3 dòng
  • Tương tự, nếu leaf entry trong chỉ mục trỏ tới heap tuple đã chết thì công việc lãng phí trong quá trình scan sẽ tiếp tục tích lũy
  • Nếu DB dọn dead tuple chậm hơn tốc độ tạo ra chúng thì hệ thống sẽ bước vào quỹ đạo thất bại
  • Một cụm Postgres được tinh chỉnh tốt có thể gánh hàng chục nghìn tác vụ hàng đợi mỗi giây

Khi autovacuum bị vô hiệu hóa

  • Các nguyên nhân chính khiến autovacuum thất bại trong việc dọn dead tuple
    • Một table lock nào đó chặn cleanup
    • Cấu hình autovacuum không phù hợp
    • Phổ biến nhất là giao dịch đang hoạt động ngăn việc thu hồi dead tuple
  • Postgres không vacuum các dead tuple vẫn còn có thể nhìn thấy với giao dịch đang hoạt động
    • Giao dịch hoạt động lâu nhất đặt ra mốc cắt → MVCC horizon
    • Cho tới khi giao dịch đó kết thúc, mọi dead tuple phát sinh sau snapshot đó đều phải được giữ lại
  • Chỉ một giao dịch kéo dài 2 phút cũng có thể cố định horizon trong 2 phút
  • Mẫu thất bại tương tự cũng xảy ra với các truy vấn thời lượng trung bình chạy chồng lấn
    • Ví dụ: chạy so le 3 truy vấn phân tích, mỗi truy vấn 40 giây và cách nhau 20 giây; từng truy vấn riêng lẻ không timeout, nhưng lúc nào cũng có một truy vấn còn hoạt động nên horizon không thể tiến lên
  • Khi theo triết lý “Just use Postgres” và đặt nhiều workload trong cùng một DB, vấn đề không phải bản thân xử lý job nhanh, mà là các truy vấn chậm chồng lấn khiến việc dọn dead tuple bị ùn lại

Công cụ hiện có và giới hạn

  • Các tùy chọn tinh chỉnh autovacuum: autovacuum_vacuum_cost_delay, autovacuum_vacuum_cost_limit
  • Các timeout để giới hạn truy vấn chạy lâu
    • statement_timeout (Postgres 7.3): dừng từng câu SQL vượt quá thời gian chỉ định
    • idle_in_transaction_session_timeout (9.6): đóng phiên ở trạng thái nhàn rỗi quá lâu bên trong giao dịch
    • transaction_timeout (17.0): đóng giao dịch hoạt động hoặc không hoạt động khi vượt thời gian quy định
  • Nhưng các timeout này chỉ nhắm vào thời gian chạy của từng truy vấn đơn lẻ, không giới hạn được tính đồng thời hay chi phí thực thi, nên không phù hợp để chặn các workload liên tục cố định MVCC horizon
  • Điều cần thiết là phân biệt theo lớp lưu lượng, giữ nguyên workload ưu tiên cao và chỉ điều tiết mức dùng tài nguyên của workload ưu tiên thấp

Database Traffic Control™

  • Đây là tính năng chỉ dành cho PlanetScale Postgres, thuộc tiện ích mở rộng Insights do PlanetScale phát triển
  • Dùng khi cần kiểm soát chi tiết hiệu năng và mức dùng tài nguyên của từng truy vấn
  • Với Resource Budget, có thể đặt giới hạn tài nguyên cho truy vấn mục tiêu → vượt ngưỡng thì bị chặn
  • Chiến lược giải quyết là giới hạn số lượng và tần suất chạy đồng thời của các truy vấn chậm bị chồng lấn, để autovacuum có khoảng trống dọn dead tuple với tốc độ phù hợp
  • Các truy vấn bị chặn không bị từ chối vĩnh viễn mà phải được thử lại, nên logic retry ở phía ứng dụng là bắt buộc
  • Đây là cách làm phẳng tốc độ thực thi nhưng vẫn giữ nguyên tổng khối lượng công việc

Cấu hình demo và bối cảnh

  • Ý tưởng cho bài viết này đến từ bài blog năm 2015 của Brandur Leach, "Postgres Job Queues & Failure By MVCC"
    • Ghi lại một failure mode nghiêm trọng của job queue dựa trên Postgres
    • Kèm test bench chứng minh hiện tượng giao dịch không đóng làm cố định MVCC horizon và cản trở cleanup
  • Test bench gốc được công khai tại brandur/que-degradation-test

Tái hiện vấn đề (dựa trên Postgres 18)

  • Test gốc dùng Ruby + Que gem v0.x + Postgres 9.4
  • Tác giả viết lại bằng TypeScript + Bun để cô lập kiểm chứng hành vi ở tầng SQL
  • Giữ nguyên mẫu recursive CTE như Que, cùng schema, producer rate, work duration, số worker và mẫu long-runner
  • Chạy trên cụm PlanetScale PS-5 (từ 5 USD/tháng)
  • Kết quả: suy giảm hiệu năng rõ rệt nhưng vẫn trong mức có thể kiểm soát
    • Test gốc từng đẩy DB vào death spiral trong vòng 15 phút, nhưng trên PS-5, worker vẫn giữ hàng đợi quanh mức 0 trong 15 phút
    • Tuy vậy, dead tuple vẫn tăng tuyến tính, cho thấy nếu chạy lâu hơn thì vấn đề tương tự sẽ quay lại
    • Nhờ các cải tiến dọn chỉ mục B-tree (bottom-up deletion với version churn, loại dead index tuple dựa trên scan, v.v.), vấn đề được giảm nhẹ nhưng chưa biến mất

Thử cải tiến: SKIP LOCKED + xử lý theo lô

  • Có 2 cải tiến hiện đại mà năm 2015 chưa có
    1. FOR UPDATE SKIP LOCKED — thay toàn bộ recursive CTE bằng một SELECT duy nhất, bỏ qua các dòng đã bị worker khác khóa
    2. Batch processing (10 job mỗi giao dịch) — xử lý 10 job sau một lần lấy khóa, giúp phân tán chi phí scan chỉ mục
  • Điều kiện giữ nguyên: 8 worker, producer 50 job/giây, công việc 10ms, long-runner bắt đầu sau 45 giây
  • Kết quả chính
Chỉ số original (recursive CTE) enhanced (SKIP LOCKED + batch)
Thời gian khóa baseline 2–3ms 1.3–3.0ms
Thời gian khóa cuối kỳ (điển hình) 10–34ms 9–29ms
Mức tăng đột biến tệ nhất 84.5ms (dead tuple 33k) 180ms (dead tuple 24k)
Độ sâu hàng đợi 0–100 (dao động) 0 (phần lớn thời gian)
Dead tuple lúc kết thúc 42,400 42,450
Thông lượng ~89/s ~50/s
  • Đường cong suy giảm gần như giống hệt nhau — vì cả hai cách đều quét cùng chỉ mục B-tree và gặp cùng lượng dead tuple
  • Chênh lệch về thông lượng không đến từ chiến lược khóa mà từ thiết kế bài test (worker CTE lấy job nhanh hơn producer, còn worker theo lô thì dọn sạch hàng đợi rồi ngủ backoff)
  • Kết luận: thiết kế hàng đợi từng có thể giết DB sau 15 phút cách đây 10 năm nay chịu đựng được lâu hơn, nhưng vấn đề gốc vẫn còn — nếu tăng lên 500 job/giây thì vấn đề sẽ tái hiện nhanh hơn

Giải quyết bằng Traffic Control

  • Resource Budget cung cấp các cơ chế kiểm soát sau
    • Server share & burst limit: tỷ lệ tài nguyên máy chủ và tốc độ tiêu thụ
    • Per-query limit: thời lượng truy vấn được phép chạy tính theo giây dựa trên mức sử dụng máy chủ
    • Maximum concurrent workers: tỷ lệ so với số worker process sẵn có
  • Việc chỉ định nhóm truy vấn chủ yếu dựa trên metadata trong thẻ SQLCommenter (ví dụ: action=analytics)
  • Thay vì long-runner bị bắt bởi idle_in_transaction_session_timeout, bài viết dùng kịch bản thực tế hơn là các truy vấn phân tích đang hoạt động nhưng chồng lấn nhau để gây suy giảm (trường hợp mà session timeout không xử lý được)
  • Giới hạn Maximum concurrent workers của truy vấn action=analytics xuống còn 1 worker (max_worker_processes 25%) → chỉ cho 1 truy vấn phân tích chạy cùng lúc
  • Để tạo death spiral trong khung 15 phút, tăng producer lên 800 job/giây
  • Chạy workload "enhanced" 2 lần từ EC2 tới cùng DB PlanetScale
    • 800 job/giây
    • 3 truy vấn phân tích, mỗi truy vấn 120 giây, chạy đồng thời và bố trí so le để luôn chồng lấn
    • Kéo dài 15 phút
  • So sánh kết quả
Chỉ số Traffic Control tắt Traffic Control bật
Backlog hàng đợi 155,000 job 0 job
Thời gian khóa 300ms+ 2ms
Dead tuple lúc kết thúc 383,000 0–23,000 (dao động theo chu kỳ)
Truy vấn phân tích 3 truy vấn đồng thời, chồng lấn 1 truy vấn mỗi lần, 2 truy vấn đang retry
Hiệu quả VACUUM Bị chặn (horizon bị cố định) Bình thường (có khoảng dọn dẹp giữa các truy vấn)
Kết quả Death spiral Hoàn toàn ổn định
  • Traffic Control giới hạn trực tiếp mức đồng thời của từng workload cụ thể, mang lại kiểu kiểm soát mà tuning autovacuum hay timeout không làm được
  • Các báo cáo phân tích vẫn tiếp tục chạy trong phạm vi dung lượng cho phép và hoàn thành 15 lần trong 15 phút, trong khi hàng đợi luôn ở trạng thái khỏe mạnh

Tổng kết

  • Vấn đề MVCC dead tuple trong hàng đợi dựa trên Postgres không phải di vật từ năm 2015
  • Postgres hiện đại mang lại nhiều dư địa hơn nhờ cải tiến B-tree và SKIP LOCKED, nhưng cơ chế gốc vẫn như cũ
    • Nếu VACUUM không dọn được dead tuple thì chúng sẽ tiếp tục tích tụ
    • Nếu các giao dịch chạy lâu hoặc chồng lấn làm cố định MVCC horizon thì VACUUM sẽ không thể dọn được
  • Trong môi trường “Just use Postgres”, nơi hàng đợi, phân tích và logic ứng dụng cùng nằm trong một DB, đây không phải rủi ro lý thuyết mà là điều kiện vận hành thường ngày
  • Dạng nguy hiểm không phải là crash kịch tính mà là trạng thái cân bằng suy thoái âm thầm — thời gian khóa tăng dần, job chậm đi, nhưng không có cảnh báo nào vang lên
  • Bộ công cụ timeout của Postgres không thể phân loại workload hay giới hạn mức đồng thời
  • Nếu chạy hàng đợi cùng các workload khác, biện pháp hiệu quả nhất là đảm bảo VACUUM có thể theo kịp, và Traffic Control giúp đơn giản hóa điều đó

1 bình luận

 
Ý kiến trên Hacker News
  • Postgres vẫn còn vấn đề vacuum horizon. Đây là hiện tượng các truy vấn chạy lâu ngăn vacuum trên những bảng thay đổi nhanh. Vấn đề này đã được biết rõ từ năm 2015. Postgres mặc định không có công cụ tốt để giải quyết, nhưng bản tùy biến của công ty tác giả có tính năng xử lý việc này. Kết luận là, việc trộn các tác vụ dài kiểu OLAP với các tác vụ nhanh kiểu hàng đợi trên cùng một instance Postgres vẫn không phải là ý hay. Tùy theo yêu cầu, dùng message queue như 0MQ hoặc RMQ có thể là giải pháp dễ hơn

    • Trừ khi cơ sở dữ liệu có thể kiểm soát cache hoặc buffer pool một cách tinh vi, việc trộn các tải khác biệt như vậy luôn là lựa chọn không tốt. Vì không có cách nào ngăn các bảng phân tích làm bẩn toàn bộ cache
  • Bài viết ổn nhưng có vài điểm cần nêu ra.

    1. Phần giải thích về MVCC horizon có vẻ tự mâu thuẫn. Nếu các transaction bắt đầu ở những thời điểm khác nhau thì snapshot sẽ khác nhau, và khi transaction đầu tiên kết thúc thì vacuum phải có thể tiếp tục
    2. Vấn đề hiệu năng của truy vấn SELECT * FROM jobs WHERE status='pending' ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED; là có thật, nhưng có thể giảm bớt bằng cách thêm một cột tăng đơn điệu và lập chỉ mục cho nó. Làm vậy sẽ không cần phải xét đến dead tuple, chỉ lãng phí không gian thôi nên hiệu năng đọc sẽ giảm ít hơn. Tuy vậy, cách đảm bảo tính tăng đơn điệu trong tình huống ghi đồng thời còn phụ thuộc vào thiết kế ứng dụng
    3. Tóm lại, bài học là “đừng dùng các transaction rất dài cùng với tần suất transaction rất cao trong Postgres”
    • Ngay cả khi thêm chỉ mục, Postgres vẫn phải giữ dead tuple cho đến khi dọn sạch hoàn toàn. Có thể nhanh hơn một chút nhưng cuối cùng đĩa vẫn sẽ đầy, và vacuum của các bảng khác cũng có thể bị ảnh hưởng
  • Tôi là tác giả bài viết. Nếu có câu hỏi thì cứ hỏi

    • Có vẻ như phần triển khai hàng đợi trong blog giữ transaction mở trong lúc công việc đang chạy. Tôi hiểu lời khuyên là nên giữ nó ngắn nhất có thể, nhưng tự hỏi liệu có thể tránh chính transaction dài bằng cách cập nhật cột trạng thái thành “processing” hay không
    • Tôi tò mò không biết đã thử đặt fillfactor của bảng hàng đợi nhỏ hơn 100 và kiểm thử chưa. Nếu dùng HOT update thì khi đổi trạng thái sẽ không cần tạo mục chỉ mục mới và có thể tái sử dụng không gian chết, nên có vẻ sẽ trì hoãn thời điểm sự cố xuất hiện
    • Tôi muốn biết giải pháp này khác gì so với dùng pg_squeeze. Chúng tôi cũng gặp vấn đề tương tự trong hệ thống hàng đợi và đang thử pg_squeeze, có vẻ nó hoạt động khá tốt
  • Nghe giống quảng cáo, nhưng sẽ tốt hơn nếu có dù chỉ một chút giải thích về cách giải quyết ở khía cạnh kỹ thuật

  • Postgres thực sự làm được rất nhiều việc. Mọi người chọn Kafka hay SQS, nhưng thật ra có khá nhiều tác vụ mà chỉ với Graphile Worker là đã đủ

    • Triết lý “hãy làm mọi thứ bằng Postgres” phù hợp để giữ sự đơn giản ở quy mô nhỏ hoặc vừa, nhưng khi quy mô lớn lên thì tốt nhất nên giao cho cơ sở dữ liệu SQL càng ít việc càng tốt. Vì trong hầu hết hệ thống, đó là phần trở thành nút thắt cổ chai
    • SQS rất đơn giản và tích hợp dễ dàng trong môi trường AWS. Kafka phức tạp hơn nhiều nhưng là lựa chọn tuyệt vời nếu bạn cần những tính năng của nó. Tuy nhiên gánh nặng vận hành lớn, và cũng có nhiều dự án triển khai nó chỉ để làm đẹp CV. Trong khi đó SQS được dùng như một công cụ thực dụng. Nhưng nếu muốn rời khỏi AWS thì sự phụ thuộc này có thể trở thành vấn đề
  • Trong Postgres, vấn đề sẽ nghiêm trọng hơn nhiều khi cập nhật hàng. Nếu chỉ dùng chèn và xóa thì có thể cầm cự được khá lâu

    • Trong Postgres, UPDATE về cơ bản là tổ hợp của INSERT và DELETE