DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

PostgreSQL Advisory Locks for Distributed Job Scheduling

---
title: "Build a Distributed Job Scheduler With Only PostgreSQL"
published: true
description: "Replace Redis+BullMQ with PostgreSQL advisory locks and SKIP LOCKED. Schema, polling pattern, lock tuning, and production benchmarks from a 3-node cluster."
tags: postgresql, architecture, api, performance
canonical_url: https://blog.mvpfactory.co/postgresql-advisory-locks-distributed-job-scheduling
---

## What We Will Build

Let me show you a pattern I use in every project that needs background jobs across multiple API nodes: a distributed job scheduler powered entirely by PostgreSQL. No Redis. No BullMQ. No new infrastructure.

By the end of this tutorial, you will have a working job queue schema, a lock-safe polling pattern using `SKIP LOCKED`, advisory lock coordination for job-type throttling, and the confidence to know exactly when this approach stops scaling.

## Prerequisites

- PostgreSQL 12+ (we use `SKIP LOCKED` and `pg_advisory_xact_lock`)
- Basic SQL and transaction knowledge
- A multi-node API setup (or the intent to build one)

## Step 1: Create the Job Queue Schema

Here is the minimal setup to get this working. This schema has run in production on a 3-node cluster:

Enter fullscreen mode Exit fullscreen mode


sql
CREATE TABLE job_queue (
id BIGSERIAL PRIMARY KEY,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 3
);

CREATE INDEX idx_job_queue_fetchable
ON job_queue (created_at)
WHERE status = 'pending';


That partial index is important — it keeps the planner fast by only indexing rows workers actually care about.

## Step 2: The Polling Pattern With SKIP LOCKED

`FOR UPDATE SKIP LOCKED` is the key primitive. Each worker grabs the next available row without blocking other workers:

Enter fullscreen mode Exit fullscreen mode


sql
BEGIN;

UPDATE job_queue
SET status = 'processing', locked_at = now(), attempts = attempts + 1
WHERE id = (
SELECT id FROM job_queue
WHERE status = 'pending'
AND attempts < max_attempts
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *;

-- Execute job logic here

UPDATE job_queue SET status = 'completed', completed_at = now() WHERE id = $1;
COMMIT;


This handles 80% of job scheduling needs. No advisory lock required for the basic case. If a worker crashes mid-job, the transaction rolls back and the row stays `pending` — automatic failure recovery.

## Step 3: Layer in Advisory Locks for Coordination

Advisory locks become worth it when you need job-type-level coordination. For example, ensuring only one worker processes `invoice_generation` at a time:

Enter fullscreen mode Exit fullscreen mode


sql
SELECT pg_try_advisory_xact_lock(hashtext('invoice_generation'));


PostgreSQL offers two flavors, and the docs do not mention this clearly enough, but the choice matters a lot:

| Feature | Session (`pg_advisory_lock`) | Transaction (`pg_advisory_xact_lock`) |
|---|---|---|
| Release | Explicit or session end | Transaction commit/rollback |
| Best for | Long-running jobs | Short transactional work |
| Risk | Leak if not released | Holds connections longer |

**Use transaction-level locks.** They self-clean on commit or rollback, which eliminates the most common advisory lock bug: leaked session-level locks from crashed workers.

## Step 4: Tune Lock Timeout

The default `lock_timeout` of 0 means infinite wait — dangerous for job schedulers. Set it explicitly:

Enter fullscreen mode Exit fullscreen mode


sql
SET lock_timeout = '5s';


I have found 2–5 seconds to be the sweet spot. Go shorter and you burn cycles on retries. Go longer and you risk worker starvation during contention spikes. Start at 5 seconds and tune from there.

## Step 5: Monitor Lock Contention

You can observe advisory lock contention directly via `pg_stat_activity`:

Enter fullscreen mode Exit fullscreen mode


sql
SELECT pid, wait_event_type, wait_event, query, state
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND wait_event LIKE '%advisory%';


Set up alerts when advisory lock waits consistently exceed your `lock_timeout` threshold. That is your signal to add workers or revisit lock granularity.

## Production Benchmarks

I benchmarked both setups on a 3-node API cluster (4 vCPU, 8GB RAM each) with a single PostgreSQL 15 instance (8 vCPU, 32GB RAM) against a comparable Redis 7 instance. Mixed job types, 1–10KB payloads:

| Metric | Postgres SKIP LOCKED | Redis + BullMQ |
|---|---|---|
| Throughput (jobs/min) | ~850 | ~920 |
| p50 latency (ms) | 12 | 8 |
| p99 latency (ms) | 85 | 34 |
| Infra components | 1 (Postgres) | 2 (Postgres + Redis) |
| Failure recovery | Automatic (tx rollback) | Requires stalled job detection |

At sub-1000 jobs/minute, Postgres is within 8% on throughput. The p99 gap is real — 85ms vs 34ms — but for background work like email sends, report generation, and webhook delivery, nobody notices that difference.

## Gotchas

**Deadlock ordering.** Never combine advisory locks with row-level locks in inconsistent orders. If Worker A holds an advisory lock and waits for a row lock while Worker B holds that row lock and waits for the same advisory lock, PostgreSQL's deadlock detector will resolve it — but at the cost of a rolled-back transaction and a retry. The fix: always acquire advisory locks *before* row locks, always in a deterministic order.

**Session lock leaks.** If you use `pg_advisory_lock` (session-level) and your worker crashes without calling `pg_advisory_unlock`, that lock persists until the session ends. This is why I recommend transaction-level locks for job scheduling — they cannot leak.

**Infinite lock timeout.** The default `lock_timeout = 0` will silently hang your workers. Set it explicitly. Every time.

**Skipping SKIP LOCKED.** Using plain `FOR UPDATE` instead of `FOR UPDATE SKIP LOCKED` serializes all your workers through a single row. One missing keyword, all your concurrency gone.

## Conclusion

Start with `SKIP LOCKED` before reaching for advisory locks. Layer advisory locks only when you need job-type coordination or per-resource throttling. Use transaction-level locks and set an explicit `lock_timeout`.

Defer Redis until you pass ~1,000 jobs/minute or need sub-50ms p99. Below that, one fewer infrastructure component to monitor is worth the latency tradeoff — and when you do outgrow Postgres, migration is straightforward if your job interface is clean.

If you are the kind of engineer who ends up staring at `pg_stat_activity` dashboards for hours while debugging lock contention, your body is doing its own form of resource starvation. I use [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) to force break reminders during those deep sessions — honestly helps me spot deadlock patterns faster when I come back fresh.

**Further reading:**
- [PostgreSQL Advisory Locks documentation](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS)
- [SKIP LOCKED in the SELECT docs](https://www.postgresql.org/docs/current/sql-select.html)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)