Database Best Practices for Scalable Applications
By Oleksandr Andrushchenko — Published on
Database scalability is rarely solved by one optimization. Production systems scale through a combination of correct schemas, bounded queries, selective indexes, short transactions, controlled concurrency, caching, partitioning, replication, observability, and safe operational workflows.
The strongest database design is not the one that handles the highest synthetic throughput. It is the one that preserves correctness, predictable latency, recoverability, and manageable cost as traffic, data volume, tenant count, and engineering teams grow.
Table of Contents
- Design for Access Patterns and Invariants
- Build Efficient Schemas and Indexes
- Control Queries and Concurrency
- Scale Reads and Writes Deliberately
- Design Safe Write Paths
- Production Design Example
- Ready-to-Use Example
- Operational Practices
- Common Mistakes
- Production Checklist
- Conclusion
Design for Access Patterns and Invariants
A scalable database design starts by defining what must remain correct and how the data will be accessed. Tables, indexes, caches, replicas, and partitions should follow those requirements.
For each important workflow, document:
- the business invariant
- the required input keys
- the expected result size
- the sort order
- the maximum acceptable latency
- the required consistency level
- the peak concurrency
- the retry and failure behavior
| Workflow | Invariant | Access Pattern | Design Implication |
|---|---|---|---|
| Create order | One customer reference creates at most one order | Write by account and external reference | Unique constraint and idempotent insert |
| Reserve inventory | Available quantity cannot become negative | Atomic update by warehouse and SKU | Conditional update inside a transaction |
| List recent shipments | Results must be tenant-isolated | Filter by account and cursor, sort by time | Composite index and keyset pagination |
| Search historical records | Search may be eventually consistent | Flexible text and attribute filters | Asynchronous search projection |
Correctness requirements should be enforced as close to the data as possible. Application validation is useful, but it cannot replace unique constraints, foreign keys, checks, and atomic updates under concurrency.
CREATE TABLE customer_orders (
order_id UUID PRIMARY KEY,
account_id BIGINT NOT NULL,
external_reference TEXT NOT NULL,
status TEXT NOT NULL,
total_amount_cents BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT customer_orders_reference_unique
UNIQUE (account_id, external_reference),
CONSTRAINT customer_orders_status_check
CHECK (status IN (
'pending',
'confirmed',
'cancelled',
'completed'
)),
CONSTRAINT customer_orders_amount_check
CHECK (total_amount_cents >= 0)
);
The unique constraint remains correct even when several application instances process the same request concurrently.
Build Efficient Schemas and Indexes
Schema and index design determine how much data the database must read, write, lock, replicate, cache, back up, and maintain. Small design choices become expensive at scale because they affect every row and request.
Choose Stable Data Types
Use data types that reflect the business domain and remain stable as the system grows.
- Use integers for monetary amounts when fixed precision is sufficient.
- Use timestamps with time zones for global systems.
- Use constrained text values or database enums for stable state machines.
- Use UUIDs when distributed identifier generation is required.
- Keep frequently filtered values in typed columns instead of hiding everything in JSON.
- Use JSON for flexible metadata, not for core relational ownership and constraints.
A hybrid schema can keep stable queryable fields in columns and variable provider data in JSON:
CREATE TABLE carrier_events (
event_id UUID PRIMARY KEY,
account_id BIGINT NOT NULL,
shipment_id UUID NOT NULL,
carrier_code TEXT NOT NULL,
event_type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
provider_payload JSONB NOT NULL,
CONSTRAINT carrier_events_event_type_check
CHECK (event_type IN (
'picked_up',
'in_transit',
'delayed',
'delivered',
'exception'
))
);
This keeps operational filters efficient while preserving the original provider payload for debugging and reconciliation.
Create Purpose-Built Indexes
An index should support a measured query. Every additional index increases insert latency, update cost, replication volume, storage, vacuum work, and backup size.
For an account shipment timeline:
CREATE INDEX shipments_account_created_idx
ON shipments (
account_id,
created_at DESC,
shipment_id DESC
)
INCLUDE (
status,
carrier_code,
tracking_number
);
This index supports tenant filtering, stable ordering, keyset pagination, and index-only reads for common summary fields.
Column order should follow the query:
SELECT
shipment_id,
status,
carrier_code,
tracking_number,
created_at
FROM shipments
WHERE account_id = 4812
AND (created_at, shipment_id) < (
TIMESTAMPTZ '2026-08-02 15:00:00+00',
'c9b37146-e8ac-4e76-8b10-b2226aa1cc77'
)
ORDER BY created_at DESC, shipment_id DESC
LIMIT 100;
The account identifier comes first because it is an equality predicate. The ordered cursor fields follow because they control range filtering and sorting.
Use partial indexes when only a small subset of rows is operationally important:
CREATE INDEX shipments_active_exception_idx
ON shipments (
account_id,
updated_at DESC
)
WHERE status IN ('delayed', 'exception');
This is smaller and cheaper than indexing the status of every completed historical shipment.
Avoid Unbounded Record Growth
Rows and documents should not grow indefinitely. Unbounded arrays, histories, comments, events, and embedded collections eventually create large writes, lock contention, storage amplification, and record-size limits.
Store unbounded collections separately:
Shipment
|
+-- fixed shipment metadata
+-- current status
+-- current carrier
|
+--> Shipment Events table
+--> Shipment Documents table
+--> Shipment Comments table
The aggregate keeps only current state. Historical and repeating entities receive their own rows, indexes, retention rules, and pagination.
Control Queries and Concurrency
Database overload is often caused by uncontrolled work rather than insufficient hardware. One unbounded query, connection storm, lock queue, or retry loop can consume the capacity intended for thousands of normal requests.
Require Bounded Queries
Every high-volume list endpoint should require:
- a tenant or ownership filter
- a maximum page size
- a stable sort order
- a cursor or bounded time range
- a query timeout
Avoid offset pagination for deep datasets:
-- Avoid for large offsets.
SELECT shipment_id, status, created_at
FROM shipments
WHERE account_id = 4812
ORDER BY created_at DESC
OFFSET 500000
LIMIT 100;
The database still walks through the skipped rows. Keyset pagination reads from a known position:
SELECT shipment_id, status, created_at
FROM shipments
WHERE account_id = 4812
AND (created_at, shipment_id) < (:cursor_time, :cursor_id)
ORDER BY created_at DESC, shipment_id DESC
LIMIT 100;
Keyset pagination provides stable performance as the dataset grows.
Keep Transactions Short
Long transactions retain row versions, hold locks, delay cleanup, increase deadlock probability, and make failure recovery more expensive.
Do not call external services inside a database transaction:
Bad flow:
BEGIN
INSERT payment
call external payment provider
wait for network response
UPDATE invoice
COMMIT
Problems:
- locks remain held during network latency
- provider timeout extends transaction duration
- retries become ambiguous
- deadlock risk increases
Commit local state and an outbox event, then perform external work asynchronously:
BEGIN
INSERT payment attempt
INSERT outbox event
COMMIT
|
v
Payment worker
|
+--> call provider
+--> update result in a new short transaction
Protect the Database with Admission Control
Increasing the connection count does not automatically increase throughput. Too many concurrent queries increase context switching, memory use, lock contention, and storage queue depth.
Application connection pools should be bounded:
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
def create_database_engine(database_url: str) -> AsyncEngine:
return create_async_engine(
database_url,
pool_size=20,
max_overflow=10,
pool_timeout=5,
pool_recycle=1_800,
pool_pre_ping=True,
connect_args={
"server_settings": {
"statement_timeout": "5000",
"lock_timeout": "1500",
"idle_in_transaction_session_timeout": "10000",
}
},
)
Pool and timeout values must be tested against database capacity and the number of application instances. One hundred containers with thirty connections each can overwhelm a database configured for several hundred active sessions.
Admission control should also exist at the request level. Expensive exports, backfills, and analytics jobs should have separate concurrency limits from customer-facing requests.
Scale Reads and Writes Deliberately
Scaling mechanisms solve different bottlenecks. Caches reduce repeated reads, replicas distribute read traffic, partitioning reduces scanned and maintained data, while sharding distributes storage and writes across independent databases.
Use Caching for the Right Data
Caching is useful when data is read frequently, changes less often, and can tolerate a defined staleness window.
Good cache candidates:
- configuration
- reference data
- permissions with short expiration
- computed summaries
- frequently requested entity details
Poor cache candidates:
- highly volatile records
- security decisions without controlled invalidation
- financial balances requiring authoritative reads
- large values with low reuse
- queries whose keys are not stable
| Pattern | Advantages | Disadvantages | Best Use |
|---|---|---|---|
| Cache-aside | Simple and application-controlled | Stale values and cache misses require handling | Read-heavy entity and configuration data |
| Write-through | Cache updated with every write | Adds write latency and partial-failure complexity | Systems where cache freshness is important |
| Event-driven invalidation | Decouples write path from cache nodes | Invalidation can lag or fail | Distributed services with asynchronous projections |
Use expiration even when invalidation exists. TTL provides eventual cleanup if an invalidation event is lost.
Use Replicas with Explicit Consistency
Read replicas improve read capacity and isolate reporting traffic, but asynchronous replicas can return stale data.
Route reads based on consistency requirements:
- Use the primary for read-after-write workflows.
- Use replicas for timelines, dashboards, exports, and noncritical reads.
- Fall back to the primary when replica lag exceeds a threshold.
- Do not assume successful writes are immediately visible on replicas.
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
class ShipmentRepository(Protocol):
async def get(self, shipment_id: str) -> dict[str, object] | None:
...
@dataclass(frozen=True)
class ReplicaHealth:
lag_seconds: float
class ShipmentReadRouter:
def __init__(
self,
primary: ShipmentRepository,
replica: ShipmentRepository,
maximum_replica_lag_seconds: float = 2.0,
) -> None:
self._primary = primary
self._replica = replica
self._maximum_replica_lag_seconds = maximum_replica_lag_seconds
async def get(
self,
shipment_id: str,
require_fresh: bool,
replica_health: ReplicaHealth,
) -> dict[str, object] | None:
if require_fresh:
return await self._primary.get(shipment_id)
if replica_health.lag_seconds > self._maximum_replica_lag_seconds:
return await self._primary.get(shipment_id)
return await self._replica.get(shipment_id)
This makes replica consistency an explicit application decision.
Partition Before Sharding
Partitioning can improve large-table pruning, retention, maintenance, and index size while keeping transactions inside one database cluster. Sharding distributes data across separate database instances but adds routing, cross-shard query, transaction, migration, and operational complexity.
Use partitioning when:
- large tables have natural time or tenant boundaries
- retention requires removing old data
- queries can prune unrelated ranges
- one database still has sufficient total write capacity
Consider sharding when:
- the primary write workload exceeds one cluster
- storage cannot fit one database safely
- tenant isolation requires separate failure domains
- connection and CPU demand cannot be reduced or scaled vertically
Sharding should solve a measured single-cluster limit, not anticipated future popularity.
Design Safe Write Paths
Scalable write paths must handle retries, concurrent updates, network timeouts, duplicate messages, and partial failures without corrupting state.
Make Writes Idempotent
Clients retry requests when responses are lost. Workers retry messages after crashes. A scalable system must treat retries as normal behavior.
Use a business key or idempotency key with a unique constraint:
CREATE TABLE request_idempotency (
account_id BIGINT NOT NULL,
idempotency_key TEXT NOT NULL,
request_hash TEXT NOT NULL,
status TEXT NOT NULL,
response_payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (account_id, idempotency_key),
CONSTRAINT request_idempotency_status_check
CHECK (status IN ('processing', 'completed', 'failed'))
);
The request hash prevents reuse of the same key for different request bodies.
Use Optimistic Concurrency
Optimistic concurrency prevents stale requests from overwriting newer state without holding long locks.
UPDATE shipments
SET status = 'in_transit',
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE shipment_id = :shipment_id
AND version = :expected_version
AND status = 'confirmed'
RETURNING shipment_id, status, version;
If no row is updated, the caller must reload current state and decide whether to retry, reject, or merge the change.
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection
class ConcurrentUpdateError(RuntimeError):
pass
@dataclass(frozen=True)
class ShipmentState:
shipment_id: str
status: str
version: int
async def mark_in_transit(
connection: AsyncConnection,
shipment_id: str,
expected_version: int,
) -> ShipmentState:
result = await connection.execute(
text(
"""
UPDATE shipments
SET status = 'in_transit',
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE shipment_id = :shipment_id
AND version = :expected_version
AND status = 'confirmed'
RETURNING shipment_id, status, version
"""
),
{
"shipment_id": shipment_id,
"expected_version": expected_version,
},
)
row = result.mappings().one_or_none()
if row is None:
raise ConcurrentUpdateError(
"Shipment changed before the update was applied"
)
return ShipmentState(
shipment_id=str(row["shipment_id"]),
status=str(row["status"]),
version=int(row["version"]),
)
Separate Transactions from Asynchronous Work
Use the transactional outbox pattern when a committed database change must produce a message.
CREATE TABLE outbox_events (
event_id UUID PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id UUID NOT NULL,
aggregate_version BIGINT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMPTZ,
CONSTRAINT outbox_event_version_unique
UNIQUE (
aggregate_type,
aggregate_id,
aggregate_version
)
);
CREATE INDEX outbox_events_pending_idx
ON outbox_events (created_at)
WHERE published_at IS NULL;
The business write and outbox insert occur in one transaction. A publisher retries until the event is delivered. Consumers must still be idempotent because delivery can happen more than once.
Production Design Example
Consider a multi-tenant logistics platform processing shipment creation, tracking updates, customer timelines, search, and reporting.
The platform must support:
- transactional shipment creation
- high-volume tracking event ingestion
- fast recent shipment lists
- read-after-write consistency for shipment details
- eventually consistent search and analytics
- thousands of concurrent tenant requests
Architecture
Clients
|
v
API Gateway
|
v
Application Services
|
+--------------------+
| |
v v
Redis Cache PostgreSQL Primary
|
+---------+---------+
| |
v v
Read Replicas Outbox Publisher
|
v
Message Broker
|
+-----------------+----------------+
| | |
v v v
Search Index Timeline Cache Analytics Store
PostgreSQL owns authoritative shipment state. Redis caches frequently read summaries. Replicas handle noncritical reads and reporting. Search and analytics remain asynchronous projections.
Request and Data Flows
Shipment creation:
- The API validates the account and idempotency key.
- A short PostgreSQL transaction creates the shipment and outbox event.
- The API commits before publishing external messages.
- The outbox publisher sends the event asynchronously.
- Search, cache, and analytics consumers update their projections.
Shipment details:
- The service checks the cache for noncritical requests.
- A request requiring current state reads from the primary.
- Normal historical reads use a healthy replica.
- The cache receives a short TTL and is invalidated by shipment events.
Tracking event ingestion:
- The API validates and normalizes provider data.
- Events are written in batches where possible.
- The event table is partitioned by ingestion time.
- A selective index supports shipment timeline queries.
- Old partitions are archived and detached according to retention rules.
Failure Scenarios
The cache fails. Reads fall back to the database with request coalescing and strict concurrency limits. The database must not receive an uncontrolled cache-miss storm.
A replica lags. Requests needing freshness use the primary. Reporting traffic may pause if lag exceeds the operational limit.
The publisher crashes after sending an event. The event is published again. Consumers ignore duplicates using event IDs and aggregate versions.
A slow query deployment reaches production. Statement timeouts stop unbounded execution. Query monitoring identifies the new fingerprint, and the deployment can be rolled back.
The database reaches its connection limit. Application pools reject or queue requests instead of opening additional sessions. Noncritical work is shed first.
Storage approaches capacity. Retention jobs remove verified historical partitions, nonessential maintenance pauses, and alerts provide sufficient time to scale storage safely.
Ready-to-Use Example
The following examples combine schema constraints, idempotent writes, bounded pagination, cache-aside reads, and monitoring for a scalable shipment service.
PostgreSQL Schema
CREATE TYPE shipment_status AS ENUM (
'confirmed',
'in_transit',
'delivered',
'cancelled',
'exception'
);
CREATE TABLE accounts (
account_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('active', 'suspended')),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE shipments (
shipment_id UUID PRIMARY KEY,
account_id BIGINT NOT NULL
REFERENCES accounts(account_id),
external_reference TEXT NOT NULL,
status shipment_status NOT NULL,
carrier_code TEXT,
tracking_number TEXT,
version BIGINT NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT shipments_account_reference_unique
UNIQUE (account_id, external_reference),
CONSTRAINT shipments_tracking_unique
UNIQUE NULLS NOT DISTINCT (
carrier_code,
tracking_number
)
);
CREATE INDEX shipments_account_created_idx
ON shipments (
account_id,
created_at DESC,
shipment_id DESC
)
INCLUDE (
status,
carrier_code,
tracking_number
);
CREATE INDEX shipments_active_attention_idx
ON shipments (
account_id,
updated_at DESC
)
WHERE status IN ('exception', 'in_transit');
FastAPI Write Service
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from uuid import UUID, uuid4
from fastapi import FastAPI, Header, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncEngine
class CreateShipmentRequest(BaseModel):
account_id: int
external_reference: str = Field(min_length=1, max_length=100)
carrier_code: str | None = Field(default=None, max_length=20)
tracking_number: str | None = Field(default=None, max_length=100)
@dataclass(frozen=True)
class CreatedShipment:
shipment_id: UUID
status: str
version: int
class ShipmentService:
def __init__(self, engine: AsyncEngine) -> None:
self._engine = engine
async def create(
self,
request: CreateShipmentRequest,
idempotency_key: str,
) -> CreatedShipment:
request_hash = hashlib.sha256(
json.dumps(
request.model_dump(),
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
shipment_id = uuid4()
event_id = uuid4()
try:
async with self._engine.begin() as connection:
idempotency_result = await connection.execute(
text(
"""
INSERT INTO request_idempotency (
account_id,
idempotency_key,
request_hash,
status,
expires_at
)
VALUES (
:account_id,
:idempotency_key,
:request_hash,
'processing',
CURRENT_TIMESTAMP + INTERVAL '24 hours'
)
ON CONFLICT (account_id, idempotency_key)
DO NOTHING
RETURNING idempotency_key
"""
),
{
"account_id": request.account_id,
"idempotency_key": idempotency_key,
"request_hash": request_hash,
},
)
if idempotency_result.scalar_one_or_none() is None:
existing = await connection.execute(
text(
"""
SELECT
request_hash,
status,
response_payload
FROM request_idempotency
WHERE account_id = :account_id
AND idempotency_key = :idempotency_key
"""
),
{
"account_id": request.account_id,
"idempotency_key": idempotency_key,
},
)
row = existing.mappings().one()
if row["request_hash"] != request_hash:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Idempotency key reused with different data",
)
if row["status"] == "completed":
payload = row["response_payload"]
return CreatedShipment(
shipment_id=UUID(payload["shipment_id"]),
status=str(payload["status"]),
version=int(payload["version"]),
)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Request is already processing",
)
shipment_result = await connection.execute(
text(
"""
INSERT INTO shipments (
shipment_id,
account_id,
external_reference,
status,
carrier_code,
tracking_number
)
SELECT
:shipment_id,
account_id,
:external_reference,
'confirmed',
:carrier_code,
:tracking_number
FROM accounts
WHERE account_id = :account_id
AND status = 'active'
RETURNING shipment_id, status, version
"""
),
{
**request.model_dump(),
"shipment_id": shipment_id,
},
)
shipment = shipment_result.mappings().one_or_none()
if shipment is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Active account not found",
)
payload = {
"shipment_id": str(shipment_id),
"account_id": request.account_id,
"status": shipment["status"],
"version": shipment["version"],
}
await connection.execute(
text(
"""
INSERT INTO outbox_events (
event_id,
aggregate_type,
aggregate_id,
aggregate_version,
event_type,
payload
)
VALUES (
:event_id,
'shipment',
:shipment_id,
:version,
'shipment.confirmed',
CAST(:payload AS JSONB)
)
"""
),
{
"event_id": event_id,
"shipment_id": shipment_id,
"version": shipment["version"],
"payload": json.dumps(payload),
},
)
await connection.execute(
text(
"""
UPDATE request_idempotency
SET status = 'completed',
response_payload = CAST(:payload AS JSONB)
WHERE account_id = :account_id
AND idempotency_key = :idempotency_key
"""
),
{
"account_id": request.account_id,
"idempotency_key": idempotency_key,
"payload": json.dumps(payload),
},
)
return CreatedShipment(
shipment_id=shipment_id,
status=str(shipment["status"]),
version=int(shipment["version"]),
)
except IntegrityError as error:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Shipment conflicts with an existing record",
) from error
app = FastAPI()
The idempotency record, shipment, and outbox event commit in one transaction. A retry receives the previous response instead of creating another shipment.
Bounded Pagination
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection
@dataclass(frozen=True)
class ShipmentCursor:
created_at: datetime
shipment_id: UUID
@dataclass(frozen=True)
class ShipmentSummary:
shipment_id: UUID
status: str
carrier_code: str | None
tracking_number: str | None
created_at: datetime
async def list_shipments(
connection: AsyncConnection,
account_id: int,
limit: int,
cursor: ShipmentCursor | None = None,
) -> list[ShipmentSummary]:
safe_limit = min(max(limit, 1), 100)
cursor_filter = ""
parameters: dict[str, object] = {
"account_id": account_id,
"limit": safe_limit,
}
if cursor is not None:
cursor_filter = """
AND (created_at, shipment_id)
< (:cursor_created_at, :cursor_shipment_id)
"""
parameters.update(
{
"cursor_created_at": cursor.created_at,
"cursor_shipment_id": cursor.shipment_id,
}
)
result = await connection.execute(
text(
f"""
SELECT
shipment_id,
status,
carrier_code,
tracking_number,
created_at
FROM shipments
WHERE account_id = :account_id
{cursor_filter}
ORDER BY created_at DESC, shipment_id DESC
LIMIT :limit
"""
),
parameters,
)
return [
ShipmentSummary(
shipment_id=row["shipment_id"],
status=str(row["status"]),
carrier_code=row["carrier_code"],
tracking_number=row["tracking_number"],
created_at=row["created_at"],
)
for row in result.mappings()
]
The method enforces tenant filtering, a maximum page size, stable ordering, and keyset pagination.
Cache-Aside Reader
from __future__ import annotations
import asyncio
import json
from typing import Protocol
class CacheClient(Protocol):
async def get(self, key: str) -> str | None:
...
async def set(
self,
key: str,
value: str,
expiration_seconds: int,
) -> None:
...
class ShipmentRepository(Protocol):
async def get(self, shipment_id: str) -> dict[str, object] | None:
...
class ShipmentCachedReader:
def __init__(
self,
cache: CacheClient,
repository: ShipmentRepository,
ttl_seconds: int = 30,
) -> None:
self._cache = cache
self._repository = repository
self._ttl_seconds = ttl_seconds
self._locks: dict[str, asyncio.Lock] = {}
async def get(
self,
shipment_id: str,
) -> dict[str, object] | None:
cache_key = f"shipment:{shipment_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return json.loads(cached)
# Coalesce concurrent misses inside one application process.
lock = self._locks.setdefault(cache_key, asyncio.Lock())
async with lock:
cached = await self._cache.get(cache_key)
if cached is not None:
return json.loads(cached)
shipment = await self._repository.get(shipment_id)
if shipment is None:
return None
await self._cache.set(
cache_key,
json.dumps(shipment, default=str),
expiration_seconds=self._ttl_seconds,
)
return shipment
Request coalescing reduces repeated database reads during cache misses. A distributed lock is not always necessary; short TTLs and database protection may be simpler and safer.
Database Monitoring Queries
Identify expensive query fingerprints:
SELECT
queryid,
calls,
ROUND(mean_exec_time::NUMERIC, 2) AS mean_exec_ms,
ROUND(total_exec_time::NUMERIC, 2) AS total_exec_ms,
rows,
LEFT(query, 300) AS query_sample
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
Find long-running transactions:
SELECT
pid,
usename,
application_name,
state,
CURRENT_TIMESTAMP - xact_start AS transaction_age,
wait_event_type,
wait_event,
LEFT(query, 300) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY transaction_age DESC;
Review table and index sizes:
SELECT
schemaname,
relname AS table_name,
pg_size_pretty(
pg_total_relation_size(
quote_ident(schemaname) || '.' || quote_ident(relname)
)
) AS total_size,
n_live_tup,
n_dead_tup,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(
quote_ident(schemaname) || '.' || quote_ident(relname)
) DESC
LIMIT 20;
Operational Practices
Scalability depends on how safely the database is changed, monitored, backed up, and recovered.
Use backward-compatible migrations. Add new columns and tables before deploying code that depends on them. Backfill separately, switch reads and writes gradually, then remove old fields later.
Avoid large blocking changes. Build indexes using online or concurrent mechanisms where supported. Validate constraints separately when possible. Break backfills into restartable batches.
Throttle maintenance. Backfills, index builds, exports, and archival jobs should pause when CPU, replica lag, lock waits, or storage latency exceed thresholds.
Test restoration. A backup is not proven until it has been restored, verified, and used to recover the application.
Separate workloads. Transactional requests, analytics, search, exports, and maintenance should not compete without explicit resource controls.
Track capacity trends. Monitor data growth, index growth, connection use, cache hit rates, write amplification, replica lag, and time remaining before storage thresholds.
Common Mistakes
| Mistake | Production Impact | Better Approach |
|---|---|---|
| Adding indexes without measuring queries | Higher write latency and storage cost | Build indexes for proven access patterns |
| Using offset pagination for deep datasets | Latency increases with page depth | Use stable keyset pagination |
| Opening too many database connections | Memory pressure, lock contention, and unstable latency | Use bounded pools and admission control |
| Calling external services inside transactions | Long locks and ambiguous failures | Commit local state and use an outbox |
| Caching without TTL or invalidation | Stale data persists indefinitely | Combine invalidation with expiration |
| Sending every read to a replica | Read-after-write requests return stale data | Route reads by consistency requirement |
| Storing unbounded arrays or JSON documents | Large updates and record-size problems | Move repeating entities into separate records |
| Relying only on application validation | Concurrent requests violate invariants | Use database constraints and atomic updates |
| Running unbounded exports on the primary | Customer queries compete with long scans | Use replicas or analytical stores |
| Sharding before reaching one-cluster limits | Adds routing and transaction complexity too early | Optimize queries, partition, cache, and scale vertically first |
| Performing one large backfill transaction | Replica lag, long locks, and expensive rollback | Use small idempotent batches with checkpoints |
| Monitoring only CPU | Locks, I/O, lag, and slow queries remain hidden | Monitor the complete request and storage path |
Production Checklist
- Enforce business invariants with database constraints.
- Index measured queries, not hypothetical ones.
- Require tenant filters and bounded pagination.
- Set query, lock, and transaction timeouts.
- Keep database transactions short.
- Bound connection pools and background-job concurrency.
- Make client and worker writes idempotent.
- Route reads according to consistency requirements.
- Use TTLs and invalidation for cached data.
- Monitor slow queries, locks, lag, storage, and connection use.
- Run backfills in restartable batches.
- Use backward-compatible schema migrations.
- Test backups and disaster recovery regularly.
- Separate transactional, search, and analytical workloads.
- Introduce sharding only after measuring a single-cluster limit.
Conclusion
Scalable database architecture begins with correct schemas, bounded access patterns, selective indexes, short transactions, and controlled concurrency. Caching, replicas, partitioning, and sharding should be added to solve specific measured bottlenecks.
Operational discipline matters as much as schema design. Safe migrations, idempotent backfills, replica-aware reads, tested backups, capacity monitoring, and failure recovery determine whether the database remains reliable as the system grows.
Key Takeaway: Build scalability by reducing unnecessary database work, protecting invariants, controlling concurrency, and introducing distributed complexity only when simpler optimizations no longer meet measured production requirements.
Comments (0)