Lasha's blog

Taming Databases in Microservices

tl;dr: Microservices give flexibility and independence, but databases don’t scale as easily as code. To survive, you need sharding, replication, caching, polyglot persistence, and a healthy respect for indexes.


Why Databases Are the Hardest Part

Microservice architectures promise flexibility, scalability, and independent teams. But databases are the stubborn part. They are stateful, not stateless, which makes them hard to scale horizontally. Unlike application servers, you can’t just spin up ten more nodes and call it a day.

In microservices, each service often has its own database, which creates new challenges:

Let’s look at these problems one by one — and the strategies that actually work.


Problem 1: Scaling Relational Databases

Relational databases (SQL) are stateful. You can throw more CPU and RAM at them (vertical scaling), but that hits limits quickly. Horizontal scaling — splitting across servers — is complex because of consistency.

Solution: Sharding and Federated Databases

Sharding means splitting the data into logical parts.

Examples:

In SQL Server, the Federated Database approach routes inserts and queries to the right shard using filters and keys. Instead of one massive DB, you have multiple smaller ones, each responsible for part of the data.

It’s messy, but it works — and suddenly, one bottleneck DB becomes many manageable ones.


Problem 2: Different Data Needs

Not every service has the same requirements.

Trying to force one database type to serve them all is a recipe for pain.

Solution: Polyglot Persistence

Use the right database for the job. This is called polyglot persistence.

Yes, it adds complexity (more databases to manage), but it avoids bending one system into something it’s not.


Problem 3: Wrong Requirements Early

At the start of a project, it’s easy to pick a database because it’s “popular” or “easy.” Later, you realize it’s not suited for the load or consistency you actually need.

Solution: Proper Requirements Analysis

Before picking a database for each microservice, check:

Asking these questions up front prevents painful migrations later.


Problem 4: Synchronizing Data

When data changes in one service, others often need to know. Without synchronization, systems drift into inconsistent states.

Example: In an e-commerce system, if a customer updates their email in the account service, the notification service also needs to know — otherwise, messages go to the wrong place.

Solution: Event-Driven Architecture & CDC

Event-driven is best for workflows. CDC works well for read-heavy systems where you don’t want to add more writes.


Beyond the Core Problems: Reliability Matters Too

So far, we’ve focused on scaling and performance problems: how to split databases, use the right type, or keep services in sync. But microservices don’t just need to be fast — they need to be reliable.

A single database outage can take down a whole service (or worse, your entire system). That’s why replication, caching, and tuning are just as important as sharding or polyglot persistence.


Replication and High Availability

Replication means keeping copies of the database on multiple nodes.

Benefits:

Example: In PostgreSQL, streaming replication copies the Write Ahead Log (WAL) to standby nodes in real time. If the master dies, a standby takes over.

This makes the system more reliable and also helps with scaling reads.


Caching: The Pressure Valve

Sometimes the best optimization is to avoid hitting the DB at all.

With Redis or Memcached, you can cache frequent queries:

This massively reduces DB load and speeds up responses.


Query and Schema Optimization

Scaling is not always about bigger hardware. Often it’s about smarter queries.

These basics often buy you months of breathing space before needing heavier solutions like sharding.


Indexes: The Secret Weapon

Indexes are where you get real performance gains. In microservices, where each service owns its own DB, indexes are often the key to keeping things fast.

Why Indexes Matter

Example: Pending Orders

Imagine a SaaS system with millions of rows in an Orders table. You run a query like this:

SELECT * 
FROM orders 
WHERE shipped_at IS NULL 
ORDER BY created_at;

It asks for all unshipped orders, sorted by creation time. As the table grows, this query gets slower and slower.

The result: UI delays, background jobs hanging, even blocked connection pools.

Why the DB Chooses Bad Plans


Fixing It

1. Partial Index

CREATE INDEX idx_orders_unshipped_created 
ON orders(created_at) 
WHERE shipped_at IS NULL;

This indexes only the rows you care about (unshipped). Smaller, faster, and updates only happen when relevant rows change.

Result: query time dropped from ~3.3s to ~230ms.


2. Combined Index

CREATE INDEX idx_orders_shipped_created 
ON orders(shipped_at, created_at);

This organizes rows by shipped_at and then by created_at. It helps, but the index is larger (covers all rows), so it’s less efficient than the partial index.


3. Extended Statistics

Postgres can better optimize if it knows the real data distribution:

ALTER TABLE orders ALTER COLUMN shipped_at SET STATISTICS 1000;
ALTER TABLE orders ALTER COLUMN created_at SET STATISTICS 1000;
ANALYZE orders;

This helps the planner make smarter choices.


Extra Microservices Considerations


Best Practices

From experience, here’s what works best:


Lessons Learned