Scaling a Taxonomy Service and Database: Architecture and Bottlenecks

Arpit Bhayani

Arpit Bhayani

May 02, 2021 • 6 min read

Play

A Taxonomy Service is a foundational component across e-commerce, content platforms, and search engines. It manages category trees, classifications, product attributes, and tagging hierarchies. While the access pattern is heavily read-biased, scaling a taxonomy service introduces unique architectural challenges—particularly around hierarchical query patterns, cache invalidation cascading, and database connection limits.


1. High-Level Architecture Overview

A traditional taxonomy service setup typically follows a standard stateless tier with an in-memory cache and a relational backing store:

flowchart TD
    Client[Client / Internal Services] --> APIGW[API Gateway]
    APIGW --> LB[Load Balancer]
    LB --> App1[Taxonomy Service Node 1]
    LB --> App2[Taxonomy Service Node 2]
    LB --> AppN[Taxonomy Service Node N]
    
    App1 & App2 & AppN --> Cache[(Distributed Cache - Redis)]
    App1 & App2 & AppN --> DB[(Relational Database)]

Request Lifecycle

  1. Ingress: Client requests arrive at the API Gateway, which authenticates, applies rate limiting, and routes them to the taxonomy cluster.
  2. Load Distribution: A layer-7 or layer-4 load balancer evenly distributes requests across stateless taxonomy service nodes.
  3. Cache Lookups (Cache-Aside Pattern): The application checks the distributed cache (e.g., Redis or Memcached). If a cache hit occurs, the data is returned immediately.
  4. Database Query: On a cache miss, the service queries the relational database (RDBMS), parses the hierarchical category payload, populates the cache with a time-to-live (TTL), and responds to the client.

2. Common Scaling Pitfalls and Bottlenecks

While adding more stateless application instances behind a load balancer is trivial, scaling the data access tier for hierarchical taxonomy data creates severe friction points.

A. Connection Exhaustion on the Primary Database

As traffic surges, auto-scaling adds dozens or hundreds of application instances. If each instance opens its own connection pool (e.g., 20–50 connections) directly to the database:

Total Connections=Number of Instances×Pool Size per Instance\text{Total Connections} = \text{Number of Instances} \times \text{Pool Size per Instance}

Relational databases like PostgreSQL or MySQL allocate dedicated memory and processes/threads per connection. Uncontrolled connection counts lead to context-switching overhead, memory exhaustion, and eventual connection rejection.

B. The Tree Traversal Query Problem

Taxonomies are graphs or trees (root categories, subcategories, leaf nodes). When modeled in an RDBMS using simple adjacency lists (parent_id pointers):

  • Fetching an entire branch or validating ancestral paths requires recursive queries (WITH RECURSIVE CTEs) or multiple sequential network round-trips.
  • At scale, these queries are CPU-intensive and place a high read strain on the database engine whenever cache misses occur.

C. Cascading Cache Invalidation

When a taxonomy node changes (e.g., re-parenting a category, renaming a parent node, or reordering subcategories):

  • Invalidating just the modified node leaves dependent descendant paths stale.
  • Busting the entire category cache triggers a cache stampede (thundering herd), where thousands of concurrent requests bypass the cache and overwhelm the underlying database simultaneously.

D. Replication Lag and Stale Reads

Introducing read replicas offloads read traffic from the primary instance. However:

  • Asynchronous replication introduces a replication lag window.
  • Updates to category structures made by admin tools may not immediately reflect across all read replicas, leading to inconsistent browsing experiences or broken navigation trees.

3. Strategies for Scaling the Taxonomy Data Layer

flowchart LR
    App[Taxonomy Service] --> Proxy[Connection Pooler / ProxySQL / PgBouncer]
    Proxy --> Primary[(Primary DB - Writes)]
    Proxy --> Replica1[(Read Replica 1)]
    Proxy --> Replica2[(Read Replica 2)]
    
    Primary -. Replication .-> Replica1
    Primary -. Replication .-> Replica2

1. Dedicated Connection Pooling Layer

Instead of letting application nodes connect directly to database nodes, deploy a dedicated proxy layer such as PgBouncer (for PostgreSQL) or ProxySQL (for MySQL):

  • Decouples client connections from server connections.
  • Allows thousands of application threads to share a fixed pool of 50–100 active database connections using transaction-level pooling.

2. Modeling Hierarchical Data for Read Performance

Depending on the read-to-write ratio, evaluate alternatives to naive adjacency lists:

Modeling PatternRead Complexity (Subtree)Write Complexity (Insert/Move)Best Used When
Adjacency ListO(N)O(N) via Recursive CTEO(1)O(1)Small trees, frequent structural updates
Path Enumeration (Materialized Path)O(1)O(1) via LIKE '/electronics/audio/%'O(N)O(N) (requires path updates for children)Category breadcrumbs, deep static trees
Closure TableO(1)O(1) via separate relation tableO(N)O(N) row insertsComplex DAGs, high read throughput needed

3. Multi-Tier Caching (L1 Local + L2 Distributed)

Because category trees rarely change relative to product reads, querying Redis on every request can still saturate Redis bandwidth at hyper-scale.

  • L1 Cache (In-Memory): Maintain the hot category tree directly in application memory (e.g., Caffeine/Guava in Java, or in-process memory in Go).
  • L2 Cache (Distributed): Redis cluster acts as the shared source of truth.
  • Change Propagation: When an update occurs, publish an event over Redis Pub/Sub or Kafka. Application instances listen to this event and evict or reload their local L1 cache.
sequenceDiagram
    autonumber
    participant Admin as Admin Portal
    participant DB as Primary DB
    participant Bus as Invalidation Bus (Kafka/Redis)
    participant App as App Instance (L1 Cache)
    participant Redis as L2 Cache

    Admin->>DB: Update Category Node
    Admin->>Redis: Invalidate / Update L2 Cache
    Admin->>Bus: Publish 'CategoryUpdated' Event
    Bus-->>App: Broadcast Invalidation
    App->>App: Evict local L1 In-Memory Cache

4. Snapshot-Based Tree Serving

For enterprise catalogs where taxonomy changes happen in scheduled batches:

  • Treat the entire category tree as a immutable, versioned artifact (e.g., JSON blob or Protobuf payload).
  • Store this snapshot in object storage (S3) or an in-memory datastore.
  • Application servers load the entire snapshot into memory on startup and poll for new version tags every few minutes. Reads become zero-hop memory lookups (O(1)O(1) with no network I/O).

4. Key Takeaways

  1. Horizontal App Scaling Isn’t Enough: Adding more application nodes without addressing database connections and query models simply pushes the bottleneck to the database connection pool.
  2. Optimize for the 99:1 Read Bias: Taxonomy structures are overwhelmingly read-heavy. Push reads as close to the application runtime as possible using in-memory (L1) caching or pre-computed snapshots.
  3. Use Connection Multiplexers: Always place connection poolers (ProxySQL, PgBouncer) in front of relational databases when auto-scaling application tiers.
  4. Carefully Design Cache Invalidation: Avoid naive full-cache flushes on category updates; use targeted pub/sub broadcasts or versioned snapshots to protect the database from thundering herd events.
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses