{"id":492996,"date":"2026-08-31T17:43:21","date_gmt":"2026-08-31T17:43:21","guid":{"rendered":"https:\/\/savepearlharbor.com\/?p=492996"},"modified":"-0001-11-30T00:00:00","modified_gmt":"2026-08-31T17:43:21","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=492996","title":{"rendered":"I Revisited My 7x Go API Benchmark: What ApacheBench Was Actually Measuring"},"content":{"rendered":"<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>My Go API benchmark once showed an almost 7x throughput improvement after I moved a read\u2011heavy endpoint from PostgreSQL to a local in\u2011memory cache. The result looked obvious: PostgreSQL was the bottleneck, the cache removed it, and the API became much faster. Later I realized that this interpretation was too simple. ApacheBench had measured an entire request path consisting of HTTP handling, connection management, pgxpool waiting, SQL execution, decoding, serialization, and response writing. I decided to decompose that path and understand what the 7x result actually meant.<\/p>\n<figure class=\"full-width \"><img decoding=\"async\" src=\"https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/3e9\/208\/18a\/3e920818a5612cb3664c908c75337525.png\" width=\"1672\" height=\"941\" sizes=\"auto, (max-width: 780px) 100vw, 50vw\" srcset=\"https:\/\/habrastorage.org\/r\/w780\/getpro\/habr\/upload_files\/3e9\/208\/18a\/3e920818a5612cb3664c908c75337525.png 780w,&#10;       https:\/\/habrastorage.org\/r\/w1560\/getpro\/habr\/upload_files\/3e9\/208\/18a\/3e920818a5612cb3664c908c75337525.png 781w\" loading=\"lazy\" decode=\"async\"\/><\/figure>\n<h3>The number was correct, but my explanation was too simple<\/h3>\n<p>The original setup was deliberately small. A Go service exposed an endpoint that returned a user by ID. Without caching, every request reached PostgreSQL. With caching enabled, frequently requested objects were returned directly from process memory.<\/p>\n<p>The performance difference was large. ApacheBench showed enough separation between the two implementations that the result could reasonably be summarized as an approximately sevenfold throughput improvement.<\/p>\n<p>At first I treated that as evidence that PostgreSQL itself was the bottleneck.<\/p>\n<p>That sentence now bothers me.<\/p>\n<p>A PostgreSQL\u2011backed HTTP request is not a SQL query. It is a chain of operations, and the query is only one of them.<\/p>\n<p>The database version of the endpoint effectively followed this path:<\/p>\n<pre><code>HTTP request    \u2193router    \u2193pgxpool acquisition    \u2193PostgreSQL protocol round trip    \u2193index lookup and SQL execution    \u2193row transfer    \u2193Scan into Go values    \u2193JSON serialization    \u2193HTTP response<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:87px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The cache hit was much shorter:<\/p>\n<pre><code>HTTP request    \u2193router    \u2193cache synchronization    \u2193map lookup    \u2193HTTP response<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>This was the first thing I had underestimated.<\/p>\n<p>I had not replaced PostgreSQL with a map.<\/p>\n<p>I had removed an entire section of the request pipeline.<\/p>\n<p>In my implementation, the cache could even contain the already serialized representation. In that case a cache hit avoided not only database access, but also scanning values into a struct and running JSON serialization again.<\/p>\n<p>Calling the complete difference PostgreSQL overhead therefore hides too much information.<\/p>\n<p>A more useful simplified model is:<\/p>\n<pre><code>Tdb_request =    Thttp  + Tpool  + Troundtrip  + Tsql  + Tdecode  + Tserialize  + Twrite<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>For a cache hit:<\/p>\n<pre><code>Tcache_request =    Thttp  + Tlock  + Tlookup  + Twrite<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The benchmark compared these two sums.<\/p>\n<p>It never isolated one SQL operation and one memory lookup.<\/p>\n<p>That sounds obvious once written down. It was much less obvious while looking at a single requests\u2011per\u2011second number.<\/p>\n<h3>ApacheBench is part of the experiment<\/h3>\n<p>The second problem was my mental model of the load generator.<\/p>\n<p>I had implicitly treated ApacheBench as a stopwatch sitting outside the system.<\/p>\n<p>It is not.<\/p>\n<p>A benchmark client creates a particular workload, and the workload affects the result.<\/p>\n<p>Take these two commands:<\/p>\n<pre><code class=\"bash\">ab -n 100000 -c 64 http:\/\/127.0.0.1:8080\/users\/42<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>and:<\/p>\n<pre><code class=\"bash\">ab -k -n 100000 -c 64 http:\/\/127.0.0.1:8080\/users\/42<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The second enables persistent HTTP connections.<\/p>\n<p>From the perspective of the application code, nothing changed. The handler is identical. PostgreSQL is identical. The cache is identical.<\/p>\n<p>The transport behavior is not.<\/p>\n<p>Without connection reuse, the benchmark creates much more connection\u2011management work. Even on <a href=\"http:\/\/localhost\" rel=\"noopener nofollow\">localhost<\/a>, creating and closing sockets is not free. The kernel maintains TCP state, the server accepts connections, file descriptors are used, and additional system calls appear around requests.<\/p>\n<p>This matters particularly when one endpoint becomes extremely cheap.<\/p>\n<p>Imagine that the PostgreSQL path requires several layers of work while the cached path is already close to the minimum cost of serving an HTTP response.<\/p>\n<p>Once the cache path approaches the limit of the HTTP stack, making the cache itself twice as fast does not make the API twice as fast.<\/p>\n<p>Amdahl&#8217;s law appears in a very practical form.<\/p>\n<p>If only a small fraction of the remaining request time belongs to the cache lookup, optimizing that lookup has very little effect on the complete request.<\/p>\n<p>This also means that connection handling can alter the ratio between the database and cache versions without changing either implementation.<\/p>\n<p>The speedup is not a constant property of the architecture.<\/p>\n<p>It is a property of the architecture under a particular workload.<\/p>\n<h3>pgxpool can quietly become the real queue<\/h3>\n<p>There is another layer between Go and PostgreSQL that is easy to ignore when looking only at SQL execution time.<\/p>\n<p>The connection pool.<\/p>\n<p>Suppose the benchmark runs with a concurrency of 64\u00a0while pgxpool is limited to eight connections.<\/p>\n<p>Sixty\u2011four requests can be active at the HTTP layer.<\/p>\n<p>Only eight can use PostgreSQL at the same time.<\/p>\n<p>The remaining requests have to wait.<\/p>\n<p>From the outside, this waiting appears as API latency.<\/p>\n<p>PostgreSQL may still execute each query extremely quickly.<\/p>\n<p>This creates a confusing but perfectly valid pair of observations:<\/p>\n<pre><code>SQL execution: fastHTTP request: slow<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The missing time can exist before PostgreSQL even sees the query.<\/p>\n<p>pgxpool exposes enough information to detect this:<\/p>\n<pre><code class=\"go\">stat := pool.Stat()log.Printf(    \"total=%d acquired=%d idle=%d acquire_count=%d acquire_duration=%s\",    stat.TotalConns(),    stat.AcquiredConns(),    stat.IdleConns(),    stat.AcquireCount(),    stat.AcquireDuration(),)<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The field I care about most here is <code>AcquireDuration<\/code>.<\/p>\n<p>If it grows sharply as concurrency increases, requests are spending noticeable time waiting for connections.<\/p>\n<p>At that point a benchmark is partly measuring queueing inside the application.<\/p>\n<p>The obvious response is to increase <code>MaxConns<\/code>.<\/p>\n<p>That helps only until the bottleneck moves again.<\/p>\n<p>With a very small pool, requests wait inside Go.<\/p>\n<p>With a larger pool, PostgreSQL receives more parallel work.<\/p>\n<p>Increase it far enough and more concurrency can start producing contention rather than useful throughput. CPU scheduling, shared structures, memory pressure and database backend activity become increasingly important.<\/p>\n<p>The queue did not disappear.<\/p>\n<p>It moved.<\/p>\n<p>That idea changed how I interpret connection\u2011pool tuning.<\/p>\n<p>There is no universally correct pool size that makes PostgreSQL infinitely parallel.<\/p>\n<p>A pool is a concurrency control mechanism.<\/p>\n<p>Too small and the application queues excessively.<\/p>\n<p>Too large and the database may receive more simultaneous work than it can process efficiently.<\/p>\n<p>A benchmark that ignores this can easily attribute pool behavior to PostgreSQL performance.<\/p>\n<h3>I should have measured the empty HTTP path first<\/h3>\n<p>A nearly empty handler is one of the simplest controls I can add:<\/p>\n<pre><code class=\"go\">func baseline(w http.ResponseWriter, r *http.Request) {    w.WriteHeader(http.StatusNoContent)}<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>It does almost nothing.<\/p>\n<p>That is exactly why it is useful.<\/p>\n<p>It establishes a rough lower bound for the cost of serving requests through the current HTTP stack.<\/p>\n<p>Suppose a cached endpoint approaches the throughput of this empty handler.<\/p>\n<p>At that point, continuing to optimize the map implementation is unlikely to produce a proportional API improvement.<\/p>\n<p>The request has another bottleneck.<\/p>\n<p>It may be socket processing.<\/p>\n<p>It may be response writing.<\/p>\n<p>It may be the Go scheduler.<\/p>\n<p>It may even be the benchmark generator.<\/p>\n<p>Without a baseline, I can keep optimizing a component that has already stopped mattering.<\/p>\n<p>The same principle applies to PostgreSQL.<\/p>\n<p>Instead of measuring only the full HTTP endpoint, I want to think in three layers:<\/p>\n<pre><code>PostgreSQL query onlyGo \u2192 PostgreSQL \u2192 GoHTTP \u2192 Go \u2192 PostgreSQL \u2192 Go \u2192 HTTP<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The first isolates database execution as much as possible.<\/p>\n<p>The second adds the driver, pool, protocol transfer, decoding and runtime.<\/p>\n<p>The third adds the HTTP stack.<\/p>\n<p>If the SQL query is fast but the second layer is much slower, SQL optimization may not be where the largest gain exists.<\/p>\n<p>If the second layer is fast while the complete endpoint is much slower, the database may not be the interesting part at all.<\/p>\n<p>This decomposition is far more useful than knowing that one complete endpoint produced some impressive RPS value.<\/p>\n<h3>Closed\u2011loop load changes when the server becomes slow<\/h3>\n<p>There was another property of the benchmark that I initially ignored.<\/p>\n<p>With a fixed concurrency, ApacheBench behaves essentially like a closed\u2011loop load generator.<\/p>\n<p>A certain number of requests are active.<\/p>\n<p>When one finishes, another can take its place.<\/p>\n<p>This creates an approximate relationship:<\/p>\n<pre><code>throughput \u2248 concurrency \/ response_time<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>If requests become slower, the achieved request rate naturally decreases because existing requests occupy concurrency slots for longer.<\/p>\n<p>That is perfectly valid for one type of benchmark.<\/p>\n<p>It does not behave exactly like every production workload.<\/p>\n<p>A real public API can receive requests at an externally determined rate.<\/p>\n<p>If 20,000\u00a0requests per second arrive and the database suddenly becomes slower, clients do not necessarily reduce their arrival rate to protect the server.<\/p>\n<p>Requests continue to arrive.<\/p>\n<p>Queues grow.<\/p>\n<p>Tail latency grows.<\/p>\n<p>Timeouts begin.<\/p>\n<p>Retries can potentially create even more load.<\/p>\n<p>This difference matters particularly close to saturation.<\/p>\n<p>A closed\u2011loop benchmark partly reduces offered load as the server slows down.<\/p>\n<p>A constant\u2011rate workload does not necessarily do that.<\/p>\n<p>That is why maximum requests per second should not be the only result I care about.<\/p>\n<p>I also want to know what happens while approaching the saturation point.<\/p>\n<h3>p99\u00a0tells a different story from average throughput<\/h3>\n<p>RPS is attractive because it creates a ranking.<\/p>\n<p>Implementation A produces more requests per second than implementation B.<\/p>\n<p>A wins.<\/p>\n<p>Real systems are less cooperative.<\/p>\n<p>Two implementations can reach similar throughput while having completely different latency distributions.<\/p>\n<p>Consider two endpoints.<\/p>\n<p>Both can sustain approximately the same request rate.<\/p>\n<p>The first has a narrow distribution: p50, p95\u00a0and p99\u00a0remain relatively close.<\/p>\n<p>The second has a reasonable median but a rapidly growing p99.<\/p>\n<p>They do not provide the same user experience.<\/p>\n<p>The second system may already be building queues even though its headline throughput still looks healthy.<\/p>\n<p>The PostgreSQL path contains several places where variability can enter:<\/p>\n<pre><code>pool acquisitiondatabase schedulingquery executionprotocol transferrow decodingGo schedulingserializationresponse writing<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Most requests may pass through all of them quickly.<\/p>\n<p>A few do not.<\/p>\n<p>Those few define the tail.<\/p>\n<p>The cache path removes several of these stages entirely.<\/p>\n<p>That can make its biggest practical advantage predictability rather than maximum throughput.<\/p>\n<p>For a latency\u2011sensitive API, reducing p99\u00a0from a problematic region can be more valuable than increasing maximum RPS by another impressive percentage.<\/p>\n<p>This is why I now consider a performance comparison incomplete if it contains only average latency and throughput.<\/p>\n<p>At minimum I want one coherent set of measurements:<\/p>\n<ul>\n<li>\n<p>throughput together with p50, p95\u00a0and p99<\/p>\n<\/li>\n<li>\n<p>CPU usage for the Go process and PostgreSQL<\/p>\n<\/li>\n<li>\n<p>pgxpool acquisition time<\/p>\n<\/li>\n<li>\n<p>active connection count<\/p>\n<\/li>\n<li>\n<p>multiple runs of the same scenario<\/p>\n<\/li>\n<li>\n<p>identical connection behavior between compared implementations<\/p>\n<\/li>\n<li>\n<p>a clear description of concurrency or offered request rate<\/p>\n<\/li>\n<\/ul>\n<p>The exact numbers matter.<\/p>\n<p>The shape of the system under increasing load matters more.<\/p>\n<h3>The 7x result was not fake<\/h3>\n<p>After all this decomposition, it would be easy to conclude that the original benchmark was useless.<\/p>\n<p>I do not think it was.<\/p>\n<p>The result described something real.<\/p>\n<p>Under that workload, on that system, with that concurrency, connection behavior, pool configuration and request distribution, the cached implementation completed dramatically more requests.<\/p>\n<p>That observation still exists.<\/p>\n<p>The mistake was treating it as a universal constant.<\/p>\n<p>An API does not have one performance number.<\/p>\n<p>Its behavior depends on the workload.<\/p>\n<p>The same cache can look completely different under these two request distributions:<\/p>\n<pre><code>one hot ID requested repeatedly<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>and:<\/p>\n<pre><code>millions of uniformly distributed IDs<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>The first case can produce an almost perfect hit rate.<\/p>\n<p>The second can destroy it.<\/p>\n<p>The same PostgreSQL query behaves differently when the database runs on <a href=\"http:\/\/localhost\" rel=\"noopener nofollow\">localhost<\/a> and when every query requires a network round trip.<\/p>\n<p>The same local cache behaves differently when it contains ten thousand entries and when it contains several million objects that increase GC pressure.<\/p>\n<p>The same benchmark behaves differently with a warm process and immediately after deployment.<\/p>\n<p>A read\u2011only test tells me almost nothing about invalidation cost.<\/p>\n<p>A 200-byte JSON response tells me very little about a multi\u2011megabyte response.<\/p>\n<p>This means benchmark configuration is not implementation detail.<\/p>\n<p>It is part of the result.<\/p>\n<h3>The speedup is a function, not a number<\/h3>\n<p>The simplest representation of the original experiment is:<\/p>\n<pre><code>speedup =    database_path_time \/ cache_path_time<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>That looks like one constant.<\/p>\n<p>A more realistic view is:<\/p>\n<pre><code>speedup = f(    concurrency,    pool_size,    HTTP_connection_reuse,    cache_hit_rate,    request_distribution,    payload_size,    database_topology,    database_state,    cache_size,    machine_load)<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Now the 7x result makes much more sense.<\/p>\n<p>It is one point in a multidimensional space.<\/p>\n<p>Move to another point and the ratio changes.<\/p>\n<p>This is not a weakness of benchmarking.<\/p>\n<p>It is exactly what benchmarking is supposed to reveal.<\/p>\n<p>The dangerous part begins when one point is presented as if the rest of the space did not exist.<\/p>\n<p>For example, suppose caching removes almost all database work and produces a huge improvement at low concurrency.<\/p>\n<p>As concurrency increases, the cached endpoint may eventually hit the HTTP or CPU ceiling.<\/p>\n<p>Its throughput curve flattens.<\/p>\n<p>The database path may flatten earlier because pgxpool or PostgreSQL saturates first.<\/p>\n<p>The ratio between them therefore changes with concurrency.<\/p>\n<p>There is no reason to expect a constant multiplier.<\/p>\n<p>The architecture contains multiple saturation points.<\/p>\n<p>Caching changes which one is reached first.<\/p>\n<h3>What I would benchmark now<\/h3>\n<p>If I were evaluating the same optimization again, I would begin with the workload rather than the command.<\/p>\n<p>I would define what I actually want to know.<\/p>\n<p>For example:<\/p>\n<p>How much does the cache reduce request latency at a fixed offered load?<\/p>\n<p>At what concurrency does pgxpool acquisition become significant?<\/p>\n<p>Where does PostgreSQL throughput stop scaling?<\/p>\n<p>How close is the cached endpoint to the empty HTTP baseline?<\/p>\n<p>What happens to p99\u00a0near saturation?<\/p>\n<p>How does the result change when the hit rate falls?<\/p>\n<p>What happens when the key distribution becomes Zipf\u2011like instead of one hot object?<\/p>\n<p>How does a remote PostgreSQL instance change the result?<\/p>\n<p>Those are different experiments.<\/p>\n<p>Trying to answer all of them with one ApacheBench invocation produces a precise number with an ambiguous meaning.<\/p>\n<p>This was probably the most useful thing I learned from revisiting the original test.<\/p>\n<p>A benchmark command is not an experiment.<\/p>\n<p>It is one component of an experiment.<\/p>\n<p>The question comes first.<\/p>\n<h3>Conclusion<\/h3>\n<p>My original benchmark showed an almost sevenfold difference between a PostgreSQL\u2011backed Go API path and a local cache.<\/p>\n<p>I still believe the optimization was useful.<\/p>\n<p>What changed is my understanding of what the number represented.<\/p>\n<p>ApacheBench was not measuring PostgreSQL.<\/p>\n<p>It was not measuring the cache either.<\/p>\n<p>It was measuring the complete behavior of the system under one particular workload.<\/p>\n<p>The 7x result included HTTP handling, transport behavior, pool acquisition, PostgreSQL interaction, decoding, serialization, response writing and the behavior of the load generator itself.<\/p>\n<p>Caching removed several of those costs simultaneously.<\/p>\n<p>Then the bottleneck moved somewhere else.<\/p>\n<p>That is the part I find more interesting now.<\/p>\n<p>Performance work is often described as making a component faster.<\/p>\n<p>In practice, it is usually a process of moving the queue.<\/p>\n<p>Remove database latency and HTTP starts to matter.<\/p>\n<p>Fix HTTP overhead and CPU becomes visible.<\/p>\n<p>Increase the pool and PostgreSQL becomes the queue.<\/p>\n<p>Optimize PostgreSQL and serialization may appear.<\/p>\n<p>At some point the load generator itself can become suspicious.<\/p>\n<p>I used to look at a result like 7x and ask whether it was large enough to justify the optimization.<\/p>\n<p>Now I ask different questions.<\/p>\n<p>Which stages disappeared from the request?<\/p>\n<p>Where did the next queue form?<\/p>\n<p>What happens to p99\u00a0as load increases?<\/p>\n<p>How close am I to the baseline of the surrounding system?<\/p>\n<p>And which assumption in the benchmark is responsible for the number I am looking at?<\/p>\n<p>The cache did not become less useful when I stopped treating 7x as a universal property.<\/p>\n<p>The result became more useful once I understood what was inside it.<\/p>\n<\/div>\n<p>\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/1076914\/\">https:\/\/habr.com\/ru\/articles\/1076914\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>My Go API benchmark once showed an almost 7x throughput improvement after I moved a read\u2011heavy endpoint from PostgreSQL to a local in\u2011memory cache. The result looked obvious: PostgreSQL was the bottleneck, the cache removed it, and the API became much faster. Later I realized that this interpretation was too simple. ApacheBench had measured an entire request path consisting of HTTP handling, connection management, pgxpool waiting, SQL execution, decoding, serialization, and response writing. I decided to decompose that path and understand what the 7x result actually meant.The number was correct, but my explanation was too simpleThe original setup was deliberately small. A Go service exposed an endpoint that returned a user by ID. Without caching, every request reached PostgreSQL. With caching enabled, frequently requested objects were returned directly from process memory.The performance difference was large. ApacheBench showed enough separation between the two implementations that the result could reasonably be summarized as an approximately sevenfold throughput improvement.At first I treated that as evidence that PostgreSQL itself was the bottleneck.That sentence now bothers me.A PostgreSQL\u2011backed HTTP request is not a SQL query. It is a chain of operations, and the query is only one of them.The database version of the endpoint effectively followed this path:HTTP request    \u2193router    \u2193pgxpool acquisition    \u2193PostgreSQL protocol round trip    \u2193index lookup and SQL execution    \u2193row transfer    \u2193Scan into Go values    \u2193JSON serialization    \u2193HTTP responseThe cache hit was much shorter:HTTP request    \u2193router    \u2193cache synchronization    \u2193map lookup    \u2193HTTP responseThis was the first thing I had underestimated.I had not replaced PostgreSQL with a map.I had removed an entire section of the request pipeline.In my implementation, the cache could even contain the already serialized representation. In that case a cache hit avoided not only database access, but also scanning values into a struct and running JSON serialization again.Calling the complete difference PostgreSQL overhead therefore hides too much information.A more useful simplified model is:Tdb_request =    Thttp  + Tpool  + Troundtrip  + Tsql  + Tdecode  + Tserialize  + TwriteFor a cache hit:Tcache_request =    Thttp  + Tlock  + Tlookup  + TwriteThe benchmark compared these two sums.It never isolated one SQL operation and one memory lookup.That sounds obvious once written down. It was much less obvious while looking at a single requests\u2011per\u2011second number.ApacheBench is part of the experimentThe second problem was my mental model of the load generator.I had implicitly treated ApacheBench as a stopwatch sitting outside the system.It is not.A benchmark client creates a particular workload, and the workload affects the result.Take these two commands:ab -n 100000 -c 64 http:\/\/127.0.0.1:8080\/users\/42and:ab -k -n 100000 -c 64 http:\/\/127.0.0.1:8080\/users\/42The second enables persistent HTTP connections.From the perspective of the application code, nothing changed. The handler is identical. PostgreSQL is identical. The cache is identical.The transport behavior is not.Without connection reuse, the benchmark creates much more connection\u2011management work. Even on localhost, creating and closing sockets is not free. The kernel maintains TCP state, the server accepts connections, file descriptors are used, and additional system calls appear around requests.This matters particularly when one endpoint becomes extremely cheap.Imagine that the PostgreSQL path requires several layers of work while the cached path is already close to the minimum cost of serving an HTTP response.Once the cache path approaches the limit of the HTTP stack, making the cache itself twice as fast does not make the API twice as fast.Amdahl&#8217;s law appears in a very practical form.If only a small fraction of the remaining request time belongs to the cache lookup, optimizing that lookup has very little effect on the complete request.This also means that connection handling can alter the ratio between the database and cache versions without changing either implementation.The speedup is not a constant property of the architecture.It is a property of the architecture under a particular workload.pgxpool can quietly become the real queueThere is another layer between Go and PostgreSQL that is easy to ignore when looking only at SQL execution time.The connection pool.Suppose the benchmark runs with a concurrency of 64\u00a0while pgxpool is limited to eight connections.Sixty\u2011four requests can be active at the HTTP layer.Only eight can use PostgreSQL at the same time.The remaining requests have to wait.From the outside, this waiting appears as API latency.PostgreSQL may still execute each query extremely quickly.This creates a confusing but perfectly valid pair of observations:SQL execution: fastHTTP request: slowThe missing time can exist before PostgreSQL even sees the query.pgxpool exposes enough information to detect this:stat := pool.Stat()log.Printf(    &#171;total=%d acquired=%d idle=%d acquire_count=%d acquire_duration=%s&#187;,    stat.TotalConns(),    stat.AcquiredConns(),    stat.IdleConns(),    stat.AcquireCount(),    stat.AcquireDuration(),)The field I care about most here is AcquireDuration.If it grows sharply as concurrency increases, requests are spending noticeable time waiting for connections.At that point a benchmark is partly measuring queueing inside the application.The obvious response is to increase MaxConns.That helps only until the bottleneck moves again.With a very small pool, requests wait inside Go.With a larger pool, PostgreSQL receives more parallel work.Increase it far enough and more concurrency can start producing contention rather than useful throughput. CPU scheduling, shared structures, memory pressure and database backend activity become increasingly important.The queue did not disappear.It moved.That idea changed how I interpret connection\u2011pool tuning.There is no universally correct pool size that makes PostgreSQL infinitely parallel.A pool is a concurrency control mechanism.Too small and the application queues excessively.Too large and the database may receive more simultaneous work than it can process efficiently.A benchmark that ignores this can easily attribute pool behavior to PostgreSQL performance.I should have measured the empty HTTP path firstA nearly empty handler is one of the simplest controls I can add:func baseline(w http.ResponseWriter, r *http.Request) {    w.WriteHeader(http.StatusNoContent)}It does almost nothing.That is exactly why it is useful.It establishes a rough lower bound for the cost of serving requests through the current HTTP stack.Suppose a cached endpoint approaches the throughput of this empty handler.At that point, continuing to optimize the map implementation is unlikely to produce a proportional API improvement.The request has another bottleneck.It may be socket processing.It may be response writing.It may be the Go scheduler.It may even be the benchmark generator.Without a baseline, I can keep optimizing a component that has already stopped mattering.The same principle applies to PostgreSQL.Instead of measuring only the full HTTP endpoint, I want to think in three layers:PostgreSQL query onlyGo \u2192 PostgreSQL \u2192 GoHTTP \u2192 Go \u2192 PostgreSQL \u2192 Go \u2192 HTTPThe first isolates database execution as much as possible.The second adds the driver, pool, protocol transfer, decoding and runtime.The third adds the HTTP stack.If the SQL query is fast but the second layer is much slower, SQL optimization may not be where the largest gain exists.If the second layer is fast while the complete endpoint is much slower, the database may not be the interesting part at all.This decomposition is far more useful than knowing that one complete endpoint produced some impressive RPS value.Closed\u2011loop load changes when the server becomes slowThere was another property of the benchmark that I initially ignored.With a fixed concurrency, ApacheBench behaves essentially like a closed\u2011loop load generator.A certain number of requests are active.When one finishes, another can take its place.This creates an approximate relationship:throughput \u2248 concurrency \/ response_timeIf requests become slower, the achieved request rate naturally decreases because existing requests occupy concurrency slots for longer.That is perfectly valid for one type of benchmark.It does not behave exactly like every production workload.A real public API can receive requests at an externally determined rate.If 20,000\u00a0requests per second arrive and the database suddenly becomes slower, clients do not necessarily reduce their arrival rate to protect the server.Requests continue to arrive.Queues grow.Tail latency grows.Timeouts begin.Retries can potentially create even more load.This difference matters particularly close to saturation.A closed\u2011loop benchmark partly reduces offered load as the server slows down.A constant\u2011rate workload does not necessarily do that.That is why maximum requests per second should not be the only result I care about.I also want to know what happens while approaching the saturation point.p99\u00a0tells a different story from average throughputRPS is attractive because it creates a ranking.Implementation A produces more requests per second than implementation B.A wins.Real systems are less cooperative.Two implementations can reach similar throughput while having completely different latency distributions.Consider two endpoints.Both can sustain approximately the same request rate.The first has a narrow distribution: p50, p95\u00a0and p99\u00a0remain relatively close.The second has a reasonable median but a rapidly growing p99.They do not provide the same user experience.The second system may already be building queues even though its headline throughput still looks healthy.The PostgreSQL path contains several places where variability can enter:pool acquisitiondatabase schedulingquery executionprotocol&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-492996","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/492996","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=492996"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/492996\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=492996"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=492996"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=492996"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}