# Postgres를 작업 큐로 쓰기: FOR UPDATE SKIP LOCKED를 제대로 쓰는 법

PostgreSQL 17에서 SELECT ... FOR UPDATE SKIP LOCKED로 백그라운드 작업 큐를 만드는 방법을 설명합니다. 단순한 SELECT 후 UPDATE가 같은 작업을 두 번 가져가는 이유, FOR UPDATE만으로는 워커가 기다리게 되는 이유, 리스와 재시도, LISTEN/NOTIFY, vacuum, 그리고 폴링하는 워커가 서버리스 컴퓨트에 미치는 영향을 다룹니다. 모든 문장은 Postgres 17.11에서 재현했습니다.

**백그라운드 작업을 실행하는 데 Redis나 메시지 브로커가 꼭 필요하지는 않습니다. Postgres 테이블 하나와 `FOR UPDATE SKIP LOCKED`만 있으면 각 작업을 한 번에 한 워커에게만 넘기고, 장애가 나도 살아남으며, 작업을 그 작업이 속한 데이터와 같은 트랜잭션 안에 둘 수 있습니다. 문제는 누구나 처음 떠올리는 버전이 틀렸다는 점입니다. 저희 테스트에서 "가장 오래된 작업을 고른 다음 업데이트한다"를 그대로 실행한 워커 여덟 개는 같은 행을 최대 여덟 번까지 가져갔고, 10,000개 작업 중 약 2,100개만 끝냈습니다. 이 글은 그 버그를 재현하고, 이를 고친 큐(가져가기, 완료, 재시도, 회수)를 만든 다음, 나중에 문제가 되는 부분들을 다룹니다. 작업하는 동안 열어 둔 트랜잭션, LISTEN/NOTIFY, vacuum, 그리고 유휴 시 일시 중지되는 컴퓨트에 폴링하는 워커가 미치는 영향입니다. 아래의 모든 문장과 출력은 Relyt One이 실행하는 메이저 버전인 로컬 PostgreSQL 17.11에서 나온 것입니다.**

## 요약

| 증상                         | 원인                                                   | 해결 방법                              |
| -------------------------- | ---------------------------------------------------- | ---------------------------------- |
| 두 워커가 같은 작업을 실행함           | 행 잠금 없는 `UPDATE ... WHERE id = (SELECT ... LIMIT 1)` | 고른 행을 잠금: `FOR UPDATE SKIP LOCKED` |
| 워커가 병렬로 실행되지 않고 차례를 기다림    | `SKIP LOCKED` 없는 `FOR UPDATE`: 각 워커가 앞의 워커를 기다림      | `SKIP LOCKED` 추가                   |
| vacuum이 정리를 멈추고 테이블이 커짐    | 작업이 실행되는 동안 워커가 트랜잭션을 열어 둠                           | 짧은 트랜잭션에서 가져가고 리스를 기록              |
| 장애 후 작업이 영원히 `running`에 머묾 | 아무도 그 작업을 회수하지 않음                                    | 리스를 만료시키고 작업을 되돌림                  |
| 뒤처진 워커가 회수된 작업을 완료로 표시함    | 완료 처리 시 작업의 소유자를 확인하지 않음                             | 시도 횟수로 완료 처리를 보호                   |
| 서버리스 컴퓨트가 전혀 일시 중지되지 않음    | 워커가 몇 초마다 폴링함                                        | 폴링 전략을 의도적으로 선택(마지막 절 참고)          |

## 테이블

테이블 하나, 작업당 행 하나입니다. 부분 인덱스는 워커가 찾는 행만 포함하므로, 테이블에 끝난 작업의 긴 기록이 쌓여도 작게 유지됩니다.

```sql
CREATE TABLE jobs (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  queue        text        NOT NULL DEFAULT 'default',
  payload      jsonb       NOT NULL,
  status       text        NOT NULL DEFAULT 'queued'
               CHECK (status IN ('queued', 'running', 'done', 'failed')),
  run_at       timestamptz NOT NULL DEFAULT now(),
  attempts     int         NOT NULL DEFAULT 0,
  max_attempts int         NOT NULL DEFAULT 5,
  locked_until timestamptz,
  last_error   text,
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX jobs_ready_idx ON jobs (queue, run_at) WHERE status = 'queued';

INSERT INTO jobs (payload)
SELECT jsonb_build_object('email_id', g) FROM generate_series(1, 10) AS g;
```

`run_at`은 재시도 일정 역할도 합니다. 작업이 `queued`이고 `run_at <= now()`이면 실행할 준비가 된 것입니다. `locked_until`은 리스이며, 뒤에서 다룹니다.

## 실수 1: 작업을 고른 다음 업데이트하기

대부분의 사람이 처음 작성하는 버전입니다. 문장 하나이기 때문에 원자적으로 보입니다.

```sql
UPDATE jobs
SET status = 'running', attempts = attempts + 1
WHERE id = (
  SELECT id FROM jobs
  WHERE status = 'queued'
  ORDER BY run_at
  LIMIT 1
)
RETURNING id, attempts;
```

두 세션에서 동시에 실행해 보세요. 세션 A는 트랜잭션 안에서 이 문장을 실행하고, 세션 B가 같은 문장을 실행할 때 아직 작업 중입니다.

```text
-- session A (inside BEGIN, not committed yet)
 id | attempts
----+----------
  1 |        1

-- session B, returns after A commits
 id | attempts
----+----------
  1 |        2
```

두 세션 모두 작업 1을 가져갔고, 작업 2는 여전히 `queued`입니다. 이유는 이렇습니다. 하위 쿼리는 한 번 실행되어 작업 1을 가장 오래된 `queued` 작업으로 보고, 바깥쪽 `UPDATE`에 `id = 1`이라는 값을 넘깁니다. 그러면 B는 A의 행 잠금을 기다려야 합니다. `READ COMMITTED`에서는 A가 커밋하면 Postgres가 새 버전의 행을 대상으로 [`WHERE` 절을 다시 평가](https://www.postgresql.org/docs/17/transaction-iso.html#XACT-READ-COMMITTED)합니다. 남은 조건은 `id = 1`뿐이고 이는 여전히 일치하므로, B는 A가 이미 가져간 작업을 업데이트합니다.

실험실의 두 세션만으로는 부족하므로, `pgbench`로 워커 여덟 개를 대기 중인 작업 10,000개에 대해 실행했습니다. 워커당 가져가기는 1,250번입니다.

```bash
pgbench -n -c 8 -j 8 -t 1250 -f naive.sql postgres
```

`naive.sql`은 위 문장에서 `status = 'done'`으로 바꾼 것이므로, 가져가기가 곧 완료입니다. 실행 후 결과는 다음과 같습니다.

```text
 status | count | max_attempts
--------+-------+--------------
 done   |  2100 |            8
 queued |  7900 |            0
```

10,000번의 가져가기로 끝난 작업은 약 2,100개였고(두 대의 머신에서 여덟 번 실행한 결과 2,068개에서 2,137개 사이), 일부 작업은 여덟 워커 모두가 가져갔습니다. 실제 시스템이라면 같은 이메일이 여덟 통 발송된 것입니다.

## 실수 2: SKIP LOCKED 없는 FOR UPDATE

고른 행을 잠그면 중복 가져가기가 해결됩니다. 선택을 `FOR UPDATE`가 붙은 CTE에 넣고 이를 통해 업데이트합니다.

```sql
WITH next AS (
  SELECT id FROM jobs
  WHERE queue = 'default' AND status = 'queued' AND run_at <= now()
  ORDER BY run_at
  LIMIT 1
  FOR UPDATE
)
UPDATE jobs j
SET status = 'running', attempts = j.attempts + 1
FROM next
WHERE j.id = next.id
RETURNING j.id;
```

이제 세션 B는 작업 1에서 차단됩니다. A가 커밋하면 Postgres는 작업 1을 다시 확인하고, 더 이상 `status = 'queued'`와 일치하지 않음을 확인한 뒤 이를 버리고 작업 2로 넘어갑니다. 올바른 동작이지만, B의 소요 시간을 보세요.

```text
 id
----
  2
(1 row)

UPDATE 1
Time: 1858.126 ms (00:01.858)
```

B는 A가 트랜잭션을 열어 둔 내내 기다렸습니다. 워커가 더 많으면 모두 같은 첫 번째 행 뒤에 줄을 서서 한 번에 하나씩 실행됩니다.

## 해결 방법: FOR UPDATE SKIP LOCKED

두 단어를 추가합니다. [`SELECT` 레퍼런스](https://www.postgresql.org/docs/17/sql-select.html#SQL-FOR-UPDATE-SHARE)에는 "With `SKIP LOCKED`, any selected rows that cannot be immediately locked are skipped."(`SKIP LOCKED`를 사용하면, 즉시 잠글 수 없는 선택된 행은 건너뛴다.)라고 되어 있습니다. 문서는 그 용도도 밝힙니다. 이 옵션은 데이터를 일관되지 않은 시각으로 보여 주므로 범용 작업에는 적합하지 않지만, "but can be used to avoid lock contention with multiple consumers accessing a queue-like table."(큐와 같은 테이블에 여러 소비자가 접근할 때 잠금 경합을 피하는 데 사용할 수 있다.)

A가 여전히 작업 1을 잡고 있는 상태에서 B는 즉시 작업 2를 얻습니다.

```text
 id
----
  2
(1 row)

UPDATE 1
Time: 2.512 ms
```

![세 워커가 작업을 가져갑니다. 왼쪽은 행 잠금이 없는 경우로, 워커 A와 B가 모두 작업 1을 가져가고 워커 C도 가져갈 수 있으며 작업 2와 3은 기다립니다. 오른쪽은 FOR UPDATE SKIP LOCKED를 사용한 경우로, 각 워커가 다른 워커가 잠근 행을 건너뛰기 때문에 워커 A, B, C가 작업 1, 2, 3을 가져갑니다.](/blog/assets/651a29f4ea5795d0.svg)

단순한 문장 대신 `SKIP LOCKED`를 사용해 같은 `pgbench`를 실행한 결과입니다.

```text
 status | count | max_attempts
--------+-------+--------------
 done   | 10000 |            1
```

모든 작업이 끝났고, 모든 작업이 정확히 한 번씩 실행되었습니다.

앞으로 사용할 가져가기 문장은 다음과 같습니다. 왕복 한 번에 작업을 최대 10개까지 가져오며(작업 하나하나가 빨라지면 중요해집니다), 리스를 기록합니다.

```sql
WITH next AS (
  SELECT id FROM jobs
  WHERE queue = 'default' AND status = 'queued' AND run_at <= now()
  ORDER BY run_at
  LIMIT 10
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status       = 'running',
    attempts     = j.attempts + 1,
    locked_until = now() + interval '5 minutes'
FROM next
WHERE j.id = next.id
RETURNING j.id, j.payload, j.attempts;
```

행이 몇천 개인 테이블에서 `EXPLAIN (COSTS OFF)`는 원하는 형태를 보여 줍니다. `jobs_ready_idx`에 대한 인덱스 스캔 위에 `LockRows`, 그 위에 `Limit`이 있으므로, 워커는 준비된 행만 건드리고 10개를 가져오면 멈춥니다.

```text
 Update on jobs j
   CTE next
     ->  Limit
           ->  LockRows
                 ->  Index Scan using jobs_ready_idx on jobs
                       Index Cond: ((queue = 'default'::text) AND (run_at <= now()))
                       Filter: (status = 'queued'::text)
   ->  Nested Loop
         ->  CTE Scan on next
         ->  Index Scan using jobs_pkey on jobs j
               Index Cond: (id = next.id)
```

## 작업이 실행되는 동안 트랜잭션을 붙잡고 있지 마세요

이 가져가기 문장을 사용하는 방법은 두 가지입니다.

1. **작업하는 동안 잠그기.** `BEGIN`, 가져가기, 작업 수행, 완료 표시, `COMMIT`. 장애가 나면 롤백되어 작업이 다시 풀려나므로 매력적입니다.
2. **리스.** `status = 'running'`과 `locked_until`을 기록하며 가져간 뒤 바로 `COMMIT`합니다. 작업은 어떤 트랜잭션 밖에서 수행합니다. 두 번째 짧은 트랜잭션에서 완료로 표시합니다.

첫 번째 방법은 밀리초 단위로 끝나는 작업에는 괜찮습니다. API를 호출하거나, 이메일을 보내거나, 파일을 렌더링하는 작업이라면 리스를 사용하세요. 무언가를 쓴 다음(가져가기는 쓰기입니다) 대기하고 있는 트랜잭션은 `idle in transaction` 상태이며, 이 트랜잭션이 열려 있는 동안 vacuum은 해당 데이터베이스의 어떤 테이블에서도 이 트랜잭션이 시작된 뒤에 죽은 행 버전을 제거할 수 없습니다. 열어 둔 `REPEATABLE READ` 트랜잭션은 읽기만 했더라도 같은 일을 일으킵니다.

그런 식으로 가져가기 하나를 열어 둔 채 다른 워커들이 작업 2,000개를 처리하게 한 뒤 `VACUUM (VERBOSE) jobs`를 실행했습니다.

```text
tuples: 0 removed, 11992 remain, 2000 are dead but not yet removable
```

열어 둔 트랜잭션이 커밋된 뒤 같은 명령의 결과입니다.

```text
tuples: 2001 removed, 8542 remain, 0 are dead but not yet removable
```

큐 테이블은 업데이트와 삭제가 전부이므로, vacuum이 막히면 가장 먼저 비대해지는 테이블입니다. 열어 둔 트랜잭션은 서버 연결도 하나 붙잡고 있으며, 트랜잭션 모드의 풀러 뒤에 있다면 그 연결은 커밋할 때까지 풀에서 빠져 있습니다. 짧은 트랜잭션은 두 문제를 모두 피합니다.

## 완료, 재시도, 회수

리스를 사용하면 작업의 전체 수명 주기는 짧은 문장 네 개입니다. 워커가 가져가기 이후에 실행하는 문장은 `status = 'running'`과, 가져가기가 반환한 값인 `attempts`를 확인하므로, 워커는 자신이 아직 소유한 작업만 변경할 수 있습니다.

**완료.** 워커는 가져가기에서 받은 id와 시도 횟수를 그대로 넘깁니다.

```sql
UPDATE jobs
SET status = 'done', locked_until = NULL
WHERE id = 1 AND status = 'running' AND attempts = 1;
```

**실패 후 백오프를 두고 재시도.** 첫 번째 실패 후 20초, 두 번째 실패 후 40초로 두 배씩 늘리되 상한은 한 시간이며, `max_attempts`를 넘기면 최종적으로 `failed`가 됩니다. 기본값인 5회 시도에서는 가장 긴 대기가 160초이며, 상한은 `max_attempts`를 늘릴 때만 의미가 있습니다.

```sql
UPDATE jobs
SET status       = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'queued' END,
    run_at       = now() + least(interval '1 hour', interval '10 seconds' * 2 ^ attempts),
    locked_until = NULL,
    last_error   = 'SMTP 451: try again later'
WHERE id = 2 AND status = 'running' AND attempts = 1
RETURNING id, status, attempts, run_at - now() AS retry_in;
```

```text
 id | status | attempts | retry_in
----+--------+----------+----------
  2 | queued |        1 | 00:00:20
```

**만료된 리스 회수.** 워커가 작업 도중 죽으면 리스가 만료되고, 어떤 워커든(또는 cron 작업이) 그 작업을 되돌립니다. 매번 워커를 죽이는 작업은 그대로 두면 영원히 되돌아오므로, 회수 작업도 `max_attempts`를 지킵니다.

```sql
UPDATE jobs
SET status       = CASE WHEN attempts >= max_attempts THEN 'failed' ELSE 'queued' END,
    locked_until = NULL,
    last_error   = coalesce(last_error, 'lease expired')
WHERE status = 'running' AND locked_until < now()
RETURNING id, status, attempts;
```

**시도 횟수 확인이 중요한 이유.** 작업 3을 맡은 워커가 죽은 것이 아니라 느렸을 뿐이라고 해 봅시다. 리스가 만료되어 회수 작업이 작업을 되돌렸고, 다른 워커가 이를 가져갔으므로 `attempts`는 이제 2입니다. 느린 워커가 마침내 작업을 끝내고 `attempts = 1`로 완료 문장을 실행하면 아무 일도 일어나지 않습니다.

```text
UPDATE 0
```

작업은 현재 소유자에게 남아 있습니다. 이 확인이 없다면 느린 워커는 두 번째 워커가 아직 실행 중인 작업을 완료로 표시할 것입니다. 느린 워커의 부수 효과는 이미 일어났으므로 작업 자체는 여전히 멱등이어야 합니다. 이 확인은 작업의 상태를 정확하게 유지할 뿐입니다.

리스는 정상적인 작업 중 가장 느린 것보다 길게 잡거나, 오래 걸리는 작업이 진행하면서 스스로 리스를 연장하게 하세요. 연장이 아무 행도 업데이트하지 않으면 작업이 더 이상 자기 것이 아니므로 중단해야 합니다.

```sql
UPDATE jobs
SET locked_until = now() + interval '5 minutes'
WHERE id = $1 AND status = 'running' AND attempts = $2;
```

## 워커 깨우기: 폴링 또는 LISTEN/NOTIFY

워커는 할 일이 있다는 것을 알아야 합니다. 가장 간단한 방법은 가져가기 문장을 루프에서 실행하고, 아무것도 반환되지 않으면 잠시 쉬는 것입니다. 다른 방법은 Postgres가 알려 주게 하는 것입니다.

```sql
CREATE FUNCTION jobs_notify() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  PERFORM pg_notify('jobs', NEW.queue);
  RETURN NULL;
END;
$$;

CREATE TRIGGER jobs_notify
AFTER INSERT ON jobs
FOR EACH ROW EXECUTE FUNCTION jobs_notify();
```

워커는 `LISTEN jobs`를 실행하고 알림이 도착하면 가져가기를 수행합니다. [`NOTIFY` 레퍼런스](https://www.postgresql.org/docs/17/sql-notify.html)에 나오는 세 가지 특성이 사용 방법을 결정합니다.

- **전달은 커밋 시점에 일어납니다.** 트랜잭션 안에서 보낸 알림은 "are not delivered until and unless the transaction is committed."(트랜잭션이 커밋되기 전에는, 그리고 커밋되지 않는 한 전달되지 않는다.) 저희 테스트에서 생산자는 13:08:30에 작업을 삽입하고 13:08:33에 커밋했습니다. 13:08:31에 리스너가 확인했을 때는 아무것도 없었고, 알림은 다음 확인 때 나타났습니다. 이것이 원하는 동작입니다. 일찍 깨어난 워커는 아직 보이지 않는 작업을 찾게 되기 때문입니다.
- **알림은 큐가 아닙니다.** 그 순간 수신 대기 중인 세션만 알림을 받으며, 한 트랜잭션 안의 동일한 알림은 하나로 합쳐집니다. 알림은 작업 그 자체가 아니라 "가서 확인하라"는 신호로 다루고, 재연결하는 동안 놓친 것을 위해 더 느린 폴링을 대비책으로 유지하세요.
- **페이로드는 작습니다.** 기본 설정에서 페이로드는 8000바이트보다 짧아야 합니다. 작업이 아니라 큐 이름이나 id를 보내세요.

하나 더 있습니다. `LISTEN`은 서버 연결에 속하므로, 트랜잭션 모드 풀러를 거치는 연결이 아니라 직접 연결이 필요합니다. 그 이유는 [PgBouncer 트랜잭션 풀링 뒤에서 깨지는 것들과 각각의 해결 방법](/blog/ko/pgbouncer-transaction-pooling)에서 다뤘습니다. 생산자와 가져가기 쿼리는 풀링된 연결을 사용해도 됩니다.

## 일시 중지되는 컴퓨트에서의 큐

Relyt One에서는 약 10분 동안 할 일이 없었던 컴퓨트가 일시 중지되고, 다음 연결이 들어오면 재개됩니다. 큐에서 중요한 것은 무엇이 "할 일"로 간주되느냐입니다. 컴퓨트의 활동 모니터는 0.5초마다 클라이언트 세션을 확인합니다.

- `idle`이 아닌 세션(쿼리 실행 중이거나 `idle in transaction`)은 지금 이 순간의 활동으로 간주됩니다.
- `idle` 세션은 마지막 상태 변경 시점, 즉 마지막 쿼리가 끝난 시점의 활동으로 간주됩니다.
- 실행 중인 autovacuum 워커나 활성 논리 복제 연결도 컴퓨트를 깨어 있게 합니다.

따라서 1초마다, 또는 5분마다 폴링하는 워커는 밤새 큐가 비어 있더라도 컴퓨트를 24시간 내내 깨어 있게 합니다. 작업하는 동안 트랜잭션을 열어 두는 워커는 작업 전체 시간 동안 컴퓨트를 깨어 있게 합니다. 유휴 연결 자체는 그렇지 않습니다. 마지막 상태 변경 시점이 계속 과거로 멀어지기 때문입니다. 여기서 세 가지가 따라 나옵니다. 일시 중지는 `LISTEN` 연결을 포함한 모든 연결을 닫으며, 곧바로 재연결하는 워커는 컴퓨트를 재개시킵니다. 드라이버나 풀의 헬스 체크 쿼리도 활동으로 간주됩니다. 그리고 10분보다 자주 실행되는 대비용 폴링은 컴퓨트를 계속 켜 둡니다.

그러면 솔직한 선택지는 세 가지입니다.

1. **작업이 몇 초 안에 시작되어야 하는 경우.** 워커가 실행되는 동안 컴퓨트가 항상 켜져 있는 것을 받아들이세요. 어느 플랫폼에서든 낮은 지연 시간의 대가입니다. 감당할 수 있는 간격으로 폴링하거나, 대비용 폴링과 함께 `LISTEN`을 사용하세요.
2. **작업이 한동안 기다려도 되는 경우.** 워커를 24시간 내내 실행하지 마세요. 유휴 시간 제한보다 긴 간격이 생기도록 일정에 따라 워커를 시작하고, 배치 가져가기로 큐를 비운 뒤 종료하게 하세요. 실행 사이에 컴퓨트는 일시 중지될 수 있으며, 다음 실행의 첫 연결이 컴퓨트를 재개합니다.
3. **작업이 여러분의 앱에서 생기는 경우.** 작업을 큐에 넣는 요청이 워커도 함께 기동하게 하세요(서버리스 함수, 플랫폼의 작업 실행기에 있는 태스크 등). 상태, 재시도, 리스의 기준은 여전히 데이터베이스이며, 아무것도 폴링할 필요가 없습니다.

여기서도 브랜치가 도움이 됩니다. 브랜치마다 자체 컴퓨트가 있으므로, 테스트용 워커 집단을 프로덕션 데이터 사본이 있는 브랜치로 향하게 하고 가져가기 쿼리를 마음껏 두드려도 프로덕션 컴퓨트는 건드리지 않습니다.

## 테이블을 작게 유지하기

끝난 작업이 자주 쓰이는 테이블에 남아 있을 필요는 없습니다. 일정에 따라 배치로 삭제하거나 아카이브 테이블로 옮기세요.

```sql
DELETE FROM jobs
WHERE id IN (
  SELECT id FROM jobs
  WHERE status = 'done' AND created_at < now() - interval '7 days'
  LIMIT 10000
);
```

가져가기, 완료, 재시도는 매번 새 행 버전을 쓰므로, 이 테이블에서는 dead tuple이 꾸준히 생깁니다. `jobs`에 대해 `pg_stat_user_tables`의 `n_dead_tup`과 `last_autovacuum`을 지켜보고, vacuum이 찾아낸 것을 실제로 제거할 수 있도록 트랜잭션을 짧게 유지하세요.

## Postgres 큐가 맞지 않는 경우

작업 테이블은 작업이 같은 데이터베이스의 행과 관련되어 있고, 처리량이 작업당 `UPDATE` 한 번으로 감당할 수 있는 범위이며, 큐에 넣는 일을 데이터 변경과 같은 트랜잭션에 포함하고 싶을 때 잘 맞습니다. 마지막 부분은 외부 브로커가 별도의 장치 없이는 제공할 수 없는 것입니다. 반면 여러 독립적인 소비자로의 팬아웃, 나중에 재생하기 위한 스트림 보존, 업데이트 부하 자체가 병목이 되는 규모에는 잘 맞지 않습니다. 그런 경우에는 브로커나 로그를 사용하세요.

## 체크리스트

- [ ] CTE와 `FOR UPDATE SKIP LOCKED`로 가져가고, 잠금 없는 `WHERE id = (SELECT ... LIMIT 1)`은 절대 쓰지 않기
- [ ] 준비 조건에 부분 인덱스 사용하기
- [ ] 가져간 뒤 바로 커밋하고, `locked_until`을 기록하고, 작업은 트랜잭션 밖에서 수행하기
- [ ] 완료, 실패, 연장은 `WHERE id = $1 AND status = 'running' AND attempts = $2`로 하고 행 수를 확인하기
- [ ] 만료된 리스를 회수하는 작업을 실행하고, `max_attempts`로 재시도 횟수를 제한하기
- [ ] `LISTEN`은 직접 연결에서 사용하고, 알림은 힌트로 다루고, 대비용 폴링을 유지하기
- [ ] 끝난 작업은 삭제하거나 아카이브하고, `n_dead_tup`을 지켜보기
- [ ] 서버리스 컴퓨트를 깨어 있게 하는 것이 무엇인지 알고 워커의 대기 방식을 정하기

이 글의 모든 문장은 확장 없이 순수한 PostgreSQL 17 SQL입니다. 무료 Relyt One 프로젝트를 만들고, 브랜치를 연 다음, `psql` 세션 두 개를 연결해 `SKIP LOCKED`가 각 세션에 서로 다른 행을 넘겨주는 것을 확인해 보세요.

[무료로 시작하기](/auth/signin?ref=blog-postgres-job-queue-skip-locked)
