Tools
You Probably Don't Need Redis for Distributed Locking
2025-12-25
0 views
admin
Introduction ## Problem statement ## Available solutions ## Pros and cons: Postgres advisory locks vs Redis locks ## Code example: PostgreSQL advisory lock (Spring Boot) ## Code example: Redis lock (SET NX EX + token + Lua release + renewal + SCAN) ## Security considerations ## Behaviour with read replicas (Postgres) ## Summary 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. 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: This post walks through the trade-offs, real-world pitfalls, and production-ready code patterns for both approaches so you can choose confidently. You need a mechanism to enforce mutual exclusion across processes (possibly running on different hosts) that: Additionally, you want to keep operational complexity low and avoid rare but catastrophic failures like split-brain or data corruption. This post compares the two most common pragmatic choices: Postgres advisory locks and Redis locks. 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. Notes & best practices This pattern shows safe acquire, atomic release using Lua, optional renewal for long-running actions, and SCAN usage for key iteration. Acquire-with-retry + renewal example (outline) If not acquired: sleep with jitter and retry until timeout. SCAN usage for key iteration Replace jedis.keys(pattern) with an iterative SCAN: Important Redis caveats For Postgres advisory locks Advisory locks live on a single Postgres instance (on the server’s lock manager). Important implications: When to use PostgreSQL advisory locks When to use Redis locks Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK:
// AdvisoryLockService.java
@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); }
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// AdvisoryLockService.java
@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); }
} COMMAND_BLOCK:
// AdvisoryLockService.java
@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); }
} COMMAND_BLOCK:
// RedisLockingConfigStore.java (snippets) 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 String tryAcquire(Jedis jedis, String lockKey, String lockValue, int ttlSeconds) { // SET key value NX EX ttl return jedis.set(lockKey, lockValue, SetParams.setParams().nx().ex(ttlSeconds));
} private void release(JedisPool pool, String lockKey, String lockValue) { try (Jedis jedis = pool.getResource()) { jedis.eval(RELEASE_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); }
} // Simple renewer thread (daemon) to extend TTL while action runs
private Thread startRenewer(JedisPool pool, String lockKey, String lockValue, int ttlSeconds, AtomicBoolean running) { Thread renewer = new Thread(() -> { try (Jedis jedis = pool.getResource()) { while (running.get()) { Thread.sleep((ttlSeconds * 1000L) / 2); // sleep half the TTL String cur = jedis.get(lockKey); if (lockValue.equals(cur)) { jedis.expire(lockKey, ttlSeconds); // renew } else { break; // lock lost } } } catch (InterruptedException ignored) { } }); renewer.setDaemon(true); renewer.start(); return renewer;
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// RedisLockingConfigStore.java (snippets) 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 String tryAcquire(Jedis jedis, String lockKey, String lockValue, int ttlSeconds) { // SET key value NX EX ttl return jedis.set(lockKey, lockValue, SetParams.setParams().nx().ex(ttlSeconds));
} private void release(JedisPool pool, String lockKey, String lockValue) { try (Jedis jedis = pool.getResource()) { jedis.eval(RELEASE_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); }
} // Simple renewer thread (daemon) to extend TTL while action runs
private Thread startRenewer(JedisPool pool, String lockKey, String lockValue, int ttlSeconds, AtomicBoolean running) { Thread renewer = new Thread(() -> { try (Jedis jedis = pool.getResource()) { while (running.get()) { Thread.sleep((ttlSeconds * 1000L) / 2); // sleep half the TTL String cur = jedis.get(lockKey); if (lockValue.equals(cur)) { jedis.expire(lockKey, ttlSeconds); // renew } else { break; // lock lost } } } catch (InterruptedException ignored) { } }); renewer.setDaemon(true); renewer.start(); return renewer;
} COMMAND_BLOCK:
// RedisLockingConfigStore.java (snippets) 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 String tryAcquire(Jedis jedis, String lockKey, String lockValue, int ttlSeconds) { // SET key value NX EX ttl return jedis.set(lockKey, lockValue, SetParams.setParams().nx().ex(ttlSeconds));
} private void release(JedisPool pool, String lockKey, String lockValue) { try (Jedis jedis = pool.getResource()) { jedis.eval(RELEASE_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); }
} // Simple renewer thread (daemon) to extend TTL while action runs
private Thread startRenewer(JedisPool pool, String lockKey, String lockValue, int ttlSeconds, AtomicBoolean running) { Thread renewer = new Thread(() -> { try (Jedis jedis = pool.getResource()) { while (running.get()) { Thread.sleep((ttlSeconds * 1000L) / 2); // sleep half the TTL String cur = jedis.get(lockKey); if (lockValue.equals(cur)) { jedis.expire(lockKey, ttlSeconds); // renew } else { break; // lock lost } } } catch (InterruptedException ignored) { } }); renewer.setDaemon(true); renewer.start(); return renewer;
} COMMAND_BLOCK:
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;
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
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;
} COMMAND_BLOCK:
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;
} - PostgreSQL advisory locks — lightweight, DB-managed mutexes.
- Redis-based locks (SET NX EX + owner token + atomic release) — popular for their speed and network friendliness. - 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. - 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 seconds for 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. - Use pg_try_advisory_xact_lock (transaction-scoped) to avoid leaking locks through pooled sessions. If your code uses pg_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 in REQUIRES_NEW.
- Use parameterized queries (?) to avoid injection; hash the key with hashtext() or your own stable hash to map to 64-bit key(s). - Generate lockValue = UUID.randomUUID().toString().
- Attempt SET NX EX with 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.
- 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. - Start a renewer thread that periodically extends TTL while verifying ownership.
- Execute action; finally, stop the renewer and run the atomic release Lua script. - 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 KEYS command in production; prefer SCAN. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - Advisory locks are simple, safe, and correct for DB-centric workflows — use pg_try_advisory_xact_lock inside 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 KEYS in Redis — use SCAN.
- 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.
how-totutorialguidedev.toaiservernetworkfirewallpostgresqlnodedatabase