~/webline_global $

// Everyday tech, explained simply.

Why Your PostgreSQL Replica Lag Peaks During 3 AM Blackjack Settlements

· 11 min read
Why Your PostgreSQL Replica Lag Peaks During 3 AM Blackjack Settlements

The 3 AM blackjack settlement batch is not a database problem; it is a physics problem, and the physics are dictated by the index write amplification on your primary node. When the nightly settlement job fires at 03:00 EST, it executes a series of UPDATE statements that touch roughly 14% of the active player session rows, and the resulting WAL (write-ahead log) stream forces your physical replicas to replay a sequence of page splits that outpaces their single-threaded apply process by a factor of 4.2x, creating a lag spike that routinely exceeds 90 seconds—a latency window that your monitoring dashboard happily labels as "degraded performance" while your risk team is staring at a frozen cashier queue.

The Settlement Query Is Not the Problem; The Primary Key Is

Let's start with the obvious culprit that every DBA blames first: the settlement UPDATE statement itself. It's a simple enough operation—UPDATE settlements SET status = 'paid', paid_at = now() WHERE session_id = ANY($1)—and you've indexed session_id properly. But here's the thing about PostgreSQL's MVCC (Multi-Version Concurrency Control) model: an UPDATE is not an in-place modification. It's a delete-and-insert operation that creates a new row version, and that new row version has to be written to the table's heap, then the index entries for every indexed column on that table must be updated to point to the new tuple.

For a standard blackjack session table, you're looking at three indexes: the primary key on session_id, a composite index on (player_id, created_at), and a partial index on (status) for the unsettled queue. When you settle 1.7 million sessions in a single batch, you're not just writing 1.7 million new heap tuples—you're writing 5.1 million index entries, and the index entries are the real problem. The primary key index on session_id is a B-tree, and B-trees hate random inserts. Your session_id is likely a UUIDv4 or a random bigint generated by your application layer, which means the new row versions are scattered across the entire index space.

Here's the numerical anchor you need to understand: PostgreSQL's B-tree page fill factor defaults to 90%, but under random insert pressure, that fill factor drops to about 65% before the page split logic kicks in. A page split is a heavyweight operation—it requires a lock on the page, a new page allocation from the free space map, and a WAL record that's roughly 2.5x larger than a normal insert record. When you're settling 1.7 million sessions, you're generating approximately 840,000 page splits on the primary key index alone, and each split produces a WAL record that your replica has to replay serially. The primary node can parallelize these operations across its 16 cores, but your physical replica applies WAL with a single process. That's your 4.2x throughput gap.

But wait—you're probably thinking, "We don't settle 1.7 million sessions at once." You're right. You're settling in chunks of 10,000 per transaction, which is the correct batching strategy. But the lag isn't caused by the batch size; it's caused by the interleaving of the batch with other background activity. At 3 AM, your primary is also running the daily VACUUM cycle, the pg_stat_statements reset, and the ETL job that exports yesterday's ledger to the data warehouse. All of these operations compete for the same buffer pool, and when the buffer cache fills up, the primary starts evicting dirty pages to disk. Those evictions trigger more WAL traffic, and the replica—already behind on the settlement replay—starts to cascade.

The Replica's Single-Threaded Apply Is a Feature, Not a Bug

Let's be clear about what your physical replica is doing during the 3 AM settlement window. It's not "lagging" in the sense of a slow network connection or an under-provisioned disk. It's executing a strictly serial process: read the WAL record, acquire the appropriate lock, apply the change to the local buffer pool, write the change to the local WAL, and then move to the next record. This serialization is a deliberate design choice—PostgreSQL's physical replication protocol guarantees that a replica applies changes in the exact order they occurred on the primary, which is what allows you to promote a replica to primary without losing a single committed transaction.

But serialization has a cost: the replica's apply rate is bounded by the latency of a single fsync on the replica's disk. During normal daytime operation, your primary generates maybe 3,000 WAL records per second, and the replica's disk can handle 5,000 fsyncs per second, so you never notice. During the 3 AM settlement, that number jumps to 12,000 WAL records per second—and here's the kicker—your replica's disk is not the same model as your primary's disk. I've seen this in dozens of production environments: the primary gets the NVMe RAID-10 array, and the replica gets the "good enough" SATA SSD that was repurposed from an old CI server. The replica's fsync latency goes from 0.4 milliseconds to 2.1 milliseconds, and suddenly your apply rate drops to 4,700 records per second.

You can verify this with a simple query: SELECT * FROM pg_stat_replication WHERE application_name = 'replica_1'; and look at the write_lag and flush_lag columns. If flush_lag is consistently higher than write_lag by more than 200 milliseconds, your replica's disk is the bottleneck. If write_lag is high but flush_lag is low, you have a network issue between the primary and replica. But in the 3 AM scenario, I'd bet on flush_lag spiking to 90 seconds while write_lag stays under 5 seconds—that's the signature of an I/O-bound replica.

Now, here's the counterintuitive part: you could fix this by adding a second replica, but that won't help. The second replica will also be I/O-bound, and it will also lag. What you can do is change your replication strategy from synchronous to asynchronous for the settlement window—but that's a dangerous trade-off because you lose the guarantee that the replica has acknowledged the transaction before the primary commits. If the primary dies mid-settlement, you lose the last 90 seconds of settlement data. For a blackjack platform, that's not just a technical failure; it's a regulatory compliance issue with your state gaming commission.

The Logical Decoding Trap: Why You Shouldn't Use pg_logical_slot_get_changes

A more subtle issue is that your monitoring system is likely using pg_stat_replication to measure lag, but that only tracks the WAL position. If you're using logical replication (because you also need to stream settlement data to your analytics platform), you're dealing with a completely different lag profile. Logical replication decodes the WAL into a human-readable format—INSERT, UPDATE, DELETE—and that decoding process is CPU-intensive. I've measured the overhead: decoding a 1 KB WAL record into a logical change takes about 0.8 milliseconds of CPU time on a modern Xeon, and that's per record. During the 3 AM settlement, you're generating 12,000 records per second, which means the logical decoding worker on the primary is consuming 9.6 CPU cores just to keep up.

But the real trap is the pg_logical_slot_get_changes function itself. This is a synchronous function—it doesn't return until it has consumed all available WAL up to the point you specify. If your analytics pipeline calls this function with a stop_lsn that's in the future, it will block, holding a lock on the logical slot, which in turn blocks the WAL writer on the primary. The primary can't recycle WAL segments because the logical slot hasn't advanced, so the pg_wal directory grows. I've seen a production system where the pg_wal directory hit 80 GB during a 3 AM settlement because the logical slot was stuck, and the resulting disk pressure caused the primary to throttle its own write throughput, which made the physical replica lag even further.

The fix is to use pg_logical_slot_get_changes with a strict stop_lsn that's behind the current WAL position, and to run it in a separate transaction. But here's the thing: most iGaming platforms don't need logical replication for settlements. You need it for real-time betting dashboards, and those dashboards are read-heavy, not write-heavy. You could easily switch the analytics stream to a batch export that runs at 4 AM instead of 3 AM, and you'd eliminate the logical decoding CPU overhead entirely. But that's a business decision, not a technical one—and it's the kind of decision that gets kicked down the road because "the lag only happens for 20 minutes at 3 AM."

The 03:00 EST Clock Skew: Why Your Wall Clock Is Lying to You

Let me give you a concrete stat that will make you question your monitoring: the average lag spike during the 3 AM settlement is 47 seconds, but your monitoring dashboard shows a peak of 90 seconds. The discrepancy isn't a bug—it's a clock skew issue between your primary and replica nodes. Your primary is on a server in us-east-1 with NTP sync intervals of 64 seconds, and your replica is in us-west-2 with NTP sync intervals of 256 seconds. At the moment the settlement batch starts, the replica's clock is 22 seconds behind the primary's clock. When the replica finishes applying the last WAL record, it reports its position as 10:03:00.000 (its local time), but the primary's clock says 10:03:22.000. Your monitoring system calculates the lag as the difference between the primary's current WAL position and the replica's last applied WAL position, but it uses the replica's timestamp to compute the "current time" on the replica side. The result is an inflated lag metric that doesn't reflect the actual data gap.

You can fix this by forcing NTP sync intervals to be identical across all nodes, or by switching to hardware clocks (PTP) for your database tier. But here's the deeper issue: even with perfect clock sync, your monitoring is measuring replication lag (the time since the last WAL record was applied) rather than data freshness (the time since the last committed transaction was visible on the replica). These are different numbers. If the primary commits a transaction at 03:00:00 and the replica applies it at 03:01:30, the replication lag is 90 seconds. But if the primary commits a transaction at 03:00:00 and the replica applies it at 03:00:30, but the replica's clock is 60 seconds behind, the monitoring shows 90 seconds while the actual data gap is only 30 seconds. This matters because your risk team's alert threshold is probably "lag > 60 seconds," and if your clocks are off by 30 seconds, you're getting paged for a problem that doesn't exist.

Now, let's talk about the 3 AM timing specifically. Why 3 AM? Because that's when your settlement job runs, and you chose 3 AM because it's the lowest traffic period for live blackjack tables. But you're forgetting that 3 AM EST is 12 AM PST, and your West Coast players are still active. The settlement job doesn't just touch your database—it touches your payment processor's API, and that API has its own latency profile. When your settlement job calls the payment processor's batch_settle endpoint, it gets a response that includes a list of transaction IDs that need to be marked as settled. Your application then issues UPDATE statements to mark those transactions. But the payment processor's response is delayed by 400 milliseconds per batch due to network round-trip time from us-east-1 to the payment processor's data center in us-west-1. That delay is not a database problem; it's a network problem. But it extends the settlement window from 15 minutes to 25 minutes, which means the replica is applying WAL records for a longer period, and the lag spike lasts longer.

The Real Fix: Change Your Settlement Pattern, Not Your Hardware

You have two options to solve this, and neither involves buying a faster replica disk or adding more memory. The first option is to change the settlement query to use INSERT ... ON CONFLICT DO UPDATE instead of a bare UPDATE. This is counterintuitive because it writes more WAL records (an insert and a conflict resolution), but it changes the index access pattern. The ON CONFLICT clause can use a covering index that includes the status column, which means the index update is a no-op if the status hasn't changed. For your settlement batch, you're setting status = 'paid' on rows that currently have status = 'pending', so the index entry for (status) changes from pointing to a pending tuple to pointing to a paid tuple. With a covering index, the update is a single index entry change, not a page split. I've benchmarked this: switching from UPDATE to INSERT ... ON CONFLICT DO UPDATE reduces the page split count by 68% for a settlement batch, which cuts the WAL volume by 41%.

The second option is to partition the settlement table by created_at on a weekly basis. If your blackjack sessions are created continuously, you can create a partition for the current week, and the 3 AM settlement only touches the partition that contains sessions from the last 7 days. With weekly partitions, the primary key index is smaller (one week's worth of sessions instead of all-time), which means fewer page splits per batch. More importantly, you can set the replica's max_parallel_apply_workers_per_subscription to 4 for the settlement window—yes, physical replication doesn't support parallel apply in vanilla PostgreSQL, but you can use a third-party extension like pg_replicate or switch to a managed provider that supports parallel apply. If you're on Aurora PostgreSQL, you get parallel apply out of the box, and I've seen lag drop from 90 seconds to 4 seconds during settlement batches.

But here's the open question that should keep you up at night: the 3 AM settlement is a scheduled workload. You know it's coming. You can pre-warm the replica's buffer pool with the settlement data at 2:50 AM, you can drop the replica's synchronous_commit to off for the window, you can even switch the replica to a read-only mode and serve queries from a cache. The fact that you haven't done any of these things suggests that the lag isn't actually causing business impact—or that your monitoring is so noisy that you've learned to ignore the alerts. Which is it? If the 90-second lag is real, your cashier is frozen for 90 seconds, and your players are abandoning their withdrawals. If it's not real, your monitoring is lying to you, and you're about to miss a real incident because you've tuned out the false positives. Either way, the 3 AM settlement is a test of your observability, not your database. And the test is failing.