<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Shreyash Kakde</title>
    <description>The latest articles on DEV Community by Shreyash Kakde (@shreyash_kakde).</description>
    <link>https://dev.to/shreyash_kakde</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2964486%2F42edd220-c272-4a33-b23f-d84047c921e3.jpg</url>
      <title>DEV Community: Shreyash Kakde</title>
      <link>https://dev.to/shreyash_kakde</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shreyash_kakde"/>
    <language>en</language>
    <item>
      <title>Two Pods, One Job Table: A Safe Postgres Queue with SKIP LOCKED in Spring Boot</title>
      <dc:creator>Shreyash Kakde</dc:creator>
      <pubDate>Tue, 03 Nov 2026 09:56:30 +0000</pubDate>
      <link>https://dev.to/shreyash_kakde/two-pods-one-job-table-a-safe-postgres-queue-with-skip-locked-in-spring-boot-4l79</link>
      <guid>https://dev.to/shreyash_kakde/two-pods-one-job-table-a-safe-postgres-queue-with-skip-locked-in-spring-boot-4l79</guid>
      <description>&lt;p&gt;It's 10:20 AM on a Monday and support has forwarded three screenshots of the same "Your invoice is ready" email, received three times by one customer. The dashboards look fine. CPU is low, the error rate is zero. Your logs show this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10:15:00.112 [scheduling-1] INFO  c.a.n.NotificationWorker - picked 50 jobs (pod notif-7c9d-a)
10:15:00.115 [scheduling-1] INFO  c.a.n.NotificationWorker - picked 50 jobs (pod notif-7c9d-b)
10:15:00.118 [scheduling-1] INFO  c.a.n.NotificationWorker - picked 50 jobs (pod notif-7c9d-c)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three pods, one &lt;code&gt;notification_job&lt;/code&gt; table, the same 50 rows. The deployment went from one replica to three last week, and nobody asked what &lt;code&gt;@Scheduled&lt;/code&gt; does when it lives in three JVMs at once.&lt;/p&gt;

&lt;p&gt;If you already run PostgreSQL, a job table with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; is a reasonable queue for moderate volumes, and you don't need a broker for it. There are a few ways to get it subtly wrong, so this post goes through them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The innocent worker&lt;/li&gt;
&lt;li&gt;Why a plain SELECT hands out the same rows&lt;/li&gt;
&lt;li&gt;What FOR UPDATE alone does&lt;/li&gt;
&lt;li&gt;SKIP LOCKED&lt;/li&gt;
&lt;li&gt;Pattern 1: claim in a short transaction, work outside it&lt;/li&gt;
&lt;li&gt;Pattern 2: leases for crashed workers&lt;/li&gt;
&lt;li&gt;Keeping the table healthy&lt;/li&gt;
&lt;li&gt;Gotchas and when not to use this&lt;/li&gt;
&lt;li&gt;Checklist&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The innocent worker
&lt;/h2&gt;

&lt;p&gt;This is roughly what the first version looks like in most codebases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationWorker&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;NotificationJobRepository&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;EmailClient&lt;/span&gt; &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;NotificationWorker&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationJobRepository&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EmailClient&lt;/span&gt; &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;emailClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotificationJob&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
                &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findTop50ByStatusOrderByIdAsc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JobStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PENDING&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationJob&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getRecipient&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPayload&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JobStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DONE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With one pod this works. &lt;code&gt;@Scheduled&lt;/code&gt; is per JVM, so three pods mean three schedulers, and nothing in this code coordinates them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a plain SELECT hands out the same rows
&lt;/h2&gt;

&lt;p&gt;PostgreSQL defaults to READ COMMITTED. A plain &lt;code&gt;SELECT&lt;/code&gt; takes no row locks, so every pod sees the same committed rows with &lt;code&gt;status = 'PENDING'&lt;/code&gt;. Pod A hasn't committed its update to &lt;code&gt;DONE&lt;/code&gt; when pod B reads, because pod A is still calling the email provider inside the transaction. All three pods read the same 50 rows.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant A as Pod A
    participant B as Pod B
    participant DB as PostgreSQL
    A-&amp;gt;&amp;gt;DB: SELECT ... WHERE status='PENDING' LIMIT 50
    B-&amp;gt;&amp;gt;DB: SELECT ... WHERE status='PENDING' LIMIT 50
    DB--&amp;gt;&amp;gt;A: rows 1..50
    DB--&amp;gt;&amp;gt;B: rows 1..50
    A-&amp;gt;&amp;gt;A: send 50 emails
    B-&amp;gt;&amp;gt;B: send 50 emails
    A-&amp;gt;&amp;gt;DB: UPDATE status='DONE', COMMIT
    B-&amp;gt;&amp;gt;DB: UPDATE status='DONE', COMMIT&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Both updates succeed, because setting a row to &lt;code&gt;DONE&lt;/code&gt; twice is not a conflict. There is no error anywhere, only duplicate emails.&lt;/p&gt;

&lt;h2&gt;
  
  
  What FOR UPDATE alone does
&lt;/h2&gt;

&lt;p&gt;The obvious fix is a pessimistic lock. In Spring Data JPA that is &lt;code&gt;@Lock(LockModeType.PESSIMISTIC_WRITE)&lt;/code&gt; on the repository method, which Hibernate turns into &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;. Pod B's query now tries to lock the same rows and waits for pod A to commit.&lt;/p&gt;

&lt;p&gt;The duplicates are gone, but the pods now run one after another. Pod B holds a pooled connection and waits for as long as pod A's batch takes. If 50 emails take 10 seconds, pod B waits 10 seconds, then re-checks &lt;code&gt;status = 'PENDING'&lt;/code&gt; against the updated rows and finds nothing. You pay for three workers and get one.&lt;/p&gt;

&lt;h2&gt;
  
  
  SKIP LOCKED
&lt;/h2&gt;

&lt;p&gt;The PostgreSQL &lt;code&gt;SELECT&lt;/code&gt; docs say that with &lt;code&gt;SKIP LOCKED&lt;/code&gt;, any selected rows that cannot be immediately locked are skipped. The same page warns that this gives an inconsistent view of the data and isn't suited to general-purpose work, but that it can be used to avoid lock contention with multiple consumers of a queue-like table. That is our case.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;notification_job&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a &lt;code&gt;LIMIT&lt;/code&gt;, locking stops once enough rows have been returned. Pod A locks rows 1 to 50, pod B skips them and locks 51 to 100, pod C gets 101 to 150. They run in parallel without waiting.&lt;/p&gt;

&lt;p&gt;Now look at the transaction boundary. A row lock lasts until the transaction ends. If you keep the original &lt;code&gt;@Transactional&lt;/code&gt; around the email calls, you hold 50 row locks and one HikariCP connection for the whole batch. With the default &lt;code&gt;maximumPoolSize&lt;/code&gt; of 10, ten slow batches use every connection, which is the same trap as a transaction wrapped around a third-party API call. The claim has to be short.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1: claim in a short transaction, work outside it
&lt;/h2&gt;

&lt;p&gt;Mark the rows as taken in the same statement that selects them, and commit at once. The status column then does the job of the lock.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notification_job&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;           &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;recipient&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt;      &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt;       &lt;span class="nb"&gt;TEXT&lt;/span&gt;        &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attempts&lt;/span&gt;     &lt;span class="nb"&gt;INT&lt;/span&gt;         &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;locked_until&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;   &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_notification_job_pending&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notification_job&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partial index holds only pending rows, so it stays small however many finished rows pile up. The claim is one &lt;code&gt;UPDATE ... RETURNING&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.Duration&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.List&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.jdbc.core.JdbcTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.stereotype.Repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.transaction.annotation.Transactional&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Repository&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationJobStore&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;CLAIM_SQL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""
            UPDATE notification_job
            SET status = 'RUNNING',
                attempts = attempts + 1,
                locked_until = now() + make_interval(secs =&amp;gt; ?)
            WHERE id IN (
                SELECT id
                FROM notification_job
                WHERE status = 'PENDING'
                ORDER BY id
                LIMIT ?
                FOR UPDATE SKIP LOCKED
            )
            RETURNING id, recipient, payload, attempts
            """&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;JdbcTemplate&lt;/span&gt; &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;NotificationJobStore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JdbcTemplate&lt;/span&gt; &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jdbc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;Job&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Job&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;claim&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;batchSize&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt; &lt;span class="n"&gt;lease&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="no"&gt;CLAIM_SQL&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Job&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                        &lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLong&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
                        &lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"recipient"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
                        &lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getString&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"payload"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
                        &lt;span class="n"&gt;rs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"attempts"&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt;
                &lt;span class="n"&gt;lease&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toSeconds&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;batchSize&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;markDone&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UPDATE notification_job SET status = 'DONE', locked_until = NULL WHERE id = ?"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;markFailed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;giveUp&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"UPDATE notification_job SET status = ?, locked_until = NULL WHERE id = ?"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;giveUp&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"FAILED"&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"PENDING"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;claim&lt;/code&gt; is a transaction of a few milliseconds. The subquery's row locks are released at commit, but by then the rows are &lt;code&gt;RUNNING&lt;/code&gt;, so the next poller's &lt;code&gt;status = 'PENDING'&lt;/code&gt; filter no longer matches them. The worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.time.Duration&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.scheduling.annotation.Scheduled&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.stereotype.Component&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationWorker&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="no"&gt;MAX_ATTEMPTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;NotificationJobStore&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;EmailClient&lt;/span&gt; &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;NotificationWorker&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NotificationJobStore&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;EmailClient&lt;/span&gt; &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;emailClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;poll&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;claim&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;send&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;recipient&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
                &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markDone&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markFailed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="no"&gt;MAX_ATTEMPTS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;poll()&lt;/code&gt; has no &lt;code&gt;@Transactional&lt;/code&gt;. Each store call is its own short transaction, and I like that the boundaries are visible by reading the store class.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2: leases for crashed workers
&lt;/h2&gt;

&lt;p&gt;Once the lock is gone, a pod killed mid-batch leaves its rows as &lt;code&gt;RUNNING&lt;/code&gt; forever. &lt;code&gt;locked_until&lt;/code&gt; handles that: a second scheduled method puts expired jobs back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Scheduled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fixedDelay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;reclaimExpired&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;jdbc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"""
            UPDATE notification_job
            SET status = 'PENDING', locked_until = NULL
            WHERE status = 'RUNNING' AND locked_until &amp;lt; now()
            """&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put this in &lt;code&gt;NotificationJobStore&lt;/code&gt; (or inject the &lt;code&gt;JdbcTemplate&lt;/code&gt; where you use it), and keep the lease longer than your slowest job. If a job takes 3 minutes and the lease is 2, another pod picks it up while the first is still working and the duplicates are back.&lt;/p&gt;

&lt;p&gt;Even with a sound lease, delivery is at least once. A pod can send the email, die before &lt;code&gt;markDone&lt;/code&gt;, and the job runs again. Pass the job id as an idempotency key if your email provider supports one, or record sent ids in a table with a unique constraint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the table healthy
&lt;/h2&gt;

&lt;p&gt;A queue table is written constantly, and every UPDATE in PostgreSQL leaves a dead row version behind. Here a job is updated two or three times (claim, done, sometimes a retry). Autovacuum starts when dead tuples exceed &lt;code&gt;autovacuum_vacuum_threshold&lt;/code&gt; (default 50) plus &lt;code&gt;autovacuum_vacuum_scale_factor&lt;/code&gt; (default 0.2) times the row count. With 5 million rows in the table, that is about a million dead tuples before vacuum even starts, and your pollers wade through them meanwhile.&lt;/p&gt;

&lt;p&gt;Both settings can be overridden per table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notification_job&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;autovacuum_vacuum_scale_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;autovacuum_vacuum_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deleting or archiving &lt;code&gt;DONE&lt;/code&gt; rows on a schedule helps more, since a small table needs little tuning. Long-running transactions elsewhere in the database also stop vacuum from removing recent dead rows, and a queue table is the first place you'll feel it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas and when not to use this
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strict ordering needed&lt;/td&gt;
&lt;td&gt;Each worker gets whatever is free, so job 51 can finish before job 12. Per-customer ordering needs partitioning by key.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ORDER BY&lt;/code&gt; with a locking clause under READ COMMITTED&lt;/td&gt;
&lt;td&gt;The docs say rows can come back out of order if a lock wait happens. Fine for a queue, wrong for a ledger.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;DISTINCT&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, &lt;code&gt;UNION&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Locking clauses can't be combined with them. Keep the claim query simple.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Very high throughput&lt;/td&gt;
&lt;td&gt;Each job costs several writes plus WAL. Tens of thousands of jobs per second belongs on Kafka or SQS.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polling latency&lt;/td&gt;
&lt;td&gt;With &lt;code&gt;fixedDelay = 2000&lt;/code&gt; a new job waits up to 2 s. &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; can wake workers sooner but adds moving parts.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poison jobs&lt;/td&gt;
&lt;td&gt;Without &lt;code&gt;attempts&lt;/code&gt; and a &lt;code&gt;FAILED&lt;/code&gt; state, one bad row retries forever.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When the jobs come from a database write anyway, I prefer this to a new broker, because you can insert the job in the same transaction as the business change. That gives you outbox behaviour with no extra infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claim with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Pods stop receiving the same rows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commit the claim before doing the work&lt;/td&gt;
&lt;td&gt;No row locks or pooled connection held during slow I/O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store a lease and reclaim expired rows&lt;/td&gt;
&lt;td&gt;Crashed pods don't strand jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cap &lt;code&gt;attempts&lt;/code&gt;, keep a &lt;code&gt;FAILED&lt;/code&gt; state&lt;/td&gt;
&lt;td&gt;Poison messages stop looping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Make the handler idempotent&lt;/td&gt;
&lt;td&gt;Delivery is at least once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial index on &lt;code&gt;status = 'PENDING'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Polling cost doesn't grow with history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-table autovacuum settings, purge &lt;code&gt;DONE&lt;/code&gt; rows&lt;/td&gt;
&lt;td&gt;Dead tuples don't pile up&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>postgres</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Silent Killer in Spring Boot: Why @Transactional Around Third-Party APIs Exhausts Your Connection Pool</title>
      <dc:creator>Shreyash Kakde</dc:creator>
      <pubDate>Mon, 28 Sep 2026 20:11:05 +0000</pubDate>
      <link>https://dev.to/shreyash_kakde/the-silent-killer-in-spring-boot-why-transactional-around-third-party-apis-exhausts-your-31np</link>
      <guid>https://dev.to/shreyash_kakde/the-silent-killer-in-spring-boot-why-transactional-around-third-party-apis-exhausts-your-31np</guid>
      <description>&lt;p&gt;It’s 03:00 PM on a Tuesday. Your monitoring dashboards go red. &lt;/p&gt;

&lt;p&gt;Incoming requests are timing out, HTTP 504 errors are spiking, and users are reporting that the checkout screen hangs indefinitely. &lt;/p&gt;

&lt;p&gt;You pull up your metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application CPU:&lt;/strong&gt; 7%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL CPU:&lt;/strong&gt; 4%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JVM Heap:&lt;/strong&gt; 30% used (GC pauses under 15ms)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database Deadlocks:&lt;/strong&gt; 0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your servers are practically idling. Yet the application is completely unresponsive.&lt;/p&gt;

&lt;p&gt;You check the application logs and find hundreds of stack traces ending with this exact line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;org.springframework.transaction.CannotCreateTransactionException: 
Could not open JPA EntityManager for transaction; 
nested exception is org.hibernate.exception.JDBCConnectionException: 
Unable to acquire JDBC Connection
    ...
Caused by: java.sql.SQLTransientConnectionException: 
HikariPool-1 - Connection is not available, request timed out after 30000ms.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your HikariCP connection pool is 100% saturated. Every single connection is checked out. New threads wait 30 seconds for a connection, time out, and die. Kubernetes health probes (&lt;code&gt;/actuator/health&lt;/code&gt;) attempt to query the database, fail to acquire a connection, mark the pods as unready, and terminate them. &lt;/p&gt;

&lt;p&gt;The restart cascade begins.&lt;/p&gt;

&lt;p&gt;The culprit isn’t a slow SQL query or an unindexed table. It is an innocent-looking &lt;code&gt;@Transactional&lt;/code&gt; annotation sitting on a method that makes a third-party HTTP call.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Innocent Bug: An Autopsy&lt;/li&gt;
&lt;li&gt;What Happens Under the Hood&lt;/li&gt;
&lt;li&gt;The Math of Pool Exhaustion (Little’s Law)&lt;/li&gt;
&lt;li&gt;The Illusion of Distributed Atomicity&lt;/li&gt;
&lt;li&gt;
The Fix: Decoupling Transactions from Network I/O

&lt;ul&gt;
&lt;li&gt;Pattern 1: Programmatic Boundaries with TransactionTemplate&lt;/li&gt;
&lt;li&gt;Pattern 2: Domain Events &amp;amp; @TransactionalEventListener&lt;/li&gt;
&lt;li&gt;Pattern 3: The Transactional Outbox Pattern&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
Defensive Configuration: Catching Connection Leaks Before Production

&lt;ul&gt;
&lt;li&gt;1. Enable HikariCP Leak Detection&lt;/li&gt;
&lt;li&gt;2. Configure Strict HTTP Client Timeouts&lt;/li&gt;
&lt;li&gt;3. Delay Connection Acquisition with Hibernate&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Summary Checklist&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Innocent Bug: An Autopsy
&lt;/h2&gt;

&lt;p&gt;Here is the exact pattern that causes this outage. It exists in thousands of production Spring Boot codebases right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CheckoutService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;PaymentGatewayClient&lt;/span&gt; &lt;span class="n"&gt;stripeClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Feign, RestClient, or WebClient&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;OrderConfirmation&lt;/span&gt; &lt;span class="nf"&gt;processOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CheckoutRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Step 1: Read user from database (~2ms)&lt;/span&gt;
        &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;

        &lt;span class="c1"&gt;// Step 2: Create initial order record (~3ms)&lt;/span&gt;
        &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;

        &lt;span class="c1"&gt;// Step 3: Call external payment provider over HTTPS (~1,800ms)&lt;/span&gt;
        &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripeClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStripeCustomerId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; 
            &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Step 4: Update order status (~2ms)&lt;/span&gt;
        &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markPaid&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transactionId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderConfirmation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"SUCCESS"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first glance, this code looks clean. Developers add &lt;code&gt;@Transactional&lt;/code&gt; because they want the entire checkout flow to be atomic: &lt;em&gt;“If the payment fails, I want the order row rolled back.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To understand why this destroys your application under load, you have to follow what happens at the JDBC connection layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Happens Under the Hood
&lt;/h2&gt;

&lt;p&gt;When Spring encounters &lt;code&gt;@Transactional&lt;/code&gt;, it coordinates with &lt;code&gt;JpaTransactionManager&lt;/code&gt; and your connection pool (HikariCP).&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    autonumber
    participant App as CheckoutService
    participant Pool as HikariCP Pool (Size: 10)
    participant DB as PostgreSQL
    participant Stripe as External Payment API

    App-&amp;gt;&amp;gt;Pool: Borrow Connection
    Pool--&amp;gt;&amp;gt;App: Connection #1 Checked Out
    App-&amp;gt;&amp;gt;DB: BEGIN TRANSACTION
    App-&amp;gt;&amp;gt;DB: SELECT * FROM users WHERE id = ...
    App-&amp;gt;&amp;gt;DB: INSERT INTO orders ...

    Note over App,Stripe: Thread pauses in socketRead0 waiting for network packet!
    App-&amp;gt;&amp;gt;Stripe: POST https://api.stripe.com/v1/charges (Network I/O)
    Note over Pool: Connection #1 sits 100% IDLE in DB, but HELD by thread for 1.8 seconds!

    Stripe--&amp;gt;&amp;gt;App: HTTP 200 OK (After 1,800ms)
    App-&amp;gt;&amp;gt;DB: UPDATE orders SET status = 'PAID' ...
    App-&amp;gt;&amp;gt;DB: COMMIT TRANSACTION
    App-&amp;gt;&amp;gt;Pool: Return Connection #1&lt;/code&gt;&lt;/pre&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Step 1 (&lt;code&gt;userRepository.findById&lt;/code&gt;)&lt;/strong&gt;: Hibernate borrows a physical &lt;code&gt;java.sql.Connection&lt;/code&gt; from HikariCP and issues &lt;code&gt;BEGIN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 2 (&lt;code&gt;orderRepository.save&lt;/code&gt;)&lt;/strong&gt;: The &lt;code&gt;INSERT&lt;/code&gt; statement executes. Total database time elapsed: &lt;strong&gt;5 milliseconds&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 3 (&lt;code&gt;stripeClient.charge&lt;/code&gt;)&lt;/strong&gt;: The thread halts its execution and waits for a TLS handshake, network routing, and Stripe’s server processing. Total time elapsed: &lt;strong&gt;1,800 milliseconds&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Throughout those 1,800 milliseconds, &lt;strong&gt;the physical database connection remains locked to this thread&lt;/strong&gt;. The database is doing zero work. The connection is held hostage simply because the Java thread hasn't exited the &lt;code&gt;@Transactional&lt;/code&gt; method.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step 4&lt;/strong&gt;: The &lt;code&gt;UPDATE&lt;/code&gt; executes, &lt;code&gt;COMMIT&lt;/code&gt; is sent, and the connection is finally returned to the pool.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total transaction time: &lt;strong&gt;1,807 milliseconds&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
Time the database actually did work: &lt;strong&gt;7 milliseconds&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
Time spent holding the connection while waiting for network packets: &lt;strong&gt;1,800 milliseconds (99.6%)&lt;/strong&gt;.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Math of Pool Exhaustion (Little’s Law)
&lt;/h2&gt;

&lt;p&gt;HikariCP’s default pool size is &lt;strong&gt;10 connections&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Let’s calculate maximum system throughput under normal database operations versus when an external API is wrapped in a transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Maximum Throughput = Pool Size / Average Connection Hold Time
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Scenario A: Fast Database Transactions (No External I/O)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Average connection hold time: &lt;strong&gt;5ms&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Maximum throughput: &lt;code&gt;10 / 0.005s&lt;/code&gt; = &lt;strong&gt;2,000 requests/second&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Ten connections easily handle a high-traffic e-commerce store.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Scenario B: Database Transaction Wrapping an HTTP Call
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Average connection hold time: &lt;strong&gt;1,500ms&lt;/strong&gt; (1.5s)&lt;/li&gt;
&lt;li&gt;Maximum throughput: &lt;code&gt;10 / 1.5s&lt;/code&gt; = &lt;strong&gt;6.6 requests/second&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;6.6 requests per second.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;If just &lt;strong&gt;7 concurrent users&lt;/strong&gt; click "Checkout" at the same instant, all 10 HikariCP connections are occupied.&lt;/p&gt;

&lt;p&gt;The 8th user’s request waits. HikariCP’s default &lt;code&gt;connection-timeout&lt;/code&gt; is 30,000ms (30 seconds). While that 8th thread waits, subsequent requests pile up in Tomcat's thread pool. Within 10 seconds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tomcat exhausts its worker threads (default 200).&lt;/li&gt;
&lt;li&gt;Incoming HTTP requests are rejected at the TCP socket layer.&lt;/li&gt;
&lt;li&gt;Actuator health checks timeout.&lt;/li&gt;
&lt;li&gt;Your monitoring tool alerts you that the entire service is down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All for 7 concurrent checkout requests.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Illusion of Distributed Atomicity
&lt;/h2&gt;

&lt;p&gt;The most dangerous part of this pattern is that it does not even provide the safety developers think it does.&lt;/p&gt;

&lt;p&gt;Consider this failure mode:&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    autonumber
    participant App as Spring Boot
    participant DB as PostgreSQL
    participant Stripe as External API

    App-&amp;gt;&amp;gt;DB: INSERT INTO orders (status = PENDING)
    App-&amp;gt;&amp;gt;Stripe: POST /charges ($100)
    Stripe--&amp;gt;&amp;gt;App: 200 OK (Card Charged!)
    Note over App,DB: Database network blip, disk full, or constraint violation!
    App-&amp;gt;&amp;gt;DB: COMMIT fails or Connection drops!
    DB--&amp;gt;&amp;gt;App: ROLLBACK
    Note over App,Stripe: Money taken from customer, but NO order in database!&lt;/code&gt;&lt;/pre&gt;



&lt;ol&gt;
&lt;li&gt;Spring opens a transaction.&lt;/li&gt;
&lt;li&gt;The order is inserted.&lt;/li&gt;
&lt;li&gt;Stripe charges the customer's credit card &lt;strong&gt;\$100&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The database connection drops, a unique constraint fails, or the pod runs out of memory before &lt;code&gt;COMMIT&lt;/code&gt; finishes.&lt;/li&gt;
&lt;li&gt;The local database transaction &lt;strong&gt;rolls back&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The order does not exist in your database. But Stripe successfully charged the customer's card. &lt;/p&gt;

&lt;p&gt;A local database transaction (&lt;code&gt;@Transactional&lt;/code&gt;) has &lt;strong&gt;zero control&lt;/strong&gt; over an external HTTP service. It cannot roll back a network call that already reached another server. Wrapping third-party calls inside &lt;code&gt;@Transactional&lt;/code&gt; gives you the illusion of distributed consistency while actively degrading your system availability.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix: Decoupling Transactions from Network I/O
&lt;/h2&gt;

&lt;p&gt;To eliminate this vulnerability, your database transactions must only span database operations. Network I/O must live outside transaction boundaries.&lt;/p&gt;

&lt;p&gt;Here are the three production-proven patterns to accomplish this.&lt;/p&gt;




&lt;h3&gt;
  
  
  Pattern 1: Programmatic Boundaries with &lt;code&gt;TransactionTemplate&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Instead of annotating the entire method with &lt;code&gt;@Transactional&lt;/code&gt;, use Spring's &lt;code&gt;TransactionTemplate&lt;/code&gt; to create micro-transactions around only the database reads and writes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SafeCheckoutService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Logger&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoggerFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLogger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SafeCheckoutService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;PaymentGatewayClient&lt;/span&gt; &lt;span class="n"&gt;stripeClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;TransactionTemplate&lt;/span&gt; &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;SafeCheckoutService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UserRepository&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                               &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                               &lt;span class="nc"&gt;PaymentGatewayClient&lt;/span&gt; &lt;span class="n"&gt;stripeClient&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                               &lt;span class="nc"&gt;TransactionTemplate&lt;/span&gt; &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stripeClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripeClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transactionTemplate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;OrderConfirmation&lt;/span&gt; &lt;span class="nf"&gt;processOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CheckoutRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Phase 1: Short local transaction to reserve and initialize (~5ms)&lt;/span&gt;
        &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;pendingOrder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
                &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;

            &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PAYMENT_PENDING&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;});&lt;/span&gt;

        &lt;span class="c1"&gt;// Connection returned to HikariCP pool HERE!&lt;/span&gt;
        &lt;span class="c1"&gt;// Phase 2: External HTTP call outside any database transaction (~1,500ms)&lt;/span&gt;
        &lt;span class="nc"&gt;PaymentResult&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stripeClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;stripeCustomerId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                &lt;span class="c1"&gt;// Crucial: Use order ID as idempotency key for network retries&lt;/span&gt;
                &lt;span class="s"&gt;"checkout-order-"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;pendingOrder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Payment failed for order {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pendingOrder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;markOrderFailed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pendingOrder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Phase 3: Short local transaction to finalize state (~4ms)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pendingOrder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;markPaid&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;transactionId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
            &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OrderConfirmation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"SUCCESS"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;});&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;markOrderFailed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeWithoutResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;ifPresent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FAILED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setFailureReason&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="o"&gt;});&lt;/span&gt;
        &lt;span class="o"&gt;});&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Difference in Connection Usage
&lt;/h3&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph LR
    subgraph SafeExecution["Safe Multi-Phase Execution"]
        A["Phase 1: DB TX (~5ms)"] --&amp;gt;|Connection Released| B["Phase 2: Network I/O (~1,500ms)&amp;lt;br&amp;gt;ZERO DB Connections Held"]
        B --&amp;gt;|Borrow Connection| C["Phase 3: DB TX (~4ms)"]
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;During the 1,500ms HTTP call to Stripe, &lt;strong&gt;zero database connections are held&lt;/strong&gt;. Your pool of 10 connections remains completely open to serve other users, health checks, and read queries.&lt;/p&gt;




&lt;h3&gt;
  
  
  Pattern 2: Domain Events &amp;amp; &lt;code&gt;@TransactionalEventListener&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;If the third-party call is a non-blocking side-effect (such as sending an email via SendGrid, dispatching a webhook, or emitting an audit log), use Spring's application events.&lt;/p&gt;

&lt;p&gt;By setting the listener phase to &lt;code&gt;AFTER_COMMIT&lt;/code&gt;, Spring guarantees that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The external API is called &lt;strong&gt;only after&lt;/strong&gt; the local database transaction has safely committed.&lt;/li&gt;
&lt;li&gt;The database connection has already been returned to the Hikari pool before the network call starts.&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  The Event Definition
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;OrderPlacedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;customerEmail&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;totalAmount&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  The Transactional Service
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderPlacementService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;ApplicationEventPublisher&lt;/span&gt; &lt;span class="n"&gt;eventPublisher&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;OrderPlacementService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderRepository&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; 
                                 &lt;span class="nc"&gt;ApplicationEventPublisher&lt;/span&gt; &lt;span class="n"&gt;eventPublisher&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;eventPublisher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;eventPublisher&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CreateOrderCommand&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Order&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="c1"&gt;// Publish event inside transaction&lt;/span&gt;
        &lt;span class="n"&gt;eventPublisher&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;publishEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OrderPlacedEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; 
            &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;customerEmail&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; 
            &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;totalAmount&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// Connection is COMMITTED and RELEASED here!&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  The Event Listener
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderNotificationListener&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Logger&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoggerFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLogger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderNotificationListener&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;EmailClient&lt;/span&gt; &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// External HTTP Client (SendGrid, Postmark)&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;OrderNotificationListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EmailClient&lt;/span&gt; &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;emailClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Async&lt;/span&gt; &lt;span class="c1"&gt;// Run in background thread pool to avoid blocking HTTP request&lt;/span&gt;
    &lt;span class="nd"&gt;@TransactionalEventListener&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransactionPhase&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AFTER_COMMIT&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;onOrderPlaced&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OrderPlacedEvent&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Sending order confirmation email for order {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="c1"&gt;// This HTTP call runs with NO database transaction or connection attached!&lt;/span&gt;
        &lt;span class="n"&gt;emailClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sendOrderConfirmation&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;customerEmail&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the database transaction fails and rolls back, &lt;code&gt;onOrderPlaced&lt;/code&gt; will never execute. No customer will ever receive a "Your order is confirmed!" email for an order that failed to save.&lt;/p&gt;




&lt;h3&gt;
  
  
  Pattern 3: The Transactional Outbox Pattern for Mission-Critical Integrations
&lt;/h3&gt;

&lt;p&gt;When an external integration &lt;em&gt;must&lt;/em&gt; complete reliably (such as syncing with SAP, Salesforce, or dispatching an event to Kafka), use the Transactional Outbox pattern.&lt;/p&gt;

&lt;p&gt;Instead of calling the external API synchronously:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write the business entity and an &lt;code&gt;outbox&lt;/code&gt; record in the same local transaction (takes &amp;lt; 5ms).&lt;/li&gt;
&lt;li&gt;A separate background worker reads unprocessed outbox records and delivers them to the external API with retries and exponential backoff.
&lt;/li&gt;
&lt;/ol&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph TD
    subgraph ClientRequest["Client Request (Fast)"]
        A["User Request"] --&amp;gt; B["Save Order + Outbox Row in 1 TX (~5ms)"]
        B --&amp;gt; C["Return 200 OK to User immediately"]
    end

    subgraph BackgroundWorker["Background Worker (Resilient)"]
        D[("Outbox Table")] --&amp;gt;|Poll / Listen| E["Outbox Poller Thread"]
        E --&amp;gt;|Call External API| F["Stripe / Salesforce / SAP"]
        F --&amp;gt;|200 OK| G["Mark Outbox Row as PROCESSED"]
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;This insulates your customer-facing response times from third-party outages and guarantees at-least-once delivery.&lt;/p&gt;




&lt;h2&gt;
  
  
  Defensive Configuration: Catching Connection Leaks Before Production
&lt;/h2&gt;

&lt;p&gt;Even with careful code reviews, an unvetted library or rogue &lt;code&gt;@Transactional&lt;/code&gt; can slip through. Add these three guardrails to your Spring Boot configuration:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Enable HikariCP Leak Detection
&lt;/h3&gt;

&lt;p&gt;HikariCP has a built-in leak detection mechanism that logs a stack trace whenever a thread holds a connection longer than a set threshold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# application.properties
# Log a warning if a thread holds a connection longer than 3 seconds
&lt;/span&gt;&lt;span class="py"&gt;spring.datasource.hikari.leak-detection-threshold&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a thread holds a connection for longer than 3,000ms, HikariCP outputs a warning with the full stack trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WARN 12940 --- [pool-thread-2] com.zaxxer.hikari.pool.ProxyLeakTask: 
Connection leak detection triggered for org.postgresql.jdbc.PgConnection@4a2f8d on thread http-nio-8080-exec-4, 
stack trace follows:
    at com.example.service.CheckoutService.processOrder(CheckoutService.java:42)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can pinpoint the exact line of code holding the connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Configure Strict HTTP Client Timeouts
&lt;/h3&gt;

&lt;p&gt;Never use default timeouts on your HTTP clients (&lt;code&gt;RestClient&lt;/code&gt;, &lt;code&gt;WebClient&lt;/code&gt;, Feign, or Apache HttpClient). Default read timeouts are often infinite or 60+ seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Configuration&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HttpClientConfig&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Bean&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;RestClient&lt;/span&gt; &lt;span class="nf"&gt;stripeRestClient&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;SimpleClientHttpRequestFactory&lt;/span&gt; &lt;span class="n"&gt;factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SimpleClientHttpRequestFactory&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setConnectTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setReadTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// Fail fast if external server hangs&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RestClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestFactory&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;factory&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.stripe.com"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Stripe degrades, your client fails in 4 seconds instead of hanging for minutes and starving your thread pools.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Delay Connection Acquisition with Hibernate
&lt;/h3&gt;

&lt;p&gt;By default, Hibernate might acquire a physical database connection as soon as a transaction begins. You can instruct Hibernate to delay physical connection acquisition until the first actual SQL query is issued:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.jpa.properties.hibernate.connection.provider_disables_autocommit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents early connection checkout if your service performs non-database validation logic before its first SQL query.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary Checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Anti-Pattern&lt;/th&gt;
&lt;th&gt;Recommended Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;External HTTP call inside &lt;code&gt;@Transactional&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;TransactionTemplate&lt;/code&gt; to split into short pre- and post-transactions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email, notifications, or metrics inside &lt;code&gt;@Transactional&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;@TransactionalEventListener(phase = AFTER_COMMIT)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-running batch or external sync&lt;/td&gt;
&lt;td&gt;Transactional Outbox pattern with background worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undetected connection hogs&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;spring.datasource.hikari.leak-detection-threshold=3000&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hanging HTTP calls&lt;/td&gt;
&lt;td&gt;Set explicit 2–4s connect and read timeouts on all HTTP clients&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A database connection is one of the most expensive and scarce resources in your entire architecture. &lt;/p&gt;

&lt;p&gt;Treat it like an in-memory lock: &lt;strong&gt;acquire it at the last possible millisecond, do your SQL work, and release it immediately.&lt;/strong&gt; Keep your network calls out of your database transactions, and your connection pool will effortlessly handle traffic surges without breaking a sweat.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>postgres</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Eliminating Race Conditions in Clustered Spring Boot with PostgreSQL Advisory Locks</title>
      <dc:creator>Shreyash Kakde</dc:creator>
      <pubDate>Mon, 28 Sep 2026 20:00:28 +0000</pubDate>
      <link>https://dev.to/shreyash_kakde/eliminating-race-conditions-in-clustered-spring-boot-with-postgresql-advisory-locks-43k2</link>
      <guid>https://dev.to/shreyash_kakde/eliminating-race-conditions-in-clustered-spring-boot-with-postgresql-advisory-locks-43k2</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pod A: Reads balance ($100) -&amp;gt; Validates -&amp;gt; Dispatches payout -&amp;gt; Updates balance ($0)
Pod B: Reads balance ($100) -&amp;gt; Validates -&amp;gt; Dispatches payout -&amp;gt; Updates balance ($0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both pods saw the balance before either could update it. Two payouts occurred; the business took the loss.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;synchronized&lt;/code&gt; keyword onto a Spring service method, or dragging in an entire Redis cluster with Redisson just to serialize one critical action.&lt;/p&gt;

&lt;p&gt;There is a sweet spot right in front of you. If PostgreSQL is your primary datastore, you already possess a robust distributed lock manager: &lt;strong&gt;PostgreSQL Advisory Locks&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Why Standard Concurrency Tools Break in a Cluster&lt;/li&gt;
&lt;li&gt;
What Are PostgreSQL Advisory Locks?

&lt;ul&gt;
&lt;li&gt;Session-Level vs. Transaction-Level&lt;/li&gt;
&lt;li&gt;The HikariCP Connection Trap&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;The Lock Functions You Need to Know&lt;/li&gt;
&lt;li&gt;
Designing a Production-Ready Implementation

&lt;ul&gt;
&lt;li&gt;1. Generating 64-Bit Keys from Arbitrary Strings&lt;/li&gt;
&lt;li&gt;2. The Advisory Lock Repository&lt;/li&gt;
&lt;li&gt;3. A Clean Functional Lock Template&lt;/li&gt;
&lt;li&gt;4. A Realistic Scenario: First-Time Welcome Reward&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;The "Secret Weapon": Lock Timeouts in PostgreSQL&lt;/li&gt;
&lt;li&gt;Verifying Concurrency with Testcontainers&lt;/li&gt;
&lt;li&gt;Production Gotchas and When NOT to Use Them&lt;/li&gt;
&lt;li&gt;Architectural Comparison: Picking the Right Tool&lt;/li&gt;
&lt;li&gt;Summary&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why Standard Concurrency Tools Break in a Cluster
&lt;/h2&gt;

&lt;p&gt;Before looking at advisory locks, let's understand why traditional tools fall short.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    autonumber
    participant Client
    participant Pod1 as Spring Boot (Pod 1)
    participant Pod2 as Spring Boot (Pod 2)
    participant DB as PostgreSQL

    Client-&amp;gt;&amp;gt;Pod1: POST /rewards/claim (User 42)
    Client-&amp;gt;&amp;gt;Pod2: POST /rewards/claim (User 42)
    Note over Pod1,Pod2: JVM locks (synchronized) only protect within the same node
    Pod1-&amp;gt;&amp;gt;DB: SELECT has_claimed FROM rewards WHERE user_id = 42
    Pod2-&amp;gt;&amp;gt;DB: SELECT has_claimed FROM rewards WHERE user_id = 42
    DB--&amp;gt;&amp;gt;Pod1: false
    DB--&amp;gt;&amp;gt;Pod2: false
    Pod1-&amp;gt;&amp;gt;DB: INSERT INTO rewards (user_id) VALUES (42)
    Pod2-&amp;gt;&amp;gt;DB: INSERT INTO rewards (user_id) VALUES (42)
    Note over DB: Race condition! Without constraints, duplicate rows occur.&lt;/code&gt;&lt;/pre&gt;



&lt;h3&gt;
  
  
  1. In-Memory JVM Locks (&lt;code&gt;synchronized&lt;/code&gt;, &lt;code&gt;ReentrantLock&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Pessimistic Row Locking (&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Row-level locks are powerful, but they come with two severe limitations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The row must already exist.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Table bloat and domain coupling.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Redis / Redisson Distributed Locks
&lt;/h3&gt;

&lt;p&gt;Spinning up Redis to handle distributed locking is common, but it introduces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extra operational complexity (failover clusters, sentinel/cluster topology, backup, patching).&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Unnecessary infrastructure cost if your PostgreSQL database is already provisioned and has plenty of capacity.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Are PostgreSQL Advisory Locks?
&lt;/h2&gt;

&lt;p&gt;PostgreSQL advisory locks are application-defined locks managed directly in the database server's memory (&lt;code&gt;shared_buffers&lt;/code&gt; / lock table).&lt;/p&gt;

&lt;p&gt;The key insight: &lt;strong&gt;PostgreSQL doesn’t attach any data semantics to an advisory lock.&lt;/strong&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;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 --&amp;gt;|pg_try_advisory_xact_lock: 9481029| A
    P2 -.-&amp;gt;|pg_try_advisory_xact_lock: 9481029 rejected!| A
    P1 --&amp;gt;|Only writes data after lock is granted| B&lt;/code&gt;&lt;/pre&gt;



&lt;h3&gt;
  
  
  Session-Level vs. Transaction-Level
&lt;/h3&gt;

&lt;p&gt;PostgreSQL provides two flavors of advisory locks. Confusing them in a connection-pooled application like Spring Boot will cause catastrophic bugs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Session-Level (&lt;code&gt;pg_advisory_lock&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Transaction-Level (&lt;code&gt;pg_advisory_xact_lock&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Lifecycle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Held until manually unlocked or connection closes&lt;/td&gt;
&lt;td&gt;Automatically released when transaction ends (COMMIT/ROLLBACK)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Unlock mechanism&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Explicit call to &lt;code&gt;pg_advisory_unlock&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;PostgreSQL handles it on transaction completion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Connection Pool Safety&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;High risk!&lt;/strong&gt; Leaks locks if connection returns to HikariCP&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Safe!&lt;/strong&gt; Cannot leak beyond transaction boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PgBouncer Compatibility&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Incompatible with transaction pooling mode&lt;/td&gt;
&lt;td&gt;Fully compatible with transaction pooling mode&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The HikariCP Connection Trap
&lt;/h3&gt;

&lt;p&gt;With HikariCP (Spring Boot’s default pool), physical database connections are reused across different HTTP requests and threads. &lt;/p&gt;

&lt;p&gt;If you acquire a session-level lock (&lt;code&gt;pg_advisory_lock&lt;/code&gt;) on Connection A and an unhandled exception prevents your unlock code from running, &lt;strong&gt;Connection A goes back into the Hikari pool while still holding the lock in Postgres&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;Later, another thread picks up Connection A, inheriting the lock. Worse, other threads requesting the lock on other connections hang indefinitely.&lt;/p&gt;

&lt;p&gt;For Spring Boot applications, &lt;strong&gt;transaction-level advisory locks (&lt;code&gt;pg_advisory_xact_lock&lt;/code&gt;) are almost always the correct choice&lt;/strong&gt;. When Spring’s transaction commits or rolls back, PostgreSQL automatically frees the lock. Even if your pod crashes mid-execution with &lt;code&gt;kill -9&lt;/code&gt;, the TCP socket drops, the transaction aborts, and PostgreSQL clears the lock immediately.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Lock Functions You Need to Know
&lt;/h2&gt;

&lt;p&gt;PostgreSQL exposes a compact family of advisory functions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;pg_advisory_xact_lock(bigint)&lt;/code&gt;: Blocks until the lock is acquired. Automatically released at the end of the current transaction.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_try_advisory_xact_lock(bigint)&lt;/code&gt;: Non-blocking. Returns &lt;code&gt;true&lt;/code&gt; if acquired immediately, &lt;code&gt;false&lt;/code&gt; if another transaction holds it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_advisory_xact_lock(integer, integer)&lt;/code&gt;: 2-part key variant. Useful for composite IDs like &lt;code&gt;(tenant_id, entity_id)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Designing a Production-Ready Implementation
&lt;/h2&gt;

&lt;p&gt;Let's build a clean, idiomatic locking mechanism for Spring Boot.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Generating 64-Bit Keys from Arbitrary Strings
&lt;/h3&gt;

&lt;p&gt;PostgreSQL advisory locks accept either a single 64-bit integer (&lt;code&gt;bigint&lt;/code&gt; / Java &lt;code&gt;long&lt;/code&gt;) or two 32-bit integers (&lt;code&gt;int&lt;/code&gt;). &lt;/p&gt;

&lt;p&gt;In enterprise applications, we usually want to lock on domain identifiers: &lt;code&gt;"wallet:claim:user_99214"&lt;/code&gt; or &lt;code&gt;"order:settle:ORD-2026-901"&lt;/code&gt;. We need a deterministic, low-collision function to map any &lt;code&gt;String&lt;/code&gt; to a &lt;code&gt;long&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A truncated SHA-256 hash or MurmurHash3 works well here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.locking.util&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.nio.ByteBuffer&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.nio.charset.StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.security.MessageDigest&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.security.NoSuchAlgorithmException&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LockKeyGenerator&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;LockKeyGenerator&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * Hashes a string identifier to a 64-bit signed long for PostgreSQL advisory locks.
     */&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="nf"&gt;toLongKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;businessKey&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;businessKey&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;businessKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalArgumentException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Lock key must not be blank"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;MessageDigest&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MessageDigest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SHA-256"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;digest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;businessKey&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBytes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StandardCharsets&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UTF_8&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
            &lt;span class="c1"&gt;// Read the first 8 bytes of the SHA-256 hash as a long&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ByteBuffer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;wrap&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;getLong&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NoSuchAlgorithmException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IllegalStateException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SHA-256 algorithm missing from JVM"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What about hash collisions?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
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.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  2. The Advisory Lock Repository
&lt;/h3&gt;

&lt;p&gt;Instead of fighting JPA's entity lifecycle, use &lt;code&gt;JdbcTemplate&lt;/code&gt; for raw advisory lock calls. It ensures the query runs on the exact physical connection bound to Spring’s active transaction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.locking.repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.jdbc.core.JdbcTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.stereotype.Repository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Repository&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostgresAdvisoryLockRepository&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;JdbcTemplate&lt;/span&gt; &lt;span class="n"&gt;jdbcTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;PostgresAdvisoryLockRepository&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JdbcTemplate&lt;/span&gt; &lt;span class="n"&gt;jdbcTemplate&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;jdbcTemplate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jdbcTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * Non-blocking attempt to acquire a transaction-scoped advisory lock.
     * Returns true if granted, false if already held by another transaction.
     */&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;tryAcquireTransactionLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;acquired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jdbcTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;queryForObject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"SELECT pg_try_advisory_xact_lock(?)"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;lockKey&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRUE&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acquired&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * Blocking call to acquire a transaction-scoped advisory lock.
     * Waits until the lock is available.
     */&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;acquireTransactionLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;jdbcTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;queryForObject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"SELECT pg_advisory_xact_lock(?)"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="nc"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;lockKey&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3. A Clean Functional Lock Template
&lt;/h3&gt;

&lt;p&gt;Annotations (like &lt;code&gt;@AdvisoryLocked&lt;/code&gt;) look nice on paper, but aspect-oriented locks often obscure transaction boundaries. If the lock is acquired &lt;em&gt;before&lt;/em&gt; &lt;code&gt;@Transactional&lt;/code&gt; begins, or released &lt;em&gt;after&lt;/em&gt; it commits, race conditions sneak back in.&lt;/p&gt;

&lt;p&gt;A functional template makes the transaction and lock scope explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.locking.service&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.example.locking.exception.LockAcquisitionException&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.example.locking.repository.PostgresAdvisoryLockRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.example.locking.util.LockKeyGenerator&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.slf4j.Logger&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.slf4j.LoggerFactory&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.stereotype.Component&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.transaction.annotation.Propagation&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.transaction.annotation.Transactional&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.function.Supplier&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Component&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AdvisoryLockTemplate&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Logger&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoggerFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLogger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AdvisoryLockTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;PostgresAdvisoryLockRepository&lt;/span&gt; &lt;span class="n"&gt;lockRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;AdvisoryLockTemplate&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PostgresAdvisoryLockRepository&lt;/span&gt; &lt;span class="n"&gt;lockRepository&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lockRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lockRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="cm"&gt;/**
     * Executes the supplier within a MANDATORY or NEW transaction holding an advisory lock.
     * Fails fast if the lock cannot be acquired immediately.
     */&lt;/span&gt;
    &lt;span class="nd"&gt;@Transactional&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;propagation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Propagation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;REQUIRED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;T&lt;/span&gt; &lt;span class="nf"&gt;executeWithTryLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Supplier&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;numericKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LockKeyGenerator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toLongKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Attempting to acquire lock for key '{}' (hash: {})"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;numericKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;acquired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lockRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tryAcquireTransactionLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numericKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;acquired&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to acquire lock for key: {}. Another process is executing."&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;LockAcquisitionException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unable to acquire lock for: "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Lock successfully acquired for key: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Note: pg_advisory_xact_lock is freed automatically when the transaction completes.&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Task completed. Lock for key '{}' will release on TX commit/rollback."&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Custom exception for clean HTTP 409 (Conflict) mappings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.locking.exception&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LockAcquisitionException&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;LockAcquisitionException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  4. A Realistic Scenario: First-Time Welcome Reward
&lt;/h3&gt;

&lt;p&gt;Let’s look at a realistic use case: granting a first-time signup bonus or wallet reward. &lt;/p&gt;

&lt;p&gt;Two requests hit different pods at the same time. The user record has no reward row yet, so &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; cannot help us.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    autonumber
    participant Pod1 as Pod 1 (Lock Acquired)
    participant Pod2 as Pod 2 (Lock Denied)
    participant DB as Postgres Lock Manager

    Pod1-&amp;gt;&amp;gt;DB: SELECT pg_try_advisory_xact_lock(hash("wallet:reward:user-100"))
    DB--&amp;gt;&amp;gt;Pod1: true (Lock Granted)

    Pod2-&amp;gt;&amp;gt;DB: SELECT pg_try_advisory_xact_lock(hash("wallet:reward:user-100"))
    DB--&amp;gt;&amp;gt;Pod2: false (Locked by Pod 1)
    Pod2--&amp;gt;&amp;gt;Pod2: Throw LockAcquisitionException (Fast Fail)

    Pod1-&amp;gt;&amp;gt;Pod1: Check if already rewarded (false)
    Pod1-&amp;gt;&amp;gt;Pod1: Credit Wallet &amp;amp; Insert Audit Log
    Pod1-&amp;gt;&amp;gt;DB: COMMIT Transaction
    DB--&amp;gt;&amp;gt;Pod1: Lock automatically dropped!&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Here is the service implementation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.rewards.service&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.example.locking.service.AdvisoryLockTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.slf4j.Logger&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.slf4j.LoggerFactory&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.stereotype.Service&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.transaction.annotation.Transactional&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.math.BigDecimal&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RewardClaimService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;Logger&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LoggerFactory&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getLogger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RewardClaimService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AdvisoryLockTemplate&lt;/span&gt; &lt;span class="n"&gt;lockTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;RewardRepository&lt;/span&gt; &lt;span class="n"&gt;rewardRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;WalletService&lt;/span&gt; &lt;span class="n"&gt;walletService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;RewardClaimService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AdvisoryLockTemplate&lt;/span&gt; &lt;span class="n"&gt;lockTemplate&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                              &lt;span class="nc"&gt;RewardRepository&lt;/span&gt; &lt;span class="n"&gt;rewardRepository&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                              &lt;span class="nc"&gt;WalletService&lt;/span&gt; &lt;span class="n"&gt;walletService&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;lockTemplate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lockTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;rewardRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rewardRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;walletService&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;walletService&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;RewardClaimResult&lt;/span&gt; &lt;span class="nf"&gt;claimWelcomeBonus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"reward:claim:welcome:"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;lockTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeWithTryLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Safe critical section across the entire cluster!&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rewardRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;existsByUserIdAndType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"WELCOME_BONUS"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"User {} already received the welcome bonus"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RewardClaimResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;alreadyClaimed&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;

            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Granting bonus to user {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;walletService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;credit&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"25.00"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
            &lt;span class="n"&gt;rewardRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;recordClaim&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"WELCOME_BONUS"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RewardClaimResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;success&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"25.00"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
        &lt;span class="o"&gt;});&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The "Secret Weapon": Lock Timeouts in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;What if you &lt;em&gt;don't&lt;/em&gt; want to fail immediately with &lt;code&gt;try_lock&lt;/code&gt;, but you also don't want your threads to wait indefinitely and starve your HikariCP pool?&lt;/p&gt;

&lt;p&gt;PostgreSQL doesn’t have a &lt;code&gt;pg_advisory_lock_with_timeout(key, duration)&lt;/code&gt; function. However, it &lt;em&gt;does&lt;/em&gt; have &lt;code&gt;SET LOCAL lock_timeout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When set inside a transaction, &lt;code&gt;lock_timeout&lt;/code&gt; applies to all lock requests in that transaction—including advisory locks!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;acquireTransactionLockWithTimeout&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;timeoutMillis&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Set local timeout for this transaction only&lt;/span&gt;
    &lt;span class="n"&gt;jdbcTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SET LOCAL lock_timeout = '"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;timeoutMillis&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"ms'"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. Will wait up to timeoutMillis; throws PSQLException with SQLState 55P03 if exceeded&lt;/span&gt;
    &lt;span class="n"&gt;jdbcTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;queryForObject&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT pg_advisory_xact_lock(?)"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Void&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lockKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the lock cannot be acquired within the specified duration, Postgres aborts the lock request with:&lt;br&gt;
&lt;code&gt;ERROR: canceling statement due to lock timeout (SQLState: 55P03)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This gives you a bounded, distributed queue without needing complex polling or backoff logic in Java.&lt;/p&gt;


&lt;h2&gt;
  
  
  Verifying Concurrency with Testcontainers
&lt;/h2&gt;

&lt;p&gt;You should never trust distributed concurrency code without an integration test that actually fires simultaneous threads against a real PostgreSQL instance.&lt;/p&gt;

&lt;p&gt;Here is a full Testcontainers-based test verifying that concurrent requests for the same lock key are cleanly serialized or rejected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.example.locking&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.example.locking.exception.LockAcquisitionException&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.example.locking.service.AdvisoryLockTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.junit.jupiter.api.DisplayName&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.junit.jupiter.api.Test&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.beans.factory.annotation.Autowired&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.boot.test.context.SpringBootTest&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.test.context.DynamicPropertyRegistry&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.test.context.DynamicPropertySource&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.testcontainers.containers.PostgreSQLContainer&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.testcontainers.junit.jupiter.Container&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.testcontainers.junit.jupiter.Testcontainers&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.concurrent.*&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.concurrent.atomic.AtomicInteger&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;static&lt;/span&gt; &lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertj&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;core&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Assertions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@SpringBootTest&lt;/span&gt;
&lt;span class="nd"&gt;@Testcontainers&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AdvisoryLockIntegrationTest&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Container&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nc"&gt;PostgreSQLContainer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PostgreSQLContainer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres:16-alpine"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nd"&gt;@DynamicPropertySource&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;configureProperties&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DynamicPropertyRegistry&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"spring.datasource.url"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;postgres:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getJdbcUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"spring.datasource.username"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;postgres:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getUsername&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"spring.datasource.password"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;postgres:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getPassword&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Autowired&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;AdvisoryLockTemplate&lt;/span&gt; &lt;span class="n"&gt;lockTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt;
    &lt;span class="nd"&gt;@DisplayName&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Simultaneous threads on the same key must only allow one execution"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;testConcurrentAccess&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;threadCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="nc"&gt;ExecutorService&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Executors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newFixedThreadPool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;CountDownLatch&lt;/span&gt; &lt;span class="n"&gt;readyLatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CountDownLatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;CountDownLatch&lt;/span&gt; &lt;span class="n"&gt;startLatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;CountDownLatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;AtomicInteger&lt;/span&gt; &lt;span class="n"&gt;successCounter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AtomicInteger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;AtomicInteger&lt;/span&gt; &lt;span class="n"&gt;lockDeniedCounter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AtomicInteger&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;sharedResourceKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"account:sync:USR-123"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;threadCount&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;submit&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;readyLatch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;countDown&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
                &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;startLatch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;await&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Synchronize thread release&lt;/span&gt;

                    &lt;span class="n"&gt;lockTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeWithTryLock&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sharedResourceKey&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                        &lt;span class="n"&gt;successCounter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;incrementAndGet&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
                        &lt;span class="c1"&gt;// Hold lock briefly to force collision&lt;/span&gt;
                        &lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sleep&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
                    &lt;span class="o"&gt;});&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LockAcquisitionException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;lockDeniedCounter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;incrementAndGet&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                    &lt;span class="c1"&gt;// unexpected&lt;/span&gt;
                &lt;span class="o"&gt;}&lt;/span&gt;
            &lt;span class="o"&gt;});&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Wait until all threads are prepped&lt;/span&gt;
        &lt;span class="n"&gt;readyLatch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;await&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SECONDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// Fire all threads simultaneously&lt;/span&gt;
        &lt;span class="n"&gt;startLatch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;countDown&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;shutdown&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;awaitTermination&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SECONDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;isTrue&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Exactly one thread should succeed; remaining 7 must be rejected&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;successCounter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lockDeniedCounter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isEqualTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;threadCount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Production Gotchas and When NOT to Use Them
&lt;/h2&gt;

&lt;p&gt;PostgreSQL advisory locks are powerful, but senior engineers understand where tools fail. Keep these three points in mind:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Connection Starvation During Slow External I/O
&lt;/h3&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph LR
    subgraph Anti-Pattern
        A[Acquire Advisory Lock] --&amp;gt; B[Hold DB Connection]
        B --&amp;gt; C[Wait for Slow 3rd Party HTTP API 3-5s]
        C --&amp;gt; D[HikariCP Pool Starvation!]
    end&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; 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 = &lt;code&gt;PENDING_PAYMENT&lt;/code&gt;) committed to the database, or use an external lock manager like Redis if the database connection pool cannot absorb the latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. PgBouncer and Transaction Pooling
&lt;/h3&gt;

&lt;p&gt;If you use PgBouncer in front of PostgreSQL:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session-level locks (&lt;code&gt;pg_advisory_lock&lt;/code&gt;) are broken&lt;/strong&gt; in &lt;code&gt;pool_mode = transaction&lt;/code&gt;. PgBouncer may assign subsequent queries in the same client session to different Postgres server backends!&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transaction-level locks (&lt;code&gt;pg_advisory_xact_lock&lt;/code&gt;) work flawlessly&lt;/strong&gt; because PgBouncer pins the client to a single physical backend connection for the exact lifetime of the transaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Postgres Shared Memory Limits
&lt;/h3&gt;

&lt;p&gt;Advisory locks are stored in PostgreSQL's shared memory, governed by &lt;code&gt;max_locks_per_transaction&lt;/code&gt; (default typically 64) multiplied by &lt;code&gt;max_connections&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architectural Comparison: Picking the Right Tool
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Java &lt;code&gt;synchronized&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;PostgreSQL Advisory Lock&lt;/th&gt;
&lt;th&gt;Redis (Redisson)&lt;/th&gt;
&lt;th&gt;DB Row Lock (&lt;code&gt;SELECT FOR UPDATE&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Clustered / Multi-Node&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Protects Non-Existent Records&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ No (requires existing row)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;External Infrastructure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None (uses existing DB)&lt;/td&gt;
&lt;td&gt;Requires Redis cluster&lt;/td&gt;
&lt;td&gt;None (uses existing DB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auto-Cleanup on Crash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;✅ Yes (with &lt;code&gt;xact&lt;/code&gt; locks)&lt;/td&gt;
&lt;td&gt;⚠️ Requires TTL / watchdogs&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Safe with Slow HTTP I/O&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ Risks DB pool starvation&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ Risks DB pool starvation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Operational Overhead&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;td&gt;Moderate to High&lt;/td&gt;
&lt;td&gt;Zero&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Before introducing Redis, ZooKeeper, or Hazelcast to solve distributed race conditions in your Spring Boot services, look closely at your existing PostgreSQL database.&lt;/p&gt;

&lt;p&gt;By combining &lt;code&gt;pg_try_advisory_xact_lock&lt;/code&gt; with Spring’s &lt;code&gt;@Transactional&lt;/code&gt; boundaries:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You get &lt;strong&gt;true cluster-wide serialization&lt;/strong&gt; across any number of pods.&lt;/li&gt;
&lt;li&gt;You eliminate dangling locks through &lt;strong&gt;automatic transaction-scoped cleanup&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;You save your organization from operating and monitoring an additional distributed caching cluster.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Simplicity is an architectural superpower. Sometimes the most resilient solution is the one already running in your stack.&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>postgres</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
