Sitemap
Boovaneshbk

Boovaneshbk

Backend Developer with hands-on experience building and optimizing Go-based microservices, distributed systems, and REST APIs

Why One Database Is Not Enough: A Practical Case for Polyglot Persistence

8 min readAug 4, 2026

--

How Redis and Cassandra sit next to Postgres/MongoDB to make backend systems actually scale — with real code references.

Press enter or click to view image in full size

The “one database for everything” trap

Almost every backend I’ve seen — including systems I’ve personally worked on — starts the same way: one relational (or document) database holding everything. User records, auth tokens, session state, business transactions, logs, all of it, in one place.

It works. Until it doesn’t.

The clearest symptom shows up around authentication and sessions. A typical flow looks like this:

  • User logs in → generate a token → write it to the main DB
  • Every request → read the DB to check if the token/session is valid
  • Token refresh → update the row in the DB again
  • Logout → delete/update the row

At low traffic, this is invisible. At real traffic, you’ve turned your primary transactional database — the one also handling orders, payments, and business logic — into a session-lookup machine. Every page view, every API call, is now competing with checkout traffic for the same connection pool, the same disk I/O, the same locks.

That’s the core idea behind polyglot persistence: don’t force one database to be good at everything. Use the right storage engine for each access pattern, not just each feature.

Why this hits differently depending on your users

Not all slowness is equal, and this is where a lot of engineers underestimate the problem.

Think about a government portal. It’s slow, occasionally clunky, and users grumble — but they tolerate it, because it’s not a daily-use product. You visit it once to renew a license and leave.

Now think about an e-commerce checkout page, or a B2B SaaS dashboard someone opens 40 times a day. The tolerance for latency is completely different. A 2-second delay on “add to cart” during a flash sale isn’t an inconvenience — it’s lost revenue and users bouncing to a competitor tab. B2B users might forgive a slow monthly report; they will not forgive a slow day-to-day workflow tool.

This is exactly why the database layer can’t be an afterthought — it directly shapes whether your product feels fast where it matters most.

Redis: solving the auth/session/caching problem properly

Redis is an in-memory data store — not a replacement for your system of record, but the layer in front of it for anything that’s temporary, frequently accessed, or needs atomic operations.

In a small reference project I built (go-redis) — a Go + Redis + PostgreSQL e-commerce backend — this shows up concretely:

  • Auth & sessions: tokens are stored in Redis with a TTL. Validating a session is an O(1) GET, not a query against a relational table. Logout is an immediate DELETE; expiry is automatic — no cron job cleaning up stale sessions.
  • API response caching: hot endpoints like product listings are cached with TTL, with invalidation on update, so most reads never touch Postgres at all.
  • Flash sales / high-demand traffic: SETNX for distributed locks stops overselling, DECR/INCR give atomic, race-condition-free stock updates, and rate limiting is just an INCR with an expiry.
  • Shopping cart: stored as a Redis Hash — constant-time add/update/remove, with the cart TTL handling abandoned-cart cleanup automatically.
  • Leaderboards & geospatial: Sorted Sets for rankings, GEO commands for “shops near me” — both are awkward and slow to do repeatedly in a relational table, and near-instant in Redis.

None of this replaces Postgres. Postgres still owns the actual order, the actual inventory count on disk, the actual user row. Redis just takes the high-frequency, latency-sensitive, disposable-state traffic off its back.

Cassandra: when you need to write a lot, fast, without blocking the money path

Redis solves “read this constantly, and it’s fine if it’s temporary.” Cassandra solves a different problem: “I need to write huge volumes of data continuously, and reading it later must be fast, but it can never block my core transactional writes.”

In another reference project (Cassandra-SQL-ENT) — a Go banking/wallet system — the split is deliberate:

  • PostgreSQL owns money. Wallet balances and the ledger need row-level locking (SELECT … FOR UPDATE), foreign keys, CHECK constraints, and real ACID transactions. Transfers use ordered row locks and idempotency keys to prevent double-spends and duplicate transfers. This is not something you compromise on for a financial core.
  • Cassandra owns the logs. transaction_logs, audit_logs, and user_activity are append-only and write-heavy, and they're queried by natural partition keys like (user_id, month_bucket) or (entity_type, entity_id). These writes go through an async worker pool — they never sit inside the same transaction as the money movement, and a Cassandra hiccup never rolls back a Postgres transfer.

The general pattern worth calling out: writes for the source of truth go to a strongly consistent store; high-volume reads/history go to a store built for horizontal write throughput and partition-based lookups. You could describe it loosely as PUT/POST landing in Postgres, and GET-heavy history/audit trails being served from Cassandra — because Cassandra is built to scale writes and reads horizontally across nodes without fighting for the same locks your OLTP engine needs.

Putting it together

A backend that scales well usually isn’t “pick the best database” — it’s “pick the right database for each job, and let them work together”:

Get Boovaneshbk’s stories in your inbox

Join Medium for free to get updates from this writer.

Concern Store Why Auth tokens / sessions Redis In-memory, TTL-native, O(1) validation Hot read caching Redis Skips the DB entirely for repeat reads Rate limiting, locks, counters Redis Atomic ops (INCR/DECR/SETNX) built in Core transactional data (orders, wallets, users) PostgreSQL / MongoDB ACID, constraints, row locking High-volume logs, audit trails, activity history Cassandra Append-only, partition-based, scales writes horizontally

Measuring it: averages lie, p95/p99 don’t

Once you split reads/writes across multiple stores, “is it fast?” stops being an average-latency question. Average latency hides exactly the users you’re trying to protect.

  • p50 (median) tells you the typical experience.
  • p95 tells you what your slowest 1-in-20 requests feel like.
  • p99 tells you what your slowest 1-in-100 requests feel like — and at real traffic volumes, 1-in-100 is thousands of real users per day.

A single slow Postgres query hidden behind a fast average is exactly the kind of thing polyglot persistence is meant to fix, but only if you’re actually watching the right number. If your session-check endpoint has a p50 of 20ms but a p99 of 4 seconds, that 4-second tail is very likely the one path still hitting the primary DB instead of Redis, or a query fighting for a lock. Track p95/p99 per database and per endpoint, not just globally — a healthy overall p99 can still be masking one specific query pattern (e.g., an un-indexed transactions history scan) that's dragging a subset of users.

This is also the practical reason the Cassandra split in the banking example matters: a transaction_logs write competing for the same lock as a wallets balance update would show up as p99 spikes on the money path — exactly the path where you can least afford it.

Failure isn’t optional: connection pools, cascades, and circuit breakers

More databases means more places where “the database is fine but slow” turns into “everything is down.” A few patterns worth designing in from day one:

  • Connection pool exhaustion. Every DB client (Postgres, Redis, Cassandra driver) has a fixed pool of connections. Under load, a slow downstream query holds connections longer, the pool fills up, new requests queue waiting for a connection, and latency compounds — a small slowdown becomes a full outage. Sizing pools per service, setting connection acquire timeouts, and alerting on pool saturation (not just DB CPU) catches this before it cascades.
  • Circuit breaker. Wrap calls to each downstream store (especially secondary/non-critical ones like a cache or an async logging store) in a circuit breaker. If Redis starts erroring or timing out repeatedly, the breaker “opens” — the app stops hammering a dying Redis and falls back to Postgres (or skips the cache) for a cooldown period, instead of every request piling up waiting on a store that’s already unhealthy. This is precisely why the banking example never lets a Cassandra failure roll back a Postgres transfer: the worker pool degrades (logs, drops under backpressure) instead of taking the money path down with it.
  • Retries with backoff, not retries by default. Blind retries on a struggling database make the problem worse — you’re adding load to something already failing. Retry only on specific, known-transient errors (deadlocks, serialization failures), with exponential backoff and a retry ceiling.
  • Bulkheads. Give each downstream dependency its own connection pool/thread pool so a slow Cassandra client can’t starve the connections your Postgres client needs. One failing dependency shouldn’t be able to take every other dependency down with it.

Idempotency: the pattern that makes retries and multi-DB writes safe

Once you have retries, circuit breakers, and multiple stores in play, the same logical operation can end up being attempted more than once — a client times out and retries, a breaker half-opens and lets a request through twice, a network blip causes a duplicate call. Without idempotency, a “transfer $50” retry can become two transfers.

The pattern: the client sends a stable idempotency key per logical operation. The server checks if that key has already been processed — if so, it returns the original result instead of executing the operation again. In practice (as in the wallet example) this looks like:

  1. Client sends an idempotency_key with the request.
  2. The server takes a lock scoped to that key (e.g., an advisory lock) so concurrent retries with the same key don’t race each other.
  3. If the key already exists in an idempotency_keys table, return the existing transaction/result — don't redo the work.
  4. A UNIQUE constraint on the key is the backstop if application logic ever misses a check.

This is what makes it safe to add retries and circuit breakers in the first place — without idempotency, resilience patterns that retry on failure become a source of duplicate side effects instead of a safety net.

The honest trade-offs

Polyglot persistence isn’t free, and it’s worth saying so plainly:

  • More moving parts to operate — Redis, Postgres, Cassandra all need monitoring, backups, and failure handling separately.
  • Eventual consistency creeps in — data in Cassandra or a cache can lag behind Postgres by design (as in the async log worker above); your application has to be okay with that lag for the specific use case.
  • Team/learning overhead — every extra data store is another thing your team needs to understand deeply enough to debug at 2 a.m.

If your system genuinely has low traffic and simple access patterns, a single well-indexed database is not “lame” — it’s the right call. The point isn’t “always use three databases.” It’s recognizing the moment your access patterns diverge enough (session lookups vs. money movement vs. audit history) that one engine can no longer serve all of them well.

Reference implementations discussed in this article:

  • go-redis — Go + Redis + PostgreSQL e-commerce backend (auth, caching, flash sales, cart, leaderboard, geo)
  • Cassandra-SQL-ENT — Go + Ent + PostgreSQL + Cassandra banking/wallet system (ACID transfers + append-only logging)

--

--

Boovaneshbk
Boovaneshbk

Backend Developer with hands-on experience building and optimizing Go-based microservices, distributed systems, and REST APIs