A SKIP LOCKED: job queue egy sima Postgres táblából – de nem ingyen

A FOR UPDATE SKIP LOCKED tíz éve ott van a Postgresben, és simán kiválthatja a Kafkát egy sima háttérmunka-sorhoz – csak épp nem ingyen jön: a valódi ára nem a tábla méretével, hanem a konkurrenciával nő.

Ha valaha összedobtál egy "küldjünk emailt a háttérben" vagy "generáljunk PDF-et, majd értesítsük a usert" feature-t, valószínűleg az első reflexed egy Redis-alapú queue vagy egy SQS volt. Pedig ott van a Postgresed, amit már úgyis futtatsz, és tíz éve pontosan erre van benne egy beépített funkció – csak épp senki nem reklámozza, mert nem egy shiny API, hanem egy három szavas kiegészítés egy SELECT végére.

Diagram: egy háttérfolyamat nyíllal átlépi a Postgres sorban a lakattal jelölt, más workerek által már lezárt sorokat, és a legelső szabad, zöld pipával jelölt sort foglalja le.

A funkció neve FOR UPDATE SKIP LOCKED, és a PostgreSQL 9.5-ben jelent meg 2016-ban, Thomas Munro implementációjában, Simon Riggs korábbi próbálkozására és szintaxis-ötleteire épülve (depesz.com, Michael Paquier feature highlightja). Tíz év, és a fejlesztők nagy része még most is Kafkát vagy külön brókert vezet be ott, ahol egy CTE és egy index bőven elég lenne.

Mit csinál pontosan

A SELECT ... FOR UPDATE amúgy is lezárja a kiválasztott sorokat, hogy más tranzakció ne módosíthassa őket, amíg a miénk tart. Ha egy másik tranzakció már lezárt egy sort, az alapértelmezett viselkedés a várakozás – a második lekérdezés blokkol, amíg az első commit-ol vagy rollback-el. NOWAIT-tel ehelyett hibát kapsz. SKIP LOCKED-del viszont a lekérdezés egyszerűen kihagyja a már lezárt sorokat, és a következő elérhetőn folytatja.

A dokumentáció szó szerint ki is mondja, mire jó ez:

Skipping locked rows provides an inconsistent view of the data, so this is not suitable for general purpose work, but can be used to avoid lock contention with multiple consumers accessing a queue-like table.

Ez a mondat a kulcs: a Postgres saját magától mondja, hogy ez nem általános célú eszköz, hanem kifejezetten queue-mintára szánták. Fontos technikai részlet is elrejtve benne: a ROW SHARE tábla-szintű lock mindig megtörténik, NOWAIT és SKIP LOCKED csak a sor-szintű lockra vonatkozik – tehát nem arról van szó, hogy a tábla egy részét "kihagyod", csak arról, hogy melyik konkrét sorokat próbálod lezárni.

A minta, amit nem én találtam ki

A tipikus queue-minta egy CTE-vel néz ki, ami kiválasztja és rögtön frissíti is a következő elérhető jobot egy tranzakcióban:

WITH next_job AS (
  SELECT id FROM jobs
  WHERE status = 'pending'
  ORDER BY priority, created_at
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'processing'
FROM next_job
WHERE j.id = next_job.id
RETURNING j.id, j.payload;

Ez nem egy blogposztban kitalált trükk. Kikerestem a pgmq forráskódját – ez a Tembo SQS-szerű, Postgres-natív üzenetsor-extension, PostgreSQL 14-18-on, éles projektekben használva – és szó szerint ugyanezt a CTE + FOR UPDATE SKIP LOCKED + LIMIT mintát futtatja a read() és pop() függvényeiben. Ugyanez a minta van a River (Go), az Oban (Elixir) és a graphile-worker (Node) magjában is. Ha ezek a projektek erre épülnek, nyugodtan lehet rá építeni egy sima cron-worker helyett is.

Leteszteltem öt párhuzamos "worker" ellen egy 200 000 soros teszttáblán (Node.js + pg):

async function claimJob(workerName) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const { rows } = await client.query(`
      WITH next_job AS (
        SELECT id FROM jobs
        WHERE status = 'pending'
        ORDER BY priority, created_at
        LIMIT 1
        FOR UPDATE SKIP LOCKED
      )
      UPDATE jobs j SET status = 'processing'
      FROM next_job WHERE j.id = next_job.id
      RETURNING j.id, j.payload;
    `);
    await client.query('COMMIT');
    return rows[0]?.id ?? null;
  } finally {
    client.release();
  }
}

const results = await Promise.all(
  Array.from({ length: 5 }, (_, i) => claimJob(`worker-${i}`))
);

Öt egyidejű hívás, öt különböző job id jött vissza minden futásnál – senki nem kapott dupla munkát, és senki nem várt a másikra. Ez az egész trükk lényege: nincs blokkolás, nincs explicit alkalmazásszintű lock, csak egy sima UPDATE ... WHERE id = ... sebességén fut az egész elosztás.

Amit nem tudtam: nem a tábla mérete drágítja, hanem a konkurrencia

A neten több cikk is állítja, hogy a FOR UPDATE a ORDER BY ... LIMIT mellett mindig eldobja az index-alapú Top-N optimalizálást, és teljes sortot csinál. Ezt nem hittem el ránézésre, ezért leteszteltem: létrehoztam egy parciális indexet (priority, created_at) WHERE status = 'pending' alakban, és lefuttattam az EXPLAIN (ANALYZE, BUFFERS)-t sima esetben és SKIP LOCKED-del is.

Az eredmény: ha van megfelelő index, nincs teljes sort semelyik esetben. Mindkét terv Index Scan-t használ, a zárolt verzióban csak egy LockRows csomópont kerül rá:

Limit (actual time=0.042..0.047 rows=5 loops=1)
  Buffers: shared hit=9
  -> LockRows (actual rows=5 loops=1)
       Buffers: shared hit=9
       -> Index Scan using idx_jobs_queue on jobs (actual rows=5 loops=1)

Ahol viszont tényleg drágul a dolog: amikor sok worker éppen a sor elején tart lezárt sorokat. Nyitottam egy tranzakciót, ami lezárta a lista első 50 sorát (FOR UPDATE, commit nélkül), majd egy másik sessionből lefuttattam ugyanazt az EXPLAIN (ANALYZE, BUFFERS)-t:

Limit (actual time=0.155..0.159 rows=5 loops=1)
  Buffers: shared hit=118
  -> LockRows (actual rows=5 loops=1)
       Buffers: shared hit=118
       -> Index Scan using idx_jobs_queue on jobs (actual rows=55 loops=1)
             Filter: (status = 'pending'::text)
             Buffers: shared hit=8

5 sor helyett 55-öt kellett megnézni, a lapolvasás 9-ről 118-ra nőtt – kizárólag azért, mert 50 másik worker épp az élen ült. A SKIP LOCKED ára tehát nem a tábla méretével skálázódik, hanem azzal, hányan versenyeznek éppen ugyanazért a néhány élen lévő sorért. Egy 10 milliós táblán, ahol egyszerre 3 worker dolgozik, ez semmi. Egy kisebb táblán, ahol 500 worker mind az első pár tucat sorért küzd, ez már érdemben lassít – és ez pont az a forgatókönyv, amiben a legtöbben elkezdik használni, mert "gyorsítani" akarnak vele.

A másik ára: a tábla, amelyik sose nyugszik

A queue-minta lényegéből adódik, hogy minden job életciklusa legalább egy UPDATE-et jelent (pending → processing → done), gyakran egy törlést vagy archiválást is. Ezt is leteszteltem: egy 1000 soros táblán futtattam 2000 UPDATE-et, majd megnéztem a pg_stat_user_tables-t:

SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'churn_demo';
--  n_live_tup | n_dead_tup
--        1000 |       2000

Kétszer annyi holt tuple, mint élő sor – és ez csak 2000 művelet után. Egy komolyabb terhelésű queue-táblán ez az arány folyamatosan pörög, ami az autovacuum alapértelmezett beállításaival (autovacuum_vacuum_scale_factor = 0.2, azaz a tábla 20%-ának kell megváltoznia egy takarítás előtt) könnyen odáig vezet, hogy a takarítás lassabban fut, mint amilyen ütemben a bloat képződik. A gyakorlati tanács ilyen táblákra: agresszívebb, tábla-szintű autovacuum_vacuum_scale_factor/autovacuum_vacuum_cost_delay beállítás, vagy – ha a volumen indokolja – rendszeres VACUUM egy karbantartási ablakban. Ez nem SKIP LOCKED-specifikus probléma, de a queue-minta pont abba a kategóriába esik, ami a legjobban kihasználja, és a legjobban meg is szenvedi.

Mikor ne dobd rá az egész queue-t

A dokumentáció "inconsistent view of the data" figyelmeztetése nem formalitás. A SKIP LOCKED nem garantál sorrendhelyességet: ha A worker épp az 1-es sort tartja, és B worker közben megkapja a 2-es sort, nincs olyan garancia, hogy a jobok pontosan created_at sorrendben futnak le – csak annyi biztos, hogy egy sort csak egy worker kap meg. Ha kemény FIFO-t vagy prioritási igazságosságot akarsz, ezt külön kell megoldanod (pl. a pgmq FIFO group key-ekkel csinálja).

Ez sem multicast: egy sort egy worker kap meg, nem sok. Ha pub/sub-ot akarsz csinálni – minden konzumer megkapja ugyanazt az üzenetet –, ez nem az az eszköz. Extrém átviteli sebességnél (tízezer+ üzenet/mp folyamatosan) sem ez a jó válasz: a connection pool és a vacuum-terhelés hamarabb korlátoz, mint amit egy dedikált bróker tudna. És ha már amúgy is van Kafkád vagy RabbitMQ-d más okból, semmi értelme nincs kettéosztani a job-kezelést két rendszer között csak azért, mert az egyik "ingyen" van.

Használnám production-ben?

Igen, közepes terhelésű, egy adatbázison belüli háttérmunkákra (email küldés, riport generálás, webhook retry) simán bevezetném – kevesebb mozgó rész, kevesebb üzemeltetési felszín, és a tranzakcionalitás miatt a job-státusz sosem csúszik el az üzleti adattól, mert egy tranzakcióban frissülnek. De nem nyúlnék hozzá kézzel: inkább egy bejáratott extension vagy library (pgmq, River, graphile-worker) felett dolgoznék, mert a retry, backoff, dead-letter és visibility timeout logika mind olyan részlet, amit ezek már kijavítottak, mielőtt én is elkövettem volna ugyanazokat a hibákat. A SKIP LOCKED maga csak az alapkő – attól, hogy ismered a szintaxist, még nincs kész queue-d.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük