<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Databases | Law Zava</title><link>https://lawzava.com/topics/databases/</link><description>Schema design, migrations, replication, sharding, and performance tuning under real query loads.</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Thu, 13 Aug 2026 07:51:57 +0000</lastBuildDate><atom:link href="https://lawzava.com/topics/databases/index.xml" rel="self" type="application/rss+xml"/><item><title>De-Risking the Black Swan: Red-Teaming Distributed Databases Before Production</title><link>https://lawzava.com/blog/2026-03-16-de-risking-black-swan-distributed-databases/</link><pubDate>Mon, 16 Mar 2026 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2026-03-16-de-risking-black-swan-distributed-databases/</guid><description>Red-teaming distributed databases before production: most catastrophic failures are compound scenarios nobody practiced, not black swans.</description><content:encoded><![CDATA[<h2 id="quick-take">Quick take</h2>
<p>Most catastrophic database incidents aren&rsquo;t novel. They&rsquo;re compounded failures that nobody practiced for. The node-failure test passes, so the team moves on. Then a network partition hits during a  <a href="/blog/2016-08-15-database-migrations-without-downtime/"
   
   >schema migration</a>
 while the on-call engineer is handling an unrelated alert, and suddenly you&rsquo;re in territory no runbook covers. Structured red-teaming exposes these compound paths before they become customer-visible outages. It costs a fraction of what a single bad incident costs.</p>
<h2 id="black-swans-vs-ignored-knowns">Black Swans vs. Ignored Knowns</h2>
<p>The term &ldquo;black swan&rdquo; gets overused in infrastructure. Most catastrophic database failures are not genuinely unpredictable. They are known failure modes that compound in ways nobody tested.</p>
<p>Consider the canonical distributed database incident: a network partition isolates a minority of nodes, those nodes continue accepting writes because the partition detection is slow, the partition heals, and now you have conflicting data that the conflict resolution logic wasn&rsquo;t designed to handle at that volume. Every component in this chain is well-understood. The failure isn&rsquo;t in any single component. It&rsquo;s in the interaction between them under specific timing conditions.</p>
<p>The honest term for most &ldquo;black swan&rdquo; database incidents is &ldquo;ignored known.&rdquo; The team knew partitions could happen. They knew conflict resolution had edge cases. They knew detection wasn&rsquo;t instant. They just never tested all three at once.</p>
<p>Red-teaming is how you turn ignored knowns into practiced scenarios.</p>
<h2 id="mission-style-red-teaming">Mission-Style Red-Teaming</h2>
<p> <a href="/blog/2020-06-08-chaos-engineering-practices/"
   
   >Chaos engineering</a>
 tools that randomly kill processes are useful, but they test a narrow failure class: single-component loss. Distributed database failures rarely look like one node dying cleanly. They look like degraded networks, clock drift, slow disks, operator errors during maintenance windows, and combinations of all of the above.</p>
<p>Mission-style red-teaming borrows from military and security practice. A dedicated team designs multi-step failure scenarios with specific objectives, executes them against production-equivalent infrastructure, and scores the defending team&rsquo;s response. The key difference from chaos engineering is intentionality: the red team isn&rsquo;t injecting random faults. They&rsquo;re pursuing a specific failure hypothesis through a sequence of realistic actions.</p>
<p>A red-team exercise has three roles:</p>
<ul>
<li><strong>Red team</strong>: designs and executes the failure scenario. Their goal is to cause data loss, unavailability, or corruption without triggering detection within a target time window.</li>
<li><strong>Blue team</strong>: the on-call and operations engineers responding as they would in a real incident. They don&rsquo;t know the scenario in advance.</li>
<li><strong>White team</strong>: observers who control the exercise, ensure safety boundaries, and document everything for the post-exercise review.</li>
</ul>
<p>The exercise runs for a fixed window, typically two to four hours. The red team executes their scenario. The blue team detects, diagnoses, and responds. Everyone debriefs afterward.</p>
<h2 id="the-stress-scenarios-that-matter">The Stress Scenarios That Matter</h2>
<p>Not all failure modes are worth practicing. Focus on scenarios that are plausible, high-impact, and poorly covered by existing automation.</p>
<p><strong>Network partitions with asymmetric visibility.</strong> One side of the partition can see the other; the other side cannot. This breaks assumptions in consensus protocols that expect symmetric failure detection. Many teams test clean partitions but never test asymmetric ones.</p>
<p><strong>Clock skew under load.</strong> Distributed databases that use timestamps for ordering (which is most of them) behave unpredictably when clocks drift. NTP usually keeps drift small, but under heavy load, NTP corrections can be delayed. The result is transaction ordering violations that are invisible until a consistency check runs, which might be hours or days later.</p>
<p><strong>Quorum erosion during maintenance.</strong> You take one node offline for a rolling upgrade. While it&rsquo;s down, a second node develops a slow disk. You now have a degraded quorum that&rsquo;s technically functional but one failure away from data unavailability. This is the most common compound failure pattern and the least practiced.</p>
<p><strong>Operator mistakes during incidents.</strong> The most dangerous moment for a distributed database is when a human is manually intervening during an incident. Wrong-node restarts, accidental force-quorum operations, and recovery commands run against the wrong cluster are responsible for a disproportionate share of catastrophic data loss. Red-teaming should include scenarios where the operator is given misleading information and time pressure.</p>
<p><strong>Backup restoration under partial failure.</strong> Most backup tests verify that a restore works on a clean target. Real restores happen during incidents, when the target environment is degraded, the team is stressed, and the backup might be from a point in time that&rsquo;s already inconsistent. Test restoration under these conditions, not just in a clean room.</p>
<h2 id="the-ooda-loop-for-incident-rehearsal">The OODA Loop for Incident Rehearsal</h2>
<p>Effective red-team exercises run on a tight observe-orient-decide-act cadence. This isn&rsquo;t just a framework. It&rsquo;s a scoring mechanism.</p>
<p><strong>Observe</strong>: How quickly does the blue team notice something is wrong? Detection time is the single most important metric. A failure that&rsquo;s detected in two minutes has a fundamentally different blast radius than one detected in twenty. Measure time from fault injection to first alert, and time from first alert to accurate diagnosis.</p>
<p><strong>Orient</strong>: Does the team correctly identify what&rsquo;s happening? Misdiagnosis is common in compound failures because the symptoms don&rsquo;t match any single runbook entry. The blue team might see elevated latency and assume it&rsquo;s a hot key, when the actual cause is a partial partition affecting replication. Measure time from first alert to correct hypothesis.</p>
<p><strong>Decide</strong>: Does the team choose an appropriate response? Under pressure, teams often default to the most familiar action (restart the node) rather than the most appropriate one (isolate the partition). Measure whether the chosen action matches the failure mode.</p>
<p><strong>Act</strong>: Does the team execute the response correctly? Even when the right decision is made, execution errors under stress are common. Typos in commands, wrong node targets, and forgotten steps in manual procedures are all frequent. Measure execution accuracy and time to containment.</p>
<p>Each phase gets a score. Over multiple exercises, these scores reveal systemic gaps: maybe detection is fast but diagnosis is slow, or decisions are sound but execution is error-prone. That tells you exactly where to invest in automation, training, or tooling.</p>
<h2 id="scoring-readiness">Scoring Readiness</h2>
<p>After each exercise, score three dimensions:</p>
<p><strong>Readiness</strong> (1-5): Could the team handle this scenario if it happened tomorrow in production? A 1 means the team didn&rsquo;t detect the failure. A 5 means they detected, diagnosed, and contained it within SLA.</p>
<p><strong>Blast radius</strong> (1-5): If the team had not responded, how bad would it have gotten? A 1 means minor degradation. A 5 means unrecoverable data loss or extended outage.</p>
<p><strong>Time to containment</strong> (minutes): Wall-clock time from fault injection to the point where the failure is contained and no longer spreading. This is the metric that matters most to your customers and your SLA.</p>
<p>Plot these over time. Improving readiness scores and decreasing containment times are the clearest signals that your red-teaming program is working. If scores plateau, your scenarios aren&rsquo;t challenging enough.</p>
<h2 id="from-findings-to-backlog">From Findings to Backlog</h2>
<p>Red-team exercises are useless if findings sit in a  <a href="/blog/2021-11-29-incident-management-practices/"
   
   >postmortem</a>
 document that nobody reads. Every exercise should produce a prioritized list of concrete improvements, each with an owner and a deadline.</p>
<p>The conversion process is simple:</p>
<ol>
<li><strong>List every gap discovered.</strong> Detection gaps, diagnostic confusion, tool limitations, missing runbooks, automation failures.</li>
<li><strong>Score each gap by blast radius times likelihood.</strong> Likelihood is informed by the exercise, not guessed.</li>
<li><strong>Assign an owner for each gap.</strong> Not a team. A person.</li>
<li><strong>Set a deadline before the next exercise.</strong> The next exercise will test whether the gap was closed. This creates accountability.</li>
</ol>
<p>Common improvements that come out of red-team exercises include automated partition detection that currently requires manual observation, runbook updates for compound failure scenarios, guardrails on dangerous operator commands during incidents, and backup restoration procedures tested under realistic conditions.</p>
<p>The backlog items from red-teaming tend to be high-value, low-glamour work. They rarely make it onto a roadmap through normal prioritization because they address risks that haven&rsquo;t materialized yet. The exercise provides the evidence needed to justify the investment.</p>
<h2 id="a-quarterly-operating-cadence">A Quarterly Operating Cadence</h2>
<p>Red-teaming works best as a regular practice, not a one-off event. A quarterly cadence balances rigor with operational overhead.</p>
<p>Run quarterly. Dedicate the first few weeks to scenario design based on recent incidents and architectural changes, a half-day to executing the exercise against a production-equivalent environment, and the remainder of the quarter to remediating the gaps you found.</p>
<p>This cadence means every quarter your team practices a realistic failure scenario, identifies concrete gaps, and fixes the most critical ones before the next exercise. Over four quarters, you&rsquo;ve tested and improved your response to a dozen failure modes. That&rsquo;s a fundamentally different reliability posture than &ldquo;we tested node failover once during setup and it worked.&rdquo;</p>
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Most catastrophic database failures are compound scenarios that nobody practiced, not genuinely unpredictable events.</li>
<li>Chaos engineering tests component failure. Red-teaming tests system failure under realistic operational conditions.</li>
<li>Score every exercise on detection time, diagnostic accuracy, decision quality, and execution correctness. Track trends.</li>
<li>Convert findings into owned backlog items with deadlines tied to the next exercise.</li>
<li>Run quarterly. Consistency matters more than intensity.</li>
</ul>
<p>Red-teaming distributed databases is not theater and it&rsquo;s not a luxury. It&rsquo;s the cheapest way to find out whether your recovery assumptions actually hold before your customers find out for you.</p>
]]></content:encoded></item><item><title>Caching: The Easy Part Is Adding It, the Hard Part Is Everything Else</title><link>https://lawzava.com/blog/2022-08-08-caching-strategies/</link><pubDate>Mon, 08 Aug 2022 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2022-08-08-caching-strategies/</guid><description>Cache-aside, write-through, invalidation strategies, and the failure modes that will wake you up at night. With Go examples.</description><content:encoded><![CDATA[<h2 id="quick-take">Quick take</h2>
<p>Cache-aside is the sane default. Invalidation is where everyone gets burned. Plan for stampedes and penetration on day one, not after the incident. And please &ndash; document what staleness window your product can tolerate before you write a single line of caching code.</p>
<p>Phil Karlton&rsquo;s line about cache invalidation being one of the two hard things in computer science gets quoted constantly. It gets quoted because it&rsquo;s true. I&rsquo;ve added caching to systems at a large consumer platform, at a real-time messaging company, and at Decloud. Every time, the initial implementation was straightforward. Every time, the edge cases in invalidation and consistency were where we spent the real engineering effort.</p>
<p>Caching isn&rsquo;t hard to implement. It&rsquo;s hard to operate correctly.</p>
<h2 id="what-is-worth-caching">What Is Worth Caching</h2>
<p>Not everything. The filter is simple:</p>
<ul>
<li><strong>High read-to-write ratio.</strong> If you&rsquo;re reading 100x more than writing, caching pays off immediately.</li>
<li><strong>Expensive to compute or fetch.</strong> Database aggregations, external API calls, anything with meaningful latency.</li>
<li><strong>Tolerant of staleness.</strong> A product listing that&rsquo;s 30 seconds stale is fine. An account balance that&rsquo;s 30 seconds stale isn&rsquo;t.</li>
</ul>
<p>If data changes constantly and correctness matters, don&rsquo;t cache it. Add an index, optimize the query, or scale the database.</p>
<h2 id="core-patterns">Core Patterns</h2>
<h3 id="cache-aside">Cache-Aside</h3>
<p>The application checks the cache, falls back to the source on a miss, and populates the cache for next time. This is the default for good reason: it&rsquo;s simple, it works with any data store, and the application controls the logic.</p>
<p>In Go, a basic cache-aside with singleflight to prevent stampedes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#f92672">import</span> <span style="color:#e6db74">&#34;golang.org/x/sync/singleflight&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">type</span> <span style="color:#a6e22e">UserCache</span> <span style="color:#66d9ef">struct</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">redis</span>  <span style="color:#f92672">*</span><span style="color:#a6e22e">redis</span>.<span style="color:#a6e22e">Client</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">db</span>     <span style="color:#f92672">*</span><span style="color:#a6e22e">sql</span>.<span style="color:#a6e22e">DB</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">group</span>  <span style="color:#a6e22e">singleflight</span>.<span style="color:#a6e22e">Group</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ttl</span>    <span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Duration</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">c</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">UserCache</span>) <span style="color:#a6e22e">Get</span>(<span style="color:#a6e22e">ctx</span> <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">Context</span>, <span style="color:#a6e22e">id</span> <span style="color:#66d9ef">string</span>) (<span style="color:#f92672">*</span><span style="color:#a6e22e">User</span>, <span style="color:#66d9ef">error</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Check cache first</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">data</span>, <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">redis</span>.<span style="color:#a6e22e">Get</span>(<span style="color:#a6e22e">ctx</span>, <span style="color:#e6db74">&#34;user:&#34;</span><span style="color:#f92672">+</span><span style="color:#a6e22e">id</span>).<span style="color:#a6e22e">Bytes</span>()
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">==</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">var</span> <span style="color:#a6e22e">u</span> <span style="color:#a6e22e">User</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">json</span>.<span style="color:#a6e22e">Unmarshal</span>(<span style="color:#a6e22e">data</span>, <span style="color:#f92672">&amp;</span><span style="color:#a6e22e">u</span>); <span style="color:#a6e22e">err</span> <span style="color:#f92672">==</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#f92672">&amp;</span><span style="color:#a6e22e">u</span>, <span style="color:#66d9ef">nil</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Singleflight: only one goroutine fetches from DB for a given key</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">val</span>, <span style="color:#a6e22e">err</span>, <span style="color:#a6e22e">_</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">group</span>.<span style="color:#a6e22e">Do</span>(<span style="color:#e6db74">&#34;user:&#34;</span><span style="color:#f92672">+</span><span style="color:#a6e22e">id</span>, <span style="color:#66d9ef">func</span>() (<span style="color:#66d9ef">interface</span>{}, <span style="color:#66d9ef">error</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">u</span>, <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">fetchFromDB</span>(<span style="color:#a6e22e">ctx</span>, <span style="color:#a6e22e">id</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">nil</span>, <span style="color:#a6e22e">err</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Populate cache</span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">data</span>, <span style="color:#a6e22e">_</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">json</span>.<span style="color:#a6e22e">Marshal</span>(<span style="color:#a6e22e">u</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">redis</span>.<span style="color:#a6e22e">Set</span>(<span style="color:#a6e22e">ctx</span>, <span style="color:#e6db74">&#34;user:&#34;</span><span style="color:#f92672">+</span><span style="color:#a6e22e">id</span>, <span style="color:#a6e22e">data</span>, <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">ttl</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">u</span>, <span style="color:#66d9ef">nil</span>
</span></span><span style="display:flex;"><span>    })
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">nil</span>, <span style="color:#a6e22e">err</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">u</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">val</span>.(<span style="color:#f92672">*</span><span style="color:#a6e22e">User</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">u</span>, <span style="color:#66d9ef">nil</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The <code>singleflight.Group</code> is the key detail here. Without it, a hot key expiry sends N concurrent requests to the database. With it, one goroutine fetches while the others wait and share the result. I&rsquo;ve seen this single change reduce database load by 10x during peak traffic.</p>
<h3 id="write-through">Write-Through</h3>
<p>Writes update the cache and the primary store together. Reads are always fast and consistent. The tradeoff is higher write latency because you&rsquo;re writing to two places.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">c</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">UserCache</span>) <span style="color:#a6e22e">Update</span>(<span style="color:#a6e22e">ctx</span> <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">Context</span>, <span style="color:#a6e22e">u</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">User</span>) <span style="color:#66d9ef">error</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">updateDB</span>(<span style="color:#a6e22e">ctx</span>, <span style="color:#a6e22e">u</span>); <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">err</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">data</span>, <span style="color:#a6e22e">err</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">json</span>.<span style="color:#a6e22e">Marshal</span>(<span style="color:#a6e22e">u</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">err</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">redis</span>.<span style="color:#a6e22e">Set</span>(<span style="color:#a6e22e">ctx</span>, <span style="color:#e6db74">&#34;user:&#34;</span><span style="color:#f92672">+</span><span style="color:#a6e22e">u</span>.<span style="color:#a6e22e">ID</span>, <span style="color:#a6e22e">data</span>, <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">ttl</span>).<span style="color:#a6e22e">Err</span>()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Good fit when writes are moderate and you can&rsquo;t tolerate stale reads. At a large consumer platform, we used write-through for restaurant availability &ndash; it changed infrequently but needed to be correct immediately.</p>
<h3 id="write-behind">Write-Behind</h3>
<p>Writes go to the cache. A background process persists them asynchronously. This absorbs write spikes but introduces data loss risk if the cache crashes before flushing.</p>
<p>I&rsquo;m cautious about write-behind. It&rsquo;s appropriate for data you can rebuild &ndash; session state, analytics counters, things where losing a few seconds of writes is acceptable. For anything transactional, avoid it.</p>
<h2 id="invalidation">Invalidation</h2>
<p>Three approaches, and most production systems combine two of them.</p>
<p><strong>TTL-based expiration.</strong> The safety net. Every cached item has a time to live. When it expires, the next read triggers a refresh. Simple, reliable, and coarse. A 60-second TTL means your data can be up to 60 seconds stale. That&rsquo;s the contract.</p>
<p><strong>Event-based invalidation.</strong> When the source data changes, publish an event that invalidates or updates the cache. More precise than TTL, but it depends on reliable event delivery. If the invalidation event gets lost, the cache serves stale data until the TTL expires.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> (<span style="color:#a6e22e">c</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">UserCache</span>) <span style="color:#a6e22e">HandleUserUpdated</span>(<span style="color:#a6e22e">ctx</span> <span style="color:#a6e22e">context</span>.<span style="color:#a6e22e">Context</span>, <span style="color:#a6e22e">event</span> <span style="color:#a6e22e">UserUpdatedEvent</span>) <span style="color:#66d9ef">error</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">c</span>.<span style="color:#a6e22e">redis</span>.<span style="color:#a6e22e">Del</span>(<span style="color:#a6e22e">ctx</span>, <span style="color:#e6db74">&#34;user:&#34;</span><span style="color:#f92672">+</span><span style="color:#a6e22e">event</span>.<span style="color:#a6e22e">UserID</span>).<span style="color:#a6e22e">Err</span>()
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Delete on invalidation rather than update. Let the next read repopulate. This avoids race conditions between the event handler and concurrent reads.</p>
<p><strong>Versioned keys.</strong> Instead of <code>user:123</code>, use <code>user:123:v7</code>. When the data changes, increment the version. Old keys age out naturally. No explicit deletes, no race conditions. The cost is more keys in the cache and a version lookup on every read.</p>
<p>My default: TTL as the safety net plus event-based invalidation for known write paths. This covers 90% of use cases.</p>
<h2 id="failure-modes">Failure Modes</h2>
<h3 id="stampedes">Stampedes</h3>
<p>Hot key expires. Hundreds of concurrent requests miss the cache and hit the database. The database buckles.</p>
<p>Mitigations:</p>
<ul>
<li><strong>Singleflight</strong> (shown above) &ndash; one fetch per key at a time</li>
<li><strong>Early refresh</strong> &ndash; refresh the cache before the TTL expires, while the current value is still valid</li>
<li><strong>Stale-while-revalidate</strong> &ndash; serve the slightly stale value while refreshing in the background</li>
</ul>
<h3 id="penetration">Penetration</h3>
<p>Requests for keys that will never exist in the source. Every request misses the cache and hits the database. Attackers love this.</p>
<p>Fix: cache negative results with a short TTL. If <code>user:999</code> doesn&rsquo;t exist, cache <code>nil</code> for 30 seconds. A Bloom filter in front of the cache can also help for large keyspaces.</p>
<h3 id="inconsistency">Inconsistency</h3>
<p>The cache says one thing. The database says another. This happens for all sorts of reasons: failed invalidation events, race conditions between writes and cache updates, network partitions between the application and Redis.</p>
<p>Accept eventual consistency where the product allows it. For the few paths that must be strongly consistent, bypass the cache and read from the source.</p>
<h2 id="multi-level-caching">Multi-Level Caching</h2>
<p>In-process memory for the hottest data. Redis for shared distributed caching. CDN for public static content. Each layer reduces latency but adds a place where stale data can hide.</p>
<p>Keep the hierarchy as shallow as your latency requirements allow. Two levels is common. Three is manageable. Four is a debugging nightmare.</p>
<h2 id="observability">Observability</h2>
<p>You need four metrics at minimum:</p>
<ul>
<li><strong>Hit rate</strong> &ndash; below 80% and you should question whether the cache is helping</li>
<li><strong>Latency</strong> &ndash; cache should be sub-millisecond; if it isn&rsquo;t, check network or serialization</li>
<li><strong>Eviction rate</strong> &ndash; high evictions mean the cache is too small or the TTLs are too long</li>
<li><strong>Error rate</strong> &ndash; a cache error should fall back to the source, not fail the request</li>
</ul>
<p>Document the expected staleness window for each cached entity. &ldquo;User profiles: up to 60 seconds stale&rdquo; is a product decision, not a technical one. Make it explicit.</p>
<h2 id="the-honest-summary">The Honest Summary</h2>
<p>Caching is the fastest way to scale reads. It&rsquo;s also the fastest way to introduce subtle bugs that only show up under load. Pick cache-aside as your default. Use singleflight. Set TTLs that match your product&rsquo;s tolerance for staleness. Plan for stampedes and penetration before they happen.</p>
<p>And remember: the best cache is the one you can explain to the on-call engineer at 3 AM.</p>
]]></content:encoded></item><item><title>PostgreSQL Performance: Measure First, Tune Second</title><link>https://lawzava.com/blog/2022-05-02-postgres-performance-tuning/</link><pubDate>Mon, 02 May 2022 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2022-05-02-postgres-performance-tuning/</guid><description>Most Postgres performance problems are indexing problems. The rest are vacuum problems. Here&amp;amp;rsquo;s how to find and fix both.</description><content:encoded><![CDATA[<h2 id="quick-take">Quick take</h2>
<p>Install <code>pg_stat_statements</code>. Find your top 10 slowest queries. Fix the indexes. Tune autovacuum for big tables. Use a connection pooler. That covers 80% of Postgres performance work. Everything else is refinement.</p>
<p>I&rsquo;ve tuned Postgres at startups with a single database and at enterprises with dozens of replicas. The pattern is always the same: someone reports &ldquo;the database is slow,&rdquo; and the first instinct is to increase instance size or tweak configuration parameters. Almost every time, the actual problem is a missing index or a query that shouldn&rsquo;t exist in its current form.</p>
<p>The discipline is simple. Measure, change one thing, measure again. If you skip the measurement step, you&rsquo;re guessing. Guessing is expensive.</p>
<h2 id="start-with-pg_stat_statements">Start with pg_stat_statements</h2>
<p>This is the single most valuable tool for Postgres performance work. It tracks every query the database executes and accumulates statistics: call count, total time, mean time, rows returned.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> EXTENSION <span style="color:#66d9ef">IF</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">EXISTS</span> pg_stat_statements;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Top 10 queries by total execution time
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>    query,
</span></span><span style="display:flex;"><span>    calls,
</span></span><span style="display:flex;"><span>    round(total_exec_time::numeric, <span style="color:#ae81ff">2</span>) <span style="color:#66d9ef">AS</span> total_ms,
</span></span><span style="display:flex;"><span>    round(mean_exec_time::numeric, <span style="color:#ae81ff">2</span>) <span style="color:#66d9ef">AS</span> avg_ms,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">rows</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_statements
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> total_exec_time <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">10</span>;
</span></span></code></pre></div><p>Total time matters more than average time. A query that runs in 5ms but gets called 500,000 times per day consumes more resources than a query that takes 2 seconds but runs once an hour. Fix the high-total-time queries first.</p>
<p>Also check for long-running queries and idle transactions, which block vacuum and hold locks:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> pid, now() <span style="color:#f92672">-</span> pg_stat_activity.query_start <span style="color:#66d9ef">AS</span> duration, query, <span style="color:#66d9ef">state</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_activity
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> (now() <span style="color:#f92672">-</span> pg_stat_activity.query_start) <span style="color:#f92672">&gt;</span> interval <span style="color:#e6db74">&#39;5 minutes&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AND</span> <span style="color:#66d9ef">state</span> <span style="color:#f92672">!=</span> <span style="color:#e6db74">&#39;idle&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> duration <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></div><h2 id="explain-is-your-microscope">EXPLAIN is your microscope</h2>
<p>Once you know which queries are slow, <code>EXPLAIN (ANALYZE, BUFFERS)</code> shows you <em>why</em>. Don&rsquo;t read the entire plan tree. Focus on four signals:</p>
<p><strong>Sequential scan on a large table</strong>: Almost always means a missing or ineffective index. A sequential scan on a 100-row lookup table is fine. A sequential scan on a 50-million-row orders table isn&rsquo;t.</p>
<p><strong>Actual rows far from estimated rows</strong>: Postgres is making bad decisions because its statistics are stale or the data is heavily skewed. Run <code>ANALYZE</code> on the table and check again.</p>
<p><strong>Nested loop with high row count on the inner side</strong>: Missing index on the join column. The planner is scanning the inner table for every row of the outer table.</p>
<p><strong>Sort with &ldquo;Sort Method: external merge Disk&rdquo;</strong>: The sort spilled to disk because <code>work_mem</code> was too small for the operation. Either add an index that eliminates the sort, or increase <code>work_mem</code> (carefully &ndash; it&rsquo;s per-operation, not per-connection).</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">EXPLAIN</span> (<span style="color:#66d9ef">ANALYZE</span>, BUFFERS, FORMAT TEXT)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> o.id, o.total, <span style="color:#66d9ef">c</span>.name
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> orders o
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">JOIN</span> customers <span style="color:#66d9ef">c</span> <span style="color:#66d9ef">ON</span> <span style="color:#66d9ef">c</span>.id <span style="color:#f92672">=</span> o.customer_id
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> o.status <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;pending&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AND</span> o.created_at <span style="color:#f92672">&gt;</span> now() <span style="color:#f92672">-</span> interval <span style="color:#e6db74">&#39;7 days&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> o.created_at <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></div><h2 id="indexing-the-right-index-not-more-indexes">Indexing: the right index, not more indexes</h2>
<p>Most performance fixes come down to having the right index. Not more indexes. Every index you add slows down writes and makes vacuum work harder.</p>
<p><strong>Composite indexes</strong>: Column order matters. Put the equality columns first, range columns last. For the query above, <code>(status, created_at)</code> lets Postgres seek to <code>status = 'pending'</code> and then scan the date range efficiently.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_orders_status_created <span style="color:#66d9ef">ON</span> orders (status, created_at <span style="color:#66d9ef">DESC</span>);
</span></span></code></pre></div><p><strong>Partial indexes</strong>: If you only ever query pending orders, index only those. Smaller index, faster lookups, less write overhead.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_orders_pending <span style="color:#66d9ef">ON</span> orders (created_at <span style="color:#66d9ef">DESC</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> status <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;pending&#39;</span>;
</span></span></code></pre></div><p><strong>Covering indexes</strong> (Postgres 11+): Include columns the query needs so Postgres can answer from the index alone without touching the table.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_orders_covering <span style="color:#66d9ef">ON</span> orders (customer_id, status)
</span></span><span style="display:flex;"><span>INCLUDE (total, created_at);
</span></span></code></pre></div><p><strong>Unused indexes</strong>: They cost writes and vacuum time for zero benefit. Find them:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>    schemaname <span style="color:#f92672">||</span> <span style="color:#e6db74">&#39;.&#39;</span> <span style="color:#f92672">||</span> relname <span style="color:#66d9ef">AS</span> <span style="color:#66d9ef">table</span>,
</span></span><span style="display:flex;"><span>    indexrelname <span style="color:#66d9ef">AS</span> <span style="color:#66d9ef">index</span>,
</span></span><span style="display:flex;"><span>    idx_scan <span style="color:#66d9ef">AS</span> scans,
</span></span><span style="display:flex;"><span>    pg_size_pretty(pg_relation_size(indexrelid)) <span style="color:#66d9ef">AS</span> <span style="color:#66d9ef">size</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_user_indexes
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> idx_scan <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AND</span> indexrelname <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">LIKE</span> <span style="color:#e6db74">&#39;%_pkey&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> pg_relation_size(indexrelid) <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></div><p>If an index has zero scans and has been running for weeks, it&rsquo;s a candidate for removal.</p>
<h2 id="configuration-what-actually-matters">Configuration: what actually matters</h2>
<p>I see teams spend hours tweaking parameters that move the needle by 1%. Focus on the ones that matter.</p>
<p><strong><code>shared_buffers</code></strong>: Postgres&rsquo;s own cache. Start at 25% of RAM. Going higher rarely helps because the OS page cache handles the rest.</p>
<p><strong><code>effective_cache_size</code></strong>: Not an allocation &ndash; a planner hint. Tell it how much cache is available total (Postgres cache + OS cache). Typically 50-75% of RAM. This changes whether the planner favors index scans over sequential scans.</p>
<p><strong><code>work_mem</code></strong>: Memory per sort/hash operation. The trap: this is per-operation, not per-connection, and a single query can have multiple sorts. If you set it to 256MB and have 100 concurrent connections each running a multi-sort query, you can run out of memory fast. Start at 4-16MB and increase carefully for specific workloads.</p>
<p><strong><code>maintenance_work_mem</code></strong>: Used for vacuum and index creation. Higher is better here. 512MB to 1GB is reasonable for most production systems.</p>
<p><strong><code>max_connections</code></strong>: Keep it low. 100-200 is plenty for most workloads. Use PgBouncer for connection pooling. Each connection costs ~10MB of memory and context-switching overhead. I&rsquo;ve seen teams set this to 1000 and then wonder why their 64GB instance is swapping.</p>
<h2 id="connection-pooling-non-negotiable">Connection pooling: non-negotiable</h2>
<p>PgBouncer in transaction pooling mode is the standard answer. The application thinks it has a dedicated connection. PgBouncer multiplexes to a smaller pool of real database connections.</p>
<p>This matters because most web application connections are idle most of the time. An application with 200 worker processes might only have 20 queries running at any given moment. PgBouncer lets you serve 200 workers with 30-50 actual database connections.</p>
<p>Session pooling is necessary if your application uses prepared statements, temp tables, or session-level settings (like <code>SET search_path</code>). Transaction pooling doesn&rsquo;t preserve these across requests.</p>
<h2 id="vacuum-the-silent-killer">Vacuum: the silent killer</h2>
<p>Postgres uses MVCC, which means every update and delete creates dead tuples. Vacuum cleans them up. If vacuum falls behind, tables bloat, indexes get slower, and eventually you hit transaction ID wraparound &ndash; which forces a stop-the-world vacuum.</p>
<p>Autovacuum works fine for most tables. But large, busy tables need tuned thresholds:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Check dead tuple counts
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span> relname, n_live_tup, n_dead_tup,
</span></span><span style="display:flex;"><span>       round(n_dead_tup::numeric <span style="color:#f92672">/</span> greatest(n_live_tup, <span style="color:#ae81ff">1</span>) <span style="color:#f92672">*</span> <span style="color:#ae81ff">100</span>, <span style="color:#ae81ff">2</span>) <span style="color:#66d9ef">AS</span> dead_pct,
</span></span><span style="display:flex;"><span>       last_autovacuum
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_user_tables
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> n_dead_tup <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">10000</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> n_dead_tup <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></div><p>For a table with 100 million rows, the default autovacuum threshold of <code>autovacuum_vacuum_scale_factor = 0.2</code> means vacuum waits until there are 20 million dead tuples. That&rsquo;s way too late. Set per-table overrides:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> large_events <span style="color:#66d9ef">SET</span> (
</span></span><span style="display:flex;"><span>    autovacuum_vacuum_scale_factor <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>.<span style="color:#ae81ff">01</span>,
</span></span><span style="display:flex;"><span>    autovacuum_analyze_scale_factor <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>.<span style="color:#ae81ff">005</span>
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>The other vacuum killer is long-running transactions. A transaction that has been open for hours prevents vacuum from cleaning up any tuples visible to that transaction. Find and fix these aggressively.</p>
<h2 id="the-tuning-loop">The tuning loop</h2>
<p>Performance tuning isn&rsquo;t a project. It&rsquo;s a practice.</p>
<ol>
<li>Check <code>pg_stat_statements</code> weekly. Know your top 10.</li>
<li><code>EXPLAIN</code> any query that shows up as a problem.</li>
<li>Add or adjust indexes. Remove unused ones.</li>
<li>Monitor vacuum health and dead tuple counts.</li>
<li>Repeat.</li>
</ol>
<p>Small, measured changes. Every time. The teams that keep Postgres fast aren&rsquo;t the ones with the cleverest configuration. They&rsquo;re the ones who look at the numbers regularly and fix what the numbers show them.</p>
]]></content:encoded></item><item><title>Zero-Downtime Database Migrations Without the Drama</title><link>https://lawzava.com/blog/2022-02-21-database-migration-strategies/</link><pubDate>Mon, 21 Feb 2022 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2022-02-21-database-migration-strategies/</guid><description>A single ALTER TABLE can ruin your weekend. How to migrate safely with expand-and-contract, batched backfills, and compatible deploys.</description><content:encoded><![CDATA[<p>At Decloud, we once ran a migration that added a NOT NULL column with a default value to a table with about 40 million rows. PostgreSQL 9.x. The migration locked the table for a full rewrite. Reads blocked. Writes queued. The API started timing out. Customers noticed within seconds. We rolled it back, but the rollback itself took minutes because the lock queue was backed up.</p>
<p>The total outage was maybe eight minutes. Felt like an hour. And the fix was something we should have known: add the column nullable, backfill in batches, then add the constraint. Three deploys instead of one. No lock, no drama.</p>
<p>That experience permanently changed how I think about database migrations. The schema change itself is rarely the problem. The problem is live traffic hitting the change while it runs.</p>
<h2 id="the-expand-and-contract-pattern">The expand-and-contract pattern</h2>
<p>Every breaking schema change should follow expand-and-contract. It isn&rsquo;t glamorous. It&rsquo;s slow. It works.</p>
<p><strong>Expand</strong>: Add the new structure alongside the old one. Deploy code that writes to both.</p>
<p><strong>Migrate</strong>: Backfill data from old to new. Switch reads to the new path.</p>
<p><strong>Contract</strong>: Once the old path is fully retired and you have confirmed stability, drop the old columns or tables.</p>
<p>A simple column rename looks like this:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Step 1: Expand
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> users <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">COLUMN</span> full_name text;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Step 2: Backfill (in batches, see below)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">UPDATE</span> users <span style="color:#66d9ef">SET</span> full_name <span style="color:#f92672">=</span> name
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> id <span style="color:#66d9ef">BETWEEN</span> <span style="color:#ae81ff">1</span> <span style="color:#66d9ef">AND</span> <span style="color:#ae81ff">10000</span> <span style="color:#66d9ef">AND</span> full_name <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NULL</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Step 3: Contract (after all code reads full_name)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> users <span style="color:#66d9ef">DROP</span> <span style="color:#66d9ef">COLUMN</span> name;
</span></span></code></pre></div><p>Three deploys. Three PRs. Each one is small, reversible, and independently verifiable. This is slower than a single <code>ALTER TABLE ... RENAME COLUMN</code>, but it doesn&rsquo;t lock the table and it doesn&rsquo;t require a maintenance window.</p>
<h2 id="application-code-has-to-play-along">Application code has to play along</h2>
<p>The migration only works if the application tolerates both schemas during the transition. This means:</p>
<ul>
<li><strong>Write to both columns</strong> during the expand phase. Old code writes to <code>name</code>. New code writes to both <code>name</code> and <code>full_name</code>.</li>
<li><strong>Read from new, fallback to old.</strong> <code>COALESCE(full_name, name)</code> in your queries, or handle it in application code.</li>
<li><strong> <a href="/blog/2021-09-06-feature-flags-at-scale/"
   
   >Feature flags</a>
 help.</strong> Switch reads to the new column behind a flag. Flip it when the backfill is done.</li>
<li><strong>Keep old queries working</strong> until you ship the contract phase. If something goes wrong after the backfill, you want the ability to revert without data loss.</li>
</ul>
<p>The key discipline: never deploy a schema change and a code change that depends on it in the same release. Separate them. Deploy the schema first, then the code. If the schema deploy breaks something, you can roll back without touching the application.</p>
<h2 id="backfills-that-dont-melt-production">Backfills that don&rsquo;t melt production</h2>
<p>A naive <code>UPDATE users SET full_name = name</code> on a table with tens of millions of rows will generate massive WAL, spike  <a href="/blog/2020-01-20-database-replication-patterns/"
   
   >replication lag</a>
, and potentially lock out autovacuum. I&rsquo;ve watched backfills bring down read replicas.</p>
<p>Batch it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Process 5000 rows at a time
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">UPDATE</span> users <span style="color:#66d9ef">SET</span> full_name <span style="color:#f92672">=</span> name
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> id <span style="color:#66d9ef">BETWEEN</span> :<span style="color:#66d9ef">start</span> <span style="color:#66d9ef">AND</span> :<span style="color:#66d9ef">end</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AND</span> full_name <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NULL</span>;
</span></span></code></pre></div><p>Run this in a loop with a small delay between batches. Keep transactions short. Commit after each batch. Record progress so the job can resume if it crashes.</p>
<p>Practical tips from doing this too many times:</p>
<ul>
<li><strong>Rate limit.</strong> 5,000 to 50,000 rows per batch depending on table width and load.</li>
<li><strong>Short transactions.</strong> Long transactions hold locks and block vacuum.</li>
<li><strong>Idempotent.</strong> The <code>WHERE full_name IS NULL</code> clause means you can safely restart.</li>
<li><strong>Off-peak when possible.</strong> Not always an option, but backfilling during low traffic reduces risk.</li>
<li><strong>Monitor replication lag.</strong> If replicas start falling behind, slow down or pause.</li>
</ul>
<h2 id="the-operations-postgresql-will-punish-you-for">The operations PostgreSQL will punish you for</h2>
<p>Some DDL operations are deceptively dangerous. They look like simple one-liners but can lock tables, rewrite data, or kill your replication.</p>
<p><strong>Creating an index without <code>CONCURRENTLY</code></strong>: A regular <code>CREATE INDEX</code> takes a <code>SHARE</code> lock on the table, blocking all writes until it finishes. On a large table, that can be minutes.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SET</span> lock_timeout <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;5s&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> CONCURRENTLY idx_users_email <span style="color:#66d9ef">ON</span> users(email);
</span></span></code></pre></div><p>Always use <code>CONCURRENTLY</code>. Always set a lock timeout so it fails fast instead of waiting indefinitely.</p>
<p><strong>Adding NOT NULL with a default (pre-PG 11)</strong>: Before PostgreSQL 11, <code>ALTER TABLE ADD COLUMN ... DEFAULT ... NOT NULL</code> rewrote the entire table. PG 11+ handles this without a rewrite, but if you&rsquo;re on an older version, add the column nullable first, backfill, then add the constraint.</p>
<p><strong>Large data copies and foreign key additions</strong>: Adding a foreign key validates every existing row. On a big table, that&rsquo;s a scan with a lock. Use <code>NOT VALID</code> to add the constraint without validation, then <code>VALIDATE CONSTRAINT</code> separately:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> orders <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">CONSTRAINT</span> fk_customer
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">FOREIGN</span> <span style="color:#66d9ef">KEY</span> (customer_id) <span style="color:#66d9ef">REFERENCES</span> customers(id)
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">VALID</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Later, without blocking writes:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> orders VALIDATE <span style="color:#66d9ef">CONSTRAINT</span> fk_customer;
</span></span></code></pre></div><h2 id="the-operational-workflow">The operational workflow</h2>
<p><strong>Before you touch production:</strong></p>
<ul>
<li>Document every step and its expected impact. Not a novel. A numbered list.</li>
<li>Rehearse on production-size data. Staging with 1,000 rows won&rsquo;t catch lock issues on a 50 million row table.</li>
<li>Verify your backups work. Not &ldquo;we&rsquo;ve backups.&rdquo; Actually restore and check.</li>
<li>Decide your abort criteria in advance. &ldquo;If lock waits exceed 5 seconds or replication lag exceeds 30 seconds, we stop.&rdquo;</li>
</ul>
<p><strong>During the migration:</strong></p>
<ul>
<li>Watch lock waits, query latency, and replication lag in real time.</li>
<li>Validate each step before moving to the next one.</li>
<li>Have the rollback commands ready in a terminal. Not in a wiki. In a terminal.</li>
</ul>
<p><strong>After:</strong></p>
<ul>
<li>Verify data integrity. Row counts, null rates, spot checks.</li>
<li>Keep the old columns around for a few days. You want a rollback window.</li>
<li>Clean up temporary code paths and old columns only after you&rsquo;re confident.</li>
</ul>
<h2 id="rollback-isnt-always-possible">Rollback isn&rsquo;t always possible</h2>
<p>Additive changes are easy to roll back &ndash; just drop the new column. Destructive changes aren&rsquo;t. If you dropped a column and need it back, you need a restore from backup.</p>
<p>This is why the contract phase should be the last step, done only after the system has been stable for days or weeks. Keep old columns around longer than feels necessary. Storage is cheap. Outages aren&rsquo;t.</p>
<p>The uncomfortable truth about  <a href="/blog/2016-08-15-database-migrations-without-downtime/"
   
   >zero-downtime migrations</a>
: they aren&rsquo;t about clever SQL or sophisticated tooling. They&rsquo;re about discipline. Small steps. Verified assumptions. Rollback plans written before you need them. The teams that do this well aren&rsquo;t smarter. They&rsquo;re more disciplined. And they get to keep their weekends.</p>
]]></content:encoded></item><item><title>Database Reliability Engineering: What I've Learned the Hard Way</title><link>https://lawzava.com/blog/2021-08-09-database-reliability-engineering/</link><pubDate>Mon, 09 Aug 2021 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2021-08-09-database-reliability-engineering/</guid><description>Practical database reliability from running Postgres in production: configs, safe migration patterns, and the operational habits that prevent outages.</description><content:encoded><![CDATA[<h2 id="quick-take">Quick take</h2>
<p>Define your RPO/RTO before you pick a replication strategy. Test your restores monthly &ndash; most backup failures are actually restore failures. Use  <a href="/blog/2016-08-15-database-migrations-without-downtime/"
   
   >safe migration patterns</a>
 (nullable columns, concurrent indexes, lock timeouts). Monitor replication lag, slow queries, and disk growth. Practice failovers before you need them. This post includes the Postgres configs and SQL patterns I use.</p>
<hr>
<p>I&rsquo;ve been responsible for databases at the fintech startup (financial data platform, Postgres), at Decloud, and at several enterprises. The lesson that took me the longest to internalize: database reliability isn&rsquo;t a technology problem. It&rsquo;s a habits problem.</p>
<p>You can run the best hardware, the fanciest replication setup, the most expensive managed service. If you don&rsquo;t practice restores, don&rsquo;t test migrations against production data volumes, and don&rsquo;t have runbooks for failover, you&rsquo;re going to have a bad day. The technology just determines how bad.</p>
<h2 id="start-with-numbers-not-architecture">Start with numbers, not architecture</h2>
<p>Before touching any config, agree on three numbers with your team and your business stakeholders:</p>
<p><strong>RPO (Recovery Point Objective):</strong> How much data can you lose? For the fintech startup&rsquo;s financial data, the answer was &ldquo;essentially zero&rdquo; &ndash; we couldn&rsquo;t afford to lose transactions. For an analytics pipeline, losing 5 minutes of data might be fine.</p>
<p><strong>RTO (Recovery Time Objective):</strong> How long can the database be down? A consumer app might tolerate 30 seconds. A payment system might need sub-second failover.</p>
<p><strong> <a href="/blog/2019-05-20-effective-slos/"
   
   >Latency SLOs</a>
:</strong> What&rsquo;s the p99 query latency you&rsquo;re committing to? This drives capacity planning, connection pooling, and index strategy.</p>
<p>These numbers dictate everything downstream. Synchronous replication for near-zero RPO. Automated failover for aggressive RTO. Proper connection pooling and query optimization for latency SLOs. Don&rsquo;t skip this conversation.</p>
<h2 id="replication-pick-your-tradeoff">Replication: pick your tradeoff</h2>
<p>At the fintech startup we ran synchronous replication for our primary financial data. The write latency penalty was real &ndash; every commit waited for at least one standby to acknowledge. But for a financial data platform, losing committed transactions wasn&rsquo;t an option.</p>
<p>For our analytics and reporting databases, we used asynchronous replication. Faster writes, slight risk of data loss on primary failure. Acceptable for data that could be re-derived.</p>
<p>In Postgres, the config difference is small but the operational impact is significant:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e"># Synchronous replication - zero data loss, higher write latency</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">synchronous_commit</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">on</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">synchronous_standby_names</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;FIRST 1 (standby1, standby2)&#39;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Asynchronous replication - lower latency, possible data loss on failover</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">synchronous_commit</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">off</span>
</span></span></code></pre></div><p>The mistake I keep seeing: running async replication without understanding the RPO implication. If your primary dies and the standby is 5 seconds behind, you just lost 5 seconds of data. If that includes a payment confirmation, you have a problem that no incident postmortem can fix.</p>
<h2 id="failover-automate-with-guardrails">Failover: automate with guardrails</h2>
<p>Automated failover sounds great until it promotes a standby that&rsquo;s behind the primary and you get split-brain. Or it triggers during a network partition and now you have two primaries accepting writes.</p>
<p>At a large telecom client, I helped set up Patroni for Postgres HA. The key decisions:</p>
<ul>
<li>Health checks from multiple locations (not just one monitoring server)</li>
<li>Minimum replication lag threshold before promotion is allowed</li>
<li>Fencing of the old primary before the new one accepts writes</li>
<li>Manual fallback documented and practiced</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># Patroni failover constraints</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">failover</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">maximum_lag_on_failover</span>: <span style="color:#ae81ff">1048576</span>  <span style="color:#75715e"># ~1MB of WAL</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">retry_timeout</span>: <span style="color:#ae81ff">10</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">ttl</span>: <span style="color:#ae81ff">30</span>
</span></span></code></pre></div><p>The <code>maximum_lag_on_failover</code> setting is critical. It prevents promotion of a standby that&rsquo;s too far behind. I&rsquo;ve seen teams leave this at the default and then wonder why they lost data during failover.</p>
<p>Test your failover. Quarterly at minimum. The first time you practice it, something will go wrong. Better during a planned exercise than during an actual outage at 3am.</p>
<h2 id="backups-the-restore-is-the-backup">Backups: the restore is the backup</h2>
<p>I can&rsquo;t emphasize this enough. Your backup strategy is only as good as your last tested restore.</p>
<p>At the fintech startup, we ran:</p>
<ul>
<li>Daily base backups via <code>pg_basebackup</code></li>
<li>Continuous WAL archiving to S3 for point-in-time recovery</li>
<li>Weekly automated restore tests into a staging environment</li>
<li>Monthly manual restore drills where an engineer actually performed the full recovery</li>
</ul>
<p>The weekly automated test caught a corrupted backup exactly once. Once was enough. Without that test, we would have discovered the corruption during an actual emergency.</p>
<p>A solid Postgres backup setup:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e"># postgresql.conf - enable WAL archiving</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">archive_mode</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">on</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">archive_command</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;aws s3 cp %p s3://backup-bucket/wal/%f&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">wal_level</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">replica</span>
</span></span></code></pre></div><p>Point-in-time recovery is the real value here. Full backups let you restore to the last backup. WAL archiving lets you restore to any point in time. When someone runs a bad <code>DELETE</code> at 2:47pm, you can recover to 2:46pm. That capability has saved me more than once.</p>
<h2 id="safe-migrations-the-number-one-reliability-risk">Safe migrations: the number one reliability risk</h2>
<p>Schema changes cause more database incidents than hardware failures. I&rsquo;ve seen it at every company. Someone runs an <code>ALTER TABLE</code> that locks a table for 20 minutes during peak traffic. The connection pool fills up. Upstream services start timing out. Cascade failure.</p>
<p>The patterns that keep you safe:</p>
<p><strong>Add columns as nullable.</strong> Never add a <code>NOT NULL</code> column without a default to an existing table in a single step. Add it nullable, backfill, then add the constraint.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Step 1: Add nullable column
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> orders <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">COLUMN</span> status_v2 text;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Step 2: Backfill in batches
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">UPDATE</span> orders <span style="color:#66d9ef">SET</span> status_v2 <span style="color:#f92672">=</span> status
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> id <span style="color:#66d9ef">BETWEEN</span> <span style="color:#ae81ff">1</span> <span style="color:#66d9ef">AND</span> <span style="color:#ae81ff">10000</span> <span style="color:#66d9ef">AND</span> status_v2 <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NULL</span>;
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- repeat for remaining batches
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Step 3: Add constraint after data is clean
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> orders <span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">COLUMN</span> status_v2 <span style="color:#66d9ef">SET</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>;
</span></span></code></pre></div><p><strong>Build indexes concurrently.</strong> A regular <code>CREATE INDEX</code> locks the table for writes. On a large table, that&rsquo;s an outage.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- This blocks writes:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_orders_status <span style="color:#66d9ef">ON</span> orders(status);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- This doesn&#39;t:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> CONCURRENTLY idx_orders_status <span style="color:#66d9ef">ON</span> orders(status);
</span></span></code></pre></div><p><strong>Set lock and statement timeouts.</strong> Prevent runaway migrations from holding locks indefinitely.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Before running migration
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SET</span> lock_timeout <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;5s&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SET</span> statement_timeout <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;30s&#39;</span>;
</span></span></code></pre></div><p>If a migration can&rsquo;t acquire its lock within 5 seconds, it fails instead of queuing behind long-running queries and creating a pileup.</p>
<p><strong>Test against production volumes.</strong> A migration that runs in 2 seconds on your dev database with 1,000 rows might take 45 minutes on production with 50 million rows. I learned this lesson at the fintech startup when a &ldquo;quick&rdquo; index build blocked writes for 12 minutes because we hadn&rsquo;t tested against actual data volume.</p>
<h2 id="monitoring-watch-the-right-things">Monitoring: watch the right things</h2>
<p>Most database monitoring setups track too many things and miss the ones that matter. Here&rsquo;s what I actually alert on:</p>
<p><strong>Alert immediately:</strong></p>
<ul>
<li>Replication lag above threshold (for us, 10 seconds)</li>
<li>Disk usage above 80%</li>
<li>Connection pool saturation above 90%</li>
<li>Failed backup or missed WAL archive</li>
</ul>
<p><strong>Alert on sustained trends:</strong></p>
<ul>
<li>Slow query count increasing over 15 minutes</li>
<li>Lock wait time increasing over 5 minutes</li>
<li>Transaction wraparound approaching (Postgres-specific, but it will bite you)</li>
</ul>
<p><strong>Track in dashboards, don&rsquo;t alert:</strong></p>
<ul>
<li>Query latency distribution (p50, p95, p99)</li>
<li>Table and index sizes over time</li>
<li>Autovacuum activity</li>
<li>Cache hit ratios</li>
</ul>
<p>A Postgres-specific query I run weekly to find trouble:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Tables approaching transaction wraparound
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span> relname, age(relfrozenxid) <span style="color:#66d9ef">as</span> xid_age,
</span></span><span style="display:flex;"><span>       pg_size_pretty(pg_total_relation_size(oid)) <span style="color:#66d9ef">as</span> total_size
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_class
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> relkind <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;r&#39;</span> <span style="color:#66d9ef">AND</span> age(relfrozenxid) <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">200000000</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> age(relfrozenxid) <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></div><p>Transaction wraparound is the kind of problem that gives you no warning and then takes the database down hard. Monitor it.</p>
<h2 id="maintenance-isnt-optional">Maintenance isn&rsquo;t optional</h2>
<p>Postgres needs vacuuming. It needs statistics updates. It needs index maintenance. These aren&rsquo;t nice-to-haves.</p>
<p>Autovacuum handles most of the work, but the defaults are conservative. For busy tables:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e"># Per-table override for high-write tables</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">ALTER TABLE events SET (</span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">autovacuum_vacuum_scale_factor</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">0.01,
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    autovacuum_analyze_scale_factor = 0.005</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">);</span>
</span></span></code></pre></div><p>The default <code>autovacuum_vacuum_scale_factor</code> of 0.2 means vacuum runs after 20% of the table has changed. On a 100-million-row table, that&rsquo;s 20 million dead tuples before vacuum kicks in. Dropping it to 0.01 keeps things cleaner.</p>
<p>Index bloat is the other silent killer. Indexes grow as data is updated and deleted. Periodically check <code>pg_stat_user_indexes</code> for indexes with low usage and high size. Rebuild bloated indexes during maintenance windows using <code>REINDEX CONCURRENTLY</code> (Postgres 12+).</p>
<h2 id="the-operational-habits-that-matter">The operational habits that matter</h2>
<p>Technology is the easy part. Habits are what prevent outages:</p>
<ul>
<li><strong>Monthly restore drills.</strong> Someone on the team restores a backup from scratch. Time it. Document the steps. Every time.</li>
<li><strong>Quarterly failover tests.</strong> Actually promote a standby. Verify data integrity. Fail back. Fix whatever surprised you.</li>
<li><strong>Runbooks for every alert.</strong> If an alert fires and the on-call engineer has to figure out what to do from scratch, the runbook is missing.</li>
<li><strong>Post-incident follow-through.</strong> Writing the postmortem is easy. Actually fixing the root cause is what most teams skip.</li>
</ul>
<p>Database reliability isn&rsquo;t something you build once. It&rsquo;s something you practice until the team can handle a 3am failover without panicking. The configs and queries above are tools. The discipline to use them consistently is the actual hard part.</p>
]]></content:encoded></item><item><title>Most Teams Should Just Use Postgres</title><link>https://lawzava.com/blog/2021-07-12-serverless-databases/</link><pubDate>Mon, 12 Jul 2021 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2021-07-12-serverless-databases/</guid><description>Serverless databases are solving problems most teams don&amp;amp;rsquo;t have. Here&amp;amp;rsquo;s why Postgres with a connection pooler is still the right answer.</description><content:encoded><![CDATA[<p>You probably don&rsquo;t need a serverless database.</p>
<p>I know. DynamoDB is cool. PlanetScale just launched and the developer experience looks great. Fauna promises global consistency over HTTP. Aurora Serverless scales to zero. It&rsquo;s a good time to be a database vendor.</p>
<p>But I talk to teams every week, and most of them have the same setup: a web app with moderate traffic, a handful of services, and access patterns that are still changing. For that &ndash; and that describes the vast majority of projects &ndash; Postgres with PgBouncer in front of it is the right choice. It has been for years.</p>
<h2 id="the-serverless-database-pitch">The serverless database pitch</h2>
<p>The promise is simple. No connection management headaches. Scales up and down automatically. Pay for what you use. No patching, no failover to think about.</p>
<p>In practice, every one of these has caveats.</p>
<p>&ldquo;Scales automatically&rdquo; means Aurora Serverless v1 scales in steps with noticeable resume latency when it wakes from auto-pause. I watched a demo environment take 25 seconds to respond to the first query after an idle period. That&rsquo;s not a database. That&rsquo;s a nap.</p>
<p>&ldquo;No connection management&rdquo; means DynamoDB, which works great &ndash; until you realize you need to design your data model entirely around access patterns you haven&rsquo;t fully figured out yet. Good luck refactoring that later.</p>
<p>&ldquo;Pay for what you use&rdquo; means unpredictable costs. I&rsquo;ve seen DynamoDB bills spike 5x in a month because of a new feature that hit a secondary index harder than expected. With Postgres on a fixed instance, your bill is your bill.</p>
<h2 id="when-they-actually-make-sense">When they actually make sense</h2>
<p>DynamoDB is genuinely excellent if your access patterns are known and stable. Key-value lookups at scale, session storage, event logs with predictable queries. It&rsquo;s fast, it&rsquo;s cheap per-request, and the operational story is unbeatable. But you need to know your query patterns before you start. If your product is still evolving, DynamoDB&rsquo;s rigidity becomes a liability.</p>
<p>Fauna is interesting if you need multi-region writes with strong consistency and you&rsquo;re willing to learn a new query language. That&rsquo;s a narrow use case. Most teams don&rsquo;t need global writes. They need a database that works.</p>
<p>PlanetScale has a genuinely good developer workflow. Schema branching and non-locking migrations are real improvements over raw MySQL. But it&rsquo;s in beta, it&rsquo;s built on Vitess (so not all MySQL features work), and you still have connection limits.</p>
<h2 id="the-postgres-argument">The Postgres argument</h2>
<p>Postgres handles relational queries, JSON documents, full-text search, and geospatial data. It has battle-tested replication. The ecosystem is enormous. The tooling is mature. Every cloud provider offers a managed version.</p>
<p>For serverless compute specifically, the connection problem is real. Lambda functions spinning up hundreds of connections will kill a Postgres instance. But PgBouncer or RDS Proxy solve this. It&rsquo;s one more component, but it&rsquo;s a well-understood one.</p>
<p>The thing about Postgres is that it&rsquo;s boring. And boring is underrated. You can find answers to Postgres questions on Stack Overflow from 2009 that are still correct. Try that with Fauna&rsquo;s FQL.</p>
<h2 id="my-advice">My advice</h2>
<p>If you&rsquo;re building a new project and you&rsquo;re not sure about your access patterns: Postgres. If you&rsquo;re running serverless functions and worried about connections: Postgres with a connection pooler. If you have a specific, well-understood, high-scale workload with stable access patterns: okay, look at DynamoDB.</p>
<p>Everything else is probably premature optimization disguised as architecture.</p>
]]></content:encoded></item><item><title>Database Replication Patterns That Actually Matter</title><link>https://lawzava.com/blog/2020-01-20-database-replication-patterns/</link><pubDate>Mon, 20 Jan 2020 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2020-01-20-database-replication-patterns/</guid><description>A practical breakdown of replication modes, topologies, and the tradeoffs between consistency, availability, and not losing your users&amp;amp;rsquo; data at 3am.</description><content:encoded><![CDATA[<h2 id="quick-take">Quick take</h2>
<p>Every replication pattern is a bet on which failure you can tolerate. Pick wrong and you either lose data or lose availability. There&rsquo;s no option C.</p>
<hr>
<p>At the fintech startup we ingested financial news and market data from dozens of sources into PostgreSQL. Millions of rows per day. The kind of dataset where &ldquo;eventually consistent&rdquo; means a trader sees yesterday&rsquo;s price and you get a very angry phone call.</p>
<p>That experience shaped how I think about replication. Not as a feature you toggle on, but as an architectural decision that determines how your system fails. Because it will fail.</p>
<h2 id="why-you-replicate">Why you replicate</h2>
<p>The reasons are fewer than people think:</p>
<ul>
<li><strong>Availability.</strong> Your primary dies, a replica takes over, users keep working.</li>
<li><strong>Read scaling.</strong> Heavy reporting queries stop competing with writes.</li>
<li><strong>Latency.</strong> Put a copy closer to users so reads don&rsquo;t cross the Atlantic.</li>
<li><strong>Isolation.</strong> Backups, analytics, migrations &ndash; all without touching production writes.</li>
</ul>
<p>That&rsquo;s basically it. If your reason isn&rsquo;t one of these, you probably don&rsquo;t need replication yet. You need a better backup strategy.</p>
<h2 id="the-three-modes">The three modes</h2>
<h3 id="asynchronous">Asynchronous</h3>
<p>The primary commits, returns success to the client, then ships WAL records to replicas whenever it gets around to it.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- postgresql.conf on the primary
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>wal_level <span style="color:#f92672">=</span> replica
</span></span><span style="display:flex;"><span>max_wal_senders <span style="color:#f92672">=</span> <span style="color:#ae81ff">5</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- No synchronous_standby_names. That&#39;s the point.
</span></span></span></code></pre></div><p>The replica connects with a <code>primary_conninfo</code> and streams changes:</p>
<pre tabindex="0"><code># recovery.conf (or standby.signal in PG12+)
primary_conninfo = &#39;host=primary-db port=5432 user=replicator&#39;
</code></pre><p>This is the fastest mode for writes. The primary doesn&rsquo;t wait for anyone. But there&rsquo;s a window &ndash; could be milliseconds, could be seconds under load &ndash; where a replica is behind. If the primary dies in that window, those transactions are gone.</p>
<p>At the fintech startup, async replication was fine for our read replicas serving the news feed. A 200ms lag on a news article? Nobody notices. A 200ms lag on a stock price used for trading signals? Different conversation entirely.</p>
<h3 id="synchronous">Synchronous</h3>
<p>The primary waits for at least one replica to confirm it wrote the WAL to disk before telling the client &ldquo;committed.&rdquo;</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- postgresql.conf
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>synchronous_standby_names <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;replica1&#39;</span>
</span></span><span style="display:flex;"><span>synchronous_commit <span style="color:#f92672">=</span> <span style="color:#66d9ef">on</span>
</span></span></code></pre></div><p>Zero data loss on failover. Sounds great. The cost: every single write now includes a network round trip to the replica. In the same datacenter, maybe 1-2ms added latency. Across regions? 50-150ms. Per write.</p>
<p>Worse: if that replica goes down, your primary blocks all writes until the replica comes back or you reconfigure. I&rsquo;ve seen this take down production systems that were &ldquo;highly available.&rdquo; The replication designed for availability became the single point of failure.</p>
<h3 id="quorum-semi-synchronous">Quorum (semi-synchronous)</h3>
<p>The pragmatic middle ground. Wait for <em>any</em> N out of M replicas to acknowledge, not all of them.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Wait for any 1 of these 3 replicas
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>synchronous_standby_names <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;ANY 1 (replica1, replica2, replica3)&#39;</span>
</span></span></code></pre></div><p>One replica can die and writes keep flowing. You still get the durability guarantee because at least one standby has the data. This is what I&rsquo;d recommend for most production PostgreSQL setups that need strong durability.</p>
<p>The math is simple: if you have 3 standbys and require <code>ANY 1</code>, you can lose 2 replicas before writes stall. With <code>ANY 2</code>, you can lose 1. Pick based on how many failures you want to survive simultaneously.</p>
<h2 id="topologies">Topologies</h2>
<h3 id="primary-replica-the-default">Primary-replica (the default)</h3>
<p>One writer, N readers. Dead simple. Works for the vast majority of applications.</p>
<pre tabindex="0"><code>  Writes           Reads
    |                |
    v                v
[Primary] ----&gt; [Replica 1]
    |
    +---------&gt; [Replica 2]
</code></pre><p>If you&rsquo;re building a new system and someone suggests multi-primary on day one, push back hard. Start here.</p>
<h3 id="cascading-replicas">Cascading replicas</h3>
<p>A replica can feed other replicas downstream instead of every replica pulling from the primary. This matters when you have many replicas or they span continents.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- On the cascading replica (replica2 feeds from replica1, not primary)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>primary_conninfo <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;host=replica1 port=5432 user=replicator&#39;</span>
</span></span></code></pre></div><p>The tradeoff: more lag for downstream replicas. Each hop adds latency. But the primary&rsquo;s <code>max_wal_senders</code> slots aren&rsquo;t exhausted by a fleet of read replicas. We used this at the fintech startup to feed analytics replicas off a primary standby &ndash; kept the main failover target clean while analytics could thrash their copy however they wanted.</p>
<h3 id="multi-primary">Multi-primary</h3>
<p>Multiple nodes accept writes and sync with each other. Sounds appealing. Is usually painful.</p>
<p>The fundamental problem: two nodes write conflicting data to the same row at the same time. Now what?</p>
<p>PostgreSQL&rsquo;s logical replication can do this with BDR or similar extensions, but you&rsquo;re signing up for conflict resolution logic, operational complexity, and debugging sessions that make you question your career choices. MySQL Group Replication and Galera have the same fundamental tradeoffs.</p>
<p>Use multi-primary when you have offices in multiple continents that all need local write latency and the business has accepted the cost. Not because it sounds cool on a whiteboard.</p>
<h2 id="read-your-writes-consistency">Read-your-writes consistency</h2>
<p>This is where async replication bites you in the application layer.</p>
<p>User writes a comment. POST succeeds (hits the primary). Page refreshes. GET goes to a replica. Comment isn&rsquo;t there. User writes the comment again. Now you have duplicates.</p>
<p>Three approaches, from simple to robust:</p>
<p><strong>1. Sticky routing after writes</strong></p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">handleRequest</span>(<span style="color:#a6e22e">w</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">ResponseWriter</span>, <span style="color:#a6e22e">r</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">Request</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">cookie</span>, <span style="color:#a6e22e">_</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">r</span>.<span style="color:#a6e22e">Cookie</span>(<span style="color:#e6db74">&#34;last_write&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> <span style="color:#a6e22e">cookie</span> <span style="color:#f92672">!=</span> <span style="color:#66d9ef">nil</span> <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Since</span>(<span style="color:#a6e22e">parseCookieTime</span>(<span style="color:#a6e22e">cookie</span>)) &lt; <span style="color:#ae81ff">5</span><span style="color:#f92672">*</span><span style="color:#a6e22e">time</span>.<span style="color:#a6e22e">Second</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#75715e">// Recent write -- route to primary</span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">db</span> = <span style="color:#a6e22e">primaryDB</span>
</span></span><span style="display:flex;"><span>    } <span style="color:#66d9ef">else</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">db</span> = <span style="color:#a6e22e">replicaDB</span>
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// ...</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Crude but effective. After a write, force reads to the primary for a few seconds. The window just needs to exceed your typical replication lag.</p>
<p><strong>2. Session affinity to a specific replica</strong></p>
<p>Pin a user session to one replica. They won&rsquo;t see inconsistency within their own session because they always read from the same copy. Doesn&rsquo;t help with cross-user consistency but handles the most visible problem.</p>
<p><strong>3. LSN tracking</strong></p>
<p>The proper solution. After a write, capture the WAL position (LSN) from the primary. Before reading from a replica, check if the replica has replayed past that LSN.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- After write, on the primary:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span> pg_current_wal_lsn();
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Returns something like: 0/16B9188
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Before read, on the replica:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span> pg_last_wal_replay_lsn();
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- If this &gt;= the saved LSN, the replica is caught up
</span></span></span></code></pre></div><p>More work to implement. More correct.</p>
<h2 id="conflict-resolution-in-multi-primary">Conflict resolution in multi-primary</h2>
<p>If you went down the multi-primary path despite my warning, you need a conflict strategy.</p>
<p><strong>Last write wins (LWW).</strong> Timestamp comparison. Whichever write has the later timestamp survives. Simple, lossy. A perfectly valid update gets silently dropped because a clock was 1ms behind. If your data can tolerate silent overwrites, fine. Financial data can&rsquo;t.</p>
<p><strong>Application-level merge.</strong> You define the merge logic per table or per field. A counter gets added. A set gets unioned. A text field takes the longer version. This works but every schema change means updating merge rules.</p>
<p><strong>CRDTs.</strong> Conflict-free replicated data types. Mathematically guaranteed to converge. Limited to specific data structures: counters, sets, registers. You can&rsquo;t CRDT your way through an arbitrary relational schema. Good for specific use cases, not a general solution.</p>
<h2 id="failover">Failover</h2>
<p>Promoting a replica sounds easy. <code>pg_promote()</code> or <code>SELECT pg_promote()</code> in PG12+. Done. Except no.</p>
<p>The hard parts:</p>
<p><strong>Fencing the old primary.</strong> If the old primary isn&rsquo;t actually dead &ndash; just slow, or network-partitioned &ndash; you now have two nodes accepting writes. Split brain. The nightmare scenario. You need STONITH (shoot the other node in the head) or at minimum, revoke the old primary&rsquo;s ability to accept connections.</p>
<p><strong>Client reconnection.</strong> Your application&rsquo;s connection string points to the old primary. Now it needs to point to the new one. Options: DNS update (slow propagation), virtual IP (fast but requires infra), connection proxy like PgBouncer or HAProxy that handles routing, or application-level logic.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># HAProxy config for automatic failover routing</span>
</span></span><span style="display:flex;"><span><span style="color:#ae81ff">listen postgres</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ae81ff">bind *:5432</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ae81ff">option httpchk GET /primary</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ae81ff">server pg1 10.0.1.1:5432 check port 8008</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ae81ff">server pg2 10.0.1.2:5432 check port 8008</span>
</span></span></code></pre></div><p><strong>Rebuilding the old primary.</strong> After failover, the old primary has diverged. It accepted some writes the new primary doesn&rsquo;t have (or vice versa). You need to either <code>pg_rewind</code> it or rebuild from scratch with <code>pg_basebackup</code>. Test this process <em>before</em> you need it.</p>
<p>I&rsquo;ve run failover drills at every company I&rsquo;ve worked at. The drill always surfaces something the documentation missed. Always.</p>
<h2 id="monitoring-replication">Monitoring replication</h2>
<p>Replication lag is a ticking bomb with a variable fuse. You need to watch it continuously.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- On the primary: check all connected replicas
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>    client_addr,
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">state</span>,
</span></span><span style="display:flex;"><span>    sent_lsn,
</span></span><span style="display:flex;"><span>    write_lsn,
</span></span><span style="display:flex;"><span>    flush_lsn,
</span></span><span style="display:flex;"><span>    replay_lsn,
</span></span><span style="display:flex;"><span>    pg_wal_lsn_diff(sent_lsn, replay_lsn) <span style="color:#66d9ef">AS</span> replay_lag_bytes
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_replication;
</span></span></code></pre></div><p>Key things to alert on:</p>
<ul>
<li><strong>Replay lag exceeding your tolerance.</strong> If you promised &ldquo;reads within 1 second of writes,&rdquo; alert before that threshold.</li>
<li><strong>Replica disconnection.</strong> A replica that silently falls off is worse than one that loudly crashes.</li>
<li><strong>WAL accumulation on the primary.</strong> If a replica can&rsquo;t keep up, the primary retains WAL segments. Disk fills. Primary crashes. You&rsquo;ve now lost the thing that was supposed to protect you.</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Check WAL retention on the primary
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span> pg_wal_lsn_diff(pg_current_wal_lsn(), <span style="color:#e6db74">&#39;0/0&#39;</span>) <span style="color:#66d9ef">AS</span> total_wal_bytes;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Or more practically, watch the pg_wal directory size
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">-- and alert when it grows beyond expected bounds.
</span></span></span></code></pre></div><h2 id="choosing-your-pattern">Choosing your pattern</h2>
<p>Skip the decision matrix. Ask three questions:</p>
<p><strong>Can you lose any committed transactions?</strong> If no, synchronous or quorum replication. Full stop. This was non-negotiable for the financial data at the fintech startup. A lost trade record isn&rsquo;t a bug, it&rsquo;s a regulatory incident.</p>
<p><strong>Do you need writes in multiple regions?</strong> If no &ndash; and it&rsquo;s almost always no &ndash; use primary-replica. If yes, accept the operational cost of multi-primary and budget engineering time accordingly. At Decloud, we keep writes centralized and replicate reads out. Simpler. Fewer 3am pages.</p>
<p><strong>What&rsquo;s your read-to-write ratio?</strong> If reads dominate (10:1 or more), async replicas for reads with synchronous replication to a single failover standby gives you the best of both: fast reads, safe failover, and write performance that&rsquo;s only slightly penalized.</p>
<hr>
<p>Replication isn&rsquo;t a feature flag. It&rsquo;s a contract between your system and your users about what happens when hardware fails, networks partition, and Murphy&rsquo;s Law does its thing. Pick the pattern that matches the promises you&rsquo;ve actually made, not the ones you wish you could make.</p>
]]></content:encoded></item><item><title>The PostgreSQL Tuning Playbook I Actually Use</title><link>https://lawzava.com/blog/2019-03-25-postgres-performance-tuning/</link><pubDate>Mon, 25 Mar 2019 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2019-03-25-postgres-performance-tuning/</guid><description>Battle-tested PostgreSQL tuning: connection pooling, memory sizing, index discipline, vacuum management, and the queries that tell you what&amp;amp;rsquo;s broken.</description><content:encoded><![CDATA[<h2 id="quick-take">Quick take</h2>
<p>Stop copying postgresql.conf snippets from blog posts. Measure your actual workload, change one thing, measure again. Most Postgres performance problems are bad queries, not bad config.</p>
<hr>
<p>I&rsquo;ve been  <a href="/blog/2016-11-14-scaling-postgresql-replication-sharding-beyond/"
   
   >running PostgreSQL in production</a>
 since my fintech startup days, where our workload was 90% reads with occasional write spikes when market data flooded in. Then at a mobility startup we had the opposite problem &ndash; constant GPS pings from bikes generating a firehose of writes. Same database engine, completely different tuning.</p>
<p>That experience taught me something that most &ldquo;Postgres tuning guides&rdquo; skip: there&rsquo;s no universal config. The defaults ship conservative on purpose. Your job is to understand your workload and nudge the knobs that matter for <em>your</em> traffic pattern.</p>
<p>Here is the playbook I actually follow.</p>
<h2 id="step-zero-know-where-time-goes">Step Zero: Know Where Time Goes</h2>
<p>You can&rsquo;t tune what you can&rsquo;t see. Before touching any config, enable <code>pg_stat_statements</code>. This single extension has saved me more hours than any other Postgres tool.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e"># postgresql.conf</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">shared_preload_libraries</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;pg_stat_statements&#39;</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> EXTENSION <span style="color:#66d9ef">IF</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">EXISTS</span> pg_stat_statements;
</span></span></code></pre></div><p>Once it&rsquo;s running, this query shows you exactly where your database spends its time:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">substring</span>(query, <span style="color:#ae81ff">1</span>, <span style="color:#ae81ff">80</span>) <span style="color:#66d9ef">AS</span> short_query,
</span></span><span style="display:flex;"><span>  calls,
</span></span><span style="display:flex;"><span>  round(total_time::numeric, <span style="color:#ae81ff">2</span>) <span style="color:#66d9ef">AS</span> total_ms,
</span></span><span style="display:flex;"><span>  round(mean_time::numeric, <span style="color:#ae81ff">2</span>) <span style="color:#66d9ef">AS</span> mean_ms,
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">rows</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_statements
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> total_time <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">20</span>;
</span></span></code></pre></div><p>The top 5 results from this query drive 80% of your tuning decisions. Not config file changes. Not hardware upgrades. Query fixes and index work.</p>
<p>At the fintech startup, this query revealed that a single unindexed join in our news aggregation pipeline was responsible for 40% of total database time. We added one composite index and response times dropped by half. No config changes needed.</p>
<h2 id="connection-management-the-silent-killer">Connection Management: The Silent Killer</h2>
<p>PostgreSQL forks a process per connection. That&rsquo;s the model. It&rsquo;s reliable but it doesn&rsquo;t scale to hundreds of idle connections sitting around doing nothing.</p>
<p>I&rsquo;ve seen this pattern at every startup I&rsquo;ve worked at: the app pool defaults to 10 connections per instance, you scale to 50 instances, and suddenly Postgres is managing 500 processes. Most of them idle. All of them eating memory.</p>
<h3 id="put-pgbouncer-in-front">Put PgBouncer in Front</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#66d9ef">[pgbouncer]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">pool_mode</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">transaction</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">max_client_conn</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">1000</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">default_pool_size</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">20</span>
</span></span></code></pre></div><p>Transaction mode is what you want for web workloads. The connection returns to the pool after each transaction, not after the client disconnects. This means 1000 application connections share 20 actual Postgres connections.</p>
<p>The catch: transaction mode breaks session-level features like prepared statements and temp tables. If you need those, use session mode for that specific pool and transaction mode for everything else.</p>
<h3 id="set-max_connections-deliberately">Set <code>max_connections</code> Deliberately</h3>
<p>With PgBouncer handling the fan-in, your Postgres <code>max_connections</code> can be surprisingly low. I typically run 50-100 even on busy systems.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Check how many connections you actually use
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> pg_stat_activity;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">count</span>(<span style="color:#f92672">*</span>) <span style="color:#66d9ef">FROM</span> pg_stat_activity <span style="color:#66d9ef">WHERE</span> <span style="color:#66d9ef">state</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;idle&#39;</span>;
</span></span></code></pre></div><p>If your idle count is more than half your total, you have a pooling problem. Fix that before tuning anything else.</p>
<h2 id="memory-four-settings-that-matter">Memory: Four Settings That Matter</h2>
<p>Memory configuration in Postgres is a balancing act between the database buffer cache and the OS page cache. Get it wrong and they fight each other.</p>
<h3 id="shared_buffers"><code>shared_buffers</code></h3>
<p>Start at 25% of total RAM. This is Postgres&rsquo;s own cache.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#a6e22e">shared_buffers</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">4GB  # on a 16GB server</span>
</span></span></code></pre></div><p>Going higher than 25-30% rarely helps and can hurt because you&rsquo;re stealing memory from the OS cache, which Postgres also relies on for reads.</p>
<h3 id="work_mem"><code>work_mem</code></h3>
<p>This one is tricky. It&rsquo;s allocated per sort or hash operation, not per connection. A single complex query with multiple joins can allocate <code>work_mem</code> several times over.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#a6e22e">work_mem</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">64MB</span>
</span></span></code></pre></div><p>Before you bump this, check if queries are actually spilling to disk:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">EXPLAIN</span> (<span style="color:#66d9ef">ANALYZE</span>, BUFFERS)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> o.<span style="color:#f92672">*</span>, u.name
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> orders o
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">JOIN</span> users u <span style="color:#66d9ef">ON</span> u.id <span style="color:#f92672">=</span> o.user_id
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> o.created_at <span style="color:#f92672">&gt;</span> <span style="color:#e6db74">&#39;2019-01-01&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> o.created_at <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></div><p>Look for <code>Sort Method: external merge Disk</code> in the output. That means the sort exceeded <code>work_mem</code> and hit disk. But the fix is usually a better query or a better index, not a bigger <code>work_mem</code>. Only raise it when you have confirmed the query is already optimal.</p>
<h3 id="effective_cache_size"><code>effective_cache_size</code></h3>
<p>A planner hint, not an allocation. Tell the planner how much total cache is available (Postgres buffers + OS cache).</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#a6e22e">effective_cache_size</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">12GB  # on a 16GB server</span>
</span></span></code></pre></div><h3 id="maintenance_work_mem"><code>maintenance_work_mem</code></h3>
<p>Used by VACUUM and CREATE INDEX. These operations run infrequently so you can be generous here without affecting query concurrency.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#a6e22e">maintenance_work_mem</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">1GB</span>
</span></span></code></pre></div><h2 id="index-discipline-where-the-real-wins-are">Index Discipline: Where the Real Wins Are</h2>
<p>I&rsquo;ll say this plainly: indexing strategy matters more than every postgresql.conf setting combined. A missing index on a hot query path will dominate your performance profile no matter how perfectly tuned your memory settings are.</p>
<h3 id="find-tables-that-need-indexes">Find Tables That Need Indexes</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  schemaname,
</span></span><span style="display:flex;"><span>  relname <span style="color:#66d9ef">AS</span> <span style="color:#66d9ef">table_name</span>,
</span></span><span style="display:flex;"><span>  seq_scan,
</span></span><span style="display:flex;"><span>  idx_scan,
</span></span><span style="display:flex;"><span>  n_live_tup <span style="color:#66d9ef">AS</span> <span style="color:#66d9ef">row_count</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_user_tables
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> seq_scan <span style="color:#f92672">&gt;</span> idx_scan
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> n_live_tup <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">10000</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> seq_scan <span style="color:#f92672">-</span> idx_scan <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></div><p>Tables with high sequential scans and low index scans on more than 10k rows are your targets.</p>
<h3 id="index-patterns-i-use-constantly">Index Patterns I Use Constantly</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Simple lookup (most common)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_users_email <span style="color:#66d9ef">ON</span> users(email);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Partial index: only index what you query
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_orders_pending
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">ON</span> orders(created_at)
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">WHERE</span> status <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;pending&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Covering index: avoid hitting the heap
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_orders_user_covering
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">ON</span> orders(user_id)
</span></span><span style="display:flex;"><span>  INCLUDE (status, total);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Expression index: for case-insensitive lookups
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_users_email_lower
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">ON</span> users(<span style="color:#66d9ef">lower</span>(email));
</span></span></code></pre></div><p>Partial indexes are underused. If you have a million-row orders table but you only ever query pending orders, indexing only the pending rows saves space and speeds up both writes and reads.</p>
<h3 id="read-the-execution-plan">Read the Execution Plan</h3>
<p>Don&rsquo;t guess. Run <code>EXPLAIN (ANALYZE, BUFFERS)</code> on every query you care about.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">EXPLAIN</span> (<span style="color:#66d9ef">ANALYZE</span>, BUFFERS)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> <span style="color:#f92672">*</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> orders
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> user_id <span style="color:#f92672">=</span> <span style="color:#ae81ff">123</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> created_at <span style="color:#f92672">&gt;</span> <span style="color:#e6db74">&#39;2019-01-01&#39;</span>;
</span></span></code></pre></div><p>Red flags to look for:</p>
<ul>
<li><code>Seq Scan</code> on tables with more than a few thousand rows</li>
<li>Large gaps between <code>estimated rows</code> and <code>actual rows</code> (means stale statistics)</li>
<li><code>Buffers: read</code> numbers that are disproportionately high (means cache misses)</li>
</ul>
<h3 id="query-habits-that-scale">Query Habits That Scale</h3>
<ul>
<li><strong>Keyset pagination over OFFSET.</strong> <code>WHERE id &gt; last_seen_id ORDER BY id LIMIT 20</code> stays fast at any depth. <code>OFFSET 10000</code> doesn&rsquo;t.</li>
<li><strong>Avoid SELECT * on wide tables.</strong> If a table has 40 columns and you need 3, select 3. The planner can use covering indexes and you reduce I/O.</li>
<li><strong>Keep transactions short.</strong> Long transactions hold locks and prevent vacuum from cleaning up dead rows. This is the number one cause of table bloat I see in practice.</li>
</ul>
<h2 id="vacuum-the-maintenance-you-cant-skip">Vacuum: The Maintenance You Can&rsquo;t Skip</h2>
<p>MVCC means every UPDATE creates a new row version and leaves the old one behind. VACUUM reclaims that space. If autovacuum falls behind your write rate, tables bloat, indexes bloat, and everything slows down.</p>
<h3 id="check-bloat-right-now">Check Bloat Right Now</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  schemaname,
</span></span><span style="display:flex;"><span>  relname,
</span></span><span style="display:flex;"><span>  n_dead_tup,
</span></span><span style="display:flex;"><span>  n_live_tup,
</span></span><span style="display:flex;"><span>  round(<span style="color:#ae81ff">100</span>.<span style="color:#ae81ff">0</span> <span style="color:#f92672">*</span> n_dead_tup <span style="color:#f92672">/</span> <span style="color:#66d9ef">nullif</span>(n_live_tup, <span style="color:#ae81ff">0</span>), <span style="color:#ae81ff">2</span>) <span style="color:#66d9ef">AS</span> dead_pct,
</span></span><span style="display:flex;"><span>  last_autovacuum
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_user_tables
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> n_dead_tup <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">1000</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> n_dead_tup <span style="color:#66d9ef">DESC</span>;
</span></span></code></pre></div><p>A <code>dead_pct</code> above 10-20% means autovacuum isn&rsquo;t keeping up.</p>
<h3 id="tune-autovacuum-for-your-write-load">Tune Autovacuum for Your Write Load</h3>
<p>The defaults assume modest write traffic. For busy tables, lower the scale factor and raise the cost limit so vacuum runs more frequently and more aggressively.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e"># Global settings</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">autovacuum_vacuum_scale_factor</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">0.02</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">autovacuum_analyze_scale_factor</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">0.01</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">autovacuum_vacuum_cost_limit</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">1000</span>
</span></span></code></pre></div><p>For specific hot tables, set per-table overrides:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> orders <span style="color:#66d9ef">SET</span> (
</span></span><span style="display:flex;"><span>  autovacuum_vacuum_scale_factor <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>.<span style="color:#ae81ff">01</span>,
</span></span><span style="display:flex;"><span>  autovacuum_vacuum_cost_limit <span style="color:#f92672">=</span> <span style="color:#ae81ff">2000</span>
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>At the mobility startup, our GPS tracking table was accumulating dead tuples faster than default autovacuum could clean them. We dropped the scale factor to 0.005 for that one table and the problem disappeared.</p>
<h2 id="wal-and-checkpoints">WAL and Checkpoints</h2>
<p>Write-heavy systems often hit checkpoint spikes &ndash; moments where the system pauses to flush dirty pages to disk. Smoothing checkpoints reduces latency jitter.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#a6e22e">checkpoint_completion_target</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">0.9</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">wal_buffers</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">64MB</span>
</span></span></code></pre></div><p>If you&rsquo;re tempted to set <code>synchronous_commit = off</code> for speed, understand what you&rsquo;re trading. You can lose the last few hundred milliseconds of committed transactions after a crash. For some workloads that&rsquo;s acceptable. For fintech, it isn&rsquo;t. Know your domain.</p>
<h2 id="the-monitoring-minimum">The Monitoring Minimum</h2>
<p>You don&rsquo;t need a fancy dashboard to start. These five checks, run daily or hooked into alerts, catch most problems early:</p>
<ol>
<li><strong>Connection count and idle sessions</strong> &ndash; <code>pg_stat_activity</code></li>
<li><strong>Cache hit ratio</strong> &ndash; should be above 99% for OLTP workloads (check <code>pg_statio_user_tables</code>)</li>
<li><strong>Top queries by total time</strong> &ndash; <code>pg_stat_statements</code></li>
<li><strong>Dead tuple ratio on hot tables</strong> &ndash; <code>pg_stat_user_tables</code></li>
<li><strong>Checkpoint frequency and duration</strong> &ndash; <code>pg_stat_bgwriter</code></li>
</ol>
<p>If your cache hit ratio drops below 99%, you either need more RAM or your working set has outgrown your instance. If checkpoint writes spike, your write load has changed and you need to revisit WAL settings.</p>
<h2 id="it-never-ends">It never ends</h2>
<p>Postgres tuning isn&rsquo;t a one-time task. Workloads change. Traffic grows. New features add new query patterns. The cycle is always the same:  <a href="/blog/2017-08-07-database-performance-tuning-systematic-approach/"
   
   >measure, identify the bottleneck, fix the bottleneck, measure again</a>
.</p>
<p>From what I&rsquo;ve seen, the fix is a better query or a missing index about 80% of the time. Config tuning accounts for maybe 15%. Hardware for the remaining 5%. Spend your time accordingly.</p>
]]></content:encoded></item><item><title>Migrating to TypeScript Without Losing Your Mind</title><link>https://lawzava.com/blog/2019-01-28-migrating-to-typescript/</link><pubDate>Mon, 28 Jan 2019 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2019-01-28-migrating-to-typescript/</guid><description>How to introduce TypeScript to a real JavaScript codebase incrementally, without halting product work or annoying your entire team.</description><content:encoded><![CDATA[<h2 id="quick-take">Quick take</h2>
<p>Stop treating TypeScript migration as a rewrite project. Flip one compiler flag, rename your boundary files to <code>.ts</code>, and tighten strictness over weeks. If the migration takes longer than shipping features, you&rsquo;re doing it wrong.</p>
<h3 id="the-codebase-that-convinced-me">The codebase that convinced me</h3>
<p>At the fintech startup we had a Node.js backend serving financial news to tens of thousands of users. JavaScript everywhere. It worked until it didn&rsquo;t.</p>
<p>The breaking point was a refactor to our news ranking pipeline. Someone renamed a field from <code>relevanceScore</code> to <code>score</code> in one module. The change looked clean, the tests passed (because the tests mocked the data with the new name), and it shipped. Two days later we noticed ranking was silently broken in production. The field name mismatch meant scores came through as <code>undefined</code>, and our sorting function treated <code>undefined</code> as zero. Every article ranked the same. Users saw noise instead of signal.</p>
<p>That kind of bug can&rsquo;t happen in TypeScript. The compiler catches it instantly. Not a fancy type trick. Just basic structural checking. After that incident I decided we were migrating.</p>
<h3 id="why-i-was-annoyed-about-it">Why I was annoyed about it</h3>
<p>I&rsquo;m primarily a Go developer. Go has had static types from day one. The idea that a language community needed years of debate to arrive at &ldquo;maybe we should check types before running code&rdquo; felt absurd to me. TypeScript shouldn&rsquo;t feel like a revelation. It should feel like the floor.</p>
<p>But here we are. JavaScript codebases exist, they power real products, and rewriting them in Go (tempting as that sounds) isn&rsquo;t always practical. So you migrate. Incrementally. Without drama.</p>
<h3 id="the-approach-that-actually-works">The approach that actually works</h3>
<p>Forget the blog posts showing a pristine greenfield TypeScript project with perfect types. Real migrations happen in messy codebases with deadlines.</p>
<p><strong>Step one: make TypeScript compile your existing JavaScript.</strong> Add a <code>tsconfig.json</code> that allows JS files and skips type checking them. This changes nothing about your build. It just proves the tooling works.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;compilerOptions&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;target&#34;</span>: <span style="color:#e6db74">&#34;ES2017&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;module&#34;</span>: <span style="color:#e6db74">&#34;commonjs&#34;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;strict&#34;</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;allowJs&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;checkJs&#34;</span>: <span style="color:#66d9ef">false</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;esModuleInterop&#34;</span>: <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;skipLibCheck&#34;</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;include&#34;</span>: [<span style="color:#e6db74">&#34;src/**/*&#34;</span>]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><strong>Step two: rename your boundary files.</strong> Not all of them. Start with the files that define the shapes of data crossing trust boundaries. API request handlers. Database query result types. Config loaders. These are where type mismatches actually cause production bugs. Rename them from <code>.js</code> to <code>.ts</code> and add types to the function signatures.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">interface</span> <span style="color:#a6e22e">Article</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">id</span>: <span style="color:#66d9ef">string</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">title</span>: <span style="color:#66d9ef">string</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">relevanceScore</span>: <span style="color:#66d9ef">number</span>;
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">parseArticle</span>(<span style="color:#a6e22e">input</span>: <span style="color:#66d9ef">unknown</span>)<span style="color:#f92672">:</span> <span style="color:#a6e22e">Article</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">data</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">input</span> <span style="color:#66d9ef">as</span> <span style="color:#a6e22e">Record</span>&lt;<span style="color:#f92672">string</span>, <span style="color:#a6e22e">unknown</span>&gt;;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">id</span>: <span style="color:#66d9ef">String</span>(<span style="color:#a6e22e">data</span>.<span style="color:#a6e22e">id</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">title</span>: <span style="color:#66d9ef">String</span>(<span style="color:#a6e22e">data</span>.<span style="color:#a6e22e">title</span>),
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">relevanceScore</span>: <span style="color:#66d9ef">Number</span>(<span style="color:#a6e22e">data</span>.<span style="color:#a6e22e">relevanceScore</span>),
</span></span><span style="display:flex;"><span>  };
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That <code>parseArticle</code> function isn&rsquo;t fancy. It&rsquo;s a boundary. Everything downstream of it knows what shape it&rsquo;s working with. The field rename bug that bit us at the fintech startup becomes a compile error instead of a silent production failure.</p>
<p><strong>Step three: tighten the compiler gradually.</strong> Don&rsquo;t flip <code>strict: true</code> on day one. You will get a thousand errors and your team will revolt. Instead, enable checks one at a time over a few weeks:</p>
<ol>
<li><code>noImplicitAny</code> first. This catches the worst category of silent failures.</li>
<li><code>strictNullChecks</code> next. This eliminates the <code>undefined</code> surprise class of bugs.</li>
<li>Full <code>strict</code> once the team has momentum and the backlog of type errors is manageable.</li>
</ol>
<p>You can even run a stricter config on the directories you have already cleaned up while leaving the rest permissive:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;extends&#34;</span>: <span style="color:#e6db74">&#34;./tsconfig.json&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;compilerOptions&#34;</span>: {
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">&#34;strict&#34;</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>  },
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;include&#34;</span>: [<span style="color:#e6db74">&#34;src/models/**/*&#34;</span>, <span style="color:#e6db74">&#34;src/utils/**/*&#34;</span>]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="what-kills-migrations">What kills migrations</h3>
<p>I&rsquo;ve seen three TypeScript migrations fail. Same pattern every time.</p>
<p><strong>Making it a side project.</strong> If migration work isn&rsquo;t part of normal sprints, it will never finish. The approach that works: new files are <code>.ts</code> by default, and any file you touch for a feature gets migrated as part of that feature work. No separate migration tickets that rot in the backlog.</p>
<p><strong>Sprinkling <code>any</code> everywhere and calling it done.</strong> Renaming <code>.js</code> to <code>.ts</code> and casting everything to <code>any</code> isn&rsquo;t a migration. It&rsquo;s a lie that makes the build green while providing zero safety. If you find yourself typing <code>as any</code> more than once per file, stop and think about what you&rsquo;re avoiding.</p>
<p><strong>Converting giant files first.</strong> A 2000-line controller file isn&rsquo;t where you start. Break it up first, then type the pieces. Trying to add types to a monolith module is miserable and teaches the team that TypeScript migration is miserable. Start with small utility files. Quick wins build momentum.</p>
<h3 id="third-party-types">Third-party types</h3>
<p>Most popular libraries ship types or have them on DefinitelyTyped. When they don&rsquo;t, declare the module and move on:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">declare</span> <span style="color:#a6e22e">module</span> <span style="color:#e6db74">&#34;legacy-widget&#34;</span>;
</span></span></code></pre></div><p>This isn&rsquo;t ideal, but it&rsquo;s honest. You&rsquo;re saying &ldquo;I don&rsquo;t know the types for this dependency and I&rsquo;m not going to pretend.&rdquo; Better than a wrong type definition that gives false confidence.</p>
<h3 id="keep-the-pressure-on">Keep the pressure on</h3>
<p>Run <code>tsc --noEmit</code> in CI from day one. Make it a blocking check. Track your migration with something embarrassingly simple like counting file extensions:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;JS: </span><span style="color:#66d9ef">$(</span>find src -name <span style="color:#e6db74">&#39;*.js&#39;</span> | wc -l<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>echo <span style="color:#e6db74">&#34;TS: </span><span style="color:#66d9ef">$(</span>find src -name <span style="color:#e6db74">&#39;*.ts&#39;</span> | wc -l<span style="color:#66d9ef">)</span><span style="color:#e6db74">&#34;</span>
</span></span></code></pre></div><p>When the numbers cross over, buy the team lunch. Seriously. Celebrate boring infrastructure wins. They are the ones that actually matter.</p>
<h3 id="the-honest-trade-off">The honest trade-off</h3>
<p>TypeScript adds friction. Build times go up. Editor tooling sometimes chokes on complex types. The type system has genuine holes (<code>any</code> is a backdoor, type assertions bypass checking, and some runtime patterns are hard to express statically).</p>
<p>But the trade-off is overwhelmingly worth it for any codebase with more than one contributor that will exist for more than six months. TypeScript directly improves stability by catching an entire class of defects before code reaches production. That alone justifies the migration cost.</p>
<p>Just do it incrementally. Do it as part of real work. And for the love of shipping, don&rsquo;t make it a three-month rewrite project.</p>
]]></content:encoded></item><item><title>API Rate Limiting: What Actually Works</title><link>https://lawzava.com/blog/2018-10-15-api-rate-limiting-strategies/</link><pubDate>Mon, 15 Oct 2018 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2018-10-15-api-rate-limiting-strategies/</guid><description>Algorithms, headers, and deployment patterns for rate limiting APIs &amp;amp;ndash; drawn from building financial data services at the fintech startup.</description><content:encoded><![CDATA[<h2 id="quick-take">Quick take</h2>
<p>Rate limiting is the difference between a healthy API and a 3am incident. Pick token bucket, use Redis, return proper headers, and stop pretending IP-based limits are enough.</p>
<hr>
<p>At the fintech startup we serve financial news and data through APIs that get hammered by everything from institutional trading bots to someone&rsquo;s weekend scraping project. I learned rate limiting the hard way: our search endpoint went down because a single client was firing 4,000 requests per minute with no backoff. No rate limiter in place. Just raw, unprotected endpoints and a very bad Monday morning.</p>
<p>After that, I spent a solid two weeks building out our rate limiting infrastructure. Here&rsquo;s what I learned.</p>
<h2 id="why-you-need-it-yesterday">Why You Need It Yesterday</h2>
<p>Rate limiting isn&rsquo;t a nice-to-have. It&rsquo;s the control plane for your API. Without it:</p>
<ul>
<li>One buggy client retry loop takes out your entire service</li>
<li>A scraper with no sleep interval saturates your database connections</li>
<li>Your expensive endpoints (search, aggregation, report generation) eat all the compute, starving the cheap ones</li>
<li>Your cloud bill explodes because downstream dependencies charge per call</li>
</ul>
<p>At the fintech startup, our financial data endpoints had wildly different costs. A simple quote lookup was cheap. A full news aggregation with NLP sentiment scoring? That could take seconds and significant compute. Treating them the same was a mistake we made early on.</p>
<h2 id="the-decisions-that-matter">The Decisions That Matter</h2>
<p>Before you pick an algorithm, answer these four questions.</p>
<h3 id="whats-the-unit-of-work">What&rsquo;s the unit of work?</h3>
<p>Requests per minute is the obvious one. But it&rsquo;s not always right. If your endpoints vary wildly in cost &ndash; like ours did &ndash; you want cost-based budgets. A sentiment analysis call might cost 10 tokens. A simple metadata fetch costs 1. Flat request counts hide this.</p>
<h3 id="who-are-you-actually-limiting">Who are you actually limiting?</h3>
<p>Your options:</p>
<ul>
<li><strong>IP address</strong> &ndash; Easy. Also terrible for anything serious. NAT gateways, corporate proxies, shared WiFi. We had a financial institution where 200 different users appeared as one IP. Useless.</li>
<li><strong>API key / access token</strong> &ndash; Much better. This is what you want for authenticated traffic.</li>
<li><strong>Organization / tenant</strong> &ndash; Best for B2B SaaS. We used org-level limits at the fintech startup because a single company might have multiple API keys across different services.</li>
</ul>
<p>Pick the most granular identifier your auth model supports. You can always aggregate up.</p>
<h3 id="how-much-burst-do-you-allow">How much burst do you allow?</h3>
<p>Financial data is bursty by nature. Market opens, everyone hits the API at once. We needed to allow short bursts without letting sustained abuse through. This is where algorithm choice matters most.</p>
<h3 id="global-or-per-endpoint">Global or per-endpoint?</h3>
<p>Start with a global limit. Then add per-endpoint limits where you know the cost is uneven. We had a global cap of 600 requests/minute but dropped the search endpoint down to 60/minute because it was 10x more expensive than everything else.</p>
<h2 id="algorithms-the-real-tradeoffs">Algorithms: The Real Tradeoffs</h2>
<h3 id="fixed-window">Fixed Window</h3>
<p>Count requests in a hard time window. 1,000 per hour, reset on the hour.</p>
<p>Dead simple. Also has a nasty edge case: a client can burn 1,000 requests at 12:59 and another 1,000 at 13:00. You just allowed 2,000 in two minutes. For financial APIs where burst = real money, this matters.</p>
<h3 id="sliding-window-counter">Sliding Window Counter</h3>
<p>Uses the current and previous window counts with a weighted average. Good enough for most cases. This is what a lot of distributed systems default to because it&rsquo;s cheap to compute and reasonably accurate.</p>
<h3 id="token-bucket">Token Bucket</h3>
<p>Tokens refill at a steady rate. Each request burns one or more tokens. Bucket has a max capacity that controls burst size.</p>
<p>This is what we ended up using at the fintech startup. Here&rsquo;s why: it naturally handles burst (the bucket fills up during quiet periods) while enforcing a hard average rate. And it maps perfectly to cost-based limiting &ndash; an expensive endpoint just costs more tokens.</p>
<p>A simplified Redis implementation:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-lua" data-lang="lua"><span style="display:flex;"><span><span style="color:#75715e">-- Token bucket in Redis via Lua script</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> key <span style="color:#f92672">=</span> KEYS[<span style="color:#ae81ff">1</span>]
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> capacity <span style="color:#f92672">=</span> tonumber(ARGV[<span style="color:#ae81ff">1</span>])
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> refill_rate <span style="color:#f92672">=</span> tonumber(ARGV[<span style="color:#ae81ff">2</span>])  <span style="color:#75715e">-- tokens per second</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> cost <span style="color:#f92672">=</span> tonumber(ARGV[<span style="color:#ae81ff">3</span>])         <span style="color:#75715e">-- tokens this request costs</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> now <span style="color:#f92672">=</span> tonumber(ARGV[<span style="color:#ae81ff">4</span>])
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> bucket <span style="color:#f92672">=</span> redis.call(<span style="color:#e6db74">&#39;hmget&#39;</span>, key, <span style="color:#e6db74">&#39;tokens&#39;</span>, <span style="color:#e6db74">&#39;last_refill&#39;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> tokens <span style="color:#f92672">=</span> tonumber(bucket[<span style="color:#ae81ff">1</span>]) <span style="color:#f92672">or</span> capacity
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> last_refill <span style="color:#f92672">=</span> tonumber(bucket[<span style="color:#ae81ff">2</span>]) <span style="color:#f92672">or</span> now
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Refill tokens based on elapsed time</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> elapsed <span style="color:#f92672">=</span> now <span style="color:#f92672">-</span> last_refill
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">local</span> new_tokens <span style="color:#f92672">=</span> math.min(capacity, tokens <span style="color:#f92672">+</span> (elapsed <span style="color:#f92672">*</span> refill_rate))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">if</span> new_tokens <span style="color:#f92672">&gt;=</span> cost <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>    new_tokens <span style="color:#f92672">=</span> new_tokens <span style="color:#f92672">-</span> cost
</span></span><span style="display:flex;"><span>    redis.call(<span style="color:#e6db74">&#39;hmset&#39;</span>, key, <span style="color:#e6db74">&#39;tokens&#39;</span>, new_tokens, <span style="color:#e6db74">&#39;last_refill&#39;</span>, now)
</span></span><span style="display:flex;"><span>    redis.call(<span style="color:#e6db74">&#39;expire&#39;</span>, key, math.ceil(capacity <span style="color:#f92672">/</span> refill_rate) <span style="color:#f92672">*</span> <span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {<span style="color:#ae81ff">1</span>, new_tokens}  <span style="color:#75715e">-- allowed, remaining tokens</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">else</span>
</span></span><span style="display:flex;"><span>    redis.call(<span style="color:#e6db74">&#39;hmset&#39;</span>, key, <span style="color:#e6db74">&#39;tokens&#39;</span>, new_tokens, <span style="color:#e6db74">&#39;last_refill&#39;</span>, now)
</span></span><span style="display:flex;"><span>    redis.call(<span style="color:#e6db74">&#39;expire&#39;</span>, key, math.ceil(capacity <span style="color:#f92672">/</span> refill_rate) <span style="color:#f92672">*</span> <span style="color:#ae81ff">2</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> {<span style="color:#ae81ff">0</span>, new_tokens}  <span style="color:#75715e">-- rejected, remaining tokens</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">end</span>
</span></span></code></pre></div><p>The Lua script is atomic in Redis. No race conditions. No distributed locks. It just works.</p>
<h3 id="leaky-bucket">Leaky Bucket</h3>
<p>Processes requests at a constant rate, queues or rejects the rest. Great for smoothing traffic, but clients hate it when they can&rsquo;t burst at all. We tried it briefly and got complaints from trading firms whose workflows were inherently bursty. Switched to token bucket within a week.</p>
<h2 id="implementation-where-and-how">Implementation: Where and How</h2>
<h3 id="enforce-at-two-layers">Enforce at two layers</h3>
<p>At the API gateway, put a coarse global limit. This catches the obvious abuse before it hits your application servers. Nginx, Kong, whatever you use &ndash; it can do basic rate limiting.</p>
<p>Inside your application, add the smart limits. Per-endpoint costs, per-org budgets, tiered plans. This is where the token bucket with Redis lives.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// Middleware pseudocode</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">func</span> <span style="color:#a6e22e">RateLimitMiddleware</span>(<span style="color:#a6e22e">next</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">Handler</span>) <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">Handler</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">HandlerFunc</span>(<span style="color:#66d9ef">func</span>(<span style="color:#a6e22e">w</span> <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">ResponseWriter</span>, <span style="color:#a6e22e">r</span> <span style="color:#f92672">*</span><span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">Request</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">orgID</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">extractOrgID</span>(<span style="color:#a6e22e">r</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">cost</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">endpointCost</span>(<span style="color:#a6e22e">r</span>.<span style="color:#a6e22e">URL</span>.<span style="color:#a6e22e">Path</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">allowed</span>, <span style="color:#a6e22e">remaining</span> <span style="color:#f92672">:=</span> <span style="color:#a6e22e">tokenBucket</span>.<span style="color:#a6e22e">Allow</span>(<span style="color:#a6e22e">orgID</span>, <span style="color:#a6e22e">cost</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">Header</span>().<span style="color:#a6e22e">Set</span>(<span style="color:#e6db74">&#34;X-RateLimit-Limit&#34;</span>, <span style="color:#e6db74">&#34;600&#34;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">Header</span>().<span style="color:#a6e22e">Set</span>(<span style="color:#e6db74">&#34;X-RateLimit-Remaining&#34;</span>, <span style="color:#a6e22e">strconv</span>.<span style="color:#a6e22e">Itoa</span>(<span style="color:#a6e22e">remaining</span>))
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">Header</span>().<span style="color:#a6e22e">Set</span>(<span style="color:#e6db74">&#34;X-RateLimit-Reset&#34;</span>, <span style="color:#a6e22e">strconv</span>.<span style="color:#a6e22e">FormatInt</span>(<span style="color:#a6e22e">resetTime</span>(), <span style="color:#ae81ff">10</span>))
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> !<span style="color:#a6e22e">allowed</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">Header</span>().<span style="color:#a6e22e">Set</span>(<span style="color:#e6db74">&#34;Retry-After&#34;</span>, <span style="color:#e6db74">&#34;30&#34;</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">Error</span>(<span style="color:#a6e22e">w</span>, <span style="color:#e6db74">&#34;rate limit exceeded&#34;</span>, <span style="color:#a6e22e">http</span>.<span style="color:#a6e22e">StatusTooManyRequests</span>)
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">next</span>.<span style="color:#a6e22e">ServeHTTP</span>(<span style="color:#a6e22e">w</span>, <span style="color:#a6e22e">r</span>)
</span></span><span style="display:flex;"><span>    })
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><h3 id="always-return-headers">Always return headers</h3>
<p>Every response, not just 429s. Clients need to know where they stand. Minimum set:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>X-RateLimit-Limit: 600
</span></span><span style="display:flex;"><span>X-RateLimit-Remaining: 42
</span></span><span style="display:flex;"><span>X-RateLimit-Reset: 1539628800
</span></span></code></pre></div><p>When you reject, give them something useful:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>HTTP/1.1 429 Too Many Requests
</span></span><span style="display:flex;"><span>Retry-After: 30
</span></span><span style="display:flex;"><span>Content-Type: application/json
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>{&#34;error&#34;: &#34;rate_limit_exceeded&#34;, &#34;retry_after&#34;: 30}
</span></span></code></pre></div><p>We included <code>retry_after</code> in the JSON body too because some HTTP client libraries make it annoyingly hard to read response headers on error responses.</p>
<h3 id="redis-for-distributed-counters">Redis for distributed counters</h3>
<p>Single-node in-memory counters are fine for prototyping. In production with multiple app servers, you need shared state. Redis with <code>EVAL</code> (Lua scripts), <code>INCR</code>, and <code>EXPIRE</code> gives you atomic operations with sub-millisecond latency. We ran a single Redis instance dedicated to rate limiting &ndash; separate from our caching layer &ndash; and it handled the load without breaking a sweat.</p>
<h3 id="tiered-limits-map-to-product-tiers">Tiered limits map to product tiers</h3>
<p>Free tier: 100 requests/minute, no access to premium endpoints. Pro: 600/minute, full access. Enterprise: custom limits, dedicated support.</p>
<p>This is a product decision as much as a technical one. Your rate limits <em>are</em> your pricing tiers. Make them explicit in your docs and in your headers.</p>
<h2 id="fail-open-or-fail-closed">Fail Open or Fail Closed?</h2>
<p>When your Redis instance goes down (and it will, eventually), what happens? You have two choices:</p>
<p><strong>Fail open</strong>: let all traffic through. Preserves availability but you&rsquo;re flying blind. One bad client can take you out.</p>
<p><strong>Fail closed</strong>: reject everything. Safe but brutal. Legitimate users get 429s for no reason.</p>
<p>We chose fail open with aggressive alerting. The reasoning: a brief period without rate limiting was less damaging than blocking all our paying customers. But we had circuit breakers on the expensive downstream calls as a secondary safety net. Pick explicitly. Don&rsquo;t let this be a surprise during an incident.</p>
<h2 id="the-checklist">The Checklist</h2>
<p>If you&rsquo;re implementing rate limiting from scratch, here&rsquo;s the short version:</p>
<ul>
<li>Use API key or org ID as the limiting key, not IP</li>
<li>Token bucket for the algorithm unless you have a specific reason not to</li>
<li>Redis with Lua scripts for distributed counters</li>
<li>Return <code>X-RateLimit-*</code> headers on every response</li>
<li>Return <code>Retry-After</code> on 429s, both in header and body</li>
<li>Per-endpoint limits where cost varies significantly</li>
<li>Log every throttle event, alert on sudden spikes</li>
<li>Decide fail-open vs fail-closed before you need to</li>
</ul>
<p>Build this before your traffic forces you to. Retrofitting rate limiting during an outage is one of the worst experiences in backend engineering.</p>
]]></content:encoded></item><item><title>Database Sharding: You Probably Don't Need It Yet</title><link>https://lawzava.com/blog/2018-08-06-database-sharding-when-and-how/</link><pubDate>Mon, 06 Aug 2018 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2018-08-06-database-sharding-when-and-how/</guid><description>Most teams shard too early. Here&amp;amp;rsquo;s how we thought about it at the fintech startup, when it actually makes sense, and the SQL-level decisions that matter most.</description><content:encoded><![CDATA[<p>Most teams shard too early. I genuinely believe this. I&rsquo;ve watched companies with a few hundred thousand rows in their biggest table start planning a sharding strategy because someone read a blog post about how Discord does it. Discord has billions of messages. You have a Django app with three microservices. Sit down.</p>
<p>At the fintech startup we dealt with real volume. Financial news, market data, user watchlists, story relevance scores &ndash; all of it flowing into PostgreSQL around the clock. Tens of millions of rows across our core tables, write-heavy ingestion pipelines running 24/7. We had every reason to think about sharding early. We didn&rsquo;t. And that restraint saved us months of engineering time.</p>
<h2 id="what-sharding-actually-solves">What sharding actually solves</h2>
<p>Sharding splits your dataset across multiple database instances. That&rsquo;s it. It gives you more write throughput and more total storage capacity by distributing the load.</p>
<p>What it doesn&rsquo;t solve: slow queries, missing indexes, bad schema design, lack of connection pooling, or the fact that your ORM is generating six joins for a page that should be one <code>SELECT</code>. Sharding won&rsquo;t fix your application. It will make your application&rsquo;s problems harder to debug.</p>
<p>At the fintech startup, before we ever discussed sharding, we squeezed everything out of a single Postgres instance. I mean everything:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Finding our worst offenders
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span> query, calls, mean_time, total_time
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_stat_statements
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> total_time <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">20</span>;
</span></span></code></pre></div><p>That query alone &ndash; run regularly &ndash; told us more about our scaling problems than any architecture whiteboard session. Nine times out of ten the fix was an index, a rewritten query, or moving a hot path behind a cache.</p>
<h2 id="when-sharding-is-actually-justified">When sharding is actually justified</h2>
<p>You need sharding when all of these are true simultaneously:</p>
<ul>
<li>Your primary is pegged on CPU or disk I/O from writes, not reads</li>
<li>Read replicas are already handling your read traffic and it&rsquo;s not enough</li>
<li>You&rsquo;ve tuned <code>work_mem</code>, <code>shared_buffers</code>, <code>effective_cache_size</code> &ndash; the obvious Postgres knobs</li>
<li>Vertical scaling has hit a wall or the cost curve has gone exponential</li>
<li>Your query patterns naturally partition along a single key</li>
</ul>
<p>If you&rsquo;re missing even one of those conditions, you&rsquo;re probably not ready. Go back and fix the basics.</p>
<h2 id="the-checklist-before-you-shard">The checklist before you shard</h2>
<p>This is the order I&rsquo;d recommend. We followed it at the fintech startup and it bought us a long time on a single primary.</p>
<p><strong>1. Fix your queries.</strong> Use <code>EXPLAIN ANALYZE</code>. Actually read the output. Look for sequential scans on large tables, nested loop joins where hash joins would work, and sorts that spill to disk.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">EXPLAIN</span> <span style="color:#66d9ef">ANALYZE</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> s.id, s.title, r.score
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> stories s
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">JOIN</span> relevance_scores r <span style="color:#66d9ef">ON</span> r.story_id <span style="color:#f92672">=</span> s.id
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> r.user_id <span style="color:#f92672">=</span> <span style="color:#ae81ff">42</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> s.published_at <span style="color:#f92672">&gt;</span> NOW() <span style="color:#f92672">-</span> INTERVAL <span style="color:#e6db74">&#39;7 days&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> r.score <span style="color:#66d9ef">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">LIMIT</span> <span style="color:#ae81ff">50</span>;
</span></span></code></pre></div><p>If that&rsquo;s doing a seq scan on <code>relevance_scores</code>, you don&rsquo;t have a sharding problem. You have an indexing problem.</p>
<p><strong>2. Add proper indexes.</strong> Composite indexes aligned to your query patterns. Partial indexes for hot subsets. Don&rsquo;t just throw a B-tree on every column.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> CONCURRENTLY idx_relevance_user_score
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> relevance_scores (user_id, score <span style="color:#66d9ef">DESC</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> score <span style="color:#f92672">&gt;</span> <span style="color:#ae81ff">0</span>.<span style="color:#ae81ff">5</span>;
</span></span></code></pre></div><p><strong>3. Introduce caching.</strong> We cached story metadata, user watchlists, and computed relevance scores in Redis. This alone dropped our read load by 60-70%.</p>
<p><strong>4. Connection pooling.</strong> PgBouncer. Set it up in transaction mode. PostgreSQL is terrible at handling thousands of connections natively. This is free performance.</p>
<p><strong>5. Archive cold data.</strong> We partitioned our historical data by month and moved anything older than six months to separate tables. Queries on recent data got dramatically faster.</p>
<p><strong>6. Read replicas.</strong> Route all reporting, analytics, and non-critical reads to replicas. Your primary should only handle writes and the reads that absolutely need consistency.</p>
<p>Only after exhausting all of that should sharding enter the conversation.</p>
<h2 id="choosing-a-shard-key">Choosing a shard key</h2>
<p>This is the decision that will haunt you. Get it wrong and you&rsquo;re looking at a painful re-sharding down the road. The shard key determines which instance holds which rows.</p>
<p>Good shard keys share these traits: high cardinality, even distribution, stability over time, and presence in nearly every query you run.</p>
<p>For us at the fintech startup, <code>user_id</code> was the obvious candidate for user-facing data. All watchlist queries, relevance scores, and personalization features were scoped to a user. Everything a user needed lived together.</p>
<p>For the ingestion pipeline &ndash; stories, sources, raw market data &ndash; that was a different story. Literally. We kept that on a single large instance with partitioning rather than sharding because the access patterns didn&rsquo;t have a natural partition key. Stories get read by many users. There&rsquo;s no clean split.</p>
<p>Bad shard keys I&rsquo;ve seen people pick:</p>
<ul>
<li><code>created_at</code> &ndash; all new writes go to one shard. Congratulations, you&rsquo;ve created a hotspot</li>
<li><code>country</code> &ndash; massively uneven. One shard gets 40% of traffic</li>
<li><code>status</code> &ndash; three distinct values. That&rsquo;s three shards at most, and one of them has 90% of the data</li>
</ul>
<h2 id="sharding-strategies-in-practice">Sharding strategies in practice</h2>
<h3 id="hash-based">Hash-based</h3>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Application-level routing
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>shard_id <span style="color:#f92672">=</span> hash(user_id) <span style="color:#f92672">%</span> num_shards
</span></span></code></pre></div><p>Even distribution. No hotspots on write. But range queries become fan-out queries across all shards, which is expensive. Adding a new shard means rehashing and moving data around. Consistent hashing helps but doesn&rsquo;t eliminate the problem.</p>
<h3 id="range-based">Range-based</h3>
<pre tabindex="0"><code>shard_1: user_id 1 - 1,000,000
shard_2: user_id 1,000,001 - 2,000,000
</code></pre><p>Simple to reason about. Range queries stay local. But new users always hit the latest shard, creating a write hotspot. Rebalancing means splitting ranges and moving data.</p>
<h3 id="directory-based">Directory-based</h3>
<p>A lookup table maps each key to a shard. Maximum flexibility &ndash; you can move individual users between shards. The cost is an extra lookup on every single query and another system (the directory) that has to be highly available. If the directory goes down, everything goes down.</p>
<h2 id="cross-shard-operations-are-where-it-hurts">Cross-shard operations are where it hurts</h2>
<p>Here&rsquo;s what nobody tells you until you&rsquo;re already committed: the moment you shard, joins become your enemy.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- This worked fine on a single instance
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span> u.name, <span style="color:#66d9ef">COUNT</span>(o.id)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> users u
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">JOIN</span> orders o <span style="color:#66d9ef">ON</span> o.user_id <span style="color:#f92672">=</span> u.id
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> u.region <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;EU&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">GROUP</span> <span style="color:#66d9ef">BY</span> u.name;
</span></span></code></pre></div><p>On a sharded setup with <code>user_id</code> as the shard key, this might still work if users and orders are co-located on the same shard. But what if you need orders joined with products, and products live on a different shard? Now you&rsquo;re doing application-level joins. Pulling data from multiple shards into your app server and joining in memory. It&rsquo;s slow and it&rsquo;s ugly.</p>
<p>Cross-shard transactions are worse. Two-phase commit exists, but it kills throughput and introduces failure modes that will wake you up at 3am. At the fintech startup we avoided this entirely by designing our data model so that everything a single request needs lives on the same shard. Sometimes that meant denormalization. Sometimes it meant copying reference data to every shard. Both are fine tradeoffs.</p>
<h2 id="routing-who-decides-which-shard">Routing: who decides which shard?</h2>
<p>Three options, each with costs.</p>
<p><strong>Application-level routing</strong> &ndash; your code computes the shard and talks to the right database. Full control. But now every service that touches the database needs routing logic. We preferred this at the fintech startup because it was explicit and debuggable.</p>
<p><strong>Proxy routing</strong> &ndash; something like PgBouncer with custom routing, Vitess, or ProxySQL sits between your app and the databases. Simpler application code but you&rsquo;re adding another piece of infrastructure to monitor, scale, and debug when things go wrong at 2am.</p>
<p><strong>Database-native sharding</strong> &ndash; Citus for PostgreSQL is the one I&rsquo;d look at if I were doing this today. It handles routing and distributed queries within Postgres itself. Less custom code. But it has its own performance characteristics and operational model that you need to understand deeply before committing.</p>
<h2 id="the-operational-reality">The operational reality</h2>
<p>Sharding isn&rsquo;t a one-time project. It&rsquo;s a permanent increase in operational complexity.</p>
<p>Every shard needs monitoring. Every shard needs backups. Every shard needs failover testing. If you have 8 shards, you have 8 databases to manage, and the failure of any single one affects a subset of your users.</p>
<p>You need to plan for rebalancing before shards fill up. If shard 3 is at 85% capacity and growing 5% per month, you have roughly three months to act. Automating this is hard. Most teams don&rsquo;t, and then it becomes a fire drill.</p>
<p>You also need runbooks for partial failures. What happens when shard 5 goes down? Do those users see errors? Degraded functionality? Do other shards pick up the load? These are questions you need answered before going to production, not during an incident.</p>
<h2 id="bottom-line">Bottom line</h2>
<p>Sharding is a last resort that works. When you genuinely need it &ndash; when a single instance can&rsquo;t keep up and you&rsquo;ve already optimized, cached, replicated, and partitioned &ndash; it&rsquo;s the right tool. But it permanently changes your operational model, your data model, and how every engineer on the team thinks about the database.</p>
<p>At the fintech startup we pushed a single PostgreSQL instance far further than most people think is possible. Proper indexing, <code>pg_stat_statements</code>, PgBouncer, aggressive caching, table partitioning. That got us to the scale we needed without the overhead of a sharded architecture.</p>
<p>If you&rsquo;re reading this and considering sharding, run <code>EXPLAIN ANALYZE</code> on your slowest queries first. The answer is probably in there.</p>
]]></content:encoded></item><item><title>Stop Guessing: How I Fix Slow Databases</title><link>https://lawzava.com/blog/2017-08-07-database-performance-tuning-systematic-approach/</link><pubDate>Mon, 07 Aug 2017 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2017-08-07-database-performance-tuning-systematic-approach/</guid><description>The repeatable process I use at the fintech startup to diagnose and fix database performance problems instead of throwing random indexes at the wall.</description><content:encoded><![CDATA[<p>Last month one of our price history endpoints at the fintech startup ground to a halt. Eight seconds per request. We&rsquo;re talking about a query that pulls stock price records for a given ticker over a date range. Should be trivial. Our first instinct was the usual panic — someone slapped a new index on the table, someone else bumped <code>shared_buffers</code>, and a third person started rewriting the query. None of it helped. Because nobody had actually looked at what was slow or why.</p>
<p>I&rsquo;ve seen this pattern enough times now that I refuse to participate in it. Here&rsquo;s how I actually approach database performance problems.</p>
<h3 id="the-database-is-slow-isnt-a-problem">&ldquo;The database is slow&rdquo; isn&rsquo;t a problem</h3>
<p>That sentence tells me nothing. Which query? Which endpoint? What&rsquo;s the acceptable latency? When did it start? What changed?</p>
<p>At the fintech startup, when someone says the API is slow, I make them fill in the blanks: &ldquo;The <code>/api/prices/{ticker}</code> endpoint exceeds 2 seconds during market hours after we added the sentiment join last Tuesday.&rdquo; Now I have a target. A timeline. Something I can actually investigate.</p>
<p>Without that specificity you&rsquo;re debugging vibes.</p>
<h3 id="measure-first-always">Measure first. Always.</h3>
<p>Before touching anything, turn on slow query logging and get a baseline. I run PostgreSQL for everything I can, so:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">SYSTEM</span> <span style="color:#66d9ef">SET</span> log_min_duration_statement <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;50ms&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> pg_reload_conf();
</span></span></code></pre></div><p>I set the threshold low — 50ms — because I want to see the full picture, not just the obvious disasters. You&rsquo;d be surprised how many &ldquo;fast&rdquo; queries add up when they run thousands of times per minute.</p>
<p>The slow query log tells you what&rsquo;s <em>actually</em> slow. Not what your gut says is slow. Not what the intern thinks is slow. What the database measured as slow.</p>
<h3 id="explain-analyze-is-your-best-friend">EXPLAIN ANALYZE is your best friend</h3>
<p>I mean this literally. If you&rsquo;re doing database work and you don&rsquo;t use <code>EXPLAIN (ANALYZE, BUFFERS)</code> regularly, you&rsquo;re flying blind.</p>
<p>Here&rsquo;s what I ran for our price history problem:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">EXPLAIN</span> (<span style="color:#66d9ef">ANALYZE</span>, BUFFERS)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> p.ticker, p.price_date, p.<span style="color:#66d9ef">open</span>, p.<span style="color:#66d9ef">close</span>, p.volume
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> price_history p
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> p.ticker <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;AAPL&#39;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> p.price_date <span style="color:#66d9ef">BETWEEN</span> <span style="color:#e6db74">&#39;2017-01-01&#39;</span> <span style="color:#66d9ef">AND</span> <span style="color:#e6db74">&#39;2017-08-01&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ORDER</span> <span style="color:#66d9ef">BY</span> p.price_date;
</span></span></code></pre></div><p>The output told me everything. The planner estimated 200 rows. Reality: 147,000. When estimates are that far off, PostgreSQL picks terrible plans. It chose a nested loop where a hash join would&rsquo;ve been orders of magnitude faster. The statistics on <code>price_history</code> were stale — we&rsquo;d bulk-loaded six months of historical data and never ran <code>ANALYZE</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ANALYZE</span> price_history;
</span></span></code></pre></div><p>One command. Query went from 8 seconds to 90 milliseconds. No new index. No config change. Just accurate statistics.</p>
<p>That&rsquo;s the thing about database tuning. The fix is often embarrassingly simple once you know what&rsquo;s wrong.</p>
<h3 id="finding-the-actual-bottleneck">Finding the actual bottleneck</h3>
<p>After you read the plan, you&rsquo;re looking for one of a few usual suspects:</p>
<p><strong>Sequential scans on large tables.</strong> If PostgreSQL is scanning 50 million rows to find 12, you need an index. But only for the columns your queries actually filter on.</p>
<p><strong>Joins that explode row counts.</strong> We had a query joining <code>price_history</code> to <code>news_sentiment</code> that produced a cartesian product on certain tickers. The join condition was missing a date constraint. The query went from returning 500 rows to materializing 3 million intermediate rows.</p>
<p><strong>Lock contention.</strong> Bulk inserts fighting with read queries. We see this during our nightly data loads. <code>pg_stat_activity</code> tells you who&rsquo;s waiting on whom.</p>
<p><strong>Memory pressure.</strong> If your working set doesn&rsquo;t fit in <code>shared_buffers</code>, every query hits disk. Check your buffer cache hit ratio:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">sum</span>(heap_blks_hit) <span style="color:#f92672">/</span> <span style="color:#66d9ef">nullif</span>(<span style="color:#66d9ef">sum</span>(heap_blks_hit) <span style="color:#f92672">+</span> <span style="color:#66d9ef">sum</span>(heap_blks_read), <span style="color:#ae81ff">0</span>) <span style="color:#66d9ef">AS</span> cache_hit_ratio
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_statio_user_tables;
</span></span></code></pre></div><p>Anything below 0.99 on a read-heavy workload means you&rsquo;re probably leaving performance on the table. Literally.</p>
<p>Pick the biggest contributor. Fix that one thing. Measure again.</p>
<h3 id="indexing-powerful-but-not-free">Indexing: powerful but not free</h3>
<p>I love indexes. I also hate bad indexes. Every index you add slows down writes and consumes disk. &ldquo;Index everything&rdquo; is a strategy born from laziness.</p>
<p>At the fintech startup our <code>price_history</code> table gets thousands of inserts per minute during market hours. We can&rsquo;t afford garbage indexes. So I index what the queries need, and nothing else.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_price_history_ticker_date
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> price_history (ticker, price_date <span style="color:#66d9ef">DESC</span>);
</span></span></code></pre></div><p>Column order matters. This index is perfect for &ldquo;give me AAPL prices sorted by date.&rdquo; Reverse the columns and the planner might not use it at all.</p>
<p>PostgreSQL partial indexes are incredibly useful for skewed data:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_active_alerts
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ON</span> price_alerts (ticker, created_at)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> active <span style="color:#f92672">=</span> <span style="color:#66d9ef">true</span>;
</span></span></code></pre></div><p>Only 5% of alerts are active at any given time. This index is tiny compared to indexing the full table, and it&rsquo;s the only query pattern we care about for active alerts.</p>
<h3 id="query-shape-matters-more-than-you-think">Query shape matters more than you think</h3>
<p>Stop writing <code>SELECT *</code>. I see this constantly. You need three columns but you&rsquo;re transferring 40 because the ORM defaulted to <code>SELECT *</code>. On a table with a JSONB column holding 2KB of metadata per row, that&rsquo;s the difference between a 50ms query and a 500ms query.</p>
<p>Rewrite subqueries as joins when it makes sense. PostgreSQL&rsquo;s planner is good but it&rsquo;s not magic. I had a correlated subquery that checked whether each ticker had recent sentiment data:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Slow: correlated subquery, runs once per row
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span> ticker, <span style="color:#66d9ef">close</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> price_history
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> price_date <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;2017-08-01&#39;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> <span style="color:#66d9ef">EXISTS</span> (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">SELECT</span> <span style="color:#ae81ff">1</span> <span style="color:#66d9ef">FROM</span> news_sentiment
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> news_sentiment.ticker <span style="color:#f92672">=</span> price_history.ticker
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">AND</span> sentiment_date <span style="color:#f92672">&gt;</span> <span style="color:#e6db74">&#39;2017-07-01&#39;</span>
</span></span><span style="display:flex;"><span>  );
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Fast: rewritten as join
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">DISTINCT</span> p.ticker, p.<span style="color:#66d9ef">close</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> price_history p
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">JOIN</span> news_sentiment s <span style="color:#66d9ef">ON</span> s.ticker <span style="color:#f92672">=</span> p.ticker
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> p.price_date <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;2017-08-01&#39;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">AND</span> s.sentiment_date <span style="color:#f92672">&gt;</span> <span style="color:#e6db74">&#39;2017-07-01&#39;</span>;
</span></span></code></pre></div><p>The join version let PostgreSQL hash the sentiment table once instead of probing it for every row. 40x improvement.</p>
<h3 id="connection-management">Connection management</h3>
<p>This one bites people who don&rsquo;t expect it. PostgreSQL forks a process per connection. 200 connections means 200 processes fighting over CPU and memory. I&rsquo;ve seen a server with 16 cores running 500 connections, where cutting to 50 connections through PgBouncer <em>doubled</em> throughput.</p>
<p>Your connection pool size should match your database&rsquo;s capacity, not your application&rsquo;s thread count. For most workloads, <code>(2 * CPU cores) + number of disks</code> is a reasonable starting point. We run 24 connections at the fintech startup for a 12-core database server and it handles our load fine.</p>
<h3 id="configuration-change-one-thing-at-a-time">Configuration: change one thing at a time</h3>
<p>PostgreSQL ships with conservative defaults because it has to run on everything from a Raspberry Pi to a 256GB server. A few settings worth adjusting:</p>
<ul>
<li><code>shared_buffers</code>: 25% of RAM is the standard advice. It&rsquo;s decent advice.</li>
<li><code>effective_cache_size</code>: 75% of RAM. Tells the planner how much cache to expect from the OS.</li>
<li><code>work_mem</code>: Be careful here. This is per-operation, not per-query. A complex query with five sorts uses <code>5 * work_mem</code>. I&rsquo;ve seen people set this to 1GB and then wonder why they&rsquo;re OOM.</li>
<li><code>maintenance_work_mem</code>: Crank this up for <code>VACUUM</code> and <code>CREATE INDEX</code> operations. 512MB or 1GB is reasonable.</li>
</ul>
<p>Change one setting. Measure. If it helps, keep it. If it doesn&rsquo;t, revert. This isn&rsquo;t the place for creativity.</p>
<h3 id="dont-ignore-the-orm">Don&rsquo;t ignore the ORM</h3>
<p>If you&rsquo;re using Django, Rails, or any ORM — you need to know what SQL it generates. ORMs are convenient. They&rsquo;re also capable of producing spectacularly bad queries when you&rsquo;re not paying attention. N+1 queries are the classic, but I&rsquo;ve seen ORMs generate queries with unnecessary subselects, missing join conditions, and implicit type casts that defeat indexes.</p>
<p>Log the SQL. Read it. If it&rsquo;s bad, drop to raw SQL for that query. The ORM won&rsquo;t be offended.</p>
<h3 id="the-loop">The loop</h3>
<p>That&rsquo;s really all there&rsquo;s to it. Define the problem precisely. Measure with real data. Read the execution plan. Fix the biggest bottleneck. Measure again. Repeat.</p>
<p>It&rsquo;s not glamorous. There&rsquo;s no single magic setting that makes everything fast. But this process works every time, and it works because you&rsquo;re making decisions based on data instead of folklore.</p>
<p>Every performance win I&rsquo;ve had at the fintech startup came from this loop. The boring, repeatable loop.</p>
]]></content:encoded></item><item><title>The Economics of State: Why Scaling Up Beats Sharding (Until It Doesn't)</title><link>https://lawzava.com/blog/2016-11-14-scaling-postgresql-replication-sharding-beyond/</link><pubDate>Mon, 14 Nov 2016 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2016-11-14-scaling-postgresql-replication-sharding-beyond/</guid><description>A production-grounded case for exhausting single-server headroom with pooling, replicas, and partitioning before taking on sharding complexity.</description><content:encoded><![CDATA[<h2 id="quick-take">Quick take</h2>
<p>The short version: state scales cheapest when you get more out of one machine before you distribute. Connection pooling is free headroom, replication is the first major lever, and partitioning buys time before sharding complexity. I&rsquo;ve been through this progression at the fintech startup and the order matters more than any single technique.</p>
<hr>
<h3 id="the-context">The context</h3>
<p>At the fintech startup we ingest financial news from thousands of sources. Earnings calls, filings, analyst reports, social signals &ndash; all of it lands in PostgreSQL. When I joined as CTO, the database was a single server doing everything: writes from the ingestion pipeline, reads from the API, analytics queries from internal tools. It worked. Then the data volume doubled in three months and it stopped working.</p>
<p>This post is what I learned fixing it. Not theory. The actual progression we followed, the configs we used, and the tradeoffs we hit.</p>
<h3 id="find-the-bottleneck-first">Find the bottleneck first</h3>
<p>Everything looks the same when the database is slow. Queries pile up, latency spikes, the app feels sluggish. But the fix depends entirely on where the pressure is coming from.</p>
<p>Read pressure means CPU is saturated serving SELECT queries. Write pressure means WAL generation and fsync are the bottleneck. Connection pressure means the process-per-connection model is eating memory. Storage pressure means the tables are too big for efficient vacuuming and indexing.</p>
<p>At the fintech startup, we had all four. But they didn&rsquo;t all matter equally. Connection pressure was killing us first because the ingestion workers each held their own connection. We had maybe 300 workers and the server was spending more time context-switching between backends than doing actual work.</p>
<h3 id="connection-pooling-with-pgbouncer">Connection pooling with PgBouncer</h3>
<p>This was the first fix and it was nearly free. PgBouncer sits between the application and PostgreSQL, multiplexes hundreds of client connections over a small pool of real database connections, and eliminates the fork-per-connection overhead.</p>
<p>Here is close to what we ran:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#66d9ef">[databases]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">fintech_db</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">host=127.0.0.1 port=5433 dbname=fintech_db</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[pgbouncer]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">listen_addr</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">0.0.0.0</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">listen_port</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">5432</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">auth_type</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">md5</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">auth_file</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">/etc/pgbouncer/userlist.txt</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">pool_mode</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">transaction</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">max_client_conn</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">2000</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">default_pool_size</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">30</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">reserve_pool_size</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">5</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">reserve_pool_timeout</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">3</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">server_idle_timeout</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">300</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">server_lifetime</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">3600</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">log_connections</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">0</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">log_disconnections</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">0</span>
</span></span></code></pre></div><p>A few things worth noting. <code>pool_mode = transaction</code> is the right default for almost every workload. It returns the server connection to the pool after each transaction completes, so 2000 application connections share 30 real database connections. Session mode keeps the connection pinned for the entire client session, which defeats the purpose. Statement mode is the most aggressive but breaks anything that uses multi-statement transactions or prepared statements.</p>
<p><code>reserve_pool_size</code> gives you a small buffer for traffic spikes. If all 30 connections are busy, PgBouncer will open up to 5 more for a short window before rejecting clients. This saved us during batch ingestion runs where write volume would spike for a few minutes.</p>
<p>The result: our 300 ingestion workers plus API servers plus internal tools all shared 30 actual PostgreSQL backends. Memory usage on the database server dropped by 40%. Query latency improved because the server wasn&rsquo;t spending cycles managing hundreds of idle connections.</p>
<h3 id="streaming-replication">Streaming replication</h3>
<p>Pooling bought us time. The next bottleneck was read traffic. The API served search results, financial summaries, and news feeds &ndash; all read-heavy queries competing with the write path on a single server.</p>
<p>PostgreSQL streaming replication is straightforward. The primary streams WAL (Write-Ahead Log) segments to one or more standbys, which replay them and serve read traffic. Here is the relevant part of the primary&rsquo;s configuration:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e"># postgresql.conf on the primary</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">wal_level</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">replica</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">max_wal_senders</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">5</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">wal_keep_segments</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">64</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># For monitoring replication lag</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">track_commit_timestamp</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">on</span>
</span></span></code></pre></div><p>And the standby&rsquo;s <code>recovery.conf</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#a6e22e">standby_mode</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">on</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">primary_conninfo</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;host=primary.internal port=5432 user=replicator password=xxx&#39;</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">trigger_file</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;/tmp/postgresql.trigger&#39;</span>
</span></span></code></pre></div><p>We set up two read replicas behind a simple connection routing layer in Go. Write queries went to the primary. Read queries went to the replicas using round-robin. Nothing fancy.</p>
<p>The thing people underestimate about replication is lag. A user writes a comment, the API returns success, the user refreshes and the comment isn&rsquo;t there because the replica hasn&rsquo;t caught up. At the fintech startup this mattered less because our data was financial news &ndash; a few hundred milliseconds of lag on search results is invisible. But for any read-after-write path, you need to either route reads back to the primary for a short window or track replication position and only read from replicas that have caught up.</p>
<p>We monitored lag with a simple query on the replicas:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span>
</span></span><span style="display:flex;"><span>  now() <span style="color:#f92672">-</span> pg_last_xact_replay_timestamp() <span style="color:#66d9ef">AS</span> replication_lag;
</span></span></code></pre></div><p>In steady state we saw 50-200ms of lag. During batch ingestion spikes it would climb to 1-2 seconds. We set alerts at 5 seconds. If lag hits 10 seconds, something is wrong with the replica&rsquo;s I/O or the WAL shipping is backing up.</p>
<h3 id="partitioning-for-high-volume-tables">Partitioning for high-volume tables</h3>
<p>After pooling and replicas, the next problem was table size. Our main events table held every financial event we had ever ingested. Hundreds of millions of rows. VACUUM took hours. Index rebuilds blocked writes. Queries that should have been fast were scanning enormous B-trees.</p>
<p>In 2016, PostgreSQL doesn&rsquo;t have native declarative partitioning. That&rsquo;s coming in version 10. What we&rsquo;ve is table inheritance with CHECK constraints and manual routing. It&rsquo;s ugly. It works.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Parent table
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TABLE</span> financial_events (
</span></span><span style="display:flex;"><span>    id          BIGSERIAL,
</span></span><span style="display:flex;"><span>    event_time  <span style="color:#66d9ef">TIMESTAMP</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>    source_id   INTEGER <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>    event_type  VARCHAR(<span style="color:#ae81ff">50</span>) <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>,
</span></span><span style="display:flex;"><span>    payload     JSONB <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>
</span></span><span style="display:flex;"><span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Monthly partitions
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TABLE</span> financial_events_2016_10 (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">CHECK</span> (event_time <span style="color:#f92672">&gt;=</span> <span style="color:#e6db74">&#39;2016-10-01&#39;</span> <span style="color:#66d9ef">AND</span> event_time <span style="color:#f92672">&lt;</span> <span style="color:#e6db74">&#39;2016-11-01&#39;</span>)
</span></span><span style="display:flex;"><span>) <span style="color:#66d9ef">INHERITS</span> (financial_events);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TABLE</span> financial_events_2016_11 (
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">CHECK</span> (event_time <span style="color:#f92672">&gt;=</span> <span style="color:#e6db74">&#39;2016-11-01&#39;</span> <span style="color:#66d9ef">AND</span> event_time <span style="color:#f92672">&lt;</span> <span style="color:#e6db74">&#39;2016-12-01&#39;</span>)
</span></span><span style="display:flex;"><span>) <span style="color:#66d9ef">INHERITS</span> (financial_events);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Indexes on each partition, not the parent
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_fe_2016_10_time <span style="color:#66d9ef">ON</span> financial_events_2016_10 (event_time);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_fe_2016_10_source <span style="color:#66d9ef">ON</span> financial_events_2016_10 (source_id);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_fe_2016_11_time <span style="color:#66d9ef">ON</span> financial_events_2016_11 (event_time);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> idx_fe_2016_11_source <span style="color:#66d9ef">ON</span> financial_events_2016_11 (source_id);
</span></span></code></pre></div><p>The insert routing was a trigger function:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">OR</span> <span style="color:#66d9ef">REPLACE</span> <span style="color:#66d9ef">FUNCTION</span> financial_events_insert_trigger()
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">RETURNS</span> <span style="color:#66d9ef">TRIGGER</span> <span style="color:#66d9ef">AS</span> <span style="color:#960050;background-color:#1e0010">$$</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">IF</span> <span style="color:#66d9ef">NEW</span>.event_time <span style="color:#f92672">&gt;=</span> <span style="color:#e6db74">&#39;2016-11-01&#39;</span> <span style="color:#66d9ef">AND</span> <span style="color:#66d9ef">NEW</span>.event_time <span style="color:#f92672">&lt;</span> <span style="color:#e6db74">&#39;2016-12-01&#39;</span> <span style="color:#66d9ef">THEN</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> financial_events_2016_11 <span style="color:#66d9ef">VALUES</span> (<span style="color:#66d9ef">NEW</span>.<span style="color:#f92672">*</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">ELSIF</span> <span style="color:#66d9ef">NEW</span>.event_time <span style="color:#f92672">&gt;=</span> <span style="color:#e6db74">&#39;2016-10-01&#39;</span> <span style="color:#66d9ef">AND</span> <span style="color:#66d9ef">NEW</span>.event_time <span style="color:#f92672">&lt;</span> <span style="color:#e6db74">&#39;2016-11-01&#39;</span> <span style="color:#66d9ef">THEN</span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">INTO</span> financial_events_2016_10 <span style="color:#66d9ef">VALUES</span> (<span style="color:#66d9ef">NEW</span>.<span style="color:#f92672">*</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">-- ... older months
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">ELSE</span>
</span></span><span style="display:flex;"><span>        RAISE <span style="color:#66d9ef">EXCEPTION</span> <span style="color:#e6db74">&#39;No partition for event_time %&#39;</span>, <span style="color:#66d9ef">NEW</span>.event_time;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">END</span> <span style="color:#66d9ef">IF</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">RETURN</span> <span style="color:#66d9ef">NULL</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">END</span>;
</span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">$$</span> <span style="color:#66d9ef">LANGUAGE</span> plpgsql;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">TRIGGER</span> insert_financial_events
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">BEFORE</span> <span style="color:#66d9ef">INSERT</span> <span style="color:#66d9ef">ON</span> financial_events
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">FOR</span> <span style="color:#66d9ef">EACH</span> <span style="color:#66d9ef">ROW</span> <span style="color:#66d9ef">EXECUTE</span> <span style="color:#66d9ef">FUNCTION</span> financial_events_insert_trigger();
</span></span></code></pre></div><p>Yes, you have to maintain this trigger as you create new partitions. We wrote a cron job in Go that created next month&rsquo;s partition and updated the trigger on the first of every month. Not glamorous. Reliable.</p>
<p>The wins were immediate. VACUUM on a monthly partition with 20 million rows takes minutes, not hours. Queries with <code>WHERE event_time BETWEEN ...</code> hit only the relevant partitions because constraint exclusion prunes the rest. And when data aged past our retention window, we dropped entire partitions instead of running massive DELETE queries &ndash; <code>DROP TABLE financial_events_2015_06</code> is instant and generates zero dead tuples.</p>
<h3 id="sharding-the-last-resort">Sharding: the last resort</h3>
<p>We didn&rsquo;t shard at the fintech startup. I want to be honest about that. We got to the edge where it was on the table, but the combination of pooling, two replicas, and monthly partitioning handled our volume. We were ingesting roughly 50,000 events per minute at peak and PostgreSQL on decent hardware with these optimizations kept up.</p>
<p>But I&rsquo;ve seen sharding done at a previous company, and my strong opinion is: don&rsquo;t do it unless you have exhausted everything above and a single primary genuinely can&rsquo;t handle the write throughput.</p>
<p>Sharding means splitting data across independent PostgreSQL instances. You pick a shard key &ndash; usually a tenant ID or a hash of some natural key &ndash; and route writes and reads to the correct shard. The application or a middleware layer owns the routing.</p>
<p>The hidden costs are brutal.  <a href="/blog/2016-08-15-database-migrations-without-downtime/"
   
   >Schema migrations</a>
 have to be applied to every shard. Cross-shard queries become application-level aggregation. Rebalancing shards when data distribution skews means moving live data between databases. Transactions that span shards are either impossible or require two-phase commit, which is slow and fragile. Every operational runbook gets multiplied by the number of shards.</p>
<p>If you&rsquo;re at the point where sharding is necessary, you have a big enough team and budget to handle the operational complexity. If you don&rsquo;t have that team, sharding will hurt more than the performance problem it solves.</p>
<h3 id="the-progression-matters">The progression matters</h3>
<p>The order isn&rsquo;t arbitrary.</p>
<ol>
<li><strong>Pooling</strong> is nearly free and should be in place from day one. There&rsquo;s no reason to let PostgreSQL manage hundreds of connections directly.</li>
<li><strong>Read replicas</strong> are the first real scaling lever. They offload the most common pressure (reads) with minimal application changes.</li>
<li><strong>Partitioning</strong> makes large tables manageable and solves vacuum, indexing, and retention problems that replicas don&rsquo;t help with.</li>
<li><strong>Sharding</strong> is the nuclear option. Powerful. Expensive. Irreversible in practice.</li>
</ol>
<p>Each step is worth a significant amount of effort to delay the next one. We spent a week tuning PgBouncer and it delayed the need for replicas by two months. We spent two weeks setting up replication and it delayed the partitioning work by four months. Partitioning has delayed any sharding conversation indefinitely.</p>
<h3 id="what-i-would-do-differently">What I would do differently</h3>
<p>I would set up PgBouncer before the first production deploy, not after the first connection storm. I would build the partitioning infrastructure from the start for any table expected to grow past 50 million rows. And I would invest more in monitoring replication lag early &ndash; we flew blind for the first few weeks after setting up replicas and got lucky that lag never caused a visible bug.</p>
<p>Scaling PostgreSQL is a sequence, not a leap. Each step buys you months. Skip the sequence and you pay for every shortcut at once.</p>
]]></content:encoded></item><item><title>Log Aggregation at Scale: ELK vs Alternatives</title><link>https://lawzava.com/blog/2016-09-05-log-aggregation-at-scale-elk-vs-alternatives/</link><pubDate>Mon, 05 Sep 2016 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2016-09-05-log-aggregation-at-scale-elk-vs-alternatives/</guid><description>ELK is powerful. It&amp;amp;rsquo;s also a second full-time job. Here&amp;amp;rsquo;s what I learned running it at a mobility startup, and what I&amp;amp;rsquo;d consider instead.</description><content:encoded><![CDATA[<p>Once you have more than a handful of services, SSH-and-grep stops working. A single user request at a mobility startup touches the mobile API, the fleet service, the payment layer, and at least two background workers. When something breaks, I need to search one place for all the related events. That isn&rsquo;t optional. That&rsquo;s the baseline.</p>
<p>So we set up ELK. Elasticsearch, Logstash, Kibana. The pitch was compelling: open source, flexible, great full-text search, a plugin for everything. We stood up a three-node cluster, pointed Logstash at it, gave the team Kibana dashboards. For the first few weeks, it felt like a superpower.</p>
<p>Then the cluster started misbehaving. And I spent the next several months learning a painful lesson about the gap between &ldquo;powerful&rdquo; and &ldquo;worth the operational cost.&rdquo;</p>
<p><strong>If you don&rsquo;t have someone who wants to babysit Elasticsearch full time, don&rsquo;t run ELK yourself.</strong> Hosted Elasticsearch, Graylog, or even Splunk will save you more engineering hours than they cost.</p>
<h3 id="the-operational-tax-nobody-warns-you-about">The Operational Tax Nobody Warns You About</h3>
<p>Elasticsearch isn&rsquo;t a database you deploy and forget. It&rsquo;s a distributed system that demands constant attention. Shard allocation, index lifecycle, JVM heap tuning, split-brain prevention, disk watermarks. Every one of these will bite you, and they will bite you at 3 AM.</p>
<p>At the mobility startup, our log volume was moderate. Maybe a few gigabytes a day. Nothing that should stress a three-node cluster. But Elasticsearch doesn&rsquo;t care about your expectations. It cares about index design, merge policies, and whether you remembered to set <code>bootstrap.memory_lock</code>. We spent more time keeping the logging infrastructure healthy than we spent on some of our actual product services.</p>
<p>The worst part is that when your logging system goes down, you lose visibility into everything else at the same time. Your safety net disappears exactly when you need it most.</p>
<h3 id="what-elk-actually-gets-right">What ELK Actually Gets Right</h3>
<p>I&rsquo;m not going to pretend it&rsquo;s all bad. Elasticsearch&rsquo;s search is genuinely excellent. When the cluster is healthy, the ability to run arbitrary queries across millions of log lines with sub-second response times is hard to match. Logstash can parse almost any log format into structured fields. Kibana dashboards gave our product team visibility they never had before.</p>
<p>The ecosystem is real. Beats shippers are lightweight, community plugins cover most integrations, and the documentation is solid. If you have the operational muscle, ELK is the most flexible open source logging stack available.</p>
<h3 id="what-id-do-differently">What I&rsquo;d Do Differently</h3>
<p>If I were starting over, I wouldn&rsquo;t self-host Elasticsearch for logging. Full stop.</p>
<p><strong>Hosted Elasticsearch</strong> removes the worst of the operational burden. You keep the same query model, the same Kibana dashboards, the same integrations. You lose some control over cluster configuration and you pay more per gigabyte, but you gain back the engineering hours you were burning on cluster babysitting. For most teams, that tradeoff is obvious.</p>
<p><strong>Graylog</strong> is worth a look if you want Elasticsearch search without the full DIY build. It wraps Elasticsearch in a more opinionated log management experience with built-in alerting and stream routing. Less flexible than raw ELK, but faster to get running and easier to keep running.</p>
<p><strong>Splunk</strong> is the enterprise answer. Powerful, mature, battle-tested. Also expensive enough to make your finance team flinch. If budget isn&rsquo;t the constraint, Splunk is a safe bet. For a startup, it rarely makes sense.</p>
<p><strong>Cloud provider logging</strong> is the lowest-effort option. AWS CloudWatch Logs, for example, integrates deeply with everything else in AWS and requires zero operational overhead. The query capabilities are basic compared to Elasticsearch, but basic is often enough. You can always export to something more powerful later.</p>
<h3 id="structured-logging-is-the-real-win">Structured Logging Is the Real Win</h3>
<p>Regardless of which aggregation tool you pick, the single best investment is structured logging. A JSON log line with consistent fields turns debugging from archaeology into search.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;timestamp&#34;</span>: <span style="color:#e6db74">&#34;2016-09-05T10:23:45Z&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;level&#34;</span>: <span style="color:#e6db74">&#34;error&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;service&#34;</span>: <span style="color:#e6db74">&#34;payment-api&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;trace_id&#34;</span>: <span style="color:#e6db74">&#34;abc123&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;message&#34;</span>: <span style="color:#e6db74">&#34;Payment processing failed&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;error&#34;</span>: <span style="color:#e6db74">&#34;Connection timeout&#34;</span>,
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;customer_id&#34;</span>: <span style="color:#e6db74">&#34;cust_456&#34;</span>
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Carry a trace ID through every service. Use consistent field names. Emit JSON instead of free-form text. These decisions pay off no matter what sits behind the ingestion pipeline. If you do nothing else, do this.</p>
<h3 id="put-a-buffer-in-front-of-ingestion">Put a Buffer in Front of Ingestion</h3>
<p>One more thing I learned the hard way: put a queue between your log shippers and your aggregation layer. Kafka, Redis, even a simple file buffer. Traffic spikes will happen. Deploys will happen. If your pipeline has no buffer, you drop logs during the exact moments you need them most.</p>
<h3 id="pick-your-logging-battles">Pick your logging battles</h3>
<p>ELK is powerful software with brutal operational costs. Most teams underestimate how much work Elasticsearch is to run, and they find out at the worst possible time. If you can afford someone who genuinely enjoys tuning JVM garbage collection and shard allocation, go for it. Otherwise, pay for a hosted solution or pick a simpler tool. Your on-call engineers will thank you.</p>
]]></content:encoded></item><item><title>Database Migrations Without Downtime</title><link>https://lawzava.com/blog/2016-08-15-database-migrations-without-downtime/</link><pubDate>Mon, 15 Aug 2016 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2016-08-15-database-migrations-without-downtime/</guid><description>A practical guide to evolving schemas without maintenance windows by keeping old and new code compatible at every step.</description><content:encoded><![CDATA[<h2 id="quick-take">Quick take</h2>
<p>If your migration strategy is &ldquo;take the site down, run ALTER TABLE, pray, bring it back up,&rdquo; you&rsquo;re doing it wrong. Every schema change can be decomposed into steps that keep old and new code running simultaneously. It takes more discipline but zero heroics.</p>
<h3 id="the-problem-with-just-run-the-migration">The problem with &ldquo;just run the migration&rdquo;</h3>
<p>At the fintech startup we serve financial data. Market data doesn&rsquo;t pause because your engineering team needs a maintenance window. A feed that goes dark during trading hours is a feed that loses subscribers. When I took over database operations, the existing pattern was to schedule migrations at 2 AM on Sundays, take the API offline, run the DDL, and hope the application came back cleanly. It worked until it didn&rsquo;t.</p>
<p>The breaking point was a column type change on a table with 40 million rows of historical price data. The migration ran for 47 minutes. During that time the table was locked, the API returned errors, and a downstream consumer silently switched to a stale cache it never recovered from. We spent the next two days cleaning up data consistency issues.</p>
<p>After that I decided every migration would be zero-downtime or it wouldn&rsquo;t ship.</p>
<h3 id="the-expand-and-contract-pattern">The expand-and-contract pattern</h3>
<p>The core idea is simple. Instead of making a breaking change in one step, you split it into phases where the schema is always compatible with whatever application code is currently running.</p>
<p><strong>Expand</strong>: add the new structure alongside the old one. Both coexist.</p>
<p><strong>Migrate</strong>: deploy code that writes to both old and new, reads from new. Backfill historical data.</p>
<p><strong>Contract</strong>: once every running instance uses the new structure, remove the old one.</p>
<p>This pattern handles nearly every schema change. The details vary, but the rhythm stays the same.</p>
<h3 id="adding-a-column-safely">Adding a column safely</h3>
<p>The simplest case. A nullable column with no default doesn&rsquo;t rewrite the table in PostgreSQL. It&rsquo;s metadata-only and takes a lock for milliseconds.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> trades <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">COLUMN</span> settlement_date DATE;
</span></span></code></pre></div><p>Fast. Safe. Old code ignores the column. New code starts writing to it.</p>
<p>If you need a NOT NULL constraint, don&rsquo;t add it in the same statement. PostgreSQL will scan the entire table to verify the constraint, holding an ACCESS EXCLUSIVE lock the whole time. Instead:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Step 1: add nullable column
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> trades <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">COLUMN</span> settlement_date DATE;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Step 2: backfill in batches (see below)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Step 3: add constraint without full table scan
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> trades <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">CONSTRAINT</span> trades_settlement_not_null
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">CHECK</span> (settlement_date <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">NULL</span>) <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">VALID</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Step 4: validate separately (only takes a SHARE UPDATE EXCLUSIVE lock)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> trades VALIDATE <span style="color:#66d9ef">CONSTRAINT</span> trades_settlement_not_null;
</span></span></code></pre></div><p>The <code>NOT VALID</code> trick is critical on large tables. It tells PostgreSQL to enforce the constraint on new writes immediately but skip the full table scan. The <code>VALIDATE</code> step runs later with a weaker lock that doesn&rsquo;t block writes. On our 40-million-row tables, this was the difference between a 200-millisecond migration and a 20-minute outage.</p>
<h3 id="renaming-a-column">Renaming a column</h3>
<p>You don&rsquo;t rename columns in production. Full stop.</p>
<p>What you actually do is expand-and-contract. Here is the concrete sequence we used when renaming <code>price</code> to <code>unit_price</code> on the trades table:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Phase 1: Expand
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> trades <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">COLUMN</span> unit_price NUMERIC(<span style="color:#ae81ff">18</span>,<span style="color:#ae81ff">8</span>);
</span></span></code></pre></div><p>Deploy code that writes to both <code>price</code> and <code>unit_price</code>. Reads come from <code>unit_price</code> with a fallback to <code>price</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Phase 2: Backfill
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">UPDATE</span> trades <span style="color:#66d9ef">SET</span> unit_price <span style="color:#f92672">=</span> price
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> unit_price <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NULL</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AND</span> id <span style="color:#66d9ef">BETWEEN</span> <span style="color:#ae81ff">1</span> <span style="color:#66d9ef">AND</span> <span style="color:#ae81ff">100000</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Repeat in batches...
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">UPDATE</span> trades <span style="color:#66d9ef">SET</span> unit_price <span style="color:#f92672">=</span> price
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> unit_price <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NULL</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AND</span> id <span style="color:#66d9ef">BETWEEN</span> <span style="color:#ae81ff">100001</span> <span style="color:#66d9ef">AND</span> <span style="color:#ae81ff">200000</span>;
</span></span></code></pre></div><p>Once backfill is complete and every application instance reads from <code>unit_price</code>:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Phase 3: Contract
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> trades <span style="color:#66d9ef">DROP</span> <span style="color:#66d9ef">COLUMN</span> price;
</span></span></code></pre></div><p>Three deployments minimum. That&rsquo;s the cost. The benefit is zero downtime and a clean rollback at every step.</p>
<h3 id="changing-a-column-type">Changing a column type</h3>
<p>Same pattern, different details. We had a case where an instrument identifier was stored as <code>INTEGER</code> but needed to become <code>TEXT</code> to support a new exchange format.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#75715e">-- Expand
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> instruments <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">COLUMN</span> external_id_new TEXT;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">-- Backfill
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">UPDATE</span> instruments <span style="color:#66d9ef">SET</span> external_id_new <span style="color:#f92672">=</span> external_id::TEXT
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> external_id_new <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NULL</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">AND</span> id <span style="color:#66d9ef">BETWEEN</span> <span style="color:#ae81ff">1</span> <span style="color:#66d9ef">AND</span> <span style="color:#ae81ff">50000</span>;
</span></span></code></pre></div><p>Deploy dual-write code. Cut over reads. Drop the old column.</p>
<p>The temptation is to use <code>ALTER COLUMN ... TYPE TEXT</code> which rewrites the entire table under an exclusive lock. On a table with millions of rows of financial data that&rsquo;s actively being queried, that isn&rsquo;t an option.</p>
<h3 id="batched-backfills-that-dont-kill-the-database">Batched backfills that don&rsquo;t kill the database</h3>
<p>Large backfills are where most zero-downtime migrations go wrong. A single <code>UPDATE ... WHERE condition</code> on 40 million rows will generate enormous WAL, bloat the table, and hold locks that block concurrent operations.</p>
<p>The pattern I use:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">DO</span> <span style="color:#960050;background-color:#1e0010">$$</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">DECLARE</span>
</span></span><span style="display:flex;"><span>  batch_size INT :<span style="color:#f92672">=</span> <span style="color:#ae81ff">5000</span>;
</span></span><span style="display:flex;"><span>  max_id BIGINT;
</span></span><span style="display:flex;"><span>  current_id BIGINT :<span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">BEGIN</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">SELECT</span> <span style="color:#66d9ef">MAX</span>(id) <span style="color:#66d9ef">INTO</span> max_id <span style="color:#66d9ef">FROM</span> trades;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  WHILE current_id <span style="color:#f92672">&lt;</span> max_id LOOP
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">UPDATE</span> trades
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">SET</span> unit_price <span style="color:#f92672">=</span> price
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">WHERE</span> id <span style="color:#f92672">&gt;</span> current_id
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">AND</span> id <span style="color:#f92672">&lt;=</span> current_id <span style="color:#f92672">+</span> batch_size
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">AND</span> unit_price <span style="color:#66d9ef">IS</span> <span style="color:#66d9ef">NULL</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    current_id :<span style="color:#f92672">=</span> current_id <span style="color:#f92672">+</span> batch_size;
</span></span><span style="display:flex;"><span>    PERFORM pg_sleep(<span style="color:#ae81ff">0</span>.<span style="color:#ae81ff">1</span>);  <span style="color:#75715e">-- breathe
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">COMMIT</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">END</span> LOOP;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">END</span> <span style="color:#960050;background-color:#1e0010">$$</span>;
</span></span></code></pre></div><p>The <code>pg_sleep</code> matters. Without it, you saturate I/O and  <a href="/blog/2016-07-18-building-resilient-systems-lessons-from-production-failures/"
   
   >replication lag</a>
 spikes. At the fintech startup our replicas served read traffic for dashboards and analytics. A backfill that caused 30 seconds of replication lag would show stale prices to every user watching a portfolio. We settled on batches of 5,000 rows with 100ms pauses. The backfill took longer but production stayed healthy.</p>
<h3 id="indexes-without-locking">Indexes without locking</h3>
<p><code>CREATE INDEX</code> on PostgreSQL takes a SHARE lock on the table, which blocks writes for the duration of the build. On a large table, that can be minutes.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">CREATE</span> <span style="color:#66d9ef">INDEX</span> CONCURRENTLY idx_trades_settlement <span style="color:#66d9ef">ON</span> trades(settlement_date);
</span></span></code></pre></div><p><code>CONCURRENTLY</code> builds the index without blocking writes. It takes longer and does two table scans instead of one, but it&rsquo;s the only option for production tables. There are two caveats worth knowing:</p>
<p>First, it can&rsquo;t run inside a transaction. If your migration tool wraps everything in a transaction, you need to handle this case separately.</p>
<p>Second, if it fails partway through, it leaves an invalid index behind. Check <code>pg_stat_user_indexes</code> and drop the invalid one before retrying.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">SELECT</span> indexrelid::regclass, indisvalid
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> pg_index
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">WHERE</span> <span style="color:#66d9ef">NOT</span> indisvalid;
</span></span></code></pre></div><h3 id="foreign-keys-without-blocking">Foreign keys without blocking</h3>
<p>Adding a foreign key constraint with <code>ADD CONSTRAINT ... FOREIGN KEY</code> does a full table scan under an ACCESS EXCLUSIVE lock. Same trick as NOT NULL:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> trades
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">ADD</span> <span style="color:#66d9ef">CONSTRAINT</span> fk_trades_instrument
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">FOREIGN</span> <span style="color:#66d9ef">KEY</span> (instrument_id) <span style="color:#66d9ef">REFERENCES</span> instruments(id)
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">NOT</span> <span style="color:#66d9ef">VALID</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">ALTER</span> <span style="color:#66d9ef">TABLE</span> trades VALIDATE <span style="color:#66d9ef">CONSTRAINT</span> fk_trades_instrument;
</span></span></code></pre></div><p>The first statement adds the constraint for new writes only, taking a brief lock. The second validates existing rows with a much weaker lock.</p>
<h3 id="the-deployment-sequence">The deployment sequence</h3>
<p>The order matters. Getting it wrong means you have a window where the application and schema disagree.</p>
<ol>
<li>
<p><strong>Run expand migration.</strong> New columns, new indexes (concurrently), new constraints (NOT VALID). Schema is now compatible with both old and new code.</p>
</li>
<li>
<p><strong>Deploy new application code.</strong> Writes to both old and new columns. Reads from new with fallback to old.</p>
</li>
<li>
<p><strong>Run backfill.</strong> In batches, during low traffic if possible. Monitor replication lag and query latency.</p>
</li>
<li>
<p><strong>Verify completeness.</strong> <code>SELECT COUNT(*) FROM trades WHERE unit_price IS NULL</code> should be zero.</p>
</li>
<li>
<p><strong>Deploy read-cutover code.</strong> Reads exclusively from new columns. Still writes to both.</p>
</li>
<li>
<p><strong>Run contract migration.</strong> Drop old columns, drop temporary constraints. Schema is clean.</p>
</li>
</ol>
<p>Each step is independently reversible. If the backfill causes problems, pause it. If the new code has bugs, roll back the deployment. The old schema is still there. This is the entire point.</p>
<h3 id="what-this-costs">What this costs</h3>
<p>More deployments. More code that handles two schemas simultaneously. Migrations that used to be one PR become three or four. Application code carries temporary dual-write logic that gets cleaned up in the contract phase.</p>
<p>It&rsquo;s more work. But at the fintech startup, the alternative was telling financial data consumers that we needed a maintenance window during market hours. That conversation never goes well.</p>
<p>The discipline pays off in a different way too. When every migration follows expand-and-contract,  <a href="/blog/2016-06-06-continuous-deployment-without-chaos/"
   
   >deployments become boring</a>
. Nobody pages you at 2 AM for a schema change. Nobody holds their breath during a release. The process is mechanical and predictable.</p>
<p>The best migration is the one nobody notices. Boring by design, invisible in production.</p>
]]></content:encoded></item><item><title>Postgres vs MySQL in 2016: A Practical Comparison</title><link>https://lawzava.com/blog/2016-04-12-postgres-vs-mysql-practical-comparison/</link><pubDate>Tue, 12 Apr 2016 00:00:00 +0000</pubDate><guid>https://lawzava.com/blog/2016-04-12-postgres-vs-mysql-practical-comparison/</guid><description>A grounded look at PostgreSQL and MySQL as of April 2016, focusing on integrity, query power, and operational tradeoffs rather than benchmark hype.</description><content:encoded><![CDATA[<h2 id="quick-take">Quick take</h2>
<p>Pick Postgres. If your workload is dead-simple CRUD and your team already bleeds MySQL, fine, stay there. For everything else in 2016, PostgreSQL gives you more database and fewer workarounds.</p>
<hr>
<h3 id="the-short-answer">The short answer</h3>
<p>I&rsquo;m building the fintech startup on PostgreSQL. Financial data, JSONB documents, full-text search across thousands of sources, strict schema enforcement for anything that touches money. I evaluated MySQL honestly. Postgres won on every axis that matters to me.</p>
<p>That doesn&rsquo;t make MySQL bad. It makes the decision obvious for my workload. Here is how I see the tradeoffs.</p>
<h3 id="the-comparison">The comparison</h3>
<table>
  <thead>
      <tr>
          <th>Capability</th>
          <th>PostgreSQL</th>
          <th>MySQL (InnoDB, 5.7)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Data integrity</strong></td>
          <td>Strict by default. Type violations, overflows, and constraint breaches are errors. CHECK constraints enforced.</td>
          <td>Lenient by default. Will silently truncate or coerce unless you enable strict mode. CHECK constraints parsed but <strong>not enforced</strong>.</td>
      </tr>
      <tr>
          <td><strong>Transactional DDL</strong></td>
          <td>Yes. Failed migrations roll back cleanly.</td>
          <td>No. DDL auto-commits. A failed migration leaves you half-changed.</td>
      </tr>
      <tr>
          <td><strong>JSONB</strong></td>
          <td>First-class. GIN-indexed, queryable with operators, fast.</td>
          <td>JSON type exists but no binary storage, limited indexing. Practical queries need generated columns.</td>
      </tr>
      <tr>
          <td><strong>Full-text search</strong></td>
          <td>Built in. Dictionaries, ranking, language support. Good enough to skip Elasticsearch for many cases.</td>
          <td>Basic keyword matching. Serviceable for simple search, but you will add Solr or Elastic quickly.</td>
      </tr>
      <tr>
          <td><strong>Window functions</strong></td>
          <td>Yes, mature.</td>
          <td>No. Not until 8.0 (years away). Analytics queries become subquery nightmares.</td>
      </tr>
      <tr>
          <td><strong>CTEs</strong></td>
          <td>Yes. Recursive CTEs too.</td>
          <td>No. Same story as window functions.</td>
      </tr>
      <tr>
          <td><strong>Custom types/operators</strong></td>
          <td>Yes. You can build domain-specific behavior inside the database.</td>
          <td>Limited UDFs. No custom operators or types.</td>
      </tr>
      <tr>
          <td><strong>Concurrency model</strong></td>
          <td>MVCC with new row versions. Requires vacuum.</td>
          <td>MVCC via undo logs. Purge is less visible operationally.</td>
      </tr>
      <tr>
          <td><strong>Connection handling</strong></td>
          <td>Process-per-connection. Needs PgBouncer at scale.</td>
          <td>Thread-per-connection. Handles high connection counts more easily out of the box.</td>
      </tr>
      <tr>
          <td><strong>Replication</strong></td>
          <td>Streaming (physical). Reliable, simple, but replicates the whole cluster. Logical replication is third-party in 2016.</td>
          <td>Row-based, statement-based, or mixed. More flexible for partial replication. More edge cases.</td>
      </tr>
      <tr>
          <td><strong>Ecosystem/hosting</strong></td>
          <td>Smaller managed ecosystem. RDS supports it well. Fewer one-click options.</td>
          <td>Everywhere. Every cheap host, every managed platform. Largest install base.</td>
      </tr>
      <tr>
          <td><strong>Upgrades</strong></td>
          <td>Major version upgrades need planning. pg_upgrade helps but it isn&rsquo;t seamless.</td>
          <td>Generally smoother in-place upgrades.</td>
      </tr>
  </tbody>
</table>
<h3 id="where-postgres-pulls-ahead">Where Postgres pulls ahead</h3>
<p><strong>Correctness is the default.</strong> I don&rsquo;t want my database silently truncating a currency field or accepting a string where an integer belongs. Postgres refuses bad data. MySQL lets it through unless you configure it not to. In financial systems, the database being strict isn&rsquo;t a feature request. It&rsquo;s the minimum.</p>
<p><strong>JSONB changes what you can do.</strong> At the fintech startup we store semi-structured financial events alongside relational data. Postgres lets me index into JSONB, query nested fields, and join it with relational tables in one query. With MySQL I would be serializing JSON, pulling it into the application, and filtering there. That isn&rsquo;t a comparison. That&rsquo;s a generation gap.</p>
<p><strong>Full-text search removes a dependency.</strong> We search across news sources, filings, and analyst content. Postgres full-text search with <code>ts_vector</code>, dictionaries, and ranking handles this without bolting on a separate search cluster. One fewer service to operate, monitor, and keep in sync.</p>
<p><strong>Window functions and CTEs aren&rsquo;t optional.</strong> If you do any reporting or analytics, you need them. MySQL not having them in 2016 means your choices are ugly subqueries, dumping data into a separate analytics tool, or doing the math in application code. Postgres just does it.</p>
<h3 id="where-mysql-wins">Where MySQL wins</h3>
<p>I&rsquo;ll give MySQL its due.</p>
<p><strong>Connection scaling.</strong> Postgres forks a process per connection. At a few hundred connections, memory adds up fast and you need a pooler. MySQL handles thousands of threads without breaking a sweat. If your architecture has many direct database connections and you don&rsquo;t want to manage PgBouncer, that matters.</p>
<p><strong>Operational simplicity for upgrades.</strong> MySQL major version upgrades tend to be less painful. Postgres upgrades have gotten better, but they still require more planning and occasionally downtime.</p>
<p><strong>Ubiquity.</strong> MySQL is everywhere. Every shared host, every tutorial, every legacy system. If you&rsquo;re inheriting a MySQL codebase and the schema is simple, migrating to Postgres just because you prefer it is a waste of time. Use what is there.</p>
<h3 id="my-decision-framework">My decision framework</h3>
<p>Three questions:</p>
<ol>
<li>
<p><strong>Does your data need to be correct, or just present?</strong> If correctness matters&ndash;financial data, health records, billing&ndash;Postgres. Its strictness isn&rsquo;t friction. It&rsquo;s protection.</p>
</li>
<li>
<p><strong>Do you need more than basic SELECT/INSERT/UPDATE?</strong> If you need JSONB, full-text search, window functions, CTEs, or custom types, Postgres gives you those today. MySQL will make you bolt on external tools or wait for features that aren&rsquo;t shipping in 2016.</p>
</li>
<li>
<p><strong>Is your team already deep in MySQL?</strong> Then stay. Database expertise matters more than database features. A well-tuned MySQL with a team that knows it will outperform a poorly operated Postgres every time.</p>
</li>
</ol>
<h3 id="the-honest-take">The honest take</h3>
<p>Most of the &ldquo;Postgres vs MySQL&rdquo; content online bends over backward to be balanced. I won&rsquo;t. For my workloads&ndash;financial data, mixed relational and document storage, search, reporting&ndash;Postgres isn&rsquo;t a marginal winner. It&rsquo;s the obvious choice.</p>
<p>MySQL is a fine database for simpler workloads and teams that know it well. But if you&rsquo;re starting fresh in 2016 and your needs are anything beyond basic CRUD, pick Postgres. You will thank yourself when the first complex query lands and you don&rsquo;t have to rewrite it as three subqueries and an application-side join.</p>
<p>The best database decision is the pragmatic one, not the tribal one. Pick the tool that does more of the work for you and stop arguing about logos.</p>
]]></content:encoded></item></channel></rss>