TL;DR
PostgreSQL advisory locks are the simplest and safest choice when your app is already using Postgres, and you want correctness (mutual exclusion and crash safety) with minimal operational complexity. Redis locks (SET NX EX + owner token + Lua release) are flexible, fast, and useful when you need cross-database/technology coordination or lower-latency coordination. Still, they require careful handling (atomic release, TTL vs action duration, renewal, and secure deployment) to avoid split-brain and lost-mutual-exclusion scenarios.
Introduction
Distributed locks coordinate access to shared resources across multiple processes or machines. Correct locking avoids race conditions, duplicate work, and data corruption. Two common primitives for implementing distributed locks are:
- PostgreSQL advisory locks — lightweight, DB-managed mutexes.
- Redis-based locks (SET NX EX + owner token + atomic release) — popular for their speed and network friendliness.
This post walks through the trade-offs, real-world pitfalls, and production-ready code patterns for both approaches so you can choose confidently.
Problem statement
You need a mechanism to enforce mutual exclusion across processes (possibly running on different hosts) that:
- Guarantees only one client can hold the lock at a time (mutual exclusion).
- Releases the lock on client failure (crash-safety / no ghost locks).
- Supports reasonable timeouts/retries.
- Works with connection pools and long-running critical sections, or at least provides safe patterns for those cases.
Additionally, you want to keep operational complexity low and avoid rare but catastrophic failures like split-brain or data corruption.
Available solutions
High-level options:
-
Postgres advisory locks (
pg_try_advisory_xact_lock,pg_advisory_xact_lock,pg_try_advisory_lock,pg_advisory_lock) — stored in Postgres lock manager, tied to session or transaction. -
Redis locks using
SET key value NX EX secondsfor acquire, plus an atomic Lua script to release only if the stored owner token matches. - External coord systems — etcd, Consul, ZooKeeper — provide strong coordination guarantees at the cost of additional infra.
- Table-based locks in Postgres (a durable table row representing lock state) — works but is more complex (needs fencing tokens, cleanup) and slower.
This post compares the two most common pragmatic choices: Postgres advisory locks and Redis locks.
Pros and cons: Postgres advisory locks vs Redis locks
| Aspect | PostgreSQL advisory locks | Redis locks (SET NX EX + token + Lua) |
|---|---|---|
| Mutual exclusion | ✅ Strong — enforced by Postgres lock manager | ✅ Strong if implemented correctly (atomic acquisition + correct release) |
| Crash safety | ✅ If transaction-scoped: lock released when session/transaction ends (DB manages lifecycle) | ✅ If TTL short & renewal used carefully; otherwise risk if TTL expires while holder still running |
| Durability / WAL | Advisory locks are an in-memory lock manager (not WAL) but bound to the DB session/transaction. Locks survive a process crash because the DB closes the session. | Not durable — lock state is in Redis memory. If Redis fails, locks disappear. |
| Lock lifetime control | Tied to transaction or session lifecycle; safe and automatic | TTL-based; you must manage expiry vs action duration, plus optional renewal |
| Blocking vs non-blocking | Both options: blocking (pg_advisory_xact_lock) or non-blocking (pg_try_advisory_xact_lock) |
Non-blocking via SET NX EX; blocking behavior implemented via client-side retry |
| Ease of implementation | Very simple when you already use Postgres; few lines of SQL | Simple to acquire, but correct release requires Lua and careful handling (owner token, renewal) |
| Scalability / performance | Very fast for typical web scale; limited by DB as central point | Very fast and lightweight; Redis is designed for high throughput |
| Failover / HA complexity | If DB primary changes (failover), clients must reconnect to primary for locks to be meaningful; advisory locks are local to each instance. | Redis cluster & sentinel/replica setups have their own failover semantics — must be careful not to use a single node without protection |
| Replication / read replicas | Advisory locks live on the server holding them; read replicas do not replicate lock state — clients must acquire locks on the primary. | Redis replication does not replicate ephemeral ownership at the right times if failover occurs; use proper Redis HA patterns |
| Tooling & observability |
pg_locks view to inspect advisory locks |
Redis keys / TTL show lock state; use scripts to inspect |
| Security considerations | Uses DB auth + SQL parameters; avoid SQL injection in key hashing | Requires secure Redis (AUTH, TLS, ACLs), avoid exposing Redis publicly |
| Best fit | If your application already uses Postgres and the critical section should be protected by DB transaction semantics | If you need very low-latency locks, a separate coordination layer, or have multi-database/technology consumers |
Code example: PostgreSQL advisory lock (Spring Boot)
Below is a production-ready transaction-scoped pattern using pg_try_advisory_xact_lock with retry, jitter, and safe transaction semantics. This pattern is safe with connection pools (HikariCP) and ensures the lock is released at commit/rollback time.
@Service
public class AdvisoryLockService {
private final JdbcTemplate jdbcTemplate;
private static final Duration LOCK_RETRY_INTERVAL = Duration.ofMillis(50);
public AdvisoryLockService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public <T> T withLock(String lockKey, Duration timeout, ThrowingSupplier<T> action) {
long deadline = System.nanoTime() + timeout.toNanos();
while (System.nanoTime() < deadline) {
Boolean acquired = jdbcTemplate.queryForObject(
"SELECT pg_try_advisory_xact_lock(hashtext(?))",
Boolean.class,
lockKey
);
if (Boolean.TRUE.equals(acquired)) {
try {
return action.get(); // runs inside same transaction
} catch (RuntimeException | Error e) {
throw e; // let Spring roll back the transaction
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// polite backoff + jitter
long jitterNanos = (long) (Math.random() * 20_000_000L); // 0-20ms
LockSupport.parkNanos(LOCK_RETRY_INTERVAL.toNanos() + jitterNanos);
}
throw new LockTimeoutException("Could not acquire lock: " + lockKey + " within " + timeout);
}
}
Notes & best practices
- Use
pg_try_advisory_xact_lock(transaction-scoped) to avoid leaking locks through pooled sessions. If your code usespg_advisory_lock(session-scoped), you must ensure the connection is closed or explicitly unlocked — not recommended with pools. - Keep the critical section short to avoid blocking DB resources.
- If the action opens new transactions with
REQUIRES_NEW, be careful: the new transaction is outside the transaction holding the advisory lock, so the lock may not protect work done inREQUIRES_NEW. - Use parameterized queries (
?) to avoid injection; hash the key withhashtext()or your own stable hash to map to 64-bit key(s).
Code example: Redis lock (SET NX EX + token + Lua release + renewal + SCAN)
This pattern shows safe acquire, atomic release using Lua, optional renewal for long-running actions, and SCAN usage for key iteration.
private static final String RELEASE_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
private void releaseLock(String lockKey, String lockValue) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.eval(RELEASE_SCRIPT, 1, lockKey, lockValue);
}
}
@Override
public <T> T withLock(String lockKey, Duration timeout, ThrowingSupplier<T> action) throws Throwable {
String lockValue = UUID.randomUUID().toString();
long deadline = System.nanoTime() + timeout.toNanos();
try (Jedis jedis = jedisPool.getResource()) {
while (System.nanoTime() < deadline) {
SetParams params = SetParams.setParams().nx().ex(timeout.toSeconds());
String result = jedis.set(lockKey, lockValue, params);
if ("OK".equals(result)) {
// Start lock renewal thread
Thread renewer = startLockRenewer(lockKey, lockValue, timeout);
try {
return action.get();
} finally {
renewer.interrupt(); // stop renewal
releaseLock(lockKey, lockValue);
}
}
LockSupport.parkNanos(LOCK_RETRY_INTERVAL_MS.toNanos());
}
}
throw new TimeoutException("Could not acquire lock: " + lockKey + " within " + timeout);
}
// Renew lock every half of TTL
private Thread startLockRenewer(String lockKey, String lockValue, Duration ttl) {
Thread thread = new Thread(() -> {
try (Jedis jedis = jedisPool.getResource()) {
while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(ttl.toMillis() / 2);
String current = jedis.get(lockKey);
if (lockValue.equals(current)) {
jedis.expire(lockKey, (int) ttl.toSeconds());
} else {
break; // lost lock
}
}
} catch (InterruptedException ignored) {
}
});
thread.setDaemon(true);
thread.start();
return thread;
}
Acquire-with-retry + renewal example (outline)
- Generate
lockValue = UUID.randomUUID().toString(). - Attempt
SET NX EXwith TTL = desired TTL (must be longer than expected action duration or use renewer). -
If acquired:
- Start a renewer thread that periodically extends TTL while verifying ownership.
- Execute action; finally, stop the renewer and run the atomic release Lua script.
If not acquired: sleep with jitter and retry until timeout.
SCAN usage for key iteration
Replace jedis.keys(pattern) with an iterative SCAN:
List<String> scanKeys(Jedis jedis, String pattern) {
String cursor = ScanParams.SCAN_POINTER_START;
List<String> out = new ArrayList<>();
ScanParams params = new ScanParams().match(pattern).count(100);
do {
ScanResult<String> res = jedis.scan(cursor, params);
cursor = res.getCursor();
out.addAll(res.getResult());
} while (!cursor.equals(ScanParams.SCAN_POINTER_START));
return out;
}
Important Redis caveats
-
SET NX EX+ value token + atomic Lua release is required to avoid deleting locks held by others. - TTL must be chosen carefully; renewal must be used if you expect actions to possibly exceed TTL.
- If Redis goes down and is restored, locks may disappear → be prepared for concurrent execution.
- Avoid
KEYScommand in production; preferSCAN.
Security considerations
General
- Treat locks as part of your security/consistency boundary: a compromised lock server = compromised coordination.
- Carefully validate and sanitize lock keys, especially if derived from user input. Use hashing and parameter binding where applicable.
For Postgres advisory locks
- Use DB credentials with least privilege — typically, your app DB user already has permission.
- Avoid passing raw user input directly into SQL text; use parameterized queries (
?) so the driver escapes values properly. - Ensure DB connections (and application) use TLS if DB is accessed over untrusted networks.
- Restrict who can run arbitrary SQL (admins) — advisory locks are just SQL functions.
For Redis locks
- Configure Redis with AUTH and ACLs; do not leave it unauthenticated on the network.
- Use TLS for Redis connections if the network is not fully trusted.
- Restrict Redis access via firewall / VPC security groups; never expose Redis to the public internet.
- Use unique random lock tokens (UUIDs) and never rely on predictable values.
- Use atomic release via a Lua script to prevent race conditions.
- Beware of Redis persistence/replication failover: if failover leads to split-brain, you can get double-acquire. Consider Redis Cluster, Sentinel or managed Redis offerings with proven HA.
Behaviour with read replicas (Postgres)
Advisory locks live on a single Postgres instance (on the server’s lock manager). Important implications:
- Locks are local to the server instance where they were taken — they are not replicated to read replicas.
- If your app reads from read replicas, those reads do not see or enforce advisory locks. This is fine — locks enforce mutual exclusion for writers/transactions that also use the same primary.
- Acquire and release locks on the primary (the node accepting writes). If your application sometimes connects to replicas for read-only operations and accidentally tries to take locks there, those locks will not coordinate with locks on the primary (bad).
- During primary failover, existing DB connections are dropped, and advisory locks are cleared — survivors should reconnect to the new primary and reacquire locks if required. That behaviour is safe (no phantom locks held without a live client), but it means locks don’t survive failover, and you must be tolerant of lock loss across failover.
- For high-availability locking across DB failover, advisory locks alone aren’t a silver bullet. If you need lock survival across failovers, consider an external coordination service (etcd, Consul) or design the application to re-acquire locks after reconnection.
Practical rules
- Always point lock acquisition calls to the current write primary. If you use a DB proxy/load-balancer, configure it to route lock-taking queries to the primary.
- Don’t rely on advisory locks for cross-cluster coordination where the cluster can partition; advisory locks work best for single-primary topologies.
Summary
When to use PostgreSQL advisory locks
- You already use Postgres, and your critical section is naturally tied to the DB transaction.
- You want correctness and simplicity: automatic release on session/transaction end, no separate lock service to run.
- You prefer to avoid external infra and want transactional semantics.
When to use Redis locks
- You need a separate coordination layer or ultra-low-latency locking across polyglot systems.
- You can invest in the required safeguards: atomic Lua release, TTL management, renewal, and robust Redis HA.
- You accept the operational complexity and the model where locks are TTL-based rather than transaction-bound.
Key takeaways
- Advisory locks are simple, safe, and correct for DB-centric workflows — use
pg_try_advisory_xact_lockinside transactional code with retries and jitter. - Redis locks are fast and flexible but require careful implementation (atomic release, renewal) to avoid split-brain and concurrency hazards.
- Avoid
KEYSin Redis — useSCAN. - Never use session-scoped Postgres locks with pooled connections unless you manage session lifecycles strictly.
- For heavy production usage, add logging, metrics, backoff + jitter, and robust error handling to whichever strategy you pick.
Top comments (0)