<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: M. Alwi Sukra</title>
    <description>The latest articles on DEV Community by M. Alwi Sukra (@arkoesalwi).</description>
    <link>https://dev.to/arkoesalwi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3922142%2F6181c905-c563-4315-acd8-5eec06d607b5.jpg</url>
      <title>DEV Community: M. Alwi Sukra</title>
      <link>https://dev.to/arkoesalwi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/arkoesalwi"/>
    <language>en</language>
    <item>
      <title>TIL - Graph Thinking Without a Graph Database</title>
      <dc:creator>M. Alwi Sukra</dc:creator>
      <pubDate>Thu, 28 May 2026 17:19:16 +0000</pubDate>
      <link>https://dev.to/arkoesalwi/til-graph-thinking-without-a-graph-database-1d11</link>
      <guid>https://dev.to/arkoesalwi/til-graph-thinking-without-a-graph-database-1d11</guid>
      <description>&lt;p&gt;This week I read DDIA Chapter 2 related to data models. Most of it felt familiar. Relational vs document, many-to-many with junction tables, schema-on-read vs schema-on-write. These were things I had opinions about already.&lt;/p&gt;

&lt;p&gt;But the graph data model section was a blind spot. I assumed graph databases were for social networks, interesting but not relevant to anything I was doing.&lt;/p&gt;




&lt;h3&gt;
  
  
  What graph data models actually are
&lt;/h3&gt;

&lt;p&gt;One key aspect that the chapter emphasizes is how different data models handle many-to-many relationships. In a relational data model, we usually have several tables and a junction table that connects them. We also can add some additional columns to that junction table.&lt;/p&gt;

&lt;p&gt;For a graph data model, we can think of it having 2 different tables: &lt;strong&gt;nodes&lt;/strong&gt; and &lt;strong&gt;edges&lt;/strong&gt;. Nodes are entities. Edges are relationships between them. Both can have properties.&lt;/p&gt;

&lt;p&gt;There is not much difference between a relational and graph data model for a single relationship at a fixed depth. For example, a &lt;code&gt;friendships&lt;/code&gt; table with &lt;code&gt;user_a_id, user_b_id, since, is_close_friend&lt;/code&gt; is basically an edge with properties. Relational handles that fine.&lt;/p&gt;

&lt;p&gt;The difference shows up when we start traversing.&lt;/p&gt;

&lt;p&gt;Say we want "friends of friends". With a junction table, that's a self-join. "Friends of friends of friends" is another join. "Anyone reachable from me through any number of friendship hops" is a recursive CTE. It works, but the query complexity has nothing to do with how simple the question sounds.&lt;/p&gt;

&lt;p&gt;In a graph query language, traversal is the native operation. Here's friends of friends in Cypher (Neo4j's query language):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;me:&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$userId&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:FOLLOWS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;friend&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:FOLLOWS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fof&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;fof&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can read it almost like a sentence: &lt;em&gt;match the pattern where I follow a friend, who follows a friend-of-friend&lt;/em&gt;. The arrows are edges; &lt;code&gt;[:FOLLOWS]&lt;/code&gt; is the edge type to traverse.&lt;/p&gt;

&lt;p&gt;And arbitrary depth is just one more character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;me:&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$userId&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:FOLLOWS&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reachable&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;reachable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;*&lt;/code&gt; means "follow any number of these edges." Same query shape whether it's one hop or ten. In SQL, that jump from fixed depth to arbitrary depth means rewriting our query as a recursive CTE.&lt;/p&gt;

&lt;p&gt;The second difference is that a graph treats different relationship types uniformly. In relational, &lt;code&gt;follows&lt;/code&gt;, &lt;code&gt;blocks&lt;/code&gt;, and &lt;code&gt;memberships&lt;/code&gt; are usually separate tables, and traversing across them means a different join per table. In a graph, they're all just edges, and we can traverse across types in a single pattern.&lt;/p&gt;

&lt;p&gt;So my take is that the real distinction is the traversal. Especially variable-depth traversal across multiple relationship types. It's a first-class operation in a graph model and an awkward bolt-on in SQL.&lt;/p&gt;




&lt;h3&gt;
  
  
  The shape that fits
&lt;/h3&gt;

&lt;p&gt;The chapter convinced me that graph models suit problems where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Relationships are recursive or variable-depth (friends of friends, transitive dependencies, reachability)&lt;/li&gt;
&lt;li&gt;Multiple paths can exist between the same two entities&lt;/li&gt;
&lt;li&gt;The type of relationship matters as much as the entities themselves&lt;/li&gt;
&lt;li&gt;Queries are about traversal and reachability, not just lookup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If our data is mostly "fetch a row by ID" or "join two tables on a foreign key", relational is fine. But the moment we start asking "what's reachable from here through any valid path?", &lt;strong&gt;that's a graph question, whether we store it in a graph database or not&lt;/strong&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Looking at my own work through this lens
&lt;/h3&gt;

&lt;p&gt;I worked on an ads management system. The schema looked like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4i6iovrmcrjszyz5k6ew.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4i6iovrmcrjszyz5k6ew.png" alt="ERD" width="799" height="399"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Some queries this service needed to answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Find all keywords in a shop.&lt;/li&gt;
&lt;li&gt;Find all keywords in a group.&lt;/li&gt;
&lt;li&gt;Find all keywords in an ad. (Keywords directly on the ad, plus keywords on the group that contains it.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reasonable schema, queries, and I'd worked with this code. But when I tried drawing the data as a graph, this is what I got:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr74480pyyf2fzqjrahyy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr74480pyyf2fzqjrahyy.png" alt="Graph" width="800" height="414"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are multiple paths from a shop to a keyword: through an ad group, through an ad, through both. When I query "all keywords in a shop", I'm doing a graph traversal: "find every Keyword reachable from this Shop through any path of contains edges". I just hadn't been calling it that.&lt;/p&gt;




&lt;h3&gt;
  
  
  What actually changed for me
&lt;/h3&gt;

&lt;h4&gt;
  
  
  1. I started thinking about reachability instead of joins
&lt;/h4&gt;

&lt;p&gt;Before: every question about the data felt like a question about which tables to join. To get keywords in a shop, I join &lt;code&gt;keyword&lt;/code&gt; tables with &lt;code&gt;ad&lt;/code&gt;/&lt;code&gt;ad_group&lt;/code&gt; tables and filter by &lt;code&gt;shop_id&lt;/code&gt;. The query was a sequence of join operations.&lt;/p&gt;

&lt;p&gt;After: every question about the data feels like a question about which nodes are reachable from which. Find all keyword nodes reachable from this shop. The traversal is the question, and the join is just one implementation of the traversal.&lt;/p&gt;

&lt;p&gt;The shift sounds subtle, but it's what made other approaches (denormalization, recursive CTEs, even just rephrasing the SQL) become visible. Once the question is "what's reachable from here?", the answer doesn't have to be "join these tables." It can be anything that gets us the same set of reachable nodes.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. I noticed the multiple paths problem
&lt;/h4&gt;

&lt;p&gt;In a tree, every node has exactly one parent. The hierarchy I was working with isn't a tree. A keyword can be reached from a shop through an ad group, or through an ad, or through both. An ad can belong to a shop directly, or be inside a group.&lt;/p&gt;

&lt;p&gt;I'd been treating this as a quirk of the schema. The nullable columns, the join table, the two separate keyword tables, these were just "how things are." But the graph lens names it clearly: the data has multiple paths between the same kinds of nodes. That's a structural property, not a quirk.&lt;/p&gt;

&lt;p&gt;And it explains why my SQL queries kept needing unions. Each &lt;code&gt;UNION&lt;/code&gt; branch is one path. The graph is telling me up front that I'm going to need multiple branches; the schema was hiding that until the query made it visible.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. I saw edges that weren't in my schema
&lt;/h4&gt;

&lt;p&gt;The graph diagram has a &lt;code&gt;contains&lt;/code&gt; edge from &lt;code&gt;ad_group&lt;/code&gt; to &lt;code&gt;ad&lt;/code&gt;. In my schema, that relationship lives in the &lt;code&gt;ad_group_ad&lt;/code&gt; junction table.&lt;/p&gt;

&lt;p&gt;But the graph also has implicit relationships that my schema doesn't model directly. The "keyword in shop X" relationship is real (and we query for it constantly), but no column or table represents it directly. It's a derived relationship, computed every time we run the traversal.&lt;/p&gt;

&lt;p&gt;That's where the option space opens up. Once I can see shop-to-keyword as a meaningful relationship, I can ask whether to materialize it (denormalize shop_id onto every keyword) or keep deriving it (current approach with traversal). Both are valid; the graph view is what made the choice visible.&lt;/p&gt;




&lt;h3&gt;
  
  
  What it would look like as a graph query
&lt;/h3&gt;

&lt;p&gt;In Cypher, "all keywords in a shop" is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;s:&lt;/span&gt;&lt;span class="n"&gt;Shop&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="py"&gt;id:&lt;/span&gt; &lt;span class="n"&gt;$shopId&lt;/span&gt;&lt;span class="ss"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:CONTAINS&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;k:&lt;/span&gt;&lt;span class="n"&gt;Keyword&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our actual schema, the same query takes two branches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Keywords on groups in this shop&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;ad_group&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt;

&lt;span class="k"&gt;UNION&lt;/span&gt;

&lt;span class="c1"&gt;-- Keywords on ads in this shop&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ad_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. But this query got slow on us in a way that took us a while to understand.&lt;/p&gt;

&lt;p&gt;The issue wasn't the &lt;code&gt;JOIN&lt;/code&gt; itself, or the &lt;code&gt;UNION&lt;/code&gt;. It was the query planner. When a shop has many keywords, the planner sometimes picks an index path that ends up scanning across the ad tables (soft-deleted rows included), even when the request is for a small page of results.&lt;/p&gt;

&lt;p&gt;The behavior was hard to predict because it depended on the shop's data distribution. Small shops were fine. Large shops sometimes triggered the bad path. And because the keyword list is a batch API, a single slow query multiplied across the batch and put real pressure on the database.&lt;/p&gt;

&lt;p&gt;The team's fix was to stop letting the planner choose. We took the joins out of SQL and resolved them in application code: query each table separately with &lt;code&gt;WHERE shop_id = X AND status = 'active'&lt;/code&gt; (which uses clean indexes predictably), then stitch the results in Go.&lt;/p&gt;

&lt;p&gt;It works. But it's a workaround for a query that's conceptually one thing: find all keywords reachable from this shop. The graph traversal is happening, just spread across multiple queries and some application code, with the planner taken out of the loop entirely.&lt;/p&gt;




&lt;h3&gt;
  
  
  Would I actually use a graph database?
&lt;/h3&gt;

&lt;p&gt;Constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The hierarchy is only 3-4 levels deep. Graph databases shine on deep or unbounded traversal. Mine is bounded.&lt;/li&gt;
&lt;li&gt;Nobody on the team has run Neo4j in production. PostgreSQL we know cold.&lt;/li&gt;
&lt;li&gt;Our source of truth is already in Postgres. Adding a graph database means syncing two stores or migrating the source of truth, both big commitments.&lt;/li&gt;
&lt;li&gt;Our queries are predictable. We're not doing pattern matching or shortest-path.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm not sure I'd reach for Neo4j here. The elegance of the Cypher query is real, but the operational cost feels high for the shape of problem I have.&lt;/p&gt;

&lt;p&gt;What's more interesting is that the graph lens opened up another option.&lt;/p&gt;




&lt;h3&gt;
  
  
  A different option
&lt;/h3&gt;

&lt;p&gt;What if every entity in the hierarchy carried its ancestor IDs directly?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ad:               id, shop_id, ad_group_id (nullable), status
ad_group_keyword: id, ad_group_id, tag, shop_id
ad_keyword:       id, ad_id, shop_id, ad_group_id (nullable), status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ad_group_ad&lt;/code&gt; still records the actual ad-to-group membership, but &lt;code&gt;ad.ad_group_id&lt;/code&gt; is a maintained denormalization. Same idea on the keyword tables: each keyword carries &lt;code&gt;shop_id&lt;/code&gt;, and &lt;code&gt;ad_keyword&lt;/code&gt; also carries &lt;code&gt;ad_group_id&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt;. The ID columns are nullable where the relationship doesn't apply.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5wowcd1fafbw8t10bnqw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5wowcd1fafbw8t10bnqw.png" alt="Graph Denormalized" width="799" height="545"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now the "find" queries get simpler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- All keywords in a shop&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;shop_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;shopId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;

&lt;span class="c1"&gt;-- All keywords in a group&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;groupId&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;groupId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;

&lt;span class="c1"&gt;-- All keywords in an ad&lt;/span&gt;
&lt;span class="c1"&gt;-- Step 1: get the ad's group (single PK read)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;adId&lt;/span&gt;
&lt;span class="c1"&gt;-- Step 2: pull keywords from both sources&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;adId&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ad_group_keyword&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ad_group_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;adGroupId&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are direct lookups on indexed columns. There's no join for the planner to mis-optimize, no intermediate result set whose size depends on data distribution. The query behavior is the same whether a shop has 50 keywords or 50,000.&lt;/p&gt;

&lt;p&gt;The third case is two reads, but step 1 is just a primary-key lookup on the &lt;code&gt;ad&lt;/code&gt; row we'd usually be fetching anyway.&lt;/p&gt;

&lt;p&gt;The cost is that denormalization is now a system, not a single column. Moving an ad updates &lt;code&gt;ad_group_id&lt;/code&gt; across its keywords; soft-deleting an ad updates &lt;code&gt;status&lt;/code&gt; across its keywords. Two operations, both fan out to the keyword rows, both have to be transactional or the data drifts.&lt;/p&gt;

&lt;p&gt;Drift is the part that worries me. The query patterns get dramatically cleaner, but the write paths get more places they could go wrong. Six months later, someone adds a new way to move ads between groups and forgets to update the keywords. The reads quietly return wrong results. So I'm not sure denormalization is the answer either.&lt;/p&gt;

&lt;p&gt;What surprised me is that I'd been treating it as a normalization problem ("where should the foreign keys go?") instead of a modeling problem ("what shape does the data actually have?"). The graph perspective is what reframed it for me.&lt;/p&gt;




&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;The data was always graph-shaped. The queries were always graph queries. The schema and the application code were doing graph work without the vocabulary to describe it. And once I could see the shape, alternatives I hadn't been considering became visible, even if I haven't decided which one is right.&lt;/p&gt;

&lt;p&gt;I'm not sure I'll ever reach for a graph database. But learning about them is already changing how I think about modeling, even though I'm not using one.&lt;/p&gt;

</description>
      <category>software</category>
      <category>database</category>
    </item>
    <item>
      <title>TIL - What Response Time Metrics Really Mean</title>
      <dc:creator>M. Alwi Sukra</dc:creator>
      <pubDate>Sun, 10 May 2026 07:55:53 +0000</pubDate>
      <link>https://dev.to/arkoesalwi/til-what-response-time-metrics-really-mean-1df9</link>
      <guid>https://dev.to/arkoesalwi/til-what-response-time-metrics-really-mean-1df9</guid>
      <description>&lt;p&gt;I always thought high percentiles didn't really matter, they only impact a small number of users, right? I interpreted them as the worst case (something unlikely to affect most users).&lt;/p&gt;

&lt;p&gt;This week I read DDIA and came across the part describing how Amazon sets their response time requirements at p99.9. That means the requirement is based on 1 in 1000 users :). But the reason is something I never thought of: the users in the high percentiles are most likely the ones with the most data, which makes them important users for Amazon.&lt;/p&gt;

&lt;p&gt;I reflected on this with my experience working on an Ads Platform. Some processes were slow and it was almost always the same small group of users with many ads, which I assume also correlates with ads revenue contribution. I wonder if we had designed the system around those high-percentile users, maybe we could have made the platform better for all users, and best for our most important sellers.&lt;/p&gt;




&lt;h3&gt;
  
  
  Response time isn't the same as latency
&lt;/h3&gt;

&lt;p&gt;I don't know why, but somehow I just know that response time and latency are different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Service time&lt;/strong&gt;: how long the server actually spends processing the request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt;: time the request spends waiting (queued, in transit, blocked).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response time&lt;/strong&gt;: what the caller sees: service time + network + queueing + everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Response time is from the caller's perspective. Service time is from the callee's. They're almost never equal. I think this is important because most of us only track one side.&lt;/p&gt;




&lt;h3&gt;
  
  
  Average hides the shape
&lt;/h3&gt;

&lt;p&gt;Response times aren't a single number, they're a distribution. Most requests are fast, a few are very slow, and the average sits somewhere awkward between them.&lt;/p&gt;

&lt;p&gt;Average doesn't tell us how many users actually experienced the delay. An average of 200ms can mean everyone gets ~200ms, or that most get 50ms while a few get 2 seconds. The average doesn't tell us which one we have.&lt;/p&gt;

&lt;p&gt;That's why averages aren't enough. We need a metric that respects the shape.&lt;/p&gt;




&lt;h3&gt;
  
  
  Percentiles, properly
&lt;/h3&gt;

&lt;p&gt;A percentile shows "what response time were X% of requests faster than?"&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;p50: half were faster.&lt;/li&gt;
&lt;li&gt;p95: 95% were faster, 5% were slower.&lt;/li&gt;
&lt;li&gt;p99: 99% were faster, 1% were slower.&lt;/li&gt;
&lt;li&gt;p99.9: 99.9% were faster, 0.1% were slower.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If p99 = 500ms, it means 1 out of every 100 requests took longer than 500ms. That's the part I used to dismiss as noise.&lt;/p&gt;




&lt;h3&gt;
  
  
  Which percentile to chase
&lt;/h3&gt;

&lt;p&gt;Once we accept the tail matters, the next question is how far in?&lt;/p&gt;

&lt;p&gt;Honestly, I don't know how to answer the question. Maybe the choice isn't really technical and it's a business question: which users have we decided to serve well? p99 means we're serving 99% of requests well. p99.9 means we're including heavy users (the ones who, going back to the Amazon insight, probably matter most).&lt;/p&gt;




&lt;h3&gt;
  
  
  A few things I wish I'd known earlier
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure at both caller and callee&lt;/strong&gt;. Callee might report p99 = 50ms while caller sees p99 = 300ms for the same calls. The 250ms gap is in the network, the connection pool, queueing, or the caller's own thread pool. If we only look at one side, we miss it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts decouple the metrics&lt;/strong&gt;. If the caller times out at 200ms and the callee takes 500ms, the callee's dashboard shows a successful 500ms response to a request the caller already gave up on. Both metrics are technically correct but are misleading on their own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't average percentiles across servers&lt;/strong&gt;. This is my second confession. For years, when our dashboard showed p99 from multiple servers, I'd take the average of those numbers and call it our "global p99." That's mathematically meaningless. The average of ten p99s is not the p99 of the combined population. The right way is to merge the underlying histograms first, then compute the percentile.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Takeaway
&lt;/h3&gt;

&lt;p&gt;A metric isn't just a number. It's a statement about which users we've decided to serve well.&lt;/p&gt;

&lt;p&gt;An average says "I care about the typical user." p99 says "I care about almost everyone." p99.9 says "I care about the heavy users too, the ones who probably matter most to the business."&lt;/p&gt;

&lt;p&gt;For years, I was implicitly choosing the first one without realizing I was choosing anything.&lt;/p&gt;

</description>
      <category>software</category>
      <category>monitoring</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
