10 millions de users, 1TB de data, votre database PostgreSQL rame. Sharding ? Peut-être. Mais avant, explorons toutes les alternatives (plus simples).

D’abord : Avez-vous vraiment besoin de sharding ?

Alternatives plus simples

1. Vertical scaling (augmenter la machine)

DB actuelle : 8 CPU, 32GB RAM
DB upgradée : 32 CPU, 256GB RAM

Coût : $500/mois → $2000/mois
Effort : 1 heure de migration

Jusqu’où ? Machines jusqu’à 128 CPU, 4TB RAM existent.

2. Read replicas (scaling lecture)

┌──────────┐
│  Master  │  ← Writes
│ (Primary)│
└────┬─────┘
     │
 ┌───┴────┬─────────┐
 │        │         │
┌▼──┐  ┌─▼─┐   ┌───▼┐
│R1 │  │R2 │   │R3  │  ← Reads
└───┘  └───┘   └────┘

Cas d’usage : 90% reads, 10% writes

Effort : 1 semaine

3. Partitioning (même DB, tables séparées)

-- Partition par date
CREATE TABLE orders_2024 PARTITION OF orders
  FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');

CREATE TABLE orders_2025 PARTITION OF orders
  FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

Performance : Queries 10x plus rapides sur partition

Effort : 1 semaine

4. Caching agressif

Redis cache :
  - Requêtes fréquentes
  - Sessions
  - Rate limiting

Hit rate : 80%+ → Reduce DB load 80%

Quand sharding devient nécessaire

Signaux d’alarme

Vertical scaling maxed out

  • Machine biggest available
  • Coût prohibitif (>$10k/mois)

Write throughput saturé

  • Master CPU > 80%
  • Write lag croissant
  • Read replicas ne suffisent plus

Data size > 1TB

  • Backups trop longs (>6h)
  • Restore impossible en RTO
  • Queries lentes malgré index

Geographic distribution

  • Users worldwide
  • Latency critique
  • Data residency laws (GDPR)

Sharding : Les stratégies

1. Hash-based sharding

# Distribuer users par ID
def get_shard(user_id: int, num_shards: int) -> int:
    return hash(user_id) % num_shards

# user_id 123 → shard 1
# user_id 456 → shard 2
# user_id 789 → shard 1

Avantages :

  • Distribution uniforme
  • Simple à implémenter

Inconvénients :

  • Resharding difficile (change le hash)
  • Queries cross-shard impossibles

2. Range-based sharding

# Sharding par plage d'IDs
shard_1 : users 1-1M
shard_2 : users 1M-2M
shard_3 : users 2M-3M

def get_shard(user_id: int) -> int:
    return (user_id - 1) // 1_000_000 + 1

Avantages :

  • Resharding plus facile
  • Range queries possibles

Inconvénients :

  • Déséquilibre potentiel (shard 1 saturé)
  • Hotspots

3. Geographic sharding

# Sharding par région
shard_eu : users.region = 'EU'
shard_us : users.region = 'US'
shard_asia : users.region = 'ASIA'

def get_shard(user: User) -> str:
    return f'shard_{user.region.lower()}'

Avantages :

  • Latency optimale
  • Compliance GDPR

Inconvénients :

  • Déséquilibre géographique
  • Cross-region queries complexes

4. Tenant-based sharding (SaaS)

# Sharding par entreprise/tenant
shard_1 : tenants 1-1000
shard_2 : tenants 1001-2000

def get_shard(tenant_id: int) -> int:
    # Gros clients = shard dédié
    if tenant_id in PREMIUM_TENANTS:
        return dedicated_shards[tenant_id]

    # Petits clients = sharding hash
    return hash(tenant_id) % NUM_SHARED_SHARDS

Avantages :

  • Isolation tenants
  • Performance prédictible

Inconvénients :

  • Coût (shards dédiés)

Architecture sharding

Layer 1 : Application routing

// Application gère le routing
class ShardedUserRepository {
  private shards: Database[];

  async findUser(userId: number): Promise<User> {
    const shardIndex = this.getShardIndex(userId);
    const shard = this.shards[shardIndex];

    return shard.query('SELECT * FROM users WHERE id = $1', [userId]);
  }

  private getShardIndex(userId: number): number {
    return userId % this.shards.length;
  }
}

Avantages :

  • Contrôle total
  • Logique custom possible

Inconvénients :

  • Complexité app
  • Chaque service doit implémenter

Layer 2 : Proxy/Middleware

┌────────┐
│  App   │
└───┬────┘
    │
┌───▼──────┐
│ ProxySQL │  ← Routing automatique
│ Vitess   │
└───┬──────┘
    │
┌───┴───┬────────┬─────────┐
│Shard1 │Shard2  │Shard3   │
└───────┴────────┴─────────┘

Solutions :

  • Vitess (YouTube) : Proxy sharding MySQL
  • Citus : Extension PostgreSQL
  • ProxySQL : MySQL proxy

Avantages :

  • App agnostic
  • Resharding facilité

Inconvénients :

  • Nouveau layer
  • Performance overhead

Migration vers sharding

Approche progressive (6-12 mois)

Phase 1 : Dual writes (Mois 1-2)

# Écrire dans old DB ET nouveau shard
async def createUser(user: User):
    # Old DB (legacy)
    await old_db.insert(user)

    # New shard (progressive)
    shard = get_shard(user.id)
    await shard.insert(user)

Phase 2 : Backfill data (Mois 3-4)

# Migrer data existante
async def backfill_shard(shard_id: int):
    users = await old_db.query('''
        SELECT * FROM users
        WHERE MOD(id, {}) = {}
    ''', total_shards, shard_id)

    for user in users:
        await shards[shard_id].insert(user)

Phase 3 : Dual reads (Mois 5-6)

# Lire du shard, fallback vers old DB
async def getUser(user_id: int):
    shard = get_shard(user_id)
    user = await shard.query('SELECT * FROM users WHERE id = $1', [user_id])

    if not user:
        # Fallback (data pas encore migrée)
        user = await old_db.query('SELECT * FROM users WHERE id = $1', [user_id])

    return user

Phase 4 : Cutover (Mois 7-8)

# Lire seulement du shard
async def getUser(user_id: int):
    shard = get_shard(user_id)
    return await shard.query('SELECT * FROM users WHERE id = $1', [user_id])

# Old DB → Read-only → Archive → Delete

Problèmes complexes

Problème 1 : Cross-shard queries

-- Impossible avec sharding
SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.total > 1000;

-- users et orders dans shards différents

Solutions :

A. Dénormalisation

-- Dupliquer user data dans orders
CREATE TABLE orders (
  id INT,
  user_id INT,
  user_name VARCHAR,  -- Dupliqué !
  user_email VARCHAR, -- Dupliqué !
  total DECIMAL
);

-- Query devient possible
SELECT user_name, total
FROM orders
WHERE total > 1000;

B. Application-level joins

# Fetch de chaque shard
orders = await fetch_orders_from_all_shards(total > 1000)
user_ids = [o.user_id for o in orders]

# Fetch users (chaque shard concerné)
users = await fetch_users_by_ids(user_ids)

# Join en mémoire
results = join_in_memory(users, orders)

C. Analytics database

Sharded OLTP DBs → ETL → Data Warehouse (Snowflake, BigQuery)
                          ↓
                   Run analytics here

Problème 2 : Transactions distribuées

# Transaction cross-shard = complexe
async def transferMoney(from_user_id, to_user_id, amount):
    shard1 = get_shard(from_user_id)
    shard2 = get_shard(to_user_id)

    # Si différents shards → transaction distribuée
    if shard1 != shard2:
        # Two-phase commit (2PC) ou Saga pattern
        await distributed_transaction([
            lambda: shard1.debit(from_user_id, amount),
            lambda: shard2.credit(to_user_id, amount)
        ])

Éviter si possible :

  • Garder transactions dans même shard
  • Utiliser event sourcing
  • Eventual consistency

Problème 3 : Resharding (ajouter shards)

# 2 shards → 4 shards
# hash(user_id) % 2 → hash(user_id) % 4
# → 50% des users changent de shard

# Migration massive requise

Solutions :

A. Consistent hashing

# Minimise data movement (10-20% au lieu de 50%)
from consistent_hash import ConsistentHash

ring = ConsistentHash()
ring.add_node('shard1')
ring.add_node('shard2')

# Ajouter shard3
ring.add_node('shard3')  # Seulement 33% migré

B. Pre-sharding

# Créer 256 logical shards dès le début
# Mapper sur 4 physical shards

logical_shard = hash(user_id) % 256
physical_shard = logical_to_physical[logical_shard]

# Resharding = remapper (pas de data move)

Métriques et monitoring

Dashboard sharding

┌──────────────────────────────────────┐
│ Shard Health                         │
├──────────────────────────────────────┤
│ Shard 1: 🟢 Healthy (45% CPU)       │
│ Shard 2: 🟢 Healthy (52% CPU)       │
│ Shard 3: 🟡 Warning (78% CPU)       │
│ Shard 4: 🟢 Healthy (41% CPU)       │
│                                      │
│ Data Distribution:                   │
│ Shard 1: 240GB (24%)                │
│ Shard 2: 260GB (26%)                │
│ Shard 3: 280GB (28%) ⚠️ Hotspot    │
│ Shard 4: 220GB (22%)                │
│                                      │
│ Query Performance:                   │
│ p50: 12ms (✅ < 50ms)               │
│ p95: 45ms (✅ < 200ms)              │
│ p99: 120ms (🟡 target: < 500ms)    │
└──────────────────────────────────────┘

Cas réel : SaaS B2B (tenant sharding)

Situation

  • 5000 tenants
  • 10 tenants = 70% traffic (déséquilibre)
  • 1 DB saturée

Solution

# Stratégie hybrid
PREMIUM_TENANTS = [1, 5, 12, 23, 45]  # Gros clients

def get_db(tenant_id):
    if tenant_id in PREMIUM_TENANTS:
        return dedicated_dbs[tenant_id]  # Shard dédié

    # Petits tenants : hash sharding
    shard_id = hash(tenant_id) % 10
    return shared_shards[shard_id]

Résultats

  • Performance : p95 latency 450ms → 85ms (-81%)
  • Isolation : 0 noisy neighbor problems
  • Coût : +$3k/mois (5 shards dédiés)
  • Satisfaction : Enterprise clients 9.2/10

Conclusion

Sharding n’est pas votre premier choix.

Alternatives plus simples :

  1. Vertical scaling
  2. Read replicas
  3. Caching
  4. Partitioning

Mais si nécessaire :

  • Choisir bonne stratégie (hash, range, geo)
  • Migration progressive (6-12 mois)
  • Monitoring intensif

Complexité réelle :

  • Cross-shard queries
  • Transactions distribuées
  • Resharding

Commencez simple. Shardez seulement si vraiment requis.

Et vous, avez-vous shardé ? Retours ?