<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Manideep’s Engineering Notes]]></title><description><![CDATA[Load-testing four Postgres ticket reservation strategies with k6, and tracing where the time goes.]]></description><link>https://manideepnaidu.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 08:14:30 GMT</lastBuildDate><atom:link href="https://manideepnaidu.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[The bottleneck didn’t go away. It moved.]]></title><description><![CDATA[Moving flash-sale stock from a Postgres row into a Redis Lua script, finding the new Node.js bottleneck, and scaling from 1,000 to 6,000 req/s.]]></description><link>https://manideepnaidu.hashnode.dev/redis-vs-postgres-flash-sale-scaling</link><guid isPermaLink="true">https://manideepnaidu.hashnode.dev/redis-vs-postgres-flash-sale-scaling</guid><category><![CDATA[Redis]]></category><category><![CDATA[postgres]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[performance]]></category><category><![CDATA[scaling]]></category><category><![CDATA[System Design]]></category><dc:creator><![CDATA[Manideep Naidu]]></dc:creator><pubDate>Sat, 24 Oct 2026 12:06:43 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/5b57cfff-dc66-4e5a-807d-b7c302a3088b.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2>Where we left off</h2>
<p>At the end of Post 1, my best Postgres version topped out at around 700 reservations a second. Beyond that, requests just piled up waiting for the same row.</p>
<p>So for this part, I tried something different: keep the stock count in Redis and do the check-and-decrement in a small Lua script. Same machine, same load test. It held up to about 6,000 reservations a second without overselling a single ticket.</p>
<p>It wasn’t free, though. Moving the count out of the database fixed the speed problem and created a couple of new ones, which I’ll get to at the end.</p>
<h2>The idea</h2>
<p>The problem in Post 1 was never really the SQL. It was that every reservation had to go to the database, lock the same row, and wait its turn.</p>
<p>So the obvious question was: what if the stock count didn’t live in the database at all?</p>
<p>Redis is a good fit for this. It keeps everything in memory, so reads and writes are fast, and it handles commands one at a time. That second part matters more than it sounds. If I send Redis a small Lua script, it runs the whole script atomically, so no other request can come in between and no race conditions.</p>
<p>Why a script and not just a few normal Redis commands? Because I’d be back to the same bug as the no-lock version in <a href="https://manideepnaidu.hashnode.dev/postgres-flash-sale-four-strategies">Post 1</a>. If I read the stock with one command, check it in Node, and then decrement it with another, two buyers can still both read “1 left” in between and we will oversell. Putting the check and the decrement in one atomic script closes that gap.</p>
<p>So the plan is simple: one Lua script that checks the stock, decrement the stock, and records a hold for the buyer, all in a single step.</p>
<h2>The script</h2>
<p>Here's the part of <code>reserve.lua</code> that matters for this post: checking the stock and decrement the stock.</p>
<pre><code class="language-lua">-- KEYS[1] = stock counter for the event
-- KEYS[2] = holds (sorted set, score = expiry time)
-- ARGV[1] = qty, ARGV[2] = holdId, ARGV[3] = expiresAtMs

local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local qty   = tonumber(ARGV[1])

if stock &lt; qty then
  return {'SOLD_OUT'}
end

redis.call('DECRBY', KEYS[1], qty)                 -- take the tickets
redis.call('ZADD', KEYS[2], ARGV[3], ARGV[2])      -- hold them until expiry

return {'OK', ARGV[2]}
</code></pre>
<p>It reads the current stock and, if there isn't enough, returns right away with "sold out". Otherwise it takes the quantity off and record the hold. Because Redis runs the whole script atomically, no other request can change the stock between the check and the decrement. That's the gap that caused the overselling in <a href="https://manideepnaidu.hashnode.dev/postgres-flash-sale-four-strategies">Post 1</a>, and here it simply can't happen.</p>
<p>The rest of the script does one more thing. It records a hold for the buyer with an expiry time, so if they don't complete the payment in time(i took 10 minutes) the hold is released and the ticket goes back into stock.</p>
<h2>The setup</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/02d5b635-7197-4c70-810a-73deee467122.png" alt="" style="display:block;margin:0 auto" />

<p>Focus on the top row: k6 → Ticket Service → Redis Instead of k6 → Ticket Service → Postgres. The tracing and metrics underneath are just how I watched it, they get their own post later in the series.</p>
<h2>Correctness</h2>
<p>Here's what happened when 1,000 reservations hit 100 tickets at once:</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Holds created</th>
<th><code>available</code> at the end</th>
<th>Oversold by</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>2</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>3</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
</tbody></table>
<h2>How fast is it:</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/16bcf52f-6a4a-470d-a3b6-727c37c85d8e.png" alt="" style="display:block;margin:0 auto" />

<p>I ran the same kind of ramp as in <a href="https://manideepnaidu.hashnode.dev/postgres-flash-sale-four-strategies">Post 1</a>: start at 500 requests a second and keep adding more.</p>
<p>Up to 1,000 requests a second it was fine. p99 stayed under 50 ms and there were no errors. That's more than the 700 I got from Postgres, but I expected a bigger jump.</p>
<p>At 1,500 requests a second it still handled the load and still returned no errors, but it got slow. p99 went over 200 ms and reached about 256 ms by the end of that step. Using the same 200 ms rule as <a href="https://manideepnaidu.hashnode.dev/postgres-flash-sale-four-strategies">Post 1</a>, I'm counting the knee as 1,000 requests a second.</p>
<p>To find out why, I looked at the bottom three charts in the above image. The ticket service was running as a single Node.js process, which can only use one CPU core, and its CPU usage went up to 1 core, so it was fully busy. The event-loop lag went from around 10 ms to almost 70 ms, which means requests were waiting in Node's queue before my code even started handling them. Redis, on the other hand, used at most 0.15 of a core the whole time.</p>
<p>So the row lock problem from Post 1 is gone, but now the Node process is the limit, not the database.</p>
<h2>What happens with more Node processes?</h2>
<p>If one Node process is the limit, the next step is to have more workers. So I ran the same ramp again with 1, 2, 4 and 8 ticket workers. I did it for both the Redis version and the best Postgres version from Post 1, the atomic update. Nothing else changed between runs.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/0e1ec780-e6ea-4327-ac96-1ceb54755657.png" alt="" style="display:block;margin:0 auto" />

<p>Postgres didn't really move. It went from 600 to 800 and then back down to 700 with 8 workers. Adding workers just meant more requests waiting for the same row, so the row lock was still the limit.</p>
<p>Redis was different. Every time I doubled the workers, the knee roughly doubled too: 1,000, then 2,000, then 4,000. At 8 workers it reached 6,000. That's still more, but not double this time, because by then the laptop was running out of CPU cores to share between the workers, Redis, Postgres and k6.</p>
<p>This is the result I was really want. With Postgres, adding servers didn't help, because the database was the ceiling. With the counter in Redis, the ceiling goes up when I add servers.</p>
<h2>Side by side</h2>
<table>
<thead>
<tr>
<th>Workers</th>
<th>Postgres atomic</th>
<th>Redis + Lua</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>600 req/s</td>
<td>1,000 req/s</td>
</tr>
<tr>
<td>2</td>
<td>800 req/s</td>
<td>2,000 req/s</td>
</tr>
<tr>
<td>4</td>
<td>800 req/s</td>
<td>4,000 req/s</td>
</tr>
<tr>
<td>8</td>
<td>700 req/s</td>
<td>6,000 req/s</td>
</tr>
</tbody></table>
<h2>The setup that reached 6,000 requests a second</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/a07f2cd2-1a9d-422c-8b4b-702da3d2d094.png" alt="" style="display:block;margin:0 auto" />

<p>Same as before, with two changes: the counter lives in Redis, and the ticket service runs as 8 Node processes instead of one. Postgres isn't on this path any more. It still stores orders, but it's no longer touched for every reservation. (That part comes in Part 3.)</p>
<h2>What it costs</h2>
<p>Redis keeps everything in memory, so if it crashes I can lose recent stock changes unless it saves to disk or has a replica. I'll test that later in the series.</p>
<p>The bigger issue: stock now lives in Redis, but orders live in Postgres and they need to be in sync eventually.</p>
<h2>What I'd try at 10×</h2>
<p>One Redis server will eventually become the limit, because it handles commands one at a time. The usual next step is a Redis Cluster: several Redis servers, each holding a different part of the data.</p>
<p>There's one catch. Lua script touches several keys at once (stock, holds, events), and in a cluster a script only works if all its keys are on the same server. I already name every key with the event ID in curly braces, like <code>stock:{event42}</code>, which tells Redis to keep all of one event's keys together. So the script would work unchanged.</p>
<p>That spreads the load when there are many sales at the same time. One very big sale would still sit on a single server, though, so it would still be limited by one Redis.</p>
<hr />
<p><strong>Repo:</strong> <a href="https://github.com/manideep2810/Flash-Sale.git">https://github.com/manideep2810/Flash-Sale.git</a> · <strong>Raw results:</strong> <code>docs/results/post-2/</code> · <strong>Next:</strong> Post 3, Keeping Postgres and Redis in sync</p>
]]></content:encoded></item><item><title><![CDATA[I sold 115 tickets out of 100]]></title><description><![CDATA[Load-testing four Postgres ticket reservation strategies with k6, and tracing where the time goes.]]></description><link>https://manideepnaidu.hashnode.dev/postgres-flash-sale-four-strategies</link><guid isPermaLink="true">https://manideepnaidu.hashnode.dev/postgres-flash-sale-four-strategies</guid><category><![CDATA[postgres]]></category><category><![CDATA[System Design]]></category><category><![CDATA[database]]></category><category><![CDATA[backend]]></category><category><![CDATA[performance]]></category><dc:creator><![CDATA[Manideep Naidu]]></dc:creator><pubDate>Sun, 18 Oct 2026 18:00:13 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/1dcebdcb-1da4-4a72-bd03-c57f1f50b5da.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>100 tickets. Thousands of buyers. One second.</p>
<p>My first version sold 115 of them. I ran it again: 106. Again: 107. Same code, a different wrong answer every time.</p>
<p>Getting it back to exactly 100 turned out to be the easy part. Making it fast was the hard part.</p>
<h2>The problem</h2>
<p>The rule is simple: <strong>never sell what you don’t have.</strong></p>
<p>Each sale does two things: it takes one unit off the stock, and it creates a reservation (I call it a <em>hold</em>). Stock sold and holds created must always match.</p>
<p>All the stock for an event lives in a single row: the <code>available</code> column in the <code>inventory</code> table. Every buyer has to read that number and change it. One buyer at a time, that’s trivial. A thousand in the same second, all reaching for the same row, is where it breaks: two of them can read the same number before either one writes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/bb135f96-7c9a-4e57-9028-4b5553f12940.png" alt="" style="display:block;margin:0 auto" />

<p><em>(Per-user limits come later in the series. This post is only about not overselling.)</em></p>
<h2>How I tested</h2>
<ul>
<li><p><strong>Setup:</strong> one laptop (Ryzen 7, 16 threads, 32 GB RAM), Postgres 17 in Docker on default settings, one Node.js process with a <strong>pool of 20 connections</strong>. That means at most 20 database queries in flight; request 21 waits.</p>
</li>
<li><p><strong>Load:</strong> k6, ramping from 100 to 1,000 requests a second (+100 every 30 s). Requests keep arriving on schedule even when the server slows down, just like real buyers.</p>
</li>
<li><p><strong>Two kinds of runs:</strong></p>
<ul>
<li><p><em>Correctness:</em> 100 tickets, 1,000 reservations fired at once. Did it oversell?</p>
</li>
<li><p><em>Speed:</em> 200,000 tickets, so it never sells out. How much load can it take?</p>
</li>
</ul>
</li>
</ul>
<p>I report two numbers:</p>
<ul>
<li><p><strong>p99:</strong> 99% of requests were faster than this. It’s what your unluckiest users feel.</p>
</li>
<li><p><strong>Knee:</strong> the highest rate where p99 stayed under 200 ms. Past it, things fall apart.</p>
</li>
</ul>
<hr />
<h2>The Setup</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/69bf9ecf-0087-4b08-95a3-32cfc419bf72.png" alt="" style="display:block;margin:0 auto" />

<p>Focus on the top row: k6 → Ticket Service → Postgres. The tracing and metrics underneath are just how I watched it, they get their own post later in the series.</p>
<h2>1. No lock</h2>
<p><strong>The idea:</strong> read the stock, check it in code, then write.</p>
<pre><code class="language-sql">SELECT available FROM inventory WHERE event_id = $1;
-- in Node: if available &lt; qty → SOLD_OUT
UPDATE inventory SET available = available - $2 WHERE event_id = $1;
INSERT INTO holds (event_id, user_id, qty) VALUES ($1, $2, $3) RETURNING id;
</code></pre>
<p>Looks reasonable. Here's what happened when 1,000 reservations hit 100 tickets at once:</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Tickets</th>
<th>Holds created</th>
<th><code>available</code> at the end</th>
<th>Oversold by</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>100</td>
<td>115</td>
<td>-15</td>
<td>15</td>
</tr>
<tr>
<td>2</td>
<td>100</td>
<td>106</td>
<td>-6</td>
<td>6</td>
</tr>
<tr>
<td>3</td>
<td>100</td>
<td>107</td>
<td>-7</td>
<td>7</td>
</tr>
</tbody></table>
<p>Three runs, three different wrong answers.</p>
<p>The classic race: two buyers read <strong>1 left</strong> before either one writes, and both buy. Three runs, three different wrong answers.</p>
<p><strong>Verdict:</strong> wrong, so speed doesn't matter.</p>
<h2>2. Pessimistic Locking</h2>
<p><strong>The idea:</strong> lock the row before reading it. Only one transaction can hold the lock; everyone else waits until it commits. No gap, no race.</p>
<pre><code class="language-sql">BEGIN;
SELECT available FROM inventory WHERE event_id = $1 FOR UPDATE;
-- in Node: if available &lt; qty → ROLLBACK, SOLD_OUT
UPDATE inventory SET available = available - $2 WHERE event_id = $1;
INSERT INTO holds (event_id, user_id, qty) VALUES ($1, $2, $3) RETURNING id;
COMMIT;
</code></pre>
<p><strong>Correctness:</strong> Same test, 1,000 reservations for 100 tickets</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Holds created</th>
<th><code>available</code> at the end</th>
<th>Oversold by</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>2</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>3</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
</tbody></table>
<p><strong>How fast is it:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/4ac6a0d7-2b27-4f83-9ed6-54ca8b653a88.png" alt="" style="display:block;margin:0 auto" />

<p>It kept p99 under 200 ms up to 26<strong>0 req/s</strong> (p99 173 ms). At 300 req/s p99 jumped to 200 <strong>ms</strong>. Then the line goes flat . That's the knee.</p>
<p>To see where the time went, I traced a single request just past the knee:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/765ee866-cfd5-4c35-9939-331d2920aa88.png" alt="" style="display:block;margin:0 auto" />

<p>That one request took <strong>6.6 seconds</strong>:</p>
<ul>
<li><p><strong>~6.4 s</strong> waiting for a database connection (the span for pool acquire)</p>
</li>
<li><p><strong>225 ms</strong> waiting for the row lock (the <code>SELECT … FOR UPDATE</code>)</p>
</li>
<li><p><strong>~2.5 ms</strong> doing the actual work (<code>UPDATE</code>, <code>INSERT</code>, <code>COMMIT</code>)</p>
</li>
</ul>
<p>So 99.9% waiting, 0.1% working. And most of the wait wasn't even for the lock.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/24363c62-f5a9-4641-97f9-86b296c42843.png" alt="" style="display:block;margin:0 auto" />

<p>Here's what happens. One transaction holds the lock. The other 19 connections are each stuck inside their <code>SELECT … FOR UPDATE</code>, waiting their turn, <strong>and holding their connection while they wait</strong>. With all 20 connections busy, every new request has to wait in the app before it can even send <code>BEGIN</code>.</p>
<p>The lock creates the line, but the line forms at the pool.</p>
<p><strong>Verdict:</strong> correct, but everyone waits in line, one buyer at a time.</p>
<h2>3. Optimistic Locking</h2>
<p><strong>The idea:</strong> don't lock anything. Read the stock along with a version number, and only update if the version hasn't changed since you read it. If someone else got there first, try again.</p>
<pre><code class="language-sql">SELECT available, version FROM inventory WHERE event_id = $1;

UPDATE inventory
   SET available = available - $2, version = version + 1
 WHERE event_id = $1 AND version = $3;
-- 0 rows updated → someone else won; wait 0–2 ms and retry (up to 200 times)

INSERT INTO holds (event_id, user_id, qty) VALUES ($1, $2, $3) RETURNING id;
</code></pre>
<h3>Correctness</h3>
<p>Same test, 1,000 reservations for 100 tickets:</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Holds created</th>
<th><code>available</code> at the end</th>
<th>Oversold by</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>2</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>3</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
</tbody></table>
<h3>How fast is it:</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/805aed14-2daf-4fb1-a88c-11ee5e3ff4b0.png" alt="" style="display:block;margin:0 auto" />

<p>The worst of the three correct versions. It stayed quick up to <strong>200 req/s</strong> (p99 just 8.9 ms), then fell off a cliff: at 300 req/s p99 hit <strong>1.4 seconds</strong>.</p>
<p>At low load it's fast because there's no lock to wait for. Under a crowd, that's exactly what hurts it.</p>
<h3>Where does the time go?</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/ab51f1f2-f1e5-4da8-8471-2745d599a2a4.png" alt="" style="display:block;margin:0 auto" />

<p>One ticket, 4 <strong>attempts</strong>. Each of the first 3 attempts are a <code>SELECT</code> followed by an <code>UPDATE</code> that comes back with 0 rows, because someone else changed the version first.</p>
<p>So under contention, optimistic locking still waits on the row, just like <code>Pessmistic Locking</code>. But it also throws away the work and starts over. Every retry keeps the connection busy longer, the pool runs dry, and new requests queue behind it.</p>
<p><strong>Verdict:</strong> correct until the crowd shows up. Then it argues with itself.</p>
<h2>4. Atomic conditional update</h2>
<p><strong>The idea:</strong> let Postgres do the check. Put the condition inside the <code>UPDATE</code> itself, so reading and writing happen in one step.</p>
<pre><code class="language-sql">UPDATE inventory
   SET available = available - $2
 WHERE event_id = $1 AND available &gt;= $2
RETURNING event_id;
-- no row returned → SOLD_OUT

INSERT INTO holds (event_id, user_id, qty) VALUES ($1, $2, $3) RETURNING id;
</code></pre>
<h3>Is it correct?</h3>
<p>Yes. Same test, 1,000 reservations for 100 tickets:</p>
<table>
<thead>
<tr>
<th>Run</th>
<th>Holds created</th>
<th><code>available</code> at the end</th>
<th>Oversold by</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>2</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>3</td>
<td>100</td>
<td>0</td>
<td>0</td>
</tr>
</tbody></table>
<h3>How fast is it?</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/4ae0640c-cc85-4f3c-8768-153c3f2f580f.png" alt="" style="display:block;margin:0 auto" />

<p>The best by far. It kept p99 at just <strong>51 ms up to 700 req/s</strong>, more than double <code>Pessimistic Locking</code>.</p>
<p>Then it hit a wall. At 800 req/s p99 jumped to <strong>645ms</strong>.</p>
<p>Why is it so much faster? It still locks the row; every <code>UPDATE</code> does. But the lock is held only for that one statement. There's no trip back to Node between "check" and "write" while everyone else waits. Each turn on the row is as short as it can be.</p>
<h3>Where does the time go?</h3>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/3892f36a-919e-476f-92e6-188cbb362fe0.png" alt="" style="display:block;margin:0 auto" />

<p>The trace is boring: one <code>UPDATE</code> of 8ms and an <code>insert</code> of 7ms while the waiting time for acquring a connection from thepool reduced to 2s. The interesting part is Postgres CPU.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/0509f6ae-d5d1-415b-b440-e72002dce44a.png" alt="" style="display:block;margin:0 auto" />

<p>Through most of the ramp, CPU grew slowly and steadily, staying under 0.6 cores. That's healthy: more buyers, a bit more work, more tickets sold.</p>
<p>Then, at the knee, CPU shot up to <strong>2 cores</strong>, nearly 4× in under a minute, while throughput rose only about 12% (700 → 783 req/s). Postgres was working much harder but selling barely any more tickets. The extra effort went into passing one row back and forth between 20 connections all waiting for it.</p>
<p><strong>Verdict:</strong> the best of the four, and still capped by one row.</p>
<hr />
<h2>Side by side</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6ad3d6c723b61315c9b6f16f/a681fee5-e66e-43e2-94c1-b02c6e56cb60.png" alt="" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th>Variant</th>
<th>Correct?</th>
<th>Knee (p99 &lt; 200 ms)</th>
<th>p99 at knee</th>
<th>Waiting on</th>
</tr>
</thead>
<tbody><tr>
<td>No lock</td>
<td>Oversold</td>
<td>not measured</td>
<td>–</td>
<td>nothing, which is the bug</td>
</tr>
<tr>
<td>Pessimistic</td>
<td>Correct</td>
<td>300 req/s</td>
<td>173 ms</td>
<td>a connection, then the lock</td>
</tr>
<tr>
<td>Optimistic</td>
<td>Correct</td>
<td>200 req/s</td>
<td>8.9 ms</td>
<td>a connection, then its own retries</td>
</tr>
<tr>
<td>Atomic</td>
<td>Correct</td>
<td><strong>700 req/s</strong></td>
<td>51 ms</td>
<td>the row, then CPU</td>
</tr>
</tbody></table>
<h2>The lesson</h2>
<p><strong>The SQL isn't the problem. One hot row is.</strong> Every buyer takes turns on the same row. <code>Pessimistic</code> makes them take turns politely, <code>Optimistic</code> makes them argue, and <code>atomic</code> makes each turn as short as possible. But at the end all the turns wait for a single row i.e available.</p>
<p>The fix is to take the counting off the database. In Post 2, stock moves to Redis, and a Lua script checks and decrements in one step: no row locks, no pool to run dry.</p>
<h2>What I'd try at 10×</h2>
<ul>
<li><p><strong>Sharded counters:</strong> split 200,000 tickets into, say, 20 rows of 10,000, and send each buyer to a random row. This spreads the contention out. The cost is that the total becomes a sum you must keep right, and selling out gets fiddly when one row is empty but another isn't.</p>
</li>
<li><p><strong>A bigger machine buys time, not a fix.</strong> More cores make each turn a bit faster, but two buyers still can't change one row at the same moment.</p>
</li>
</ul>
<hr />
<p><strong>Repo:</strong> <a href="https://github.com/manideep2810/Flash-Sale.git">https://github.com/manideep2810/Flash-Sale.git</a> · <strong>Raw results:</strong> <code>docs/results/post-1/</code> · <strong>Next:</strong> Post 2, Reserving in Redis with Lua</p>
]]></content:encoded></item></channel></rss>