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 outthêm máy/process chạy song song (khác scale up = máy to hơn)
at-least-once deliveryjob chắc chắn chạy ít nhất 1 lần, nhưng có thể hơn 1 lần
idempotentchạy lại nhiều lần vẫn ra cùng kết quả như chạy 1 lần
FOR UPDATE SKIP LOCKEDkhoá 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 tuplebản ghi đã DELETE/UPDATE nhưng vẫn nằm trên đĩa, chờ VACUUM dọn
bloatbảng/index phình to vì chứa nhiều dead tuple
poison jobjob lỗi mỗi lần chạy, cứ thử lại mãi
head-of-line blockingviệc chậm ở đầu hàng làm việc nhanh phía sau phải chờ
Little's lawsố 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 khi reserved_at cũ hơn retry_after giâ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

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"]

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/sVòng 1: latencyVòng 2: job/sVòng 2: latency
13429.7 ms6016.7 ms
413429.9 ms15226.3 ms
842318.9 ms18842.5 ms
1643436.8 ms40040.0 ms
32273117.2 ms58954.3 ms
64487131.5 ms79480.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 đó:

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ăngNế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
ShouldBeUniquechặ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 ở VM1VM2, 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

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

4. Trade-off

4.1. Database driver hay đổi sang Redis / SQS?

Tiêu chídatabase (PostgreSQL)Redis + HorizonSQS / RabbitMQ
Hạ tầng thêmkhôngRedis (cần persistence + HA)dịch vụ managed / cluster riêng
Tải phù hợpvà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/sgần như không giới hạn
Enqueue cùng transaction nghiệp vụcó (cùng connection)không → cần after_commitkhông
Ảnh hưởng DB chínhcó: ghi, bloat, connectionkhôngkhông
Dashboard, auto-balance workertự làm (SQL mục 5)Horizon có sẵnCloudWatch / UI broker
Mất job khi hạ tầng sậpkhông (WAL)có thể, tuỳ AOF/RDBkhô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)
Nhanhsửa 1 dòng, supervisorctl updatedựng VM, deploy code, config
Giới hạnRAM (mỗi worker ~50–150 MB), CPU nếu job nặng CPUDB connection, cache dùng chung
Chịu lỗikhông tăngtăng
Bẫy mớiítbẫy 3, 8, 9, 13

4.3. Khi nào KHÔNG nên thêm worker

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
MetricCảnh báo khi (ví dụ minh hoạ)Nghĩa là
tuoi_job_cu_nhat_s> 120s trong 5 phútthiếu worker, hoặc worker đang kẹt
qua_retry_after> 0worker 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 đềutăng liên tục 30 phútjob 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 x3lỗi code mới deploy
n_dead_tup của jobs> 10× số dòng sốngautovacuum không theo kịp (bẫy 6)
connection theo application_name> 80% max_connectionssắ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

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.

  1. Hạ ngay numprocs về mức cũ (supervisorctl update) — cứu checkout trước, backlog chờ được.
  2. Xem pg_stat_activity nhóm theo application_name và wait_event: connection hết hay đang chờ lock/IO?
  3. Tách job ít quan trọng (email marketing) sang queue riêng, ít worker; giữ worker cho queue đơn hàng.
  4. 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).

  1. Xác nhận: log có attempts=2 nhưng không có exception ở lần 1; metric qua_retry_after > 0.
  2. Chữa cháy: chuyển job sang connection database-long (retry_after=300, --timeout=240).
  3. 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_at và kiểm tra ở đầu handle() (idempotent).
  4. 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.

  1. 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.
  2. Chữa cháy: supervisorctl restart laravel-worker:* trên VM đó.
  3. Sửa gốc: đưa .env và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

  1. Kiểm tra n_dead_tup, last_autovacuum trong pg_stat_user_tables.
  2. Tìm transaction mở lâu (pg_stat_activity, xact_start cũ) — nó chặn VACUUM dọn dead tuple, tăng worker cũng không cứu được.
  3. Cho bảng jobs autovacuum riêng, chạy dày hơn: ALTER TABLE jobs SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 1000); (cú pháp ALTER TABLE ... SET (autovacuum_...) đã dùng trong thí nghiệm; con số là ví dụ).
  4. Đã phình to thì VACUUM thường không trả dung lượng cho OS; cần VACUUM FULL (khoá bảng — dừng worker) hoặc pg_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

Đọc thêm trong repo này