It’s 02:14 AM. Your alert manager fires: an account balance dropped below zero, or a user received two trial credits instead of one. You trace the logs across your Kubernetes cluster. Two pods received near-simultaneous webhook calls for the exact same entity—5 milliseconds apart. Both pods executed: Pod A: Reads balance ($100) -> Validates -> Dispatches payout -> Updates balance ($0) Pod B: Reads balance ($100) -> Validates -> Dispatches payout -> Updates balance ($0) Enter fullscreen mode Exit fullscreen mode Both pods saw the balance before either could update it. Two payouts occurred; the business took the loss. If you have spent any time maintaining distributed systems, you know this story. But when teams sit down to fix it, they often leap straight into architectural extremes—either slapping a useless synchronized keyword onto a Spring service method, or dragging in an entire Redis cluster with Redisson just to serialize one critical action. There is a sweet spot right in front of you. If PostgreSQL is your primary datastore, you already possess a robust distributed lock manager: PostgreSQL Advisory Locks. Table of Contents Why Standard Concurrency Tools Break in a Cluster What Are PostgreSQL Advisory Locks? Session-Level vs. Transaction-Level The HikariCP Connection Trap The Lock Functions You Need to Know Designing a Production-Ready Implementation 1. Generating 64-Bit Keys from Arbitrary Strings 2. The Advisory Lock Repository 3. A Clean Functional Lock Template 4. A Realistic Scenario: First-Time Welcome Reward The "Secret Weapon": Lock Timeouts in PostgreSQL Verifying Concurrency with Testcontainers Production Gotchas and When NOT to Use Them Architectural Comparison: Picking the Right Tool Summary Why Standard Concurrency Tools Break in a Cluster Before looking at advisory locks, let's understand why traditional tools fall short. sequenceDiagram autonumber participant Client participant Pod1 as Spring Boot (Pod 1) participant Pod2 as Spring Boot (Pod 2) participant DB as PostgreSQL Client->>Pod1: POST /rewards/claim (User 42) Client->>Pod2: POST /rewards/claim (User 42) Note over Pod1,Pod2: JVM locks (synchronized) only protect within the same node Pod1->>DB: SELECT has_claimed FROM rewards WHERE user_id = 42 Pod2->>DB: SELECT has_claimed FROM rewards WHERE user_id = 42 DB-->>Pod1: false DB-->>Pod2: false Pod1->>DB: INSERT INTO rewards (user_id) VALUES (42) Pod2->>DB: INSERT INTO rewards (user_id) VALUES (42) Note over DB: Race condition! Without constraints, duplicate rows occur. 1. In-Memory JVM Locks (synchronized, ReentrantLock) Java's native synchronization primitives coordinate threads within a single JVM. The moment you run two pods behind an Application Load Balancer, your in-memory lock table is completely partitioned. Pod 1 knows nothing about Pod 2’s monitors. 2. Pessimistic Row Locking (SELECT ... FOR UPDATE) Row-level locks are powerful, but they come with two severe limitations: The row must already exist. If your task is "create this user’s default ledger account if it doesn't exist yet," there is no row to lock. You get a classic "check-then-act" race. Table bloat and domain coupling. Locking a row acquires locks within PostgreSQL’s tuple storage system, locking out concurrent readers/writers on adjacent columns, creating MVCC churn, and coupling business workflows directly to table rows. 3. Redis / Redisson Distributed Locks Spinning up Redis to handle distributed locking is common, but it introduces: Extra operational complexity (failover clusters, sentinel/cluster topology, backup, patching). The dual-system failure problem: network partitions between your app, Redis, and PostgreSQL can leave locks dangling or create split-brain states unless you implement fencing tokens correctly. Unnecessary infrastructure cost if your PostgreSQL database is already provisioned and has plenty of capacity. What Are PostgreSQL Advisory Locks? PostgreSQL advisory locks are application-defined locks managed directly in the database server's memory (shared_buffers / lock table). The key insight: PostgreSQL doesn’t attach any data semantics to an advisory lock. It doesn't lock a row, a page, or a table. It simply holds an integer key in memory that other connections can check or wait on. graph TD subgraph PostgreSQL Server A[Lock Manager Shared Memory] B[(Tuples / Tables)] end subgraph Clients P1[Pod 1 Connection] P2[Pod 2 Connection] end P1 -->|pg_try_advisory_xact_lock: 9481029| A P2 -.->|pg_try_advisory_xact_lock: 9481029 rejected!| A P1 -->|Only writes data after lock is granted| B Session-Level vs. Transaction-Level PostgreSQL provides two flavors of advisory locks. Confusing them in a connection-pooled application like Spring Boot will cause catastrophic bugs. Feature Session-Level (pg_advisory_lock) Transaction-Level (pg_advisory_xact_lock) Lifecycle Held until manually unlocked or connection closes Automatically released when transaction ends (COMMIT/ROLLBACK) Unlock mechanism Explicit call to pg_advisory_unlock PostgreSQL handles it on transaction completion Connection Pool Safety High risk! Leaks locks if connection returns to HikariCP Safe! Cannot leak beyond transaction boundaries PgBouncer Compatibility Incompatible with transaction pooling mode Fully compatible with transaction pooling mode The HikariCP Connection Trap With HikariCP (Spring Boot’s default pool), physical database connections are reused across different HTTP requests and threads. If you acquire a session-level lock (pg_advisory_lock) on Connection A and an unhandled exception prevents your unlock code from running, Connection A goes back into the Hikari pool while still holding the lock in Postgres. Later, another thread picks up Connection A, inheriting the lock. Worse, other threads requesting the lock on other connections hang indefinitely. For Spring Boot applications, transaction-level advisory locks (pg_advisory_xact_lock) are almost always the correct choice. When Spring’s transaction commits or rolls back, PostgreSQL automatically frees the lock. Even if your pod crashes mid-execution with kill -9, the TCP socket drops, the transaction aborts, and PostgreSQL clears the lock immediately. The Lock Functions You Need to Know PostgreSQL exposes a compact family of advisory functions: pg_advisory_xact_lock(bigint): Blocks until the lock is acquired. Automatically released at the end of the current transaction. pg_try_advisory_xact_lock(bigint): Non-blocking. Returns true if acquired immediately, false if another transaction holds it. pg_advisory_xact_lock(integer, integer): 2-part key variant. Useful for composite IDs like (tenant_id, entity_id). Designing a Production-Ready Implementation Let's build a clean, idiomatic locking mechanism for Spring Boot. 1. Generating 64-Bit Keys from Arbitrary Strings PostgreSQL advisory locks accept either a single 64-bit integer (bigint / Java long) or two 32-bit integers (int). In enterprise applications, we usually want to lock on domain identifiers: "wallet:claim:user_99214" or "order:settle:ORD-2026-901". We need a deterministic, low-collision function to map any String to a long. A truncated SHA-256 hash or MurmurHash3 works well here: package com.example.locking.util; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; public final class LockKeyGenerator { private LockKeyGenerator() {} /** * Hashes a string identifier to a 64-bit signed long for PostgreSQL advisory locks. */ public static long toLongKey(String businessKey) { if (businessKey == null || businessKey.isBlank()) { throw new IllegalArgumentException("Lock key must not be blank"); } try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(businessKey.getBytes(StandardCharsets.UTF_8)); // Read the first 8 bytes of the SHA-256 hash as a long return ByteBuffer.wrap(hash).getLong(); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 algorithm missing from JVM", e); } } } Enter fullscreen mode Exit fullscreen mode What about hash collisions? A 64-bit space allows for $1.8 \times 10^{19}$ possible values. If two completely unrelated keys collide (which is exceptionally rare), the consequence is benign: one process briefly waits for the other, or fails fast. Data corruption never occurs. 2. The Advisory Lock Repository Instead of fighting JPA's entity lifecycle, use JdbcTemplate for raw advisory lock calls. It ensures the query runs on the exact physical connection bound to Spring’s active transaction. package com.example.locking.repository; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; @Repository public class PostgresAdvisoryLockRepository { private final JdbcTemplate jdbcTemplate; public PostgresAdvisoryLockRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } /** * Non-blocking attempt to acquire a transaction-scoped advisory lock. * Returns true if granted, false if already held by another transaction. */ public boolean tryAcquireTransactionLock(long lockKey) { Boolean acquired = jdbcTemplate.queryForObject( "SELECT pg_try_advisory_xact_lock(?)", Boolean.class, lockKey ); return Boolean.TRUE.equals(acquired); } /** * Blocking call to acquire a transaction-scoped advisory lock. * Waits until the lock is available. */ public void acquireTransactionLock(long lockKey) { jdbcTemplate.queryForObject( "SELECT pg_advisory_xact_lock(?)", Void.class, lockKey ); } } Enter fullscreen mode Exit fullscreen mode 3. A Clean Functional Lock Template Annotations (like @AdvisoryLocked) look nice on paper, but aspect-oriented locks often obscure transaction boundaries. If the lock is acquired before @Transactional begins, or released after it commits, race conditions sneak back in. A functional template makes the transaction and lock scope explicit: package com.example.locking.service; import com.example.locking.exception.LockAcquisitionException; import com.example.locking.repository.PostgresAdvisoryLockRepository; import com.example.locking.util.LockKeyGenerator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.function.Supplier; @Component public class AdvisoryLockTemplate { private static final Logger log = LoggerFactory.getLogger(AdvisoryLockTemplate.class); private final PostgresAdvisoryLockRepository lockRepository; public AdvisoryLockTemplate(PostgresAdvisoryLockRepository lockRepository) { this.lockRepository = lockRepository; } /** * Executes the supplier within a MANDATORY or NEW transaction holding an advisory lock. * Fails fast if the lock cannot be acquired immediately. */ @Transactional(propagation = Propagation.REQUIRED) public T executeWithTryLock(String lockKey, Supplier task) { long numericKey = LockKeyGenerator.toLongKey(lockKey); log.debug("Attempting to acquire lock for key '{}' (hash: {})", lockKey, numericKey); boolean acquired = lockRepository.tryAcquireTransactionLock(numericKey); if (!acquired) { log.warn("Failed to acquire lock for key: {}. Another process is executing.", lockKey); throw new LockAcquisitionException("Unable to acquire lock for: " + lockKey); } log.debug("Lock successfully acquired for key: {}", lockKey); try { return task.get(); } finally { // Note: pg_advisory_xact_lock is freed automatically when the transaction completes. log.debug("Task completed. Lock for key '{}' will release on TX commit/rollback.", lockKey); } } } Enter fullscreen mode Exit fullscreen mode Custom exception for clean HTTP 409 (Conflict) mappings: package com.example.locking.exception; public class LockAcquisitionException extends RuntimeException { public LockAcquisitionException(String message) { super(message); } } Enter fullscreen mode Exit fullscreen mode 4. A Realistic Scenario: First-Time Welcome Reward Let’s look at a realistic use case: granting a first-time signup bonus or wallet reward. Two requests hit different pods at the same time. The user record has no reward row yet, so SELECT ... FOR UPDATE cannot help us. sequenceDiagram autonumber participant Pod1 as Pod 1 (Lock Acquired) participant Pod2 as Pod 2 (Lock Denied) participant DB as Postgres Lock Manager Pod1->>DB: SELECT pg_try_advisory_xact_lock(hash("wallet:reward:user-100")) DB-->>Pod1: true (Lock Granted) Pod2->>DB: SELECT pg_try_advisory_xact_lock(hash("wallet:reward:user-100")) DB-->>Pod2: false (Locked by Pod 1) Pod2-->>Pod2: Throw LockAcquisitionException (Fast Fail) Pod1->>Pod1: Check if already rewarded (false) Pod1->>Pod1: Credit Wallet & Insert Audit Log Pod1->>DB: COMMIT Transaction DB-->>Pod1: Lock automatically dropped! Here is the service implementation: package com.example.rewards.service; import com.example.locking.service.AdvisoryLockTemplate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; @Service public class RewardClaimService { private static final Logger log = LoggerFactory.getLogger(RewardClaimService.class); private final AdvisoryLockTemplate lockTemplate; private final RewardRepository rewardRepository; private final WalletService walletService; public RewardClaimService(AdvisoryLockTemplate lockTemplate, RewardRepository rewardRepository, WalletService walletService) { this.lockTemplate = lockTemplate; this.rewardRepository = rewardRepository; this.walletService = walletService; } public RewardClaimResult claimWelcomeBonus(String userId) { String lockKey = "reward:claim:welcome:" + userId; return lockTemplate.executeWithTryLock(lockKey, () -> { // Safe critical section across the entire cluster! if (rewardRepository.existsByUserIdAndType(userId, "WELCOME_BONUS")) { log.info("User {} already received the welcome bonus", userId); return RewardClaimResult.alreadyClaimed(); } log.info("Granting bonus to user {}", userId); walletService.credit(userId, new BigDecimal("25.00")); rewardRepository.recordClaim(userId, "WELCOME_BONUS"); return RewardClaimResult.success(new BigDecimal("25.00")); }); } } Enter fullscreen mode Exit fullscreen mode The "Secret Weapon": Lock Timeouts in PostgreSQL What if you don't want to fail immediately with try_lock, but you also don't want your threads to wait indefinitely and starve your HikariCP pool? PostgreSQL doesn’t have a pg_advisory_lock_with_timeout(key, duration) function. However, it does have SET LOCAL lock_timeout. When set inside a transaction, lock_timeout applies to all lock requests in that transaction—including advisory locks! public void acquireTransactionLockWithTimeout(long lockKey, int timeoutMillis) { // 1. Set local timeout for this transaction only jdbcTemplate.execute("SET LOCAL lock_timeout = '" + timeoutMillis + "ms'"); // 2. Will wait up to timeoutMillis; throws PSQLException with SQLState 55P03 if exceeded jdbcTemplate.queryForObject("SELECT pg_advisory_xact_lock(?)", Void.class, lockKey); } Enter fullscreen mode Exit fullscreen mode If the lock cannot be acquired within the specified duration, Postgres aborts the lock request with: ERROR: canceling statement due to lock timeout (SQLState: 55P03) This gives you a bounded, distributed queue without needing complex polling or backoff logic in Java. Verifying Concurrency with Testcontainers You should never trust distributed concurrency code without an integration test that actually fires simultaneous threads against a real PostgreSQL instance. Here is a full Testcontainers-based test verifying that concurrent requests for the same lock key are cleanly serialized or rejected: package com.example.locking; import com.example.locking.exception.LockAcquisitionException; import com.example.locking.service.AdvisoryLockTemplate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest @Testcontainers class AdvisoryLockIntegrationTest { @Container static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine"); @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } @Autowired private AdvisoryLockTemplate lockTemplate; @Test @DisplayName("Simultaneous threads on the same key must only allow one execution") void testConcurrentAccess() throws Exception { int threadCount = 8; ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch readyLatch = new CountDownLatch(threadCount); CountDownLatch startLatch = new CountDownLatch(1); AtomicInteger successCounter = new AtomicInteger(0); AtomicInteger lockDeniedCounter = new AtomicInteger(0); String sharedResourceKey = "account:sync:USR-123"; for (int i = 0; i { readyLatch.countDown(); try { startLatch.await(); // Synchronize thread release lockTemplate.executeWithTryLock(sharedResourceKey, () -> { successCounter.incrementAndGet(); // Hold lock briefly to force collision Thread.sleep(100); return null; }); } catch (LockAcquisitionException e) { lockDeniedCounter.incrementAndGet(); } catch (Exception e) { // unexpected } }); } // Wait until all threads are prepped readyLatch.await(5, TimeUnit.SECONDS); // Fire all threads simultaneously startLatch.countDown(); executor.shutdown(); boolean completed = executor.awaitTermination(5, TimeUnit.SECONDS); assertThat(completed).isTrue(); // Exactly one thread should succeed; remaining 7 must be rejected assertThat(successCounter.get()).isEqualTo(1); assertThat(lockDeniedCounter.get()).isEqualTo(threadCount - 1); } } Enter fullscreen mode Exit fullscreen mode Production Gotchas and When NOT to Use Them PostgreSQL advisory locks are powerful, but senior engineers understand where tools fail. Keep these three points in mind: 1. Connection Starvation During Slow External I/O If your locked critical section makes a slow third-party API call (e.g., waiting 5 seconds for Stripe or PayPal), your application keeps the underlying database connection checked out from HikariCP for that entire duration. graph LR subgraph Anti-Pattern A[Acquire Advisory Lock] --> B[Hold DB Connection] B --> C[Wait for Slow 3rd Party HTTP API 3-5s] C --> D[HikariCP Pool Starvation!] end Rule of thumb: Keep advisory-locked transactions lean. Execute validation, DB writes, and state transitions inside the lock. If you must call an external API, consider optimistic state transitions (e.g., status = PENDING_PAYMENT) committed to the database, or use an external lock manager like Redis if the database connection pool cannot absorb the latency. 2. PgBouncer and Transaction Pooling If you use PgBouncer in front of PostgreSQL: Session-level locks (pg_advisory_lock) are broken in pool_mode = transaction. PgBouncer may assign subsequent queries in the same client session to different Postgres server backends! Transaction-level locks (pg_advisory_xact_lock) work flawlessly because PgBouncer pins the client to a single physical backend connection for the exact lifetime of the transaction. 3. Postgres Shared Memory Limits Advisory locks are stored in PostgreSQL's shared memory, governed by max_locks_per_transaction (default typically 64) multiplied by max_connections. While tens of thousands of active transaction locks are rarely an issue, do not treat advisory locks as long-term persistent flags. They are transient execution guards. Architectural Comparison: Picking the Right Tool Requirement Java synchronized PostgreSQL Advisory Lock Redis (Redisson) DB Row Lock (SELECT FOR UPDATE) Clustered / Multi-Node ❌ No ✅ Yes ✅ Yes ✅ Yes Protects Non-Existent Records N/A ✅ Yes ✅ Yes ❌ No (requires existing row) External Infrastructure None None (uses existing DB) Requires Redis cluster None (uses existing DB) Auto-Cleanup on Crash ✅ Yes ✅ Yes (with xact locks) ⚠️ Requires TTL / watchdogs ✅ Yes Safe with Slow HTTP I/O ✅ Yes ❌ Risks DB pool starvation ✅ Yes ❌ Risks DB pool starvation Operational Overhead Zero Zero Moderate to High Zero Summary Before introducing Redis, ZooKeeper, or Hazelcast to solve distributed race conditions in your Spring Boot services, look closely at your existing PostgreSQL database. By combining pg_try_advisory_xact_lock with Spring’s @Transactional boundaries: You get true cluster-wide serialization across any number of pods. You eliminate dangling locks through automatic transaction-scoped cleanup. You save your organization from operating and monitoring an additional distributed caching cluster. Simplicity is an architectural superpower. Sometimes the most resilient solution is the one already running in your stack.
Eliminating Race Conditions in Clustered Spring Boot with PostgreSQL Advisory Locks
Full Article
Original Source
Read the full article at Dev →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.