<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>DB on 0AndWild_log</title><link>https://0andwild.com/en/categories/db/</link><description>Recent content in DB on 0AndWild_log</description><generator>Hugo -- gohugo.io</generator><language>en-US</language><lastBuildDate>Thu, 18 Jun 2026 17:19:15 +0900</lastBuildDate><atom:link href="https://0andwild.com/en/categories/db/index.xml" rel="self" type="application/rss+xml"/><item><title>Improving Product Query Performance When Sorting by Likes</title><link>https://0andwild.com/en/posts/260618_read_improvement/</link><pubDate>Thu, 18 Jun 2026 17:19:15 +0900</pubDate><guid>https://0andwild.com/en/posts/260618_read_improvement/</guid><description>&lt;img src="https://0andwild.com/" alt="Featured image of post Improving Product Query Performance When Sorting by Likes" /&gt;&lt;h2 id="goal"&gt;&lt;a href="#goal" class="header-anchor"&gt;&lt;/a&gt;Goal&#10;&lt;/h2&gt;&lt;p&gt;I wanted to improve product list query performance by reading execution plans and changing the query, indexes, and data structure step by step. The comparison uses 100,000 products and approximately 4.95 million generated like records.&lt;/p&gt;&#10;&lt;h2 id="defining-the-problem"&gt;&lt;a href="#defining-the-problem" class="header-anchor"&gt;&lt;/a&gt;Defining the problem&#10;&lt;/h2&gt;&lt;p&gt;Sorting products by likes looked like the most likely performance bottleneck.&lt;/p&gt;&#10;&lt;p&gt;The current schema has &lt;code&gt;Product&lt;/code&gt; and &lt;code&gt;Likes&lt;/code&gt; tables. A user can like each product once. Each like inserts a row, and unliking a product hard-deletes that row.&lt;/p&gt;&#10;&lt;p&gt;With a small number of users and products, even a poor query or a full scan might go unnoticed. With millions of products and hundreds of thousands or millions of users, however, the &lt;code&gt;Likes&lt;/code&gt; table can quickly grow to millions or tens of millions of rows. Aggregating that table every time someone sorts products by popularity could overwhelm the database.&lt;/p&gt;&#10;&lt;p&gt;I wanted to measure the effect of query improvements, indexes, and cached aggregates in that kind of scenario. The &lt;code&gt;Likes&lt;/code&gt; table currently has a unique key on &lt;code&gt;member_id&lt;/code&gt; and &lt;code&gt;product_id&lt;/code&gt; to prevent duplicate likes.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h2 id="run-the-aggregate-query-and-inspect-the-result"&gt;&lt;a href="#run-the-aggregate-query-and-inspect-the-result" class="header-anchor"&gt;&lt;/a&gt;Run the aggregate query and inspect the result&#10;&lt;/h2&gt;&lt;p&gt;First, a quick distinction:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; shows the estimated execution plan without running the query.&lt;/li&gt;&#10;&lt;li&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; runs the query and reports its actual execution behavior.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;like_cnt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;likes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;where&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_deleted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;like_cnt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Sort: like_cnt DESC (actual time=11413..11416 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on &amp;lt;temporary&amp;gt; (actual time=11377..11389 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Aggregate using temporary table (actual time=11377..11377 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Left hash join (ls.product_id = p.id) (cost=21.8e+9 rows=217e+9) (actual time=603..997 rows=4.95e+6 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=9258 rows=44167) (actual time=0.0618..20.9 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on p (cost=9258 rows=88334) (actual time=0.0587..17.8 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Hash&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Covering index scan on ls using uk_likes_member_id_product_id (cost=244 rows=4.92e+6) (actual time=0.457..347 rows=4.95e+6 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;figure class="mx-auto"&gt;&lt;img src="https://0andwild.com/posts/260618_read_improvement/img1.png" width="300"&gt;&#10;&lt;/figure&gt;&#10;&#10;&lt;p&gt;The first query took about 11 seconds. At more than 11 seconds per request, the server would already be in serious trouble.&lt;/p&gt;&#10;&lt;p&gt;&lt;strong&gt;What is wrong with this query?&lt;/strong&gt;&lt;/p&gt;&#10;&lt;p&gt;The execution plan makes it clear. It joins the 4.95 million &lt;code&gt;likes&lt;/code&gt; rows to the 100,000 &lt;code&gt;product&lt;/code&gt; rows before aggregating them. That produces a combined intermediate result of about 4.95 million rows. Aggregating that result takes roughly 10 seconds.&lt;/p&gt;&#10;&lt;p&gt;How can we improve it?&lt;/p&gt;&#10;&lt;h3 id="aggregate-before-the-join"&gt;&lt;a href="#aggregate-before-the-join" class="header-anchor"&gt;&lt;/a&gt;Aggregate before the JOIN&#10;&lt;/h3&gt;&lt;p&gt;If we aggregate &lt;code&gt;likes&lt;/code&gt; first, the number of rows to join falls to at most 100,000, the number of products. Grouping by only &lt;code&gt;product_id&lt;/code&gt; also simplifies the aggregation.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_cnt&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;like_cnt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;likes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_deleted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_cnt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Sort: ls.like_cnt DESC (actual time=1060..1064 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Stream results (cost=625857 rows=0) (actual time=940..1028 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop left join (cost=625857 rows=0) (actual time=940..1016 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=10532 rows=44167) (actual time=0.0611..24.6 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on p (cost=10532 rows=88334) (actual time=0.0574..20.9 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Index lookup on ls using &amp;lt;auto_key0&amp;gt; (product_id=p.id) (cost=0.25..13.9 rows=55.7) (actual time=0.00969..0.00982 rows=0.99 loops=100000)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Materialize (cost=0..0 rows=0) (actual time=939..939 rows=99000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on &amp;lt;temporary&amp;gt; (actual time=903..909 rows=99000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Aggregate using temporary table (actual time=903..903 rows=99000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Covering index scan on likes using uk_likes_member_id_product_id (cost=521764 rows=4.92e+6) (actual time=0.275..332 rows=4.95e+6 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The execution plan now follows the intended sequence: &lt;strong&gt;aggregate likes → join product → sort&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;Execution time fell from &lt;strong&gt;11.416 seconds to 1.064 seconds&lt;/strong&gt;, a reduction of about &lt;strong&gt;91%&lt;/strong&gt; compared with the first query.&lt;/p&gt;&#10;&lt;hr&gt;&#10;&lt;h3 id="add-an-index-to-the-likes-table"&gt;&lt;a href="#add-an-index-to-the-likes-table" class="header-anchor"&gt;&lt;/a&gt;Add an index to the likes table&#10;&lt;/h3&gt;&lt;p&gt;A one-second query still isn&amp;rsquo;t fast, so I looked closer. The most expensive operation was now the &lt;code&gt;GROUP BY&lt;/code&gt; aggregation returning 99,000 rows.&lt;/p&gt;&#10;&lt;p&gt;The existing unique index is ordered by &lt;code&gt;member_id, product_id&lt;/code&gt;. That ordering doesn&amp;rsquo;t suit &lt;code&gt;GROUP BY product_id&lt;/code&gt;, because rows for the same product aren&amp;rsquo;t contiguous.&lt;/p&gt;&#10;&lt;p&gt;The plan shows MySQL regrouping the rows with temporary-table aggregation: &lt;code&gt;Covering index scan on likes using uk_likes_member_id_product_id&lt;/code&gt; followed by &lt;code&gt;Aggregate using temporary table&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(member_id, product_id)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(1, 10)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(1, 20)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(1, 30)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(2, 10)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(2, 20)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(3, 10)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Reversing the unique index to &lt;code&gt;product_id, member_id&lt;/code&gt; would help this query. However, the query for products liked by a particular user relies on &lt;code&gt;member_id&lt;/code&gt; being first. I kept that index and added a separate index on &lt;code&gt;product_id&lt;/code&gt;.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;likes_product_id_index&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;likes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Then I ran the same query again.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Sort: ls.like_cnt DESC (actual time=555..559 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Stream results (cost=439e+6 rows=4.39e+9) (actual time=456..530 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop left join (cost=439e+6 rows=4.39e+9) (actual time=456..518 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=9259 rows=44167) (actual time=0.0941..20.5 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on p (cost=9259 rows=88334) (actual time=0.0923..16.9 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Index lookup on ls using &amp;lt;auto_key0&amp;gt; (product_id=p.id) (cost=1.02e+6..1.02e+6 rows=55.7) (actual time=0.00483..0.0049 rows=0.99 loops=100000)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Materialize (cost=1.02e+6..1.02e+6 rows=99338) (actual time=456..456 rows=99000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Group aggregate: count(likes.product_id) (cost=1.01e+6 rows=99338) (actual time=0.349..427 rows=99000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Covering index scan on likes using likes_product_id_index (cost=521749 rows=4.92e+6) (actual time=0.338..319 rows=4.95e+6 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The query now took &lt;strong&gt;559 ms&lt;/strong&gt;, down from &lt;strong&gt;1.064 seconds&lt;/strong&gt;: a reduction of about &lt;strong&gt;47.5%&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;The execution plan changed as follows:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Before &lt;code&gt;likes_product_id_index&lt;/code&gt;: &lt;code&gt;Aggregate using temporary table&lt;/code&gt; over a &lt;code&gt;Covering index scan on likes using uk_likes_member_id_product_id&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;After &lt;code&gt;likes_product_id_index&lt;/code&gt;: &lt;code&gt;Group aggregate: count(likes.product_id)&lt;/code&gt; over a &lt;code&gt;Covering index scan on likes using likes_product_id_index&lt;/code&gt;.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Previously, rows for each &lt;code&gt;product_id&lt;/code&gt; were scattered, so MySQL had to collect them in a temporary table. The new index places equal product IDs together. MySQL can complete each group as it reads through the index in order.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;product_id&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;10&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;10&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;10&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;20&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;20&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;30&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;hr&gt;&#10;&lt;h2 id="can-we-improve-it-further"&gt;&lt;a href="#can-we-improve-it-further" class="header-anchor"&gt;&lt;/a&gt;Can we improve it further?&#10;&lt;/h2&gt;&lt;p&gt;The query is now in reasonably good shape for aggregation at read time. To go further, we can store precomputed counts in an &lt;code&gt;MV (Materialized View)&lt;/code&gt; table and read those values instead of aggregating on every request.&lt;/p&gt;&#10;&lt;p&gt;When designing popularity sorting, I had already created a &lt;code&gt;product_stat&lt;/code&gt; table for this purpose. Here, the MV is a table that stores the precomputed aggregate. I&amp;rsquo;ll use it for the next comparison.&lt;/p&gt;&#10;&lt;p&gt;This introduces a trade-off: depending on how the table is maintained, the counts may be less current or less accurate than a direct aggregation. Read performance has to be weighed against those consistency requirements.&lt;/p&gt;&#10;&lt;h3 id="benefits-and-costs-of-an-mv"&gt;&lt;a href="#benefits-and-costs-of-an-mv" class="header-anchor"&gt;&lt;/a&gt;Benefits and costs of an MV&#10;&lt;/h3&gt;&lt;p&gt;Benefits:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Reads use precomputed data, avoiding repeated aggregation.&lt;/li&gt;&#10;&lt;li&gt;Read performance no longer scales directly with the size of the &lt;code&gt;likes&lt;/code&gt; table.&lt;/li&gt;&#10;&lt;li&gt;Database load is more predictable under heavy traffic.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;Costs:&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;Consistency must be maintained: inserts and deletes in &lt;code&gt;likes&lt;/code&gt; must also update the count in &lt;code&gt;product_stat&lt;/code&gt;.&lt;/li&gt;&#10;&lt;li&gt;Counts can drift in operation, so a periodic reconciliation batch or validation query should compare them with the original &lt;code&gt;likes&lt;/code&gt; data.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;h3 id="querying-product_stat"&gt;&lt;a href="#querying-product_stat" class="header-anchor"&gt;&lt;/a&gt;Querying product_stat&#10;&lt;/h3&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_count&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_stat&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_deleted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Sort: ps.like_count DESC, ps.product_id DESC (actual time=159..162 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Stream results (cost=24717 rows=44167) (actual time=0.0745..127 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop inner join (cost=24717 rows=44167) (actual time=0.0716..111 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=9258 rows=44167) (actual time=0.0496..21.6 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on p (cost=9258 rows=88334) (actual time=0.0478..17.1 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Single-row index lookup on ps using UK6mdv3ubr8r663491df2j2du63 (product_id=p.id) (cost=0.25 rows=1) (actual time=775e-6..793e-6 rows=1 loops=100000)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Reading the precomputed table with a simple join provides a substantial improvement. Previously, each request read roughly 4.95 million &lt;code&gt;likes&lt;/code&gt; rows, grouped them by &lt;code&gt;product_id&lt;/code&gt;, and joined the result to &lt;code&gt;product&lt;/code&gt;. With &lt;code&gt;product_stat&lt;/code&gt;, the query reads the existing &lt;code&gt;like_count&lt;/code&gt;, and access to &lt;code&gt;likes&lt;/code&gt; disappears from the execution plan entirely.&lt;/p&gt;&#10;&lt;p&gt;Execution time fell from &lt;strong&gt;559 ms to 162 ms&lt;/strong&gt;, a reduction of about &lt;strong&gt;71%&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;In this plan, the MySQL optimizer chose to scan &lt;code&gt;product&lt;/code&gt; first and perform 100,000 lookups into &lt;code&gt;product_stat&lt;/code&gt; using its unique key on &lt;code&gt;product_id&lt;/code&gt;. At the current scale, 162 ms is already reasonably fast. With millions of products, though, the full scan and repeated lookups could become a bottleneck.&lt;/p&gt;&#10;&lt;p&gt;Pagination lets us request only the rows we need instead of returning everything.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_count&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_stat&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_deleted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;like_count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;OFFSET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Limit: 20 row(s) (cost=43892 rows=20) (actual time=39.3..39.7 rows=20 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop inner join (cost=43892 rows=48653) (actual time=39.3..39.7 rows=20 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Sort: ps.like_count DESC, ps.product_id DESC (cost=9835 rows=97306) (actual time=39.2..39.2 rows=20 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Table scan on ps (cost=9835 rows=97306) (actual time=1.01..17.2 rows=100000 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=0.25 rows=0.5) (actual time=0.0252..0.0252 rows=1 loops=20)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Single-row index lookup on p using PRIMARY (id=ps.product_id) (cost=0.25 rows=1) (actual time=0.0245..0.0245 rows=1 loops=20)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;After adding &lt;code&gt;LIMIT&lt;/code&gt;, index lookups and joins fell to 20 as well.&lt;/p&gt;&#10;&lt;p&gt;Execution time dropped from &lt;strong&gt;162 ms to 39.7 ms&lt;/strong&gt;, a reduction of about &lt;strong&gt;75.5%&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;h3 id="add-a-like_count-index"&gt;&lt;a href="#add-a-like_count-index" class="header-anchor"&gt;&lt;/a&gt;Add a like_count index&#10;&lt;/h3&gt;&lt;p&gt;This is already fast enough for the current dataset. Still, given how quickly data can grow and how much read traffic peak hours can bring, an index that supports the like-count ordering seemed worthwhile.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_product_stat_like_count_product_id&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_stat&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;like_count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The intended execution flow is:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Read product_stat in like_count DESC index order&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ Look up product by PK&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ Check is_deleted = false&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;→ Stop after finding 20 eligible products&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Instead of sorting all 100,000 rows, MySQL can start with the highest-ranked rows and stop at &lt;code&gt;LIMIT 20&lt;/code&gt;.&lt;/p&gt;&#10;&lt;p&gt;I added the index and ran the same query again.&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-&amp;gt; Limit: 20 row(s) (cost=24329 rows=10) (actual time=0.632..0.953 rows=20 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Nested loop inner join (cost=24329 rows=10) (actual time=0.629..0.949 rows=20 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Covering index scan on ps using idx_product_stat_like_cnt_product_id (cost=0.0218 rows=20) (actual time=0.448..0.452 rows=20 loops=1)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Filter: (p.is_deleted = false) (cost=0.25 rows=0.5) (actual time=0.0238..0.0238 rows=1 loops=20)&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; -&amp;gt; Single-row index lookup on p using PRIMARY (id=ps.product_id) (cost=0.25 rows=1) (actual time=0.0227..0.0227 rows=1 loops=20)&#10;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The query no longer scans all of &lt;code&gt;product_stat&lt;/code&gt;. The index already provides the required order, so MySQL can read the first 20 rows without an additional sort.&lt;/p&gt;&#10;&lt;p&gt;Execution time fell from &lt;strong&gt;39.7 ms to 0.953 ms&lt;/strong&gt;, a reduction of about &lt;strong&gt;97.6%&lt;/strong&gt;.&lt;/p&gt;&#10;&lt;p&gt;For the current scale, and some growth beyond it, this looks unlikely to be the immediate read bottleneck. Working through the execution plans let me see how query structure, indexing, and a different read model each affected performance and introduced their own trade-offs.&lt;/p&gt;&#10;&lt;h3 id="results"&gt;&lt;a href="#results" class="header-anchor"&gt;&lt;/a&gt;Results&#10;&lt;/h3&gt;&lt;p&gt;The test dataset contained 100,000 products and approximately 4.95 million likes.&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Step&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Query strategy&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Key execution plan operations&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Time&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Change from previous step&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th style="text-align: right"&gt;Reduction from initial query&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;1&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Join &lt;code&gt;product&lt;/code&gt; and &lt;code&gt;likes&lt;/code&gt;, then aggregate&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;Left hash join&lt;/code&gt; followed by &lt;code&gt;Aggregate using temporary table&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;11,416 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;—&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;—&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;2&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Aggregate &lt;code&gt;likes&lt;/code&gt; by &lt;code&gt;product_id&lt;/code&gt;, then join &lt;code&gt;product&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;Materialize&lt;/code&gt; + &lt;code&gt;Aggregate using temporary table&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;1,064 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;About 90.7% less time; 10.7× faster&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;About 90.7%&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;3&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Add &lt;code&gt;likes(product_id)&lt;/code&gt; index&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;&lt;code&gt;Group aggregate&lt;/code&gt; + &lt;code&gt;likes_product_id_index&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;559 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;About 47.5% less time; 1.9× faster&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;About 95.1%&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;4&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Use &lt;code&gt;product_stat&lt;/code&gt; MV table&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Single-row &lt;code&gt;product_stat&lt;/code&gt; lookups; no access to &lt;code&gt;likes&lt;/code&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;162 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;About 71.0% less time; 3.45× faster&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;About 98.6%&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;5&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Add &lt;code&gt;LIMIT 20 OFFSET 0&lt;/code&gt; to the MV query&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Sort 100,000 &lt;code&gt;product_stat&lt;/code&gt; rows, then join the top 20&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;39.7 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;About 75.5% less time; 4.1× faster&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;About 99.65%&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;6&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Add &lt;code&gt;product_stat(like_count DESC, product_id DESC)&lt;/code&gt; index&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Read only the top 20 index entries without sorting&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;0.953 ms&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;About 97.6% less time; 41.7× faster&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td style="text-align: right"&gt;About 99.99%&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;The final query took 0.953 ms instead of 11,416 ms. That is approximately a &lt;strong&gt;99.99% reduction in execution time&lt;/strong&gt;, or a &lt;strong&gt;11,979× speedup&lt;/strong&gt; in this comparison.&lt;/p&gt;&#10;&lt;h3 id="closing-thoughts"&gt;&lt;a href="#closing-thoughts" class="header-anchor"&gt;&lt;/a&gt;Closing thoughts&#10;&lt;/h3&gt;&lt;p&gt;Three changes made the biggest difference.&lt;/p&gt;&#10;&lt;p&gt;First, aggregating before joining removed an unnecessarily large intermediate result. Second, adding &lt;code&gt;likes(product_id)&lt;/code&gt; allowed &lt;code&gt;GROUP BY product_id&lt;/code&gt; to use &lt;code&gt;Group aggregate&lt;/code&gt; instead of temporary-table aggregation. Third, the &lt;code&gt;product_stat&lt;/code&gt; MV table and like-count index removed the large aggregation and sorting costs from the read path.&lt;/p&gt;&#10;&lt;p&gt;In the final step, MySQL reads just 20 entries in the order already provided by &lt;code&gt;idx_product_stat_like_count_product_id&lt;/code&gt;, then joins &lt;code&gt;product&lt;/code&gt; by its primary key. For the popularity-sorted page query, this makes read performance largely independent of the size of the original &lt;code&gt;likes&lt;/code&gt; table.&lt;/p&gt;&#10;</description></item></channel></rss>