Scale out queue worker — database driver trên nhiều VM
Câu hỏi phỏng vấn: "Queue bị dồn, bạn thêm worker. Chuyện gì có thể hỏng?" Trả lời "thêm process trong Supervisor" là mới đúng 10%. Phần còn lại là: job chạy 2 lần, job chạy sai thứ tự, database bị chính worker đè chết, deploy xong một VM vẫn chạy code cũ.
Bối cảnh bài này (theo lựa chọn của bạn): Laravel 12 +
QUEUE_CONNECTION=database trên PostgreSQL, worker chạy bằng
queue:work dưới Supervisor trên nhiều VM, tải cỡ vài chục nghìn job/giờ.
Mọi số liệu tải (30k job/giờ, 3 VM, 1.5s/job…) là ví dụ minh hoạ.
Mọi câu SQL và output trong bài là chạy thật trên PostgreSQL 18.4
(my-test-pgdb), trên bảng tmp_jobs có cùng schema với migration
jobs của Laravel. Code Laravel trích từ vendor bản 12.64.0.
Từ điển nhanh
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| scale out | thêm máy/process chạy song song (khác scale up = máy to hơn) |
| at-least-once delivery | job chắc chắn chạy ít nhất 1 lần, nhưng có thể hơn 1 lần |
| idempotent | chạy lại nhiều lần vẫn ra cùng kết quả như chạy 1 lần |
FOR UPDATE SKIP LOCKED | khoá dòng để sửa; dòng nào đang bị người khác khoá thì bỏ qua, không chờ |
visibility timeout (retry_after) | job đã bị lấy mà quá X giây chưa xoá thì coi như worker đã chết, cho worker khác lấy lại |
| dead tuple | bản ghi đã DELETE/UPDATE nhưng vẫn nằm trên đĩa, chờ VACUUM dọn |
| bloat | bảng/index phình to vì chứa nhiều dead tuple |
| poison job | job lỗi mỗi lần chạy, cứ thử lại mãi |
| head-of-line blocking | việc chậm ở đầu hàng làm việc nhanh phía sau phải chờ |
| Little's law | số việc đang xử lý = tốc độ đến × thời gian xử lý mỗi việc (L = λ × W) |
1. Nó là gì / chạy thế nào
1.1. Bức tranh tổng
flowchart LR
subgraph WEB["Web (PHP-FPM)"]
D["dispatch(new SendInvoice)"]
end
subgraph PG["PostgreSQL"]
J[("bảng jobs")]
B[("bảng nghiệp vụ: orders...")]
end
subgraph VM1["VM worker 1 — Supervisor"]
W1["queue:work x8"]
end
subgraph VM2["VM worker 2 — Supervisor"]
W2["queue:work x8"]
end
subgraph VM3["VM worker 3 — Supervisor"]
W3["queue:work x8"]
end
R[("Redis — cache dùng chung: lock, restart signal")]
D -->|"INSERT"| J
W1 -->|"poll: SELECT ... SKIP LOCKED"| J
W2 --> J
W3 --> J
W1 -.->|"đọc/ghi"| B
W1 -.->|"lock, queue:restart"| R
W2 -.-> R
W3 -.-> R
Không có "broker" nào đẩy job tới worker. Mỗi worker tự hỏi DB liên tục (polling): "còn job nào không?". Scale out = thêm người hỏi. Mọi người hỏi cùng một bảng — đây là gốc của hầu hết vấn đề phía dưới.
1.2. Một vòng lấy job — đọc từ code Laravel thật
// vendor/laravel/framework/src/Illuminate/Queue/DatabaseQueue.php (12.64.0)
public function pop($queue = null)
{
return $this->database->transaction(function () use ($queue, &$jobRecord) {
if ($jobRecord = $this->getNextAvailableJob($queue)) {
return $this->marshalJob($queue, $jobRecord); // UPDATE reserved_at, attempts
}
});
}
protected function getLockForPopping()
{
// pgsql >= 9.5, mysql >= 8.0.1, mariadb >= 10.6 ...
return 'FOR UPDATE SKIP LOCKED';
// DB cũ hơn: return true; => FOR UPDATE thường
}
protected function isReservedButExpired($query)
{
$expiration = Carbon::now()->subSeconds($this->retryAfter)->getTimestamp();
$query->orWhere(fn ($q) => $q->where('reserved_at', '<=', $expiration));
}
Câu SQL Laravel sinh ra (in bằng toSql() trong container laravel-app):
select * from "jobs"
where "queue" = ?
and (("reserved_at" is null and "available_at" <= ?) -- job mới, đã tới giờ chạy
or ("reserved_at" <= ?)) -- job bị lấy mà quá retry_after
order by "id" asc limit 1 FOR UPDATE SKIP LOCKED
sequenceDiagram
participant W as Worker (queue:work)
participant DB as PostgreSQL
loop mỗi vòng
W->>DB: BEGIN
W->>DB: SELECT ... LIMIT 1 FOR UPDATE SKIP LOCKED
W->>DB: UPDATE jobs SET reserved_at = giờ PHP, attempts + 1
W->>DB: COMMIT (khoá dòng chỉ sống vài ms)
Note over W: handle() chạy NGOÀI transaction.
Lúc này thứ bảo vệ job chỉ còn reserved_at + retry_after
alt thành công
W->>DB: BEGIN, SELECT id FOR UPDATE, DELETE, COMMIT
else exception
W->>DB: DELETE + INSERT lại (release, available_at = now + backoff)
end
end
Điểm mấu chốt để trả lời phỏng vấn: row lock chỉ giữ trong lúc lấy job (vài ms). Trong suốt lúc job chạy, không có lock nào cả — chỉ có cột
reserved_at. Worker khác bỏ qua job đó vì điều kiện WHERE, và sẽ lấy lại nó ngay khireserved_atcũ hơnretry_aftergiây — dù worker đầu vẫn đang chạy.
1.3. Verify: 3 worker cùng lấy job một lúc
3 session song song, mỗi session pop rồi giữ transaction 2 giây (cố tình giữ lâu để thấy rõ SKIP LOCKED):
----- worker A ----- ----- worker B ----- ----- worker C -----
id | attempts | luc id | attempts | luc id | attempts | luc
----+----------+------------- ----+----------+------------- ----+----------+-------------
2 | 0 | 10:44:10.669 1 | 0 | 10:44:10.651 3 | 0 | 10:44:10.671
Time: 11.494 ms Time: 15.623 ms Time: 14.029 ms
Ba worker lấy 3 job khác nhau trong cùng 20 ms, không ai chờ ai. Để ý worker A lấy job 2 còn B lấy job 1 — thứ tự FIFO chỉ đúng ở lúc lấy, không đúng ở lúc chạy xong (xem bẫy 7).
1.4. Tính cần bao nhiêu worker — Little's law
Số worker bận trung bình L = λ × W
λ = 30.000 job/giờ ≈ 8,3 job/s (ví dụ minh hoạ)
W = 1,5 s/job (trung bình handle()) (ví dụ minh hoạ)
L = 8,3 × 1,5 ≈ 12,5 worker lúc nào cũng bận
Muốn worker bận ~60% để còn chỗ hấp thụ đỉnh nhỏ: 12,5 / 0,6 ≈ 21 → chọn 24 = 3 VM × 8
Flash sale tải x5: λ ≈ 42 job/s → L ≈ 63 worker bận → cần ~100 process
Con số 100 process nghe đơn giản, nhưng 100 process = 100 connection DB + 100 người poll cùng một bảng. Mục 3 cho thấy vì sao con số này là nơi mọi thứ bắt đầu hỏng.
2. Lợi ích
- Throughput tăng gần tuyến tính khi job chủ yếu chờ I/O (gọi API, gửi mail, dựng PDF) — tức là khi chỗ nghẽn nằm ở thời gian chờ, không nằm ở DB.
- Chịu lỗi tốt hơn: một VM chết, 2 VM còn lại vẫn chạy. Job đang chạy dở trên
VM chết sẽ được lấy lại sau
retry_after. - Tách tầng: worker không ăn CPU/RAM của web. Deploy, restart, OOM ở worker không làm web 502.
- Riêng database driver: không cần thêm Redis/SQS. Và nếu queue dùng
cùng connection với dữ liệu nghiệp vụ,
dispatch()trongDB::transaction()chỉ là một câu INSERT trong transaction đang mở — rollback thì job cũng biến mất. Đây là transactional outbox (ghi việc cần làm cùng transaction với dữ liệu) miễn phí, Redis không cho được.
3. Tác hại / hạn chế — danh sách bẫy đầy đủ
Mỗi bẫy ghi: vì sao xảy ra → bằng chứng → cách chặn.
Bẫy 1. retry_after ngắn hơn thời gian chạy job → job chạy 2 lần
Verify: worker A đã reserve job 1 cách đây 100 giây và vẫn đang chạy. Worker B pop
với retry_after = 90:
-- worker B pop như bình thường
id | attempts | luc
----+----------+--------------
1 | 1 | 10:44:16.652 <- B lấy lại đúng job A đang chạy
(1 row)
UPDATE 1
id | attempts | giay_tu_luc_reserve
----+----------+---------------------
1 | 2 | 0
-- worker A xong trước, deleteReserved(): find(id) FOR UPDATE rồi DELETE
DELETE 1
-- worker B xong sau: find(id) không thấy -> lặng lẽ bỏ qua, KHÔNG báo lỗi
id
----
(0 rows)
Cả hai worker đều chạy xong handle(): khách nhận 2 email, bị trừ tiền 2 lần.
Không có log lỗi nào, vì deleteReserved() không tìm thấy dòng thì im lặng.
Ba con số phải xếp đúng thứ tự:
flowchart LR A["job p99 duration
vd 60s"] -->|"nhỏ hơn"| B["--timeout
vd 120s"] B -->|"nhỏ hơn vài giây"| C["retry_after
vd 180s"] B -->|"nhỏ hơn"| E["Supervisor stopwaitsecs
vd 180s"]
--timeout<retry_after: tài liệu Laravel nói rõ — worker bị kill trước khi job được cho người khác lấy lại.retry_afterđặt theo connection, không theo job. Có 1 job export chạy 20 phút thì đừng kéoretry_aftercủa cả hệ thống lên 25 phút (worker chết thật thì job thường cũng phải chờ 25 phút mới được chạy lại). Tạo connection thứ haidatabase-long+ queue riêng + pool worker riêng.
Bẫy 2. Worker bị SIGKILL giữa chừng → job treo rồi chạy lại
Nguồn SIGKILL hay gặp: Supervisor stopwaitsecs quá nhỏ lúc deploy (mặc định
Supervisor chỉ chờ 10 giây), OOM killer, VM bị reboot. Job đã reserved_at nhưng
không ai xoá → nằm im tới retry_after rồi chạy lại từ đầu. Nếu job đã làm được nửa
việc (đã gọi API thanh toán, chưa ghi DB) thì lần chạy lại sẽ làm nửa đầu lần nữa.
Chặn: stopwaitsecs > --timeout; stopasgroup=true,
killasgroup=true; và quan trọng nhất — job phải idempotent
(xem chống double-submit và
idempotency key). Queue chỉ hứa at-least-once,
không bao giờ hứa exactly-once.
Bẫy 3. Đồng hồ các VM lệch nhau
Đọc code: reserved_at được ghi bằng Carbon::now() của PHP trên VM
worker (DatabaseJobRecord::touch() → InteractsWithTime::currentTime()),
và mốc hết hạn cũng tính bằng giờ PHP của VM đang pop. Không dùng now() của DB.
VM1 (giờ đúng) reserve job lúc 10:00:00, reserved_at = 10:00:00
VM2 (nhanh 40s) lúc giờ thật 10:00:55, VM2 tưởng là 10:01:35
expiration = 10:01:35 - 90s = 10:00:05 >= reserved_at
=> VM2 lấy lại job sau 55s thay vì 90s
Chặn: NTP (chrony) trên mọi VM, và cảnh báo khi lệch > 1s
(node_timex_offset_seconds của node-exporter). Chừa biên
retry_after rộng hơn độ lệch tối đa có thể có.
Bẫy 4. Không có SKIP LOCKED → worker xếp hàng chờ nhau
Laravel chỉ dùng SKIP LOCKED khi nhận ra version DB đủ mới; nếu không (MySQL
< 8.0.1, MariaDB < 10.6, hoặc proxy che mất version) nó rơi về FOR UPDATE thường.
Verify trên PG, worker B vào sau A 0,3 giây:
----- worker B (vào sau 0.3s) -----
id | attempts | luc
----+----------+--------------
2 | 0 | 10:44:16.242
Time: 1867.954 ms (00:01.868) <- chờ A commit xong mới có kết quả
Kết quả vẫn đúng (PostgreSQL kiểm tra lại dòng 1 sau khi A commit, thấy
reserved_at đã có giá trị nên bỏ qua và lấy dòng 2), nhưng B phải chờ.
Thêm 50 worker = 50 người xếp một hàng: throughput của cả hệ thống ≈ 1 worker.
Bẫy 5. Database thành nút cổ chai — chính worker đè DB
Mỗi job trong database driver tốn ít nhất: 1 INSERT (dispatch) + 1 SELECT FOR UPDATE + 1 UPDATE + 1 SELECT FOR UPDATE + 1 DELETE, và 3 lần commit (mỗi commit là 1 lần fsync WAL). Chưa kể việc job tự làm với DB.
Verify bằng pgbench: mỗi transaction là đúng 1 vòng đời job như
DatabaseQueue (pop SKIP LOCKED + UPDATE reserved, rồi SELECT FOR UPDATE + DELETE),
không có việc gì trong handle() — tức là chỉ đo phần chi phí của riêng
cái queue. Bảng 600k job, mỗi mức chạy 15 giây, 2 vòng.
| Số worker (client) | Vòng 1: job/s | Vòng 1: latency | Vòng 2: job/s | Vòng 2: latency |
|---|---|---|---|---|
| 1 | 34 | 29.7 ms | 60 | 16.7 ms |
| 4 | 134 | 29.9 ms | 152 | 26.3 ms |
| 8 | 423 | 18.9 ms | 188 | 42.5 ms |
| 16 | 434 | 36.8 ms | 400 | 40.0 ms |
| 32 | 273 | 117.2 ms | 589 | 54.3 ms |
| 64 | 487 | 131.5 ms | 794 | 80.6 ms |
Đọc số này thế nào: máy test chỉ có 4 core, 7 GB RAM, và đang chạy nhiều container khác (load average ~4.6 lúc đo), nên hai vòng lệch nhau nhiều — đừng trích số tuyệt đối. Xu hướng thì rõ và lặp lại ở cả 2 vòng: từ 1 lên 64 worker, số job/s chỉ tăng ~13–15 lần (không phải 64 lần), còn thời gian DB cho mỗi job tăng 5 lần. Từ khoảng 16 worker trở lên, thêm worker chủ yếu là thêm hàng chờ ở DB. Trên production với
handle()cũng đọc ghi DB, điểm gãy đến sớm hơn.
Thêm vào đó:
- Connection: mỗi process
queue:workgiữ 1 connection suốt đời. 100 worker + PHP-FPM của web vượtmax_connections = 100(giá trị của server test) rất nhanh. Verify, mở 110 connection vào server test:
Lỗi này rơi vào bất kỳ ai xin connection lúc đó — thường là request checkout của web, không phải worker.pgbench: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: sorry, too many clients already pgbench: error: could not create connection for client 108 - Polling khi rảnh:
--sleep=3với 100 worker = ~33 câu pop/giây ngay cả khi hàng đợi trống. Nhỏ, nhưng không bao giờ về 0. - Bảng
jobsnằm cùng DB vớiorders: một đợt dồn job làm chậm cả checkout.
Bẫy 6. Bảng jobs bị bloat — dead tuple dồn ở đầu hàng đợi
Bảng jobs là bảng "INSERT rồi DELETE ngay" — kiểu tệ nhất với MVCC
(PostgreSQL giữ bản cũ của dòng cho transaction khác đọc, dọn sau). Job đã xoá để lại
dead tuple đúng ở vùng id nhỏ — đúng chỗ câu pop ORDER BY id LIMIT 1 đọc
đầu tiên.
Verify: bảng 200k job, xoá 100k job đầu (giả lập đã xử lý xong, autovacuum chưa kịp
chạy), rồi EXPLAIN (ANALYZE, BUFFERS) câu pop:
dead tuple: 100000
-> Index Scan using tmp_jobs_bench_pkey on tmp_jobs_bench (actual rows=1.00 loops=1)
Buffers: shared hit=7692
Execution Time: 34.039 ms
--- sau VACUUM:
-> Index Scan using tmp_jobs_bench_pkey on tmp_jobs_bench (actual rows=1.00 loops=1)
Buffers: shared hit=4
Execution Time: 0.262 ms
Để lấy 1 job, PostgreSQL phải đọc 7.692 page (~60 MB) đi qua 100k dòng đã chết. Sau VACUUM: 4 page, nhanh hơn ~130 lần. Và mỗi worker trả chi phí này ở mỗi lần pop.
Tệ hơn: autovacuum có thể không chạy. Mặc định nó chỉ chạy khi dead tuple >
50 + 0.2 × số dòng (autovacuum_vacuum_threshold = 50,
autovacuum_vacuum_scale_factor = 0.2 trên server test). Sau bài pgbench ở bẫy 5
(bảng 600k dòng, ~3 phút):
n_dead_tup | n_tup_del | autovacuum_count
------------+-----------+------------------
111575 | 59555 | 0
111k dead tuple mà autovacuum chưa chạy lần nào — ngưỡng là 50 + 0.2 × 600k ≈ 120k.
Hàng đợi càng dồn (lúc cần nhanh nhất), bảng càng to, ngưỡng càng cao, autovacuum càng
đến muộn. Chặn: đặt autovacuum riêng cho bảng jobs (case 4), và tìm diệt
transaction mở lâu — nó chặn VACUUM dọn dead tuple.
Bẫy 6b. Nhiều delayed job nằm ở đầu bảng
Job lỗi được release() với backoff, hoặc job ->delay(now()->addHour()), vẫn nằm
trong bảng với id nhỏ và available_at ở tương lai. Câu pop duyệt theo
id tăng dần nên phải bước qua tất cả chúng. Verify với 100k delayed job ở đầu bảng
(đã VACUUM, nên không phải do dead tuple):
-> Index Scan using tmp_jobs_bench_pkey on tmp_jobs_bench (actual rows=1.00 loops=1)
Rows Removed by Filter: 100000
Buffers: shared hit=7373 read=321 written=111
Execution Time: 100.011 ms
100 ms cho mỗi lần pop, VACUUM không giúp gì. Kịch bản thật: đối tác sập 2 tiếng, hàng
chục nghìn job retry với backoff dài → mọi worker chậm theo, kể cả worker của queue khác
cùng bảng. Chặn: đừng dùng queue làm lịch hẹn giờ dài — lưu "việc cần làm lúc X"
vào bảng nghiệp vụ, scheduler dispatch khi tới giờ; giới hạn --tries/retryUntil()
để job lỗi sang failed_jobs thay vì nằm mãi trong jobs.
Bẫy 7. Mất thứ tự
Job lấy theo id tăng dần, nhưng chạy song song thì xong không theo thứ tự.
OrderPaid (id 100) và OrderCancelled (id 101) của cùng một đơn chạy trên
2 VM: nếu 101 xong trước, trạng thái cuối có thể là "paid". Thêm retry/backoff thì
thứ tự còn loạn hơn: job lỗi được INSERT lại với id mới, đứng sau.
Chặn: đừng tin thứ tự queue. Cho job kiểm tra trạng thái hiện tại (state
machine, cột version — xem optimistic
locking), hoặc khoá theo tài nguyên với WithoutOverlapping($orderId).
Bẫy 8. Lock/unique/rate limit dùng cache riêng từng VM → vô dụng
WithoutOverlapping, ShouldBeUnique, RateLimited,
Schedule::...->onOneServer() và cả queue:restart đều dựa vào
cache store. queue:restart chỉ là ghi key
illuminate:queue:restart vào cache (đọc từ RestartCommand.php), worker so key đó
giữa các job.
| Tính năng | Nếu CACHE_STORE=file trên mỗi VM |
|---|---|
WithoutOverlapping($id) | VM1 và VM2 mỗi bên giữ lock riêng → 2 job cùng order chạy song song |
ShouldBeUnique | chặn trùng chỉ trong phạm vi 1 VM |
RateLimited (vd 10 req/s tới API đối tác) | 3 VM → thực tế 30 req/s → bị 429 |
queue:restart chạy ở VM1 | VM2, VM3 không nhận → chạy code cũ sau deploy |
onOneServer() | task định kỳ chạy trên mọi VM có cron |
Chặn: cache dùng chung có atomic lock (Redis, hoặc database store).
Server test laravel-app đang dùng cache.default = redis — đúng.
Bẫy 9. Deploy: code cũ và mới cùng chạy
- Worker chỉ đọc code lúc khởi động. Quên
queue:restart= chạy code cũ mãi mãi. queue:restartkhông kill ngay — worker xong job hiện tại rồi mới thoát. Trong khoảng đó, web đã là code mới và dispatch payload mới; worker cũ có thể nhận payload có field lạ, hoặc class không tồn tại.- Rolling deploy qua 3 VM → có vài phút 2 phiên bản worker chạy song song.
Chặn: đổi constructor của job theo kiểu tương thích 2 chiều (thêm field có default, không đổi tên field, không xoá class job cũ ngay — chờ queue rút hết). Xem thêm chiến lược deploy.
Bẫy 10. Process sống lâu → rò rỉ bộ nhớ, state dính giữa các job
queue:work boot framework một lần rồi chạy hàng nghìn job. Static property,
singleton giữ dữ liệu của job trước, collection tích dần — giống vấn đề của Octane
(tối ưu Laravel). Chặn:
--max-jobs=500 --max-time=3600 để process tự thoát, Supervisor bật lại.
Bẫy 11. Đẩy tải xuống hệ thống phía sau
Thêm worker không làm API đối tác, SMTP hay DB nhanh hơn. 24 → 96 worker gọi API
thanh toán = gấp 4 lần request → 429/timeout → job lỗi → retry → càng nhiều request
(retry storm). Chặn: RateLimited theo đối tác, backoff tăng dần,
circuit breaker (ngắt tạm khi đối tác lỗi liên tục).
Bẫy 12. Ưu tiên queue làm queue thấp bị bỏ đói
--queue=high,default: worker chỉ nhìn default khi high trống. Đỉnh tải
high kéo dài 1 tiếng → default đứng 1 tiếng. Chặn: để một pool worker
riêng chỉ chạy default, không chỉ dựa vào thứ tự ưu tiên.
Bẫy 13. Job cần file trên đĩa local, hoặc cron chạy trên mọi VM
- Web upload file vào
storage/appcủa VM web, job chạy ở VM worker → "file not found". Dùng S3/MinIO hoặc truyền nội dung thay vì đường dẫn. schedule:runcài cron trên cả 3 VM worker → báo cáo gửi 3 lần. Chỉ 1 VM chạy scheduler, hoặc->onOneServer()(cần cache dùng chung — bẫy 8).
4. Trade-off
4.1. Database driver hay đổi sang Redis / SQS?
| Tiêu chí | database (PostgreSQL) | Redis + Horizon | SQS / RabbitMQ |
|---|---|---|---|
| Hạ tầng thêm | không | Redis (cần persistence + HA) | dịch vụ managed / cluster riêng |
| Tải phù hợp | vài chục job/s là thoải mái; hàng trăm là lúc DB bắt đầu đau (bẫy 5) | hàng nghìn job/s | gần như không giới hạn |
| Enqueue cùng transaction nghiệp vụ | có (cùng connection) | không → cần after_commit | không |
| Ảnh hưởng DB chính | có: ghi, bloat, connection | không | không |
| Dashboard, auto-balance worker | tự làm (SQL mục 5) | Horizon có sẵn | CloudWatch / UI broker |
| Mất job khi hạ tầng sập | không (WAL) | có thể, tuỳ AOF/RDB | không |
Tiêu chí đổi driver (chọn cái nào đến trước): phần tải ghi của bảng
jobs chiếm phần lớn WAL/CPU của DB; bảng jobs bloat nhanh hơn autovacuum
dọn; hoặc cần > ~100 job/s ổn định. Dưới mức đó, database driver là lựa chọn
đúng, không phải lựa chọn "tạm" — ít thành phần hơn là ít chỗ hỏng hơn.
4.2. Thêm process trên VM có sẵn hay thêm VM?
Tăng numprocs (scale up) | Thêm VM (scale out) | |
|---|---|---|
| Nhanh | sửa 1 dòng, supervisorctl update | dựng VM, deploy code, config |
| Giới hạn | RAM (mỗi worker ~50–150 MB), CPU nếu job nặng CPU | DB connection, cache dùng chung |
| Chịu lỗi | không tăng | tăng |
| Bẫy mới | ít | bẫy 3, 8, 9, 13 |
4.3. Khi nào KHÔNG nên thêm worker
- Chỗ nghẽn ở DB hoặc API đối tác: thêm worker làm tệ hơn (bẫy 5, 11). Sửa job trước.
- Job chậm vì N+1 hay thiếu index: làm job nhanh gấp 5 rẻ hơn thêm gấp 5 worker.
- Job cần tuần tự tuyệt đối (ví dụ sổ cái): 1 worker cho queue đó, hoặc khoá theo khoá nghiệp vụ.
5. Khó khăn khi vận hành thực tế
5.1. Đo cái gì — scale theo tuổi job, không theo CPU
CPU worker thấp không có nghĩa là ổn (worker có thể đang chờ API). Số đo đúng là
job đang chờ lâu nhất bao nhiêu giây. Câu SQL này đã chạy thật trên
tmp_jobs với dữ liệu trộn đủ trạng thái:
SELECT queue,
count(*) FILTER (WHERE reserved_at IS NULL AND available_at <= extract(epoch FROM now())) AS dang_cho,
count(*) FILTER (WHERE reserved_at IS NOT NULL) AS dang_chay,
count(*) FILTER (WHERE reserved_at IS NULL AND available_at > extract(epoch FROM now())) AS delayed,
max(extract(epoch FROM now())::int - available_at)
FILTER (WHERE reserved_at IS NULL AND available_at <= extract(epoch FROM now())) AS tuoi_job_cu_nhat_s,
count(*) FILTER (WHERE reserved_at <= extract(epoch FROM now())::int - 90) AS qua_retry_after
FROM jobs
GROUP BY queue ORDER BY queue;
queue | dang_cho | dang_chay | delayed | tuoi_job_cu_nhat_s | qua_retry_after
---------+----------+-----------+---------+--------------------+-----------------
default | 5 | 3 | 3 | 300 | 1
emails | 2 | 0 | 0 | 5 | 0
| Metric | Cảnh báo khi (ví dụ minh hoạ) | Nghĩa là |
|---|---|---|
tuoi_job_cu_nhat_s | > 120s trong 5 phút | thiếu worker, hoặc worker đang kẹt |
qua_retry_after | > 0 | worker chết giữa chừng, hoặc job chạy lâu hơn retry_after → sắp chạy trùng |
delayed tăng đều | tăng liên tục 30 phút | job lỗi và retry mãi (poison job, đối tác sập) |
số dòng failed_jobs mới/phút | > mức nền x3 | lỗi code mới deploy |
n_dead_tup của jobs | > 10× số dòng sống | autovacuum không theo kịp (bẫy 6) |
connection theo application_name | > 80% max_connections | sắp "too many clients" |
Có sẵn Prometheus + postgres-exporter (xem bộ công cụ vận hành): đưa câu SQL trên thành custom query của exporter.
5.2. Supervisor mỗi VM
; /etc/supervisor/conf.d/laravel-worker.conf — ví dụ minh hoạ
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work database --queue=default --sleep=3 --tries=3 --timeout=120 --max-jobs=500 --max-time=3600
numprocs=8
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stopwaitsecs=180 ; > --timeout, nếu không deploy sẽ SIGKILL job đang chạy
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/laravel-worker.log
[program:laravel-worker-long] ; pool riêng cho job dài, connection có retry_after lớn
command=php /var/www/app/artisan queue:work database-long --queue=exports --timeout=1500 --max-jobs=50
numprocs=2
stopwaitsecs=1600
5.3. Lúc 3h sáng
- Log worker nằm rải trên 3 VM: phải gom log (Loki/ELK) và log kèm
job id,hostname,attempts. Không cóhostnamethì không biết VM nào chạy trùng. - "Job chạy 2 lần" không để lại exception nào (bẫy 1). Chỉ thấy được nếu log
attempts > 1ở đầuhandle(). - Chi phí con người: mỗi VM thêm = thêm một chỗ phải deploy, NTP, cấu hình Supervisor giống nhau. Quản lý bằng Ansible hoặc image dựng sẵn, đừng sửa tay.
6. Case thực tế và cách xử lý
Case 1. Flash sale: tăng worker từ 24 lên 96, checkout chậm theo
Tình huống (minh hoạ): backlog 200k job, tăng numprocs lên 32/VM. 10 phút sau
web báo "too many clients", checkout p95 từ 300 ms lên 4 s.
- Hạ ngay
numprocsvề mức cũ (supervisorctl update) — cứu checkout trước, backlog chờ được. - Xem
pg_stat_activitynhóm theoapplication_namevàwait_event: connection hết hay đang chờ lock/IO? - Tách job ít quan trọng (email marketing) sang queue riêng, ít worker; giữ worker cho queue đơn hàng.
- Lâu dài: PgBouncer trước DB, đặt trần worker theo số connection còn trống, và đánh giá đổi driver (mục 4.1).
Case 2. Khách nhận email hoá đơn 2 lần sau khi thêm bước dựng PDF
Tình huống: job SendInvoice trước chạy 5s, thêm PDF thành 95–110s. retry_after
vẫn là 90 (mặc định trong config/queue.php).
- Xác nhận: log có
attempts=2nhưng không có exception ở lần 1; metricqua_retry_after > 0. - Chữa cháy: chuyển job sang connection
database-long(retry_after=300,--timeout=240). - Sửa gốc: tách thành 2 job — dựng PDF, rồi gửi mail; job gửi mail đánh dấu
invoice_sent_atvà kiểm tra ở đầuhandle()(idempotent). - Thêm cảnh báo: p99 duration của từng loại job > 50%
retry_after.
Case 3. Deploy xong, 1 VM vẫn chạy code cũ
Tình huống: VM mới dựng từ template cũ có CACHE_STORE=file. Script deploy chạy
queue:restart ở VM1.
- Kiểm tra:
php artisan tinker --execute 'echo cache()->get("illuminate:queue:restart");'trên từng VM — VM lỗi trả khác các VM còn lại. - Chữa cháy:
supervisorctl restart laravel-worker:*trên VM đó. - Sửa gốc: đưa
.envvào config management; healthcheck deploy so version code của worker (ghi version vào log lúc worker khởi động).
Case 4. Bảng jobs 1 GB dù chỉ còn vài chục job
- Kiểm tra
n_dead_tup,last_autovacuumtrongpg_stat_user_tables. - Tìm transaction mở lâu (
pg_stat_activity,xact_startcũ) — nó chặn VACUUM dọn dead tuple, tăng worker cũng không cứu được. - Cho bảng
jobsautovacuum riêng, chạy dày hơn:ALTER TABLE jobs SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 1000);(cú phápALTER TABLE ... SET (autovacuum_...)đã dùng trong thí nghiệm; con số là ví dụ). - Đã phình to thì
VACUUMthường không trả dung lượng cho OS; cầnVACUUM FULL(khoá bảng — dừng worker) hoặcpg_repack.
7. Câu hỏi phụ interviewer hay hỏi tiếp
Q: Sao không dùng FOR UPDATE thường?
A: Worker thứ hai phải chờ worker đầu commit (đo được 1,87 s trong thí nghiệm). Mọi worker
xếp một hàng, thêm worker không tăng throughput. SKIP LOCKED cho worker bỏ qua dòng
đang bị khoá và lấy dòng kế.
Q: Làm sao đảm bảo job chỉ chạy đúng 1 lần?
A: Không đảm bảo được bằng queue. Queue là at-least-once. Exactly-once là do
job idempotent: unique constraint, cột trạng thái, idempotency key khi gọi API đối tác.
Q: retry_after, --timeout, stopwaitsecs khác nhau thế nào?
A: retry_after — sau bao lâu job bị coi là mồ côi và cho người khác lấy.
--timeout — worker tự giết job chạy quá lâu. stopwaitsecs — Supervisor chờ bao
lâu trước khi SIGKILL khi stop. Thứ tự: p99 job < timeout < retry_after, timeout < stopwaitsecs.
Q: Scale theo metric nào?
A: Tuổi của job cũ nhất đang chờ (và độ dài hàng đợi), theo từng queue. Không phải CPU.
Q: Giữ thứ tự cho các event của cùng một đơn thế nào?
A: Job kiểm tra trạng thái/version trước khi ghi, hoặc WithoutOverlapping($orderId) với
cache dùng chung. Muốn tuần tự tuyệt đối thì 1 worker cho queue đó — chấp nhận chậm.
Q: Có nên để bảng jobs trên DB riêng?
A: Laravel cho chọn qua DB_QUEUE_CONNECTION. Được: bảo vệ DB chính khỏi tải queue. Mất:
dispatch không còn chung transaction với dữ liệu nghiệp vụ, phải bật
after_commit và chấp nhận có thể mất job nếu process chết giữa commit và dispatch.
Đã phải tách DB thì thường nên cân nhắc Redis/SQS luôn.
Q: Worker chết thì job đang chạy đi đâu?
A: Nằm trong bảng với reserved_at cũ; sau retry_after giây worker khác lấy lại,
attempts tăng. Quá --tries thì vào failed_jobs.
Checklist trước khi thêm VM worker
- [ ] Job idempotent, log
attempts+hostname - [ ] p99 job <
--timeout<retry_after;stopwaitsecs>--timeout - [ ] Job dài có connection + queue + pool riêng
- [ ]
CACHE_STOREdùng chung (Redis) trên mọi VM - [ ] NTP chạy, có cảnh báo lệch giờ
- [ ] Còn đủ DB connection: (số VM × numprocs) + FPM < 80%
max_connections - [ ] Không dùng file local giữa web và worker; scheduler chỉ ở 1 chỗ
- [ ] Deploy script gọi
queue:restart; payload job tương thích 2 chiều - [ ] Có cảnh báo tuổi job cũ nhất,
qua_retry_after,n_dead_tup - [ ]
RateLimitedcho mọi API đối tác mà job gọi
Đọc thêm trong repo này
- Tối ưu Laravel — phần 4 queue: luật viết job, batch
- Chống double-submit và idempotency key ở Redis — làm job idempotent
- Scale nginx + PHP-FPM — bài toán connection và nhiều node phía web
- Bộ công cụ vận hành — Prometheus, postgres-exporter