Giữ hàng đợi Postgres khoẻ mạnh
Tin tức chung·Hacker News·0 lượt xem

Giữ hàng đợi Postgres khoẻ mạnh

Keeping a Postgres Queue Healthy

Các bộ dữ liệu chết từ hàng đợi công việc có tỷ lệ sử dụng cao có thể âm thầm làm suy giảm cơ sở dữ liệu Postgres của bạn khi chân không bị tụt lại phía sau—đặc biệt là cùng với các khối lượng công việc cạnh tranh. Kiểm soát giao thông giúp việc dọn dẹp đi đúng hướng.

Bởi Simeon Griggs | Ngày 10 tháng 4 năm 2026

Hệ thống tiêu hóa khỏe mạnh là hệ thống giúp loại bỏ chất thải một cách hiệu quả. Chất xơ là một phần quan trọng của chế độ ăn uống lành mạnh, không phải vì nó bổ dưỡng mà vì nó giúp mọi thứ bạn tiêu thụ luôn di chuyển.

Cơ sở dữ liệu không quá khác biệt. Nếu muốn có bảng hàng đợi hoạt động tốt, bạn cần giám sát các hệ thống được thiết kế để thực hiện dọn dẹp tốt trước khi chúng được sao lưu.

Postgres đã là lựa chọn phổ biến cho khối lượng công việc dựa trên hàng đợi từ rất lâu trước khi nó phù hợp với công việc. Trong nhiều năm và nhiều phiên bản chính, Postgres thậm chí còn trở thành một lựa chọn mạnh mẽ hơn cho loại khối lượng công việc này.

Nhưng điều gì khiến hàng đợi công việc đặc biệt có vấn đề? Và bất chấp tất cả những tiến bộ này, những cạm bẫy nào vẫn còn tồn tại?

Điều đáng biết là vì chúng có thể phá hủy không chỉ hàng đợi công việc mà còn cả cơ sở dữ liệu khối lượng công việc hỗn hợp và toàn bộ ứng dụng của bạn.

Chia sẻ Load

Meme "chỉ sử dụng Postgres" củng cố thêm quan điểm rằng mọi khối lượng công việc đều thuộc về cơ sở dữ liệu Postgres. Đó không phải là ý tưởng tồi tệ nhất. Bạn thực sự có thể ném bất cứ thứ gì vào cơ sở dữ liệu Postgres và làm cho nó gắn bó. Hệ sinh thái tiện ích mở rộng phong phú sẽ lấp đầy mọi khoảng trống về chức năng trong Postgres "vanilla".

Do đó, bạn có thể có nhiều loại khối lượng công việc riêng biệt chạy trong cùng một cơ sở dữ liệu cùng một lúc. Tất cả các khối lượng công việc OLTP, OLAP, Chuỗi thời gian, Tìm nguồn cung ứng sự kiện, Văn bản đầy đủ, Không gian địa lý và/hoặc Hàng đợi của bạn đều có thể đang chạy cùng lúc trong cùng một cụm cơ sở dữ liệu với các nhu cầu, thách thức và mức độ ưu tiên khác nhau—trong khi cạnh tranh để giành được cùng một tài nguyên.

Có các dịch vụ dành riêng cho từng loại khối lượng công việc này mà bạn có thể sử dụng riêng biệt. Tuy nhiên, nếu bạn đang đọc bài đăng blog này thì có thể bạn đang tìm cách tối ưu hóa cách tất cả chúng có thể hoạt động hài hòa.

Tại PlanetScale, chúng tôi luôn ưu tiên chọn công cụ phù hợp cho từng công việc—Postgres hoặc công cụ khác. Nhưng nếu bạn tò mò về việc duy trì hàng đợi ổn định cùng với khối lượng công việc hỗn hợp trong Postgres, hãy tiếp tục đọc.

Khối lượng công việc của hàng đợi

Điều khiến bảng hàng đợi trở nên độc đáo là hầu hết các hàng đều tạm thời. Đã chèn, đọc một lần và xóa. Vì vậy, kích thước của bảng gần như không đổi trong khi thông lượng tích lũy của nó rất lớn.

Ứng dụng của bạn có thể sử dụng hàng đợi công việc để theo dõi các hành động không đồng bộ như gửi email, tạo hóa đơn hoặc tạo báo cáo. Lợi ích chính của việc thực hiện việc này trong Postgres là bạn có thể duy trì trạng thái công việc và bất kỳ logic nào khác chạy trong cơ sở dữ liệu của mình đồng bộ với giao dịch.

Nếu công việc không thành công, toàn bộ giao dịch sẽ không thành công và quay trở lại. Nếu giao dịch không thành công, công việc có thể thử lại hoặc bị xóa. Việc sử dụng nhà cung cấp bên ngoài yêu cầu phải phối hợp cẩn thận để luôn đồng bộ hóa với trạng thái giao dịch của ứng dụng của bạn.

Ví dụ của chúng tôi hôm nay

Hãy xem xét bảng hàng đợi đơn giản này mà người ta có thể sử dụng để tạo các công việc riêng lẻ cần thực hiện. Cột tải trọng chứa tất cả thông tin mà ứng dụng của bạn cần để hoàn tất thao tác.

CREATE TABLE việc làm (
 id BIGSERIAL PRIMARY KEY,
 chạy_at TIMESTAMPTZ DEFAULT bây giờ(),
 trạng thái TEXT DEFAULT 'đang chờ xử lý',
 tải trọng JSONB
);

TẠO INDEX idx_jobs_fetch ON công việc (run_at) WHERE trạng thái = 'đang chờ xử lý';

Vì ứng dụng của bạn thường xuyên thực hiện các truy vấn để kiểm tra các công việc cần thực hiện, nên ứng dụng sẽ tìm kiếm công việc cũ nhất vẫn ở trạng thái đang chờ xử lý, thực hiện mọi công việc cần thiết rồi xóa công việc đó công việc.

Nhân viên mở một giao dịch và yêu cầu giao dịch tiếp theo đang chờ xử lý công việc:

BEGIN;

SELECT * FROM công việc
WHERE trạng thái = 'đang chờ xử lý'
ĐẶT HÀNG BỞI run_at
LIMIT 1
FOR CẬP NHẬT BỎ QUA ĐÃ KHÓA;

Trong thực tế, việc giữ cho giao dịch này càng ngắn càng tốt là rất quan trọng — giao dịch này mở càng lâu thì thời gian giữ được càng lâu chân không trở lại. Các ví dụ trong bài đăng này giả định các hoạt động của nhân viên dưới một phần nghìn giây.

Nhân viên thực hiện bất kỳ công việc nào mà công việc yêu cầu. Nếu công việc không thành công, giao dịch sẽ quay trở lại — hàng không bao giờ được sửa đổi, khóa được giải phóng và công việc sẽ hiển thị lại với các nhân viên khác.

Nếu công việc thành công, nhân viên sẽ xóa công việc và cam kết:

DELETE FROM công việc WHERE id = $1;
COMMIT;

Để xử lý công việc đồng thời và nhanh hơn, bạn có thể muốn nhiều nhân viên thực hiện các công việc riêng lẻ cùng một lúc. Với truy vấn mẫu ở trên, mỗi nhân viên được bảo vệ khỏi thực hiện công việc trùng lặp theo dòng FOR UPDATE SKIP LOCKED vì cùng một truy vấn sẽ "khóa" hàng mà nó đang làm việc cho đến khi giao dịch được thực hiện.

Như chúng ta có thể thấy, bản chất của khối lượng công việc trong hàng đợi công việc khá đơn giản. Một hàng được tìm nạp và sau đó bị xóa. Tuy nhiên, bên dưới bề mặt, có nhiều điều hơn thế. Cần phải dọn dẹp.

Vấn đề phổ biến làm giảm chất lượng hàng đợi công việc và cơ sở dữ liệu mà chúng vận hành là khi cơ sở dữ liệu không thể dọn dẹp sau các giao dịch này nhanh hơn tốc độ tích lũy công việc mới.

Chỉ riêng hiệu suất không phải là vấn đề vấn đề

Postgres được những người khác ghi lại để xử lý khối lượng công việc này ở quy mô lớn. Vì vậy, khả năng hỗ trợ hàng đợi công việc của Postgres không còn là vấn đề cần bàn cãi.

Việc giữ cho hàng đợi công việc của bạn hài hòa với các khối lượng công việc cạnh tranh khác trong cơ sở dữ liệu của bạn thường là một thách thức.

Tình trạng 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 khác chạy trên cùng một phiên bản Postgres. Mặc dù các bản sao và vị trí sao chép cũng có thể hoạt động chống lại các bảng xếp hàng, nhưng bài đăng này tập trung vào việc cạnh tranh lưu lượng truy vấn trên bảng chính.

Việc dọn sạch các bộ dữ liệu chết là vấn đề

Khi các hàng thay đổi, Postgres có thể duy trì nhiều phiên bản của cùng một hàng để các giao dịch khác nhau có thể thấy các giá trị hàng tại thời điểm chúng tồn tại đã truy vấn. Đây là cách triển khai "Kiểm soát đồng thời nhiều phiên bản" (MVCC) của Postgres và là nguyên tắc cốt lõi trong thiết kế của nó.

Điều này có nghĩa là trong hàng đợi công việc của chúng tôi, một hàng trong cơ sở dữ liệu Postgres được nhắm mục tiêu bởi thao tác DELETE sẽ không bị xóa ngay lập tức. Thay vào đó, nó được đánh dấu để xóa, ẩn đối với các giao dịch mới và vẫn còn trong cơ sở dữ liệu cho đến khi được dọn sạch. Các hàng ẩn, chưa bị xóa này được gọi là "bộ dữ liệu chết".

Các bộ dữ liệu chết được dọn sạch bằng thao tác "chân không". Thao tác này có thể được thực hiện thủ công hoặc diễn ra thường xuyên trong cơ sở dữ liệu Postgres lành mạnh. Mặc dù các bộ dữ liệu chết không được trả về trong truy vấn SELECT nhưng chúng vẫn phải trả phí.

Để quét tuần tự, người thực thi sẽ đọc các bộ dữ liệu chết từ các trang vùng nhớ khối xếp và kiểm tra khả năng hiển thị của chúng trước khi loại bỏ chúng.

Để quét chỉ mục — loại mà hàng đợi công việc của chúng tôi dựa vào ORDER BY run_at LIMIT 1 — chi phí còn khủng khiếp hơn: bản thân chỉ mục cây B tích lũy các tham chiếu đến bộ dữ liệu chết, buộc quá trình quét phải duyệt qua các mục trỏ đến hàng không còn hiển thị.

Mỗi mục nhập chỉ mục chết có nghĩa là I/O bổ sung để kiểm tra trang heap chỉ để loại bỏ nó. Chi phí này không hiển thị với ứng dụng nhưng có thể tăng đáng kể theo số lượng bộ dữ liệu chết.

Về tần suất cố gắng dọn dẹp, autovacuum_naptime kiểm soát thời gian trình khởi chạy ngủ giữa các lần kiểm tra từng cơ sở dữ liệu để tìm các bảng cần dọn dẹp, thường là 1 phút theo mặc định. Thời điểm một bảng được hút bụi sẽ phụ thuộc vào ngưỡng bộ dữ liệu chết autovacuum_vacuum_thresholdautovacuum_vacuum_scale_factor.

Các bộ dữ liệu chết bên dưới mui xe

Hãy hình dung kịch bản trong đó chúng ta có một bảng công việc trong đó các nhiệm vụ thuộc các loại khác nhau được tạo và xử lý thường xuyên. Một ứng dụng khác truy cập vào cùng một cơ sở dữ liệu để thực hiện các truy vấn phân tích lớn và tạo báo cáo. Những công việc này có mức độ ưu tiên thấp hơn và hoàn thành chậm hơn.

Giả sử bạn thực hiện một truy vấn trên bảng hàng đợi công việc:

SELECT * FROM công việc WHERE trạng thái = 'đang chờ xử lý'

Phản hồi bạn mong đợi được xem hiển thị ba câu hỏi đó đang chờ xử lý công việc:

-- Những gì bạn nhìn thấy
 id |         chạy_at | trạng thái |            tải trọng
------+-----------------+----------+-------------------------------
 42 | 2026-04-07 09:01:12 UTC | đang chờ xử lý | {"type": "email", "tới": "..."
 43 | 2026-04-07 09:01:14 UTC | đang chờ xử lý | {"type": "invoice", "id": 781
 44 | 2026-04-07 09:01:15 UTC | đang chờ xử lý | {"type": "report", "id": 332
(3 hàng)

Trong mỗi hàng là siêu dữ liệu mà người thực thi truy vấn sẽ đọc để xác định xem liệu nó có nên được đưa vào phản hồi hay không hay ẩn đối với giao dịch hiện tại. Mặc dù bạn không thể truy vấn các bộ dữ liệu đã chết nhưng bạn có thể đưa siêu dữ liệu này vào phản hồi của bất kỳ bộ dữ liệu đang hoạt động nào.

-- Giao dịch này được cơ sở dữ liệu cấp ID (XID)
SELECT ctid, xmin, xmax, id, status FROM việc làm WHERE trạng thái = 'đang chờ xử lý';

 ctid |  xmin | xmax | id | trạng thái
-------+--------+------+----+----------
 (0,7) | 439821 |    0 | 42 | đang chờ xử lý
 (0,8) | 439825 | 0 | 43 | đang chờ xử lý
 (0,9) | 439830 |    0 | 44 | đang chờ xử lý
(3 hàng)
  • ctid — vị trí vật lý của bộ dữ liệu trên đĩa, được biểu thị dưới dạng (trang, offset) trong bảng heap.
  • xmin — ID giao dịch (XID) đã chèn hàng này; người đọc sử dụng nó để quyết định xem hàng có tồn tại khi giao dịch của họ bắt đầu hay không.
  • xmax — XID đã xóa hoặc khóa hàng này; giá trị 0 có nghĩa là chưa có giao dịch nào được đánh dấu để xóa.

Có thể có cũng là các bộ dữ liệu đã chết, các hàng đã bị xóa trước đó nhưng chưa bị xóa về mặt vật lý mà người thực thi Postgres vẫn phải quét trên đường trả về phản hồi. Mặc dù bạn chỉ nhìn thấy ba hàng trực tiếp nhưng người thực thi đã quét qua nhiều hàng khác:

-- Chế độ xem khái niệm: nội dung người thực thi quét (không phải đầu ra truy vấn thực)
 ctid |  xmin |  xmax | id | trạng thái |
-------+--------+--------+----+----------+
 (0,1) | 439790 | 439792 | 36 | đang chờ xử lý | -- đã chết: xmax đã đặt, bị xóa bởi giao dịch 439792
 (0,2) | 439795 | 439797 | 37 | đang chờ xử lý | -- đã chết
 (0,3) | 439800 | 439803 | 38 | đang chờ xử lý | -- đã chết
 (0,4) | 439804 | 439806 | 39 | đang chờ xử lý | -- đã chết
 (0,5) | 439808 | 439812 | 40 | đang chờ xử lý | -- đã chết
 (0,6) | 439814 | 439818 | 41 | đang chờ xử lý | -- đã chết
 (0,7) | 439821 |      0 | 42 | đang chờ xử lý | -- trực tiếp: xmax là 0, chưa bị xóa
 (0,8) | 439825 |      0 | 43 | đang chờ xử lý | -- trực tiếp
 (0,9) | 439830 |      0 | 44 | đang chờ xử lý | -- trực tiếp
(6 bộ dữ liệu đã chết + 3 trực tiếp hàng đã quét, 3 hàng đã trả về)

Câu chuyện đó không chỉ giới hạn ở vùng heap. Mọi chỉ mục trên bảng sẽ giữ các mục nhập lá theo thứ tự được sắp xếp và mỗi mục nhập tham chiếu đến ctid trên vùng nhớ heap. Quá trình quét theo thứ tự chỉ mục sẽ tuân theo các con trỏ đó và kiểm tra vùng nhớ heap. Công việc bị lãng phí trong quá trình quét đó khi bất kỳ mục nhập lá nào vẫn tồn tại khi bộ dữ liệu đống của nó đã chết. Về mặt khái niệm (trường hợp xấu nhất khi quá trình dọn dẹp vẫn chưa loại bỏ các con trỏ đó):

-- Chế độ xem khái niệm: các mục lá chỉ mục được truy cập theo thứ tự run_at (không phải đầu ra truy vấn thực)
 run_at (đang chờ xử lý) | thời gian | sau tra cứu vùng nhớ heap
----------------------+-------+-------------------
 2026-04-07 08:59:01 | (0,1) | đã chết — bị vứt bỏ
 2026-04-07 08:59:03 | (0,2) | đã chết
 2026-04-07 08:59:05 | (0,3) | đã chết
 2026-04-07 08:59:07 | (0,4) | đã chết
 2026-04-07 08:59:09 | (0,5) | đã chết
 2026-04-07 08:59:11 | (0,6) | đã chết
 2026-04-07 09:01:12 | (0,7) | trực tiếp
 2026-04-07 09:01:14 | (0,8) | trực tiếp
 2026-04-07 09:01:15 | (0,9) | trực tiếp
(6 đã chết chỉ mục mục tiêu + 3 trực tiếp hàng có thể truy cập được, hình dạng giống nhau như bước đi trên đống ở trên)

Ở quy mô tưởng tượng của chúng tôi, ba công việc và sáu bộ dữ liệu chết không có vấn đề gì.

Tuy nhiên, một cơ sở dữ liệu chắc chắn sẽ thất bại nếu không thể khắc phục được reclaim bộ dữ liệu chết nhanh hơn khối lượng công việc tạo ra chúng. Một cụm Postgres được điều chỉnh và cung cấp tốt có thể xử lý thông lượng hàng đợi công việc lên tới hàng chục nghìn công việc mỗi giây. Vậy điều gì gây ra hiện tượng phồng bảng?

Thông thường, điều này xảy ra khi tốc độ ghi cao — chu kỳ chèn, cập nhật và xóa hàng nhanh chóng — vượt quá tốc độ tự động chân không. Nhưng việc máy hút bụi tự động bị tụt lại phía sau không chỉ là vấn đề về thông lượng. Ngay cả khi tính năng tự động hút chân không chạy đủ thường xuyên, nó không thể loại bỏ các bộ dữ liệu chết mà giao dịch đang hoạt động vẫn có thể nhìn thấy.

Khi tính năng tự động hút chân không hoạt động kém

Có một số tình huống phổ biến khiến tính năng tự động hút chân không không hiệu quả trong việc dọn sạch các bộ dữ liệu chết bộ dữ liệu.

Một số khóa bảng nhất định có thể ngăn cản việc dọn dẹp và cấu hình tự động hút chân không không đúng cách có thể góp phần dẫn đến kết quả dưới mức tối ưu tốc độ dọn dẹp đối với các bộ giá trị đã chết.

Trong một cơ sở dữ liệu hoạt động tốt, tính năng tự động hút chân không sẽ chạy thường xuyên và dọn sạch các bộ giá trị đã chết khi chúng hiển thị với nó.

Tuy nhiên, việc dọn dẹp thông thường nhất sẽ bị chặn khi các giao dịch đang hoạt động ngăn không cho các bộ giá trị đã chết có thể lấy lại được. Postgres sẽ không loại bỏ bất kỳ bộ dữ liệu chết nào mà giao dịch đang hoạt động vẫn có thể nhìn thấy. Giao dịch cũ nhất như vậy đặt ra ngưỡng—được gọi là "chân trời MVCC". Cho đến khi giao dịch đó hoàn tất, mọi bộ dữ liệu chết mới hơn ảnh chụp nhanh của nó sẽ được giữ lại.

Một giao dịch duy nhất mất 2 phút để hoàn thành sẽ ghim đường chân trời trong đủ 2 phút.

Một loại khối lượng công việc khác tạo ra cùng một chế độ lỗi là nhiều truy vấn chồng chéo, không có truy vấn nào chạy dài riêng lẻ, giúp giữ nguyên Horizon được ghim liên tục.

Ví dụ: hãy tưởng tượng ba truy vấn phân tích, mỗi truy vấn chạy trong 40 giây, cách nhau 20 giây. Không có truy vấn riêng lẻ nào có thể kích hoạt thời gian chờ vì chạy quá lâu. Nhưng vì một giao dịch luôn hoạt động nên đường chân trời không bao giờ tiến triển và ảnh hưởng đến chân không cũng giống như một giao dịch không bao giờ kết thúc.

Điều này khó xảy ra nếu khối lượng công việc duy nhất mà cơ sở dữ liệu của bạn có là hàng đợi công việc. Nhưng bạn đang theo triết lý "chỉ sử dụng Postgres". Vì vậy, bạn có nhiều khối lượng công việc chồng chéo, mỗi khối lượng công việc có những ưu tiên riêng, phụ thuộc vào việc tránh xa nhau. Vấn đề của bạn không phải là Postgres không phù hợp với hàng đợi công việc hay nó không thể hoàn thành công việc đủ nhanh—mà là những công việc nhanh này và các bộ dữ liệu chết mà chúng tích lũy nhanh chóng không được dọn sạch đủ nhanh do các truy vấn chậm hơn, chồng chéo, chạy đồng thời khác.

Các công cụ tại chúng tôi xử lý

Trong nhiều năm và các phiên bản chính của Postgres, các công cụ mới đã được thêm vào đơn giản hóa việc duy trì hiệu suất hàng đợi.

Như đã đề cập, bạn có thể thử điều chỉnh cài đặt tự động hút chân không của Postgres, chẳng hạn như autovacuum_vacuum_cost_delayautovacuum_vacuum_cost_limit, để cải thiện tần suất và hiệu quả của hoạt động. Nhưng trong kịch bản tưởng tượng của chúng tôi, chúng tôi không muốn khắc phục thông lượng của hàng đợi công việc; đó là cách các khối lượng công việc khác ảnh hưởng tiêu cực đến nó.

Để ngăn các truy vấn chạy dài chạy quá lâu, có một số tùy chọn cấu hình hết thời gian chờ:

  • statement_timeout, được giới thiệu trong Postgres 7.3, loại bỏ mọi câu lệnh SQL riêng lẻ vượt quá quy định thời lượng.
  • idle_in_transaction_session_timeout, từ Postgres 9.6, chấm dứt các phiên không hoạt động trong một giao dịch đang mở lâu hơn thời gian đã chỉ định thời lượng.
  • transaction_timeout, từ Postgres 17.0, sẽ loại bỏ mọi giao dịch đang hoạt động hoặc không hoạt động vượt quá thời lượng đã chỉ định.

Tuy nhiên, không có giao dịch nào trong số này giải quyết được vấn đề cụ thể của chúng tôi. Chúng là những công cụ đơn giản chỉ nhắm mục tiêu thời gian thực hiện của một truy vấn duy nhất và không thể giới hạn chi phí thực hiện hoặc đồng thời. Chúng tôi cần ngăn chặn bất kỳ khối lượng công việc nào khiến đường chân trời MVCC liên tục bị ghim.

Điều cần thiết là một công cụ có thể phân biệt giữa các "loại" lưu lượng truy cập khác nhau, giúp khối lượng công việc có mức độ ưu tiên cao không bị ảnh hưởng và điều chỉnh tốc độ tiêu thụ tài nguyên của khối lượng công việc có mức độ ưu tiên thấp hơn.

Nhập lưu lượng truy cập cơ sở dữ liệu Control™

Kiểm soát giao thông là một phần của Thông tin chi tiết tiện ích mở rộng, được phát triển bởi PlanetScale và dành riêng cho PlanetScale Postgres. Hoàn hảo khi bạn cần quyền kiểm soát chi tiết về cách các truy vấn riêng lẻ hoạt động và số lượng tài nguyên mà chúng có thể tiêu thụ.

Các truy vấn được nhắm mục tiêu theo Ngân sách tài nguyên trong Kiểm soát lưu lượng truy cập được chỉ định một nhóm tài nguyên giới hạn; khi vượt quá giới hạn đó, chúng có thể bị chặn.

Giải pháp cho kịch bản tưởng tượng của chúng tôi là giới hạn tần suất chạy các truy vấn chậm hơn chồng chéo và số lượng truy vấn có thể chạy cùng một lúc. Thời gian chờ là một công cụ cùn không thể cung cấp cho chúng tôi loại kiểm soát chi tiết đó. Khi các truy vấn đó bị giới hạn, chúng ta có thể yên tâm rằng tính năng tự động hút chân không sẽ có nhiều khả năng dọn sạch các bộ dữ liệu chết ở mức chấp nhận được.

Vì giải pháp cho vấn đề của chúng ta liên quan đến việc chấm dứt một số truy vấn nhất định nên điều quan trọng là ứng dụng của chúng ta phải bao gồm logic thử lại. Sẽ không công bằng khi nói cơ sở dữ liệu chạy tốt hơn nếu nó hoạt động ít hơn. Chúng tôi đang cố gắng giảm bớt tốc độ thực hiện công việc trong khi vẫn thực hiện khối lượng như nhau.

Trong ứng dụng của chúng tôi, các truy vấn bị chặn không bị từ chối vĩnh viễn; chúng sẽ được thử lại vào thời điểm thích hợp hơn.

Xây dựng bản trình diễn

Cảm hứng cho bài đăng này đến từ một cuộc thảo luận nội bộ về sự khôn ngoan của việc đưa hàng đợi công việc vào cơ sở dữ liệu Postgres của bạn, trong đó bài đăng blog sau đây đã được chia sẻ.

Vào năm 2015, Brandur Leach đã xuất bản Postgres Job Queues & Failed By MVCC, ghi lại chế độ lỗi nghiêm trọng trong hàng đợi công việc được Postgres hỗ trợ. Bài đăng trên blog đó cũng bao gồm một băng ghế thử nghiệm để chứng minh cách một giao dịch không được tiết lộ có thể ghim chân trời MVCC và ngăn chặn việc dọn dẹp.

May mắn thay, băng ghế thử nghiệm ban đầu vẫn có sẵn tại brandur/que-degradation-test và do đó, để kiểm tra mọi thứ chúng ta đã học được cho đến nay, chúng ta có thể sử dụng nó làm nguồn cảm hứng để xác thực giải pháp.

Tạo lại vấn đề

Rất nhiều điều đã thay đổi kể từ năm 2015. Ý định của tôi là tạo lại khối lượng công việc ứng dụng tương tự với Postgres 18 và xem liệu tôi có thể tái tạo vấn đề tương tự hay không.

Băng thử nghiệm ban đầu yêu cầu Ruby, đá quý Que (v0.x) và nó đã được thử nghiệm trên Postgres 9.4. Việc chạy nguyên trạng sẽ kiểm tra thư viện có tuổi đời hàng thập kỷ trên Postgres hiện đại, chứ không phải mẫu trên Postgres hiện đại. Để tách biệt hành vi cấp SQL trong cơ sở mã mà tôi có thể hiểu được, tôi đã viết lại bài kiểm tra trong TypeScript và bằng Bun.

Tóm lại, chúng tôi đã duy trì mẫu CTE đệ quy giống như Que. Với cùng một lược đồ, tỷ lệ sản xuất, thời gian làm việc, số lượng công nhân và mô hình người chạy dài. Chạy trên cụm PlanetScale PS-5 (có giá khởi điểm là $5/tháng).

Kết quả là sự xuống cấp có thể nhìn thấy được nhưng có thể quản lý được. Trong khi thử nghiệm ban đầu đưa cơ sở dữ liệu vào vòng xoáy chết chóc trong vòng 15 phút, thì chiếc PS-5 của tôi vẫn giữ hàng đợi công nhân gần như bằng 0 trong cùng khoảng thời gian. Tuy nhiên, vẫn có sự tăng trưởng tuyến tính đáng chú ý trong các bộ dữ liệu chết, cho thấy rằng trong khoảng thời gian dài hơn, vấn đề tương tự sẽ gặp phải. Vì vậy, mặc dù sự cố ban đầu được giảm thiểu trong các phiên bản mới hơn của Postgres (một phần nhờ vào việc dọn dẹp chỉ mục cây B—xóa từ dưới lên đối với việc rời bỏ phiên bản, loại bỏ các bộ chỉ mục chết và hành vi liên quan bằng quá trình quét), vấn đề này vẫn chưa được loại bỏ.

Các nỗ lực khắc phục nó

Tiếp theo, tôi tự hỏi liệu các phiên bản Postgres mới hơn có cải thiện hiệu suất hay không; chúng ta có thể giải quyết vấn đề ban đầu không? Ở đó là hai cải tiến cụ thể có sẵn cho chúng tôi vào năm 2026 nhưng không có vào năm 2015.

  1. FOR UPDATE SKIP LOCKED thay thế hoàn toàn CTE đệ quy bằng một CTE đệ quy SELECT bỏ qua các hàng bị khóa bởi các nhân viên khác.
  2. Xử lý hàng loạt (10 công việc trên mỗi giao dịch) một lần thu thập khóa bao gồm 10 công việc thay vì 1, khấu hao chi phí quét chỉ mục.

Chúng tôi giữ nguyên mọi thứ khác: 8 công nhân, 50 công việc/giây nhà sản xuất, công việc 10 mili giây, thời gian chạy dài bắt đầu sau 45 giây. Kết quả có dạng như sau:

Metricorigin (CTE đệ quy)nâng cao (SKIP) ĐÃ KHÓA + lô)
Khóa cơ bản thời gian2-3 mili giây1,3-3,0 mili giây
Kết thúc thời gian khóa (điển hình)10-34 mili giây9-29 mili giây
Tăng đột biến nhất84,5 mili giây (ở mức 33k bộ dữ liệu chết)180 mili giây (ở mức 24k bộ dữ liệu chết)
Độ sâu hàng đợi0-100 (dao động)0 (chủ yếu)
Các bộ dữ liệu chết tại kết thúc42.40042.450
Thông lượng[[TAG_ 1451]]~89/s~50/s

The đường cong suy thoái gần như giống hệt nhau. Các bản cập nhật này không ảnh hưởng đến sự xuống cấp của MVCC vì cả hai phương pháp đều quét cùng một chỉ mục cây B và gặp phải các bộ dữ liệu chết giống nhau.

Cải tiến chính là sự khác biệt về thông lượng, nhưng điều này phản ánh thiết kế của thử nghiệm chứ không phải chiến lược khóa. Với tốc độ sản xuất 50 việc làm/giây, mỗi công nhân CTE đảm nhận công việc một cách độc lập và nhanh hơn nhà sản xuất, trong khi các công nhân theo đợt sẽ xếp hàng dài và dành thời gian ngủ quên. Cả hai phiên bản đều không chịu áp lực thực sự.

Tóm lại, hàng đợi do Postgres hỗ trợ được thiết kế cách đây một thập kỷ có thể giết chết cơ sở dữ liệu trong 15 phút giờ đây có thể tồn tại lâu hơn nhưng vấn đề ban đầu vẫn còn. Postgres hiện đại đã nâng sàn nhưng không loại bỏ trần nhà. Nếu thay vì chạy 50 công việc/giây, chúng tôi đã chạy 500 công việc/giây thì vấn đề tương tự xảy ra nhanh hơn, hiệu suất giảm và ứng dụng của bạn bị ảnh hưởng.

Khắc phục bằng Kiểm soát lưu lượng

Ngân sách tài nguyên trong Kiểm soát lưu lượng cung cấp cho chúng tôi một số đòn bẩy để quản lý số lượng tài nguyên truy vấn được nhắm mục tiêu có quyền truy cập vào:

  • Chia sẻ máy chủ và giới hạn nhóm: A phần trăm tài nguyên máy chủ và tốc độ sử dụng chúng.
  • Giới hạn mỗi truy vấn: Thời gian một truy vấn có thể chạy, được tính bằng số giây sử dụng toàn bộ máy chủ.
  • Số lượng nhân viên đồng thời tối đa: A tỷ lệ phần trăm của các quy trình công nhân có sẵn.

Ngân sách tài nguyên được định cấu hình để sử dụng một hoặc nhiều giới hạn này nhằm ngăn chặn các quy trình cụ thể khối lượng công việc tiêu tốn tài nguyên, nếu không sẽ ảnh hưởng tiêu cực đến các khối lượng công việc khác.

Các truy vấn được nhắm mục tiêu phổ biến nhất bởi siêu dữ liệu có trong thẻ SQLCommenter được thêm vào truy vấn. Trong ví dụ của chúng tôi, các truy vấn phân tích đã đặt action=analytics.

idle_in_transaction_session_timeout có thể bắt và loại bỏ giao dịch nhàn rỗi "chạy lâu" khỏi điểm chuẩn ban đầu, nên tôi đã chuyển trình kích hoạt xuống cấp sang kịch bản sản xuất thực tế hơn: nhiều truy vấn phân tích chồng chéo giúp giữ các giao dịch được mở thông qua công việc đang hoạt động — loại bạn không thể làm được chỉ cần giết thời gian chờ của phiên.

Để chứng minh tính hiệu quả của Kiểm soát giao thông trong việc hạn chế tình trạng xuống cấp này, tôi đã điều tiết Số lượng nhân viên đồng thời tối đa trong số tất cả action=analytics truy vấn cho 1 nhân viên (25% trong số đó max_worker_processes) với mục đích chỉ cho phép một truy vấn phân tích duy nhất chạy tại một thời điểm.

Để gây căng thẳng cho hệ thống đủ để tạo ra vòng xoáy chết chóc trong khoảng thời gian thử nghiệm 15 phút của chúng tôi, tôi đã tăng sản lượng lên 800 công việc/giây.

Tôi đã chạy khối lượng công việc "nâng cao" hai lần trên cùng một phiên bản EC2 trên cùng một cơ sở dữ liệu PlanetScale:

  • 800 công việc/giây
  • 3 nhân viên phân tích đồng thời chạy các truy vấn dài 120 giây, so le nhau để chúng liên tục chồng chéo lên nhau
  • 15 phút thời lượng

Kết quả đã chứng minh khả năng giải quyết vấn đề dọn dẹp lõi sự cố.

MetricKiểm soát giao thông đã bị tắtKiểm soát giao thông đã bật
Hàng đợi tồn đọng155.000 việc làm0 công việc
Khóa thời gian300ms+2ms
Các bộ dữ liệu chết tại kết thúc383.0000–23.000 (đi xe đạp)
Analytics truy vấn3 truy vấn đồng thời, chồng chéo1 truy vấn cùng một lúc, 2 truy vấn đang thử lại
Hiệu quả VACUUMĐã chặn (luôn ghim đường chân trời)Bình thường (cửa sổ ở giữa truy vấn)
Kết quảVòng xoáy tử thầnHoàn toàn ổn định

Kiểm soát giao thông có thể nhắm mục tiêu khối lượng công việc cụ thể và hạn chế sự đồng thời của chúng - điều không thể thực hiện được với việc điều chỉnh hoặc hết thời gian chờ cấu hình tự động. Các báo cáo phân tích vẫn được thực hiện trong khả năng cho phép, với 15 báo cáo được hoàn thành trong khoảng thời gian 15 phút. Phải mất nhiều thời gian hơn để hoàn thành nhiều truy vấn phân tích hơn nhưng hàng đợi vẫn ổn định trong suốt thời gian đó.

Summary

Vấn đề về bộ dữ liệu chết MVCC trong hàng đợi được Postgres hỗ trợ không phải là vấn đề còn tồn tại của năm 2015. Postgres hiện đại đã nâng ngưỡng — cải tiến B-tree và SKIP LOCKED mua khoảng trống đáng kể — nhưng cơ chế cơ bản không thay đổi. Các bộ dữ liệu chết tích lũy khi VACUUM không thể xóa chúng và VACUUM không thể xóa chúng khi các giao dịch chạy dài hoặc chồng chéo ghim chân trời MVCC.

Trong thế giới "chỉ sử dụng Postgres" nơi hàng đợi, phân tích và logic ứng dụng chia sẻ một cơ sở dữ liệu duy nhất, đây không phải là rủi ro về mặt lý thuyết. Đó là điều kiện hoạt động bình thường. Phiên bản nguy hiểm không phải là một sự cố nghiêm trọng — đó là một trạng thái cân bằng bị suy giảm một cách lặng lẽ, trong đó thời gian khóa tăng lên, công việc chậm lại và không có cảnh báo kích hoạt.

Postgres cung cấp các công cụ dựa trên thời gian chờ nhưng chúng không thể phân biệt giữa các loại khối lượng công việc hoặc giới hạn hoạt động đồng thời. Nếu bạn chạy hàng đợi cùng với các khối lượng công việc khác, điều có tác động lớn nhất bạn có thể làm là đảm bảo VACUUM có thể theo kịp. Kiểm soát giao thông khiến việc đó trở nên đơn giản.

Tác giả: tanelpoder