Phần trước đã đo SKIP LOCKED làm hàng đợi nhanh gấp 5,75 lần. Phần này đi sâu hơn: chính xác thì FOR UPDATE thấy gì sau khi chờ được khoá, cỡ lô nào cho thông lượng tốt nhất, và một lời cảnh báo phổ biến mà tôi không dựng lại được.
Sau khi chờ được khoá, PostgreSQL đọc lại dòng
Ở mức read committed, khi SELECT ... FOR UPDATE gặp một dòng đang bị khoá, nó đợi. Sau khi giao dịch kia commit, dòng đó đã đổi — và PostgreSQL đánh giá lại điều kiện WHERE trên phiên bản mới.
Ba sản phẩm, tồn kho đều bằng 5. Phiên A hạ tồn kho của "Áo" xuống 1 rồi commit. Phiên B chạy where ton >= 5 for update:
B khoá được: Quần ton=5
B khoá được: Mũ ton=5
"Áo" biến mất khỏi kết quả. Nó khớp điều kiện ở thời điểm chụp ảnh, nhưng sau khi B chờ được khoá và đọc lại, ton=1 không còn thoả >= 5 nữa.
Cơ chế này gọi là EvalPlanQual, và nó là lý do FOR UPDATE an toàn hơn SELECT thường: bạn không bao giờ khoá được một dòng rồi phát hiện nó không thoả điều kiện mình vừa kiểm tra.
Nhưng chiều ngược lại thì không đối xứng.
Đổi bố trí: "Mũ" ban đầu có ton=1 (không khớp), phiên A nâng nó lên 9 rồi commit. Phiên B vẫn chạy where ton >= 5 for update:
B khoá được: Áo ton=5
B khoá được: Quần ton=5
"Mũ" không xuất hiện, dù lúc B đọc thì nó đã có ton=9.
Lý do: EvalPlanQual chỉ kiểm lại những dòng đã có trong ảnh chụp ban đầu. Không có lần quét thứ hai để tìm dòng mới khớp. Dòng nào không lọt vào ảnh chụp thì vĩnh viễn nằm ngoài kết quả của câu lệnh đó.
Hệ quả thực tế: SELECT ... FOR UPDATE bảo đảm "mọi dòng tôi khoá đều thoả điều kiện", nhưng không bảo đảm "tôi khoá được mọi dòng thoả điều kiện". Nếu logic của bạn cần vế thứ hai, read committed không đủ — phải dùng serializable (phần 25) hoặc khoá ở mức bảng.
Cảnh báo về LIMIT không tái hiện được
Có một lời khuyên hay gặp: đừng dùng LIMIT cùng FOR UPDATE, vì các dòng bị chọn có thể bị loại sau khi đọc lại và bạn nhận về ít hơn số đã xin.
Tôi dựng đúng tình huống đó: 10 việc đang chờ, phiên A đổi 3 việc đầu sang "xong" rồi commit, phiên B xin limit 3 for update.
B nhận được id: 4, 5, 6
Đủ ba dòng. Không mất gì cả.
Kế hoạch giải thích vì sao:
Limit
-> LockRows
-> Sort
-> Seq Scan on v Filter: (tt = 'cho')
LockRows nằm dưới Limit. Limit kéo từng dòng lên từ LockRows; dòng nào bị loại sau khi đọc lại thì LockRows không trả lên, và Limit chỉ việc kéo tiếp dòng sau. Nó dừng khi đã đủ ba dòng hợp lệ, không phải khi đã xét ba dòng.
Trường hợp duy nhất nhận về ít hơn là khi hết ứng viên thật. Bảng chỉ có 5 dòng, phiên A chiếm mất 4:
xin LIMIT 3, nhận được: 5
Đó là hành vi đúng — chỉ còn một dòng hợp lệ trong toàn bảng thì không thể trả về ba.
Tôi ghi lại chuyện này vì tôi đã tin lời cảnh báo kia trước khi đo, và thiết kế phép thử để xác nhận nó. Kết quả ngược lại, và cách duy nhất biết được là chạy thử rồi đọc kế hoạch.
Cỡ lô: khác biệt 48 lần
Đây là con số quan trọng nhất cho ai đang xây hàng đợi công việc. Tám tiến trình lấy hết 20.000 việc, mỗi lần lấy một lô:
update viec set tt = 'xong', worker = 'w1'
where id in (
select id from viec where tt = 'cho' order by id
limit :co_lo for update skip locked
)
returning id;
| Cỡ lô | Thời gian | Việc/giây | Số lệnh SQL |
|---|---|---|---|
| 1 | 11,46 s | 1.745 | 20.008 |
| 5 | 2,57 s | 7.782 | 4.008 |
| 20 | 0,75 s | 26.667 | 1.008 |
| 100 | 0,24 s | 83.333 | 208 |
| 500 | 0,20 s | 100.000 | 48 |
Từ lô 1 lên lô 100: nhanh 48 lần. Sau đó lợi ích cạn dần — lô 500 chỉ hơn lô 100 được 20%.
Chi phí gần như toàn bộ nằm ở số vòng đi–về giữa ứng dụng và cơ sở dữ liệu. Lô 1 cần hơn 20.000 lệnh; lô 100 cần 208. Bản thân việc khoá và cập nhật rẻ hơn nhiều so với chi phí một vòng mạng.
Cái phải đánh đổi là độ công bằng, và nó nhỏ hơn tôi tưởng:
| Cỡ lô | Việc ít nhất | Việc nhiều nhất | Chênh |
|---|---|---|---|
| 1 | 2.494 | 2.510 | 1,0 lần |
| 100 | 2.400 | 2.600 | 1,1 lần |
| 500 | 2.000 | 3.000 | 1,5 lần |
Đánh đổi thật sự nằm ở chỗ khác: nếu một tiến trình chết giữa chừng, cả lô của nó bị treo ở trạng thái "đang làm". Lô 500 nghĩa là 500 việc kẹt cùng lúc thay vì 1. Với công việc có thể chạy lại thì đó là chuyện nhỏ; với công việc gửi email thì không.
Từ số liệu trên, lô 20 đến 100 là dải hợp lý cho phần lớn trường hợp: đã lấy được 15–48 lần thông lượng mà rủi ro vẫn nhỏ.
Hàng đợi phình: vấn đề ai cũng gặp sau vài tháng
Bảng hàng đợi có đặc điểm khó chịu: việc đã xong tích lại, việc đang chờ thì lẫn giữa chúng. Tôi dựng đúng trạng thái đó — 1.000 việc chờ nằm giữa 500.000 việc đã xong, bảng 21 MB:
| Chỉ mục | Kích thước | Lấy 100 việc |
|---|---|---|
| Không có chỉ mục riêng | 0 | 30,17 ms |
(tt, id) |
15 MB | 0,14 ms |
(id) where tt = 'cho' |
40 kB | 0,12 ms |
Không có chỉ mục riêng thì PostgreSQL đi theo khoá chính và bỏ qua từng dòng "xong" một cho tới khi gom đủ 100 dòng "chờ" — 30 ms cho một thao tác đáng lẽ mất 0,1 ms. Và con số này tăng dần theo thời gian, nên hàng đợi chạy tốt lúc mới triển khai rồi chậm dần đều mà không ai đổi gì.
Chỉ mục một phần (phần 13) là lời giải chính xác cho hình dạng dữ liệu này:
create index viec_cho on viec(id) where tt = 'cho';
40 kB so với 15 MB — nhỏ hơn 375 lần mà nhanh ngang, vì nó chỉ chứa đúng những dòng còn chờ. Việc nào xong thì rơi khỏi chỉ mục luôn, nên kích thước của nó phản ánh độ dài hàng đợi chứ không phải tổng lịch sử.
Bốn biến thể, dùng khi nào
| Cách viết | Khi dòng đang bị khoá | Dùng cho |
|---|---|---|
for update |
đợi | Cập nhật một dòng cụ thể, biết trước là dòng nào |
for update skip locked |
bỏ qua, lấy dòng khác | Hàng đợi công việc |
for update nowait |
báo lỗi ngay | Giao diện muốn nói "đang có người sửa" |
for share |
đợi (nếu bên kia là for update) |
Đọc mà cần chắc dòng không đổi |
Thêm of ten_bang khi câu lệnh có JOIN và bạn chỉ muốn khoá một bảng:
select d.*, k.ten
from don_hang d join khach k on k.id = d.kh_id
where d.tt = 'cho'
for update of d;
Không có of d, PostgreSQL khoá cả dòng trong khach — và bảng khách hàng thường là bảng bị nhiều tiến trình đụng tới nhất. Đây là nguồn nghẽn cổ chai hay bị bỏ sót vì câu lệnh trông như chỉ đọc bảng thứ hai.
Mẫu hàng đợi hoàn chỉnh
create table viec(
id bigserial primary key,
tt text not null default 'cho',
worker text,
nhan_luc timestamptz,
du_lieu jsonb
);
create index viec_cho on viec(id) where tt = 'cho';
-- lấy việc
update viec
set tt = 'dang_lam', worker = :worker, nhan_luc = now()
where id in (
select id from viec where tt = 'cho' order by id
limit 50 for update skip locked
)
returning id, du_lieu;
Ba điểm đáng chú ý trong câu này:
Gộp chọn và cập nhật vào một lệnh. Viết tách thành SELECT rồi UPDATE là hai vòng đi–về, và giữa hai vòng đó bạn phải giữ giao dịch mở.
RETURNING trả về dữ liệu luôn, nên không cần đọc lại. Lưu ý thứ tự trả về không theo ORDER BY bên trong — UPDATE ... RETURNING trả theo thứ tự nó cập nhật, và tôi đã đo thấy nó ra 4, 6, 5. Nếu ứng dụng cần thứ tự thì phải tự sắp xếp lại.
nhan_luc để dọn việc treo. Tiến trình chết thì việc kẹt ở dang_lam vĩnh viễn. Một câu chạy định kỳ trả chúng về hàng chờ:
update viec set tt = 'cho', worker = null
where tt = 'dang_lam' and nhan_luc < now() - interval '10 minutes';
Đây chính là cơ chế mà ContentPlanService.claim của blog này dùng, và cũng là chỗ đã vấp một lỗi đáng nhớ: báo hỏng một việc mà không đưa nó ra khỏi hàng chờ thì nó quay lại đầu hàng ở lô sau và chiếm chỗ mãi.
Thử ba mươi giây
Nếu bạn đang có bảng hàng đợi, kiểm hai thứ:
-- 1. tỷ lệ việc đã xong còn nằm lại
select tt, count(*), pg_size_pretty(pg_total_relation_size('viec')) as bang
from viec group by tt order by count(*) desc;
-- 2. có chỉ mục một phần chưa
select indexdef from pg_indexes where tablename = 'viec';
Nếu số việc đã xong lớn hơn số việc chờ vài trăm lần mà chỉ mục không có mệnh đề WHERE, bạn đang trả 30 ms cho mỗi lần lấy việc thay vì 0,12 ms — và con số đó còn tăng.
Phần sau đo advisory lock: loại khoá không gắn với dòng nào, dùng khi thứ cần bảo vệ không nằm trong một bảng.