ss
Navigate back to the homepage

Spaceout

beyond excelsior

Software development, application and system architecture

about meContact
Link to $https://www.facebook.com/spaceoutpl/Link to $https://twitter.com/spaceoutplLink to $https://www.instagram.com/spaceout.pl/Link to $https://blog.spaceout.pl/Link to $https://dribbble.com/spaceoutLink to $https://behance.com/spaceoutLink to $https://github.com/massivDash/Link to $https://huggingface.co/MassivDashLink to $https://bsky.app/profile/lukecelitan.bsky.social

Scaling SQL Databases.

by
Luke Celitan
category: post, reading time: 5 min

Introduction

Scaling SQL databases is one of the most critical—and challenging—tasks for modern software engineers, architects, and DBAs. As applications grow, so do the demands on your database: more users, more data, more transactions, and higher expectations for reliability and performance. But scaling isn’t just about adding more hardware; it’s about rethinking architecture, data distribution, and operational strategies.

In this deep-dive, I’ll walk you through the core challenges of scaling SQL databases, from distributed transactions to sharding strategies, and show you how to tackle each with practical TypeScript code examples. We’ll explore real-world scenarios, best practices, and advanced concepts, culminating in a case study from Arcesium’s journey scaling SQL Server in a Kubernetes-powered SaaS platform.

Ready to master SQL scaling? Let’s dive in!

Challenges of Scaling SQL Databases

ACID Properties and Distributed Complexity

SQL databases are renowned for their ACID guarantees—Atomicity, Consistency, Isolation, Durability. But as soon as you move from a single-node to a distributed, horizontally scaled environment, maintaining these properties becomes a formidable challenge.

ACID in Single-Node vs. Distributed SQL

  • Atomicity: All-or-nothing transactions are easy on one server. In distributed systems, you need coordination across nodes.
  • Consistency: Constraints and triggers must be enforced everywhere, not just locally.
  • Isolation: Transactions must not interfere, even when spread across shards.
  • Durability: Committed data must survive failures, even if a node goes down mid-transaction.

Two-Phase Commit Protocol

The classic solution for distributed transactions is the two-phase commit (2PC):

  1. Prepare: All involved nodes agree they can commit.
  2. Commit: If all nodes agree, the transaction is committed everywhere; otherwise, it’s rolled back.
TypeScript Example: Distributed Money Transfer

Suppose you’re transferring money between accounts on different shards. Here’s a simplified TypeScript pseudo-code for a distributed transaction manager:

1// DistributedTransactionManager.ts
2class DistributedTransactionManager {
3 async transferMoney(
4 fromShard: Shard,
5 toShard: Shard,
6 fromAccountId: string,
7 toAccountId: string,
8 amount: number,
9 ): Promise<boolean> {
10 // Phase 1: Prepare
11 const prepareFrom = await fromShard.prepareDebit(fromAccountId, amount);
12 const prepareTo = await toShard.prepareCredit(toAccountId, amount);
13 if (!prepareFrom || !prepareTo) {
14 await fromShard.rollback();
15 await toShard.rollback();
16 return false;
17 }
18 // Phase 2: Commit
19 await fromShard.commit();
20 await toShard.commit();
21 return true;
22 }
23}

Performance Consideration: 2PC adds latency and can become a bottleneck under high load. Many distributed SQL systems (like Google Spanner) use more advanced consensus protocols (Paxos/Raft) for better scalability.

Complexity of Distributed Joins

Joins are the bread and butter of SQL, but in a distributed environment, they can be a performance nightmare. Why? Because the data you need to join may live on different shards, requiring network hops and data transfer.

Example: Customer-Order Join Across Shards

Suppose you want to join Customers and Orders, sharded by CustomerID and OrderID respectively. Here’s how you might orchestrate a distributed join in TypeScript:

1// DistributedJoin.ts
2async function distributedJoin(
3 customersShard: Shard,
4 ordersShard: Shard,
5 customerId: string,
6) {
7 const customer = await customersShard.getCustomer(customerId);
8 const orders = await ordersShard.getOrdersByCustomerId(customerId);
9 return { ...customer, orders };
10}

Pitfall: Distributed joins can be slow and expensive. Avoid them by designing your schema and sharding strategy to minimize cross-shard queries.

Transaction Management Across Shards

Coordinating transactions that span multiple shards is tricky. You need to ensure atomicity and consistency, even if one shard fails mid-operation.

Example: Order Creation and Inventory Update

Let’s say you’re creating an order and updating inventory, each on a different shard. Here’s a distributed transaction manager in TypeScript:

1// DistributedOrderManager.ts
2class DistributedOrderManager {
3 async createOrderAndUpdateInventory(
4 orderShard: Shard,
5 inventoryShard: Shard,
6 order: Order,
7 productId: string,
8 quantity: number,
9 ): Promise<boolean> {
10 const orderPrepared = await orderShard.prepareCreateOrder(order);
11 const inventoryPrepared = await inventoryShard.prepareUpdateInventory(
12 productId,
13 -quantity,
14 );
15 if (!orderPrepared || !inventoryPrepared) {
16 await orderShard.rollback();
17 await inventoryShard.rollback();
18 return false;
19 }
20 await orderShard.commit();
21 await inventoryShard.commit();
22 return true;
23 }
24}

Best Practice: Use idempotent operations and compensating transactions to handle failures gracefully.

Data Distribution: Sharding Strategies

Sharding is the art of splitting your data across multiple nodes. But how you shard matters—a poor choice can lead to hotspots and uneven load.

Sharding Key Selection

  • Range-based: Good for time-series, but can create hotspots.
  • Hash-based: Distributes load evenly, but can make range queries harder.
  • Composite: Combines multiple fields for more balanced distribution.

Example: Sharding UserLogs Table

1// ShardingLogic.ts
2function getShardForUserLog(userId: string, numShards: number): number {
3 // Simple hash-based sharding
4 const hash = [...userId].reduce((acc, char) => acc + char.charCodeAt(0), 0);
5 return hash % numShards;
6}

Pitfall: Data skew can occur if your sharding key isn’t well chosen. Always analyze your data distribution before finalizing your strategy.

Ensuring Consistency Across Nodes

Replication is key to scaling reads, but it introduces the challenge of keeping data consistent across nodes. Do you want strong consistency (all nodes always in sync) or eventual consistency (nodes catch up over time)?

Example: Profile Update Propagation

Suppose you want to propagate a user profile update to all replicas. Here’s an event-driven sync in TypeScript:

1// ProfileSync.ts
2class ProfileSync {
3 async propagateUpdate(userId: string, newProfile: Profile) {
4 for (const replica of replicas) {
5 await replica.updateProfile(userId, newProfile);
6 }
7 }
8}

Performance Consideration: Replication lag can cause stale reads. Monitor lag and design your query routing to account for it.


Handling Schema Changes in Distributed Environments

Schema migrations are easy on a single node, but in a distributed system, you need to coordinate changes across all shards—without downtime.

Example: Adding a Column to Sharded Tables

1// MigrationScript.ts
2async function addColumnToAllShards(
3 shards: Shard[],
4 columnName: string,
5 columnType: string,
6) {
7 for (const shard of shards) {
8 await shard.addColumn(columnName, columnType);
9 }
10}

Best Practice: Use feature toggles and schema versioning to roll out changes safely.

Maintaining Foreign Keys and Constraints

Referential integrity is a cornerstone of SQL, but enforcing foreign keys across shards is tough. You may need to move checks to the application layer or denormalize data.

Example: Author-Book Relationship Enforcement

1// ReferentialIntegrity.ts
2async function checkAuthorExistsBeforeAddingBook(
3 authorsShard: Shard,
4 authorId: string,
5): Promise<boolean> {
6 const author = await authorsShard.getAuthor(authorId);
7 return !!author;
8}

Pitfall: Application-level checks can be error-prone. Consider denormalization or cascading actions for critical relationships.

Query Optimization in Distributed Systems

Optimizing queries in a distributed SQL system means being shard-aware, using indexes wisely, and caching aggressively.

Example: Optimized Sales Query

1// QueryRouter.ts
2async function getSalesByRegion(region: string, shards: Shard[]) {
3 const regionShard = shards.find((shard) => shard.region === region);
4 return regionShard ? await regionShard.getSales(region) : [];
5}

Best Practice: Route queries to the right shard, avoid cross-shard queries, and use caching for frequently accessed data.

How to Scale SQL Databases

Vertical Scaling: When and Why

Vertical scaling means adding more CPU, RAM, or storage to your database server. It’s simple, but has hard limits and can get expensive fast. Use it for quick wins, but plan for horizontal scaling as you grow.

Horizontal Scaling: Sharding and Partitioning

Horizontal scaling is the gold standard for large-scale SQL. Shard your data, partition your tables, and distribute the load.

Example: Setting Up Sharded Clusters

1// ShardedClusterSetup.ts
2class ShardedCluster {
3 shards: Shard[];
4 constructor(numShards: number) {
5 this.shards = Array.from({ length: numShards }, (_, i) => new Shard(i));
6 }
7 getShardForKey(key: string): Shard {
8 const hash = [...key].reduce((acc, char) => acc + char.charCodeAt(0), 0);
9 return this.shards[hash % this.shards.length];
10 }
11}

Read Replicas and High Availability

Read replicas let you scale reads, but beware replication lag and consistency trade-offs. Use a query redirection framework to route reads to the best replica.

Example: Query Redirection Framework

1// ReplicaSelector.ts
2class ReplicaSelector {
3 async selectReplica(query: Query, replicas: Replica[]): Promise<Replica> {
4 // Choose the replica with the lowest replication lag for the relevant data
5 const candidates = replicas.filter((replica) =>
6 replica.hasFreshDataFor(query),
7 );
8 return candidates.sort((a, b) => a.replicationLag - b.replicationLag)[0];
9 }
10}

Caching and Query Throttling

Caching is your best friend for scaling reads. Use Redis or similar to cache hot data and throttle expensive queries.

Example: Redis Cache Integration

1// CacheLayer.ts
2import Redis from 'ioredis';
3const redis = new Redis();
4
5async function getCachedOrQuery(key: string, queryFn: () => Promise<any>) {
6 const cached = await redis.get(key);
7 if (cached) return JSON.parse(cached);
8 const result = await queryFn();
9 await redis.set(key, JSON.stringify(result), 'EX', 60); // Cache for 60s
10 return result;
11}

Data Archival and Purging

Archiving old data keeps your hot tables lean and fast. Schedule regular archival jobs to move historical data to cold storage.

Example: Scheduled Archival Job

1// ArchivalJob.ts
2async function archiveOldRecords(shard: Shard, cutoffDate: Date) {
3 const oldRecords = await shard.getRecordsBefore(cutoffDate);
4 await shard.moveToArchive(oldRecords);
5}

Monitoring and Observability

Track metrics like replication lag, query latency, and CPU utilization. Build custom metrics collectors in TypeScript for real-time insights.

Example: Metrics Collection

1// MetricsCollector.ts
2class MetricsCollector {
3 async collectReplicationLag(replica: Replica): Promise<number> {
4 return await replica.getReplicationLag();
5 }
6 async collectQueryLatency(query: Query): Promise<number> {
7 const start = Date.now();
8 await query.execute();
9 return Date.now() - start;
10 }
11}

Best Practices

  • Model and test upfront: Simulate your sharding and scaling strategy before going live.
  • Choose the right sharding key: Analyze your data and query patterns.
  • Avoid cross-shard joins: Design your schema to minimize distributed queries.
  • Handle schema changes with feature toggles/versioning: Roll out changes safely.
  • Ensure referential integrity: Use denormalization or application-level checks.
  • Implement load shedding and failover: Prepare for overloads and outages.
  • Monitor continuously: Track key metrics and set up alerts.

Common Pitfalls

  • Hotspots from poor sharding: Leads to uneven load and bottlenecks.
  • Ignoring replication lag: Causes stale reads and data inconsistency.
  • Overcomplicating schema migrations: Increases risk of downtime.
  • Underestimating network overhead: Distributed systems are not free!
  • Failing to plan for disaster recovery: Always have a rollback and failover plan.

Real-World Case Study: Arcesium’s Scaling Journey

Arcesium, a fintech SaaS platform, faced the classic scaling challenge: surging API traffic and a maxed-out SQL Server. Here’s how they tackled it:

Query Redirection Framework

They built a TypeScript-powered framework to route queries to read replicas, based on replication lag and query determinants.

1// QueryRedirection.ts
2class QueryRedirectionFramework {
3 async routeQuery(query: Query, replicas: Replica[], primary: Replica) {
4 const lagThreshold = 5; // seconds
5 const candidates = replicas.filter(
6 (replica) => replica.getReplicationLag() < lagThreshold,
7 );
8 if (candidates.length > 0) {
9 return await candidates[0].executeQuery(query);
10 }
11 return await primary.executeQuery(query);
12 }
13}

Replication Monitoring

A poller tracks Last Sync Time (LST) for each database, publishing metrics for observability.

1// ReplicationMonitor.ts
2class ReplicationMonitor {
3 async pollReplicationLag(replicas: Replica[]) {
4 for (const replica of replicas) {
5 const lag = await replica.getReplicationLag();
6 console.log(`Replica ${replica.id} lag: ${lag}s`);
7 }
8 }
9}

Load Shedding

During replica downtime or high lag, non-priority calls are shed to protect the primary.

1// LoadShedding.ts
2class LoadShedding {
3 async handleOverload(request: Request, isPriority: boolean) {
4 if (!isPriority) {
5 throw new Error('Service unavailable due to load shedding');
6 }
7 // Proceed with request
8 }
9}

Outcome:

  • 91.5% reduction in CPU usage above 95% threshold.
  • 7–12% of workload offloaded to replicas during peak hours.
  • Improved stability and SLO compliance.

Advanced Concepts

  • Distributed consensus (Paxos, Raft): Used for leader election and transaction coordination in modern distributed SQL systems.
  • Multi-region replication: Ensures global availability and disaster recovery.
  • Hybrid SQL/NoSQL architectures: Combine strengths for scalability and flexibility.
  • Cloud-native SQL scaling: Managed services (e.g., Google Spanner, Amazon Aurora) offer built-in scaling and resilience.

Troubleshooting and Recovery

  • Rollback strategies: Use compensating transactions and idempotent operations.
  • Incident response: Automate failover and alerting.
  • Post-mortem analysis: Document root cause, impact, recovery steps, and lessons learned.

Example: Post-Mortem Template

1# Incident Post-Mortem
2
3## Summary
4
5## Timeline
6
7## Impact
8
9## Root Cause
10
11## Recovery Steps
12
13## Lessons Learned
14
15## Action Items

Conclusion and Further Reading

Scaling SQL databases is a journey, not a destination. It demands deep technical understanding, careful planning, and relentless monitoring. By mastering distributed transactions, sharding, query optimization, and real-world strategies, you’ll be ready to build resilient, high-performance systems that scale with your business.

Further Reading:

  • Google Spanner Whitepaper
  • Amazon Aurora Architecture
  • Distributed Systems for Fun and Profit
  • Arcesium Engineering Blog

Ready to scale your SQL database? Have questions or want to share your experience? Drop a comment below or reach out!

ss

LLM Scheming Frontier Risks, Evaluations, and Safety Strategies

A definitive, in-depth exploration of scheming behaviors in large language models (LLMs), based on the latest research.

4 min czytania

IndexedDB The Definitive Deep-Dive Guide for Modern Web Applications

A comprehensive, hands-on guide to IndexedDB for offline-first web apps, large-scale client-side storage, and advanced browser data management.

3 min czytania

Loading search index...