1. Hook

I almost added Redis just to prevent two cron jobs from running at the same time.

The ticket was half-written: provision Redis, wire up a client, pick a locking library, handle its failure modes, add it to the deploy pipeline, add it to the on-call runbook. All to make sure only one application instance runs a scheduled job at a time.

Then the obvious question: I already have PostgreSQL. It’s already the source of truth, already up, already in every instance’s connection pool. Why add a second coordination system for a problem the first one solves?

This pattern repeats everywhere. A team hits a coordination problem — leader election, mutual exclusion, “only one of us do this” — and reaches for whatever has “distributed” in its reputation: Redis, ZooKeeper, etcd. Meanwhile the relational database already running under the app has had a built-in locking primitive for this exact problem for over a decade. It’s called an advisory lock. Most engineers have never touched it.

2. The Problem

The setup is unremarkable, which is why it’s common: multiple instances of the same application, deployed for availability and scale. One scheduled job — clean up expired sessions, roll up daily stats, send a batch of notifications — that should run exactly once per interval.

Without coordination, every instance runs it. Every instance thinks it’s the only one. They query the same rows, update the same rows, send the same notifications twice, or race each other writing the same aggregate.

None of this shows up in staging, where one instance runs. It shows up in production, at 3 AM, as duplicate emails, double-counted revenue, or a unique constraint violation that pages someone. The job wasn’t wrong. The assumption that only one copy would run was.

3. Common Solutions

Each of these genuinely solves the problem. Each also brings a bill.

Redis locks. Fast, familiar, the default reach. But a correct distributed lock needs more than SETNX — expiry, fencing tokens, ideally Redlock to survive node failures without losing mutual exclusion. Now you're operating a second stateful system, with its own uptime and client library to patch, for a job that runs once an hour.

ZooKeeper. Battle-tested, strongly consistent, built for exactly this. Also a JVM ensemble with real operational weight — quorum management, snapshot tuning, client sessions to babysit. Fine if you already run it for Kafka. Heavy if you don’t.

etcd. Cleaner than ZooKeeper, the backbone of Kubernetes. Still a separate Raft cluster to run, back up, upgrade. New infrastructure for an old problem if you’re not already operating it.

Kubernetes leader election. Convenient if you’re already on k8s — a Lease object, client-go's election helpers, nothing new to run. The catch: coordination logic gets coupled to the orchestrator. Move off k8s, and the mechanism goes with it.

All four correctly answer “how do multiple processes coordinate.” None is the cheapest answer if you already run PostgreSQL and the need is this simple.

4. PostgreSQL Advisory Locks

PostgreSQL has had advisory locks since 8.2 — application-defined locks, identified by a number you choose, living inside the database’s lock manager. They don’t lock any table or row. They lock whatever you decide the number means.

-- Non-blocking: returns immediately, true if acquired, false if not
SELECT pg_try_advisory_lock(123456);

-- Blocking: waits until the lock is free
SELECT pg_advisory_lock(123456);

-- Release
SELECT pg_advisory_unlock(123456);

Two flavors matter:

Session locks (pg_advisory_lock / pg_try_advisory_lock) hold until explicitly unlocked, or the session ends. Good for holding a lock across a long-running job.

Transaction locks (pg_advisory_xact_lock) release automatically at commit or rollback — no explicit unlock. Good for locking exactly one unit of work.

The behavior that makes this useful for cron coordination is pg_try_advisory_lock(): non-blocking. No queue, no wait, no timeout — just true or false, immediately. That maps directly onto "one instance wins, everyone else skips this run."

From my production Rust service, scheduling periodic jobs across several instances:

use sqlx::PgPool;

const CLEANUP_JOB_LOCK_ID: i64 = 727_361_004;
async fn run_if_leader(pool: &PgPool) -> Result<(), sqlx::Error> {
    let mut conn = pool.acquire().await?;
    let acquired: bool = sqlx::query_scalar("SELECT pg_try_advisory_lock($1)")
        .bind(CLEANUP_JOB_LOCK_ID)
        .fetch_one(&mut *conn)
        .await?;
    if !acquired {
        tracing::info!("cleanup job already running elsewhere, skipping");
        return Ok(());
    }
    let result = run_cleanup_job(&mut conn).await;
    sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
        .bind(CLEANUP_JOB_LOCK_ID)
        .fetch_one(&mut *conn)
        .await?;
    result
}

Every instance’s scheduler fires on the same interval and calls run_if_leader. Exactly one gets true back and does the work. The rest log a skip and move on. No election protocol, no heartbeats, no separate cluster — a query against the database every instance already talks to.

5. Why It Works

No extra infrastructure. The lock lives in the PostgreSQL instance the app already depends on. Nothing new to provision, monitor, patch, or explain in an incident review.

The database is already shared state. Every instance already holds a connection pool to Postgres. Coordination piggybacks on infrastructure that already needs to be up for the app to function — no new single point of failure, since the old one was already load-bearing.

Automatic release on disconnect. If a session dies — crash, dropped connection — Postgres releases its session-level advisory locks automatically. No stale lock, no manual cleanup, no TTL to tune. Simpler here than a naive Redis lock, which needs explicit expiry to avoid the same problem.

Simple API. Three functions, no library, no client SDK, no protocol. pg_try_advisory_lock, pg_advisory_unlock, done.

Production reliability. Running over a year, across rolling deploys, restarts, connection pool churn — zero duplicate job executions, zero stuck locks. The failure modes are the database’s failure modes, which I’m already monitoring.

6. Lessons Learned

  • Don’t add infrastructure without a reason. “Distributed systems use Redis for locks” is a pattern, not a law. Check what you already run — a new stateful dependency is paid for every day, not just the day you add it.
  • Your database can coordinate more than data. Advisory locks, LISTEN/NOTIFY, SELECT ... FOR UPDATE SKIP LOCKED — Postgres is a coordination primitive, not just a store, and one you already operate correctly.
  • Simplicity often wins. The Redis version would work. It would also be a second thing to keep alive, a second client to update, a second dashboard to check mid-incident. The Postgres version is three function calls against infrastructure I already trust.

The instinct to reach for a specialized coordination tool isn’t wrong — some problems need one. But check what’s already running first. Sometimes the database you treat as “just storage” was a coordination service the whole time.