İçeriğe atla

DynamoDB Single-Table Design: Kapsamlı Modelleme Rehberi

DynamoDB single-table design'da ilişki modelleme, GSI ve LSI seçimi, DAX optimizasyonu ve production'da yaygın hatalardan kaçınmayı pratik örneklerle öğren.

Ayhan Sipahi Ayhan Sipahi

DynamoDB kullanırken relational tablo mantığıyla düşünmek sorun çözmekten çok sorun yaratır. Tipik yaklaşımda Users, Orders ve Products için ayrı tablolar oluşturulur; sonuç birden fazla round-trip, karmaşık uygulama mantığı ve öngörülemeyen maliyettir.

Single-table design, birden fazla entity type’ı generic partition ve sort key’ler kullanarak tek tabloda saklar. Bir customer’ı bir tablodan, order’larını başka bir tablodan çekmek yerine her şeyi tek request’te alırsın. Birlikte okunan entity’ler için varsayılan seçenek bu olsun; birlikte okunmayanlar için ayrı tablolarda kal. Pattern kendini az tablo sayısıyla değil, data locality ile amorti eder.

Temel İlkeler#

  1. Access pattern’ler önce: Schema’yı tasarlamadan önce her query’yi dokümante et
  2. Data locality: İlişkili veriyi aynı partition key kullanarak bir arada sakla
  3. Generic key’ler: Entity-specific isimler yerine PK ve SK kullan
  4. Item collection’lar: İlişkili item’ları shared partition key’lerle grupla
  5. Attribute overloading: Aynı attribute’lar farklı entity type’ları boyunca farklı amaçlara hizmet eder

Single-Table Design Ne Zaman Kullanılmalı#

Single-table design, ilişkili veriyi birlikte çekmen gerektiğinde iyi çalışır. Uygun olduğu koşullar şunlar:

İyi Kullanım Senaryoları:

  • Customer’ları order’larıyla birlikte çektiğin e-commerce sistemleri
  • Post’ları comment ve like’larla birlikte alan sosyal platformlar
  • Tenant isolation olan multi-tenant SaaS uygulamaları
  • Hierarchical veri içeren content management sistemleri
  • Cihaz bazında sensor reading’leri toplayan IoT platformları

AWS’in NoSQL tasarım rehberi kuralı basitçe koyuyor: birlikte erişilen veri birlikte saklanmalı. İlgisiz veriyi sadece bir pattern’i takip etmek için tek tabloya zorlama.

Partition Key ve Sort Key Stratejileri#

Single-table design’ın temeli, key’lerini nasıl yapılandıracağını anlamakta yatıyor.

Partition Key Pattern’leri#

// Entity type prefix - en yaygın pattern
PK: "CUSTOMER#123"
PK: "ORDER#456"
PK: "PRODUCT#789"

// Multi-tenant için composite partition key
PK: "TENANT#acme#USER#123"

// High-cardinality user-specific key
PK: "USER#${userId}" // Her user unique partition alır

Kaçınılması Gereken Anti-Pattern:

// Low-cardinality status key - hot partition yaratır
PK: "STATUS#active" // Tüm active user'lar BİR partition'da
// Partition başına 3,000 RCU limitine ulaştığında throttle olur

Sort Key Pattern’leri#

Sort key’ler range query’leri ve hierarchical organization’ı mümkün kılıyor:

// Hierarchical sort key - prefix query'leri mümkün kılar
SK: "US#CA#SanFrancisco#94102"
// Query: begins_with(SK, "US#CA") tüm California item'larını döner

// Timestamp-based chronological ordering
SK: "2024-01-15T10:30:00#EVENT#123"

// Version control pattern
SK: "v0_item123" // Current version
SK: "v1_item123" // Previous version
SK: "v2_item123" // Older version

// İlişkiler için composite
SK: "ORDERITEM#PRODUCT#789#2024-01-15"

Best Practice’ler:

  • High-cardinality partition key’ler kullan (userId, orderId, productId)
  • begins_with ve BETWEEN ile range query’leri destekleyecek sort key’ler tasarla
  • Chronological ordering için timestamp ekle
  • Entity type’ları boyunca consistent prefix’ler kullan

İlişkileri Modelleme#

Her ilişki türü belirli bir access pattern’e karşılık gelir. Aşağıda her biri için çalışan örnekler var.

One-to-One İlişkiler#

İlişkili veriyi aynı item collection’da farklı sort key’lerle sakla:

interface User {
  PK: string;
  SK: string;
  EntityType: "User";
  email: string;
  name: string;
}

interface UserPreferences {
  PK: string;
  SK: string;
  EntityType: "UserPreferences";
  theme: "dark" | "light";
  language: string;
}

// Şöyle saklanır:
{
  PK: "USER#123",
  SK: "METADATA",
  EntityType: "User",
  email: "user@example.com",
  name: "John Doe"
}
{
  PK: "USER#123",
  SK: "PREFERENCES",
  EntityType: "UserPreferences",
  theme: "dark",
  language: "en"
}

// Tek query ikisini de getirir
const params = {
  TableName: 'MainTable',
  KeyConditionExpression: 'PK = :pk',
  ExpressionAttributeValues: {
    ':pk': 'USER#123'
  }
};

One-to-Many İlişkiler#

Item collection’lar one-to-many ilişkileri basitleştiriyor:

interface Customer {
  PK: string;
  SK: string;
  EntityType: "Customer";
  name: string;
  email: string;
}

interface Order {
  PK: string; // Customer PK ile aynı
  SK: string; // Chronological sort key
  EntityType: "Order";
  orderId: string;
  total: number;
  status: string;
  orderDate: string;
}

// Customer
{
  PK: "CUSTOMER#123",
  SK: "METADATA",
  EntityType: "Customer",
  name: "John Doe",
  email: "john@example.com"
}

// Bu customer için order'lar
{
  PK: "CUSTOMER#123",
  SK: "ORDER#2024-01-15#456",
  EntityType: "Order",
  orderId: "456",
  total: 99.99,
  status: "delivered",
  orderDate: "2024-01-15"
}
{
  PK: "CUSTOMER#123",
  SK: "ORDER#2024-01-20#457",
  EntityType: "Order",
  orderId: "457",
  total: 149.99,
  status: "pending",
  orderDate: "2024-01-20"
}

// Customer ve TÜM order'larını tek query'de al
const getCustomerWithOrders = async (customerId: string) => {
  const result = await dynamodb.query({
    TableName: 'MainTable',
    KeyConditionExpression: 'PK = :pk',
    ExpressionAttributeValues: {
      ':pk': `CUSTOMER#${customerId}`
    }
  });

  return {
    customer: result.Items?.find(item => item.SK === 'METADATA'),
    orders: result.Items?.filter(item => item.SK.startsWith('ORDER#'))
  };
};

// Sadece pending order'ları al
const getPendingOrders = async (customerId: string) => {
  const result = await dynamodb.query({
    TableName: 'MainTable',
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
    FilterExpression: '#status = :status',
    ExpressionAttributeNames: {
      '#status': 'status'
    },
    ExpressionAttributeValues: {
      ':pk': `CUSTOMER#${customerId}`,
      ':sk': 'ORDER#',
      ':status': 'pending'
    }
  });

  return result.Items;
};

Many-to-Many İlişkiler#

Many-to-many ilişkileri modellemek için adjacency list pattern kullan:

interface ProductCategory {
  PK: string;
  SK: string;
  EntityType: "ProductCategory";
  productName?: string;
  categoryName?: string;
}

// Product birden fazla category'ye ait
// Forward relationship'ler
{
  PK: "PRODUCT#789",
  SK: "CATEGORY#Electronics",
  EntityType: "ProductCategory",
  categoryName: "Electronics"
}
{
  PK: "PRODUCT#789",
  SK: "CATEGORY#Gadgets",
  EntityType: "ProductCategory",
  categoryName: "Gadgets"
}

// Reverse relationship'ler (bidirectional query için ikisini de yaz)
{
  PK: "CATEGORY#Electronics",
  SK: "PRODUCT#789",
  EntityType: "CategoryProduct",
  productName: "Wireless Headphones"
}
{
  PK: "CATEGORY#Gadgets",
  SK: "PRODUCT#789",
  EntityType: "CategoryProduct",
  productName: "Wireless Headphones"
}

// Bir product için tüm category'leri query et
const getProductCategories = async (productId: string) => {
  const result = await dynamodb.query({
    TableName: 'MainTable',
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
    ExpressionAttributeValues: {
      ':pk': `PRODUCT#${productId}`,
      ':sk': 'CATEGORY#'
    }
  });

  return result.Items;
};

// Bir category'deki tüm product'ları query et
const getCategoryProducts = async (categoryName: string) => {
  const result = await dynamodb.query({
    TableName: 'MainTable',
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
    ExpressionAttributeValues: {
      ':pk': `CATEGORY#${categoryName}`,
      ':sk': 'PRODUCT#'
    }
  });

  return result.Items;
};

Trade-off: Her iki yönü yazmak write operation’larını ikiye katlar ama Scan operation’ı olmadan her iki yönde de efficient query’leri mümkün kılar.

Denormalization Pattern#

Bazen sık erişilen veriye ekstra query olmadan ihtiyacın var:

interface Order {
  PK: string;
  SK: string;
  EntityType: "Order";
  orderId: string;
  customerId: string;
  // Denormalized customer data
  customerName: string;
  customerEmail: string;
  total: number;
}

{
  PK: "ORDER#456",
  SK: "METADATA",
  EntityType: "Order",
  orderId: "456",
  customerId: "123",
  customerName: "John Doe",  // Customer record'dan kopyalandı
  customerEmail: "john@example.com", // Customer record'dan kopyalandı
  total: 99.99
}

Trade-off Analizi:

  • Daha hızlı read’ler: Customer detaylarını ayrıca fetch etmeye gerek yok
  • Daha karmaşık write’lar: Customer name güncellemesi tüm order’larını güncellemeyi gerektirir
  • Storage overhead: Customer verisi order’lar boyunca duplicate edilir
  • Eventual consistency: Customer data güncellemeleri order’ları güncellemek için background job gerektirir

Denormalization’ı read sıklığı write sıklığından önemli ölçüde fazla olduğunda ve veri nadiren değiştiğinde kullan.

GSI vs LSI: Doğru Seçim#

Global Secondary Index’ler ile Local Secondary Index’ler arasındaki farkları anlamak efficient access pattern’ler için kritik.

Local Secondary Index (LSI)#

LSI, base table ile partition key’i paylaşır ama farklı sort key kullanır:

// Table yapısı
interface Order {
  PK: string;  // Partition key
  SK: string;  // Sort key (order tarihi)
  orderId: string;
  status: string;
  total: number;
}

// Base table tarih bazında query yapar
{
  PK: "CUSTOMER#123",
  SK: "ORDER#2024-01-15#456",
  orderId: "456",
  status: "delivered",
  total: 99.99
}

// LSI strong consistency ile status bazında query'i mümkün kılar
const lsiDefinition = {
  IndexName: "LSI-Status",
  KeySchema: [
    { AttributeName: "PK", KeyType: "HASH" },  // Table ile aynı
    { AttributeName: "status", KeyType: "RANGE" } // Farklı sort key
  ],
  Projection: {
    ProjectionType: "ALL"
  }
};

// Customer'ın pending order'larını strong consistency ile query et
const params = {
  TableName: 'Orders',
  IndexName: 'LSI-Status',
  KeyConditionExpression: 'PK = :pk AND #status = :status',
  ExpressionAttributeNames: {
    '#status': 'status'
  },
  ExpressionAttributeValues: {
    ':pk': 'CUSTOMER#123',
    ':status': 'pending'
  },
  ConsistentRead: true // Sadece LSI ile mümkün
};

LSI Özellikleri:

  • Table creation’da tanımlanmalı (sonradan eklenemez)
  • Base table ile partition key paylaşır
  • Strongly consistent read’leri destekler
  • Base table ile throughput capacity paylaşır
  • Partition key value başına 10GB limit
  • Table başına maksimum 5 LSI
  • Ek capacity planning gerektirmez

Global Secondary Index (GSI)#

GSI farklı partition ve sort key’ler kullanır, tamamen yeni access pattern’leri mümkün kılar:

// Table yapısı
interface Order {
  PK: string;
  SK: string;
  EntityType: string;
  orderId: string;
  orderDate: string;
  status: string;
  GSI1PK: string;  // Tarih bazlı query'ler için
  GSI1SK: string;
}

{
  PK: "CUSTOMER#123",
  SK: "ORDER#456",
  EntityType: "Order",
  orderId: "456",
  orderDate: "2024-01-15",
  status: "delivered",
  GSI1PK: "2024-01-15",  // Tarihe göre grupla
  GSI1SK: "ORDER#456"
}

// GSI Tanımı
const gsiDefinition = {
  IndexName: "GSI1",
  KeySchema: [
    { AttributeName: "GSI1PK", KeyType: "HASH" },
    { AttributeName: "GSI1SK", KeyType: "RANGE" }
  ],
  Projection: {
    ProjectionType: "INCLUDE",
    NonKeyAttributes: ["orderId", "status", "total"]
  }
};

// TÜM order'ları tarihe göre query et (tüm customer'lar için)
const getOrdersByDate = async (date: string) => {
  const result = await dynamodb.query({
    TableName: 'MainTable',
    IndexName: 'GSI1',
    KeyConditionExpression: 'GSI1PK = :date',
    ExpressionAttributeValues: {
      ':date': date
    }
  });

  return result.Items;
};

GSI Özellikleri:

  • Table creation’dan sonra eklenebilir veya çıkarılabilir
  • Base table’dan farklı partition ve sort key’ler
  • Sadece eventually consistent (strong consistency yok)
  • Provisioned mode’da independent throughput
  • Size limit’i yok
  • Table başına maksimum 20 GSI (5’ten artırıldı)
  • Cross-partition query’leri mümkün kılar

Karar Matrisi#

GereksinimLSIGSI
Strong consistency gerekliEvetHayır
Farklı partition key gerekliHayırEvet
Table oluştuktan sonra eklenebilirHayırEvet
Aynı partition, farklı sort orderEvetEvet
Bağımsız capacity planlamasıHayırEvet (provisioned)
Cross-partition query’lerHayırEvet
Partition başına 10GB’tan fazla veriHayırEvet

LSI Ne Zaman Kullanılmalı:

  • Strong consistency gerektiğinde
  • Aynı partition’ı alternative sort order ile query ederken
  • Küçük dataset’ler (< 10GB per partition)
  • Access pattern’ler table creation’da bilindiğinde

GSI Ne Zaman Kullanılmalı:

  • Access pattern için farklı partition key gerektiğinde
  • Cross-partition query’ler gerektiğinde
  • Mevcut table’a yeni access pattern eklerken
  • Eventually consistent read’ler kabul edilebilir olduğunda
  • Büyük dataset’lerde

Sparse Index Pattern#

Sadece GSI attribute’larına sahip item’lar index’e dahil edilir, storage maliyetlerini azaltır:

interface User {
  PK: string;
  SK: string;
  EntityType: "User";
  status: "active" | "inactive";
  GSI1PK?: string; // Sadece active user'lar için set edilir
  GSI1SK?: string;
}

// Active user - index'te
{
  PK: "USER#123",
  SK: "METADATA",
  EntityType: "User",
  status: "active",
  email: "active@example.com",
  GSI1PK: "ACTIVE_USERS",  // GSI'a dahil
  GSI1SK: "USER#123"
}

// Inactive user - index'te DEĞİL
{
  PK: "USER#456",
  SK: "METADATA",
  EntityType: "User",
  status: "inactive",
  email: "inactive@example.com"
  // GSI1PK/GSI1SK yok - index'te değil, storage tasarrufu
}

// Sadece active user'ları query et
const getActiveUsers = async () => {
  const result = await dynamodb.query({
    TableName: 'MainTable',
    IndexName: 'GSI1',
    KeyConditionExpression: 'GSI1PK = :type',
    ExpressionAttributeValues: {
      ':type': 'ACTIVE_USERS'
    }
  });

  return result.Items;
};

Maliyet Tasarrufu: Sadece %10 user active ise, sparse indexing GSI storage’ı %90 azaltır.

DynamoDB Accelerator (DAX) Entegrasyonu#

DAX, read-heavy workload’lar için in-memory caching ile microsecond response time’ları sağlıyor.

DAX Ne Zaman Kullanılmalı#

// Senaryo 1: Read-heavy workload
// E-commerce product catalog
// - %95 read, %5 write
// - Beklenen cache hit rate: %90+
// - Fayda: 10x latency improvement + maliyet azaltma

// Senaryo 2: Hot key pattern
// Flash sale - tek product saniyede 10,000 read alıyor
// DAX olmadan: Throttling, yüksek maliyetler
// DAX ile: Read'leri DynamoDB'den offload eder

// Senaryo 3: Tekrarlı read'ler
// Aynı veriyi tekrar tekrar sorgulayan analytics dashboard
// DAX tüm working set'i memory'de cache'ler

DAX’ten Ne Zaman Kaçınmalı#

// Anti-pattern 1: Write-heavy workload
// Sık güncellemeli real-time analytics
// DAX fayda olmadan overhead ekler

// Anti-pattern 2: Strong consistency gerekli
// Anında consistency gerektiren finansal transaction'lar
// DAX sadece eventual consistency sağlar

// Anti-pattern 3: Düşük cache hit rate
// Random access pattern'lı ad-hoc query'ler
// Cache hit rate < %50 = kötü ROI

// Anti-pattern 4: Düşük trafik
// Saniyede < 100 request alan uygulama
// DAX maliyeti faydayı aşıyor

DAX Implementasyonu#

import { DynamoDB } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb';
import AmazonDaxClient from 'amazon-dax-client';

// DAX olmadan - direkt DynamoDB
const directClient = new DynamoDB({
  region: 'us-east-1'
});
const dynamodb = DynamoDBDocument.from(directClient);

// DAX ile
const daxClient = new AmazonDaxClient({
  endpoints: ['my-cluster.dax.us-east-1.amazonaws.com:8111'],
  region: 'us-east-1'
});
const dax = DynamoDBDocument.from(daxClient);

// Aynı API - drop-in replacement
const getProduct = async (productId: string) => {
  const params = {
    TableName: 'Products',
    Key: {
      PK: `PRODUCT#${productId}`,
      SK: 'METADATA'
    }
  };

  // İlk çağrı: DynamoDB query (5ms)
  // Sonraki çağrılar: DAX cache (500μs)
  const result = await dax.get(params);
  return result.Item;
};

// Query operation'ları da cache'lenir
const getProductReviews = async (productId: string) => {
  const params = {
    TableName: 'Products',
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :sk)',
    ExpressionAttributeValues: {
      ':pk': `PRODUCT#${productId}`,
      ':sk': 'REVIEW#'
    }
  };

  const result = await dax.query(params);
  return result.Items;
};

DAX Gecikme ve Maliyet Profili#

const latencyAndCost = {
  dynamodb: {
    getItem: '3-5ms',
    query: '5-10ms',
    cost: 'Milyon read başına $0.155'
  },
  dax: {
    getItem: '200-500μs (cache hit)',
    query: '300-700μs (cache hit)',
    cacheMiss: '4-6ms (DynamoDB + overhead)',
    cost: 'Node başına saatte $0.11 (t3.small)'
  }
};

Maliyet Analizi#

// Senaryo: %95 cache hit rate ile saniyede 1,000 request

// DAX olmadan:
// - Request'ler: Ayda 2.59B
// - DynamoDB maliyet: Ayda $401 (on-demand, $0.155/milyon)

// DAX ile (3-node t3.medium cluster):
// - DAX cluster: Ayda $482
// - Cache hit'ler (%95): 2.46B read (DAX tarafından servis edilir)
// - Cache miss'ler (%5): DynamoDB'den 130M read
// - DynamoDB maliyet: Ayda $20
// - Toplam: Ayda $502
// - Tasarruf: Bu ölçekte minimal; ana fayda 10x latency improvement

// Bu hit rate'te maliyet break-even'ı: saniyede ~1,250 req
// Bu hacmin altında DAX'in gerekçesi yalnızca latency

Query Optimizasyon Teknikleri#

Query ve Scan operation’ları arasındaki fark performans ve maliyeti dramatik şekilde etkiliyor.

Query vs Scan#

// ANTI-PATTERN: Scan operation
const findUserByEmail = async (email: string) => {
  const result = await dynamodb.scan({
    TableName: 'Users',
    FilterExpression: 'email = :email',
    ExpressionAttributeValues: {
      ':email': email
    }
  });

  return result.Items?.[0];
};
// - Tüm table'ı okur, application'da filtreler
// - Kapasite eşleşen item'la değil, table boyutuyla artar
// - Latency: saniyeler; table büyüdükçe uzuyor

// BEST PRACTICE: GSI ile query
const findUserByEmailOptimized = async (email: string) => {
  const result = await dynamodb.query({
    TableName: 'Users',
    IndexName: 'GSI-Email',
    KeyConditionExpression: 'GSI1PK = :email',
    ExpressionAttributeValues: {
      ':email': `EMAIL#${email}`
    }
  });

  return result.Items?.[0];
};
// - Direkt partition erişimi
// - 1 item = 0.5 RCU (eventually consistent)
// - Latency: 5-10ms
// - Lookup başına kapasite, table büyüse de sabit kalır

Bir milyon kullanıcılı bir table’da Scan, tek bir kaydı döndürmek için her item’ı okur; GSI query’si tek item okur. Email lookup’ına bir GSI ayırmanın tüm gerekçesi bu fark.

Projection Optimizasyonu#

// ANTI-PATTERN: Tüm attribute'ları project et
const gsiAll = {
  IndexName: 'GSI1',
  Projection: {
    ProjectionType: 'ALL' // Tüm item'ı duplicate eder
  }
};
// Storage: 2x table size (table + full GSI copy)
// Maliyet: Yüksek

// BEST PRACTICE: Sadece gerekli attribute'ları project et
const gsiInclude = {
  IndexName: 'GSI1',
  Projection: {
    ProjectionType: 'INCLUDE',
    NonKeyAttributes: ['name', 'email', 'status']
  }
};
// Storage: Minimal (key'ler + belirtilen attribute'lar)
// Maliyet: Optimize

// OPTIMAL: Full item'ı zaten fetch edeceksen sadece key'ler
const gsiKeys = {
  IndexName: 'GSI1',
  Projection: {
    ProjectionType: 'KEYS_ONLY'
  }
};
// Pattern kullan: GSI'dan ID'leri query et, sonra detaylar için BatchGetItem

Batch Operation’lar#

// ANTI-PATTERN: Sequential GetItem çağrıları
const getUsersSequential = async (userIds: string[]) => {
  const users = [];

  for (const userId of userIds) {
    const result = await dynamodb.get({
      TableName: 'Users',
      Key: {
        PK: `USER#${userId}`,
        SK: 'METADATA'
      }
    });
    users.push(result.Item);
  }

  return users;
};
// 100 user = 100 round trip = 500-1000ms

// BEST PRACTICE: BatchGetItem
import { BatchGetCommand } from '@aws-sdk/lib-dynamodb';

const getUsersBatch = async (userIds: string[]) => {
  const command = new BatchGetCommand({
    RequestItems: {
      Users: {
        Keys: userIds.map(id => ({
          PK: `USER#${id}`,
          SK: 'METADATA'
        }))
      }
    }
  });

  const result = await dynamodb.send(command);
  return result.Responses?.Users || [];
};
// 100 user = 1 request (100 item'a kadar) = 50-100ms
// 5-10x daha hızlı

Filtreleme için Composite Sort Key#

// ANTI-PATTERN: Query + FilterExpression
const getPendingOrdersWrong = async (customerId: string) => {
  const result = await dynamodb.query({
    TableName: 'Orders',
    KeyConditionExpression: 'PK = :pk',
    FilterExpression: '#status = :status',
    ExpressionAttributeNames: {
      '#status': 'status'
    },
    ExpressionAttributeValues: {
      ':pk': `CUSTOMER#${customerId}`,
      ':status': 'pending'
    }
  });

  return result.Items;
};
// TÜM order'ları okur, sonra filtreler
// Tüm order'lar için RCU tüketir, sadece pending'leri döner

// BEST PRACTICE: Composite sort key
// SK formatı: "STATUS#pending#ORDER#456"
const getPendingOrdersOptimized = async (customerId: string) => {
  const result = await dynamodb.query({
    TableName: 'Orders',
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :status)',
    ExpressionAttributeValues: {
      ':pk': `CUSTOMER#${customerId}`,
      ':status': 'STATUS#pending'
    }
  });

  return result.Items;
};
// SADECE pending order'ları okur
// Sadece eşleşen item'lar için RCU tüketir

Hot Partition’ları Önleme#

Her DynamoDB partition 3,000 RCU ve 1,000 WCU destekler. Bu limitleri aşmak throttling’e neden olur.

Hot Partition Senaryoları#

// ANTI-PATTERN 1: Low-cardinality partition key
{
  PK: "STATUS#active",  // Sadece 2-3 unique value
  SK: "USER#123"
}
// Tüm active user'lar BİR partition'da
// 3,000 RCU limitini kolayca aşar

// ANTI-PATTERN 2: Celebrity problemi
{
  PK: "USER#celebrity",
  SK: "FOLLOWER#456"
}
// Bir partition'da milyonlarca follower
// 10GB ve throughput limitlerini aşar

// ANTI-PATTERN 3: Sharding olmadan time-based key
{
  PK: "2024-01-15",  // Bugünün tüm verisi
  SK: "EVENT#123"
}
// Peak saatlerde hot partition

Önleme Stratejisi 1: Write Sharding#

// Write'ları dağıtmak için random suffix ekle
const SHARD_COUNT = 10;

const writeWithSharding = async (userId: string, data: any) => {
  const shardId = Math.floor(Math.random() * SHARD_COUNT);

  await dynamodb.put({
    TableName: 'Users',
    Item: {
      PK: `STATUS#active#${shardId}`, // 10 partition'a dağıtır
      SK: `USER#${userId}`,
      ...data
    }
  });
};

// Okuma tüm shard'ları query etmeyi gerektirir
const getActiveUsers = async () => {
  const promises = [];

  for (let i = 0; i < SHARD_COUNT; i++) {
    promises.push(
      dynamodb.query({
        TableName: 'Users',
        KeyConditionExpression: 'PK = :pk',
        ExpressionAttributeValues: {
          ':pk': `STATUS#active#${i}`
        }
      })
    );
  }

  const results = await Promise.all(promises);
  return results.flatMap(r => r.Items || []);
};

// Write'ları 10 partition'a dağıtır
// Throughput: 10,000 WCU (1,000 * 10)

Önleme Stratejisi 2: Composite High-Cardinality Key’ler#

// Low-cardinality'yi high-cardinality ile birleştir
{
  PK: `STATUS#active#USER#${userId}`, // User başına unique
  SK: "METADATA"
}

// Ya da low-cardinality query'ler için GSI kullan
{
  PK: `USER#${userId}`,
  SK: "METADATA",
  status: "active",
  GSI1PK: "STATUS#active",  // Status query'leri için GSI
  GSI1SK: `USER#${userId}`
}

Önleme Stratejisi 3: Deterministik Sharding#

import crypto from 'crypto';

const getShardId = (entityId: string, shardCount: number): number => {
  const hash = crypto.createHash('md5').update(entityId).digest('hex');
  return parseInt(hash.substring(0, 8), 16) % shardCount;
};

const shardId = getShardId(userId, 10);
const partitionKey = `USERS#${shardId}`;

Bu yaklaşım aynı entity’nin her zaman aynı shard’a gitmesini sağlar, tüm shard’ları query etmeden consistent read’leri mümkün kılar.

Maliyet Optimizasyon Stratejileri#

Provisioned vs On-Demand#

const pricing = {
  provisioned: {
    rcu: 'Saat başına $0.00013',
    wcu: 'Saat başına $0.00065',
    storage: 'GB-ay başına $0.25'
  },
  onDemand: {
    readRequest: 'Milyon read başına $0.155',
    writeRequest: 'Milyon write başına $0.78',
    storage: 'GB-ay başına $0.25'
  }
};

// Örnek: Ayda 100M read, sabit trafik
// Provisioned: ~38.5 RCU * $0.00013 * 730 saat = Ayda $3.65
// On-Demand: 100M / 1M * $0.155 = Ayda $15.50
// Fark: On-demand ile 4.25x daha pahalı

Provisioned Ne Zaman Kullanılmalı:

  • Öngörülebilir trafik pattern’leri
  • Yüksek volume (günde >1M request)
  • 7/24 production uygulamaları
  • Budget-conscious senaryolar
  • Reserved capacity’ye commit edilebiliyorsa (1 veya 3 yıllık taahhütte ek indirim)

On-Demand Ne Zaman Kullanılmalı:

  • Öngörülemez trafik
  • Bilinmeyen yükü olan yeni uygulamalar
  • Development/testing ortamları
  • Spiky workload’lar (10x varyans)
  • Küçük ölçekli uygulamalar (günde 1M’den az request)

Sparse Index Tasarrufu#

// Sparse index olmadan: Tüm 1M user index'te
// Table: 10GB
// ALL projection ile GSI: +10GB
// Toplam: 20GB * $0.25 = Ayda $5 storage

// Sparse index ile: Sadece 100K active user index'te
// Table: 10GB
// Sparse index ile GSI: +1GB (user'ların %10'u)
// Toplam: 11GB * $0.25 = Ayda $2.75 storage
// Tasarruf: %45

Single-Table Maliyet Faydaları#

// Multi-table yaklaşımı:
// - Users: 5 RCU, 5 WCU
// - Orders: 10 RCU, 10 WCU
// - Products: 15 RCU, 5 WCU
// - OrderItems: 20 RCU, 20 WCU
// Toplam: 50 RCU, 40 WCU = Ayda $23.73

// Single-table yaklaşımı:
// - MainTable: 30 RCU, 25 WCU
// Toplam: Ayda $14.71
// Tasarruf: %38; çünkü her ayrı table kendi headroom'unu istiyor

Yaygın Hatalar ve Çözümler#

Hata 1: Access Pattern Dokümantasyonu Eksikliği#

Query’leri anlamadan table tasarlamak sonradan yeniden tasarıma yol açar:

// YANLIŞ yaklaşım:
// 1. Entity'lere göre table'lar oluştur
// 2. Query'ler çalışmadığında GSI ekle
// 3. 5+ GSI ile bitir, hala inefficient

// DOĞRU yaklaşım:
const accessPatterns = [
  'Customer ve tüm order\'larını al',
  'Order ve tüm item\'larını al',
  'Tarih aralığına göre tüm order\'ları al',
  'Email\'e göre customer al',
  'Product ve tüm review\'larını al',
  'Customer review\'larını al'
];

// TÜM pattern'leri efficiently desteklemek için table ve index'leri tasarla

Hata 2: Throttling için Error Handling Eksikliği#

// Yaygın eksik: Trafik spike'ı throttling yarattığında retry logic yok
// Sonuç: ProvisionedThroughputExceededException kullanıcıya kadar gidiyor

// Çözüm: Exponential backoff
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

const client = new DynamoDBClient({
  region: 'us-east-1',
  maxAttempts: 3,
  retryMode: 'adaptive' // Built-in exponential backoff
});

// Daha iyi: Circuit breaker pattern implementasyonu
// En iyi: Hot partition'lardan kaçınacak şekilde tasarla

Hata 3: Item Size Limitlerini Görmezden Gelmek#

// Problem: Item başına 400KB limit
// Yüzlerce satır item içeren bir order, tek item olarak bu limiti aşar

// Çözüm 1: Pagination pattern
{
  PK: "ORDER#456",
  SK: "ITEMS#PAGE#1",
  items: [...] // İlk 100 item
}
{
  PK: "ORDER#456",
  SK: "ITEMS#PAGE#2",
  items: [...] // Sonraki 100 item
}

// Çözüm 2: Bireysel item'lar (tercih edilen)
{
  PK: "ORDER#456",
  SK: "ITEM#1",
  productId: "789",
  quantity: 2
}
// Her satır item'ı ayrı DynamoDB item olarak

Hata 4: Table Creation’da LSI Planlamama#

Table creation’dan sonra LSI ekleyemezsin. Sonradan ihtiyacın olursa veri migration gerektirir:

// Hemen ihtiyacın olmasa bile LSI'ları önceden planla
const tableDefinition = {
  TableName: 'Orders',
  KeySchema: [
    { AttributeName: 'PK', KeyType: 'HASH' },
    { AttributeName: 'SK', KeyType: 'RANGE' }
  ],
  LocalSecondaryIndexes: [
    {
      IndexName: 'LSI-Status',
      KeySchema: [
        { AttributeName: 'PK', KeyType: 'HASH' },
        { AttributeName: 'status', KeyType: 'RANGE' }
      ],
      Projection: { ProjectionType: 'ALL' }
    }
  ]
};

// GSI'lar sonradan eklenebilir, LSI'lar eklenemez

Hata 5: Yanlış Capacity Mode#

// Sabit trafik: 50 read/sec + 20 write/sec, 7/24
// Ayda 129.6M read + 51.84M write

// On-demand (eventually consistent read'ler 0.5 RRU olarak faturalanır)
// 64.8M RRU * $0.155/M + 51.84M WRU * $0.78/M = ayda ~$50

// Auto-scaling ile provisioned, headroom dahil
// 100 RCU * $0.00013 * 730 + 40 WCU * $0.00065 * 730 = ayda $28.47
// Fark: Bu trafik profilinde ~%43

// Öngörülebilir trafik provisioned capacity'yi işaret eder
// Ani sıçramalı, öngörülemez trafik on-demand'i işaret eder

Single-Table Design’dan Ne Zaman Kaçınmalı#

Şu durumlarda ayrı table’lar daha iyi bir seçim:

  • Farklı entity type’ları çok farklı consistency gereksinimlerine sahip
  • Ekipte DynamoDB uzmanlığı yok (öğrenme eğrisi velocity’yi etkiler)
  • Minimal ilişkili basit CRUD (overhead gerekçelendirilemiyor)
  • Ad-hoc reporting ihtiyaçları (data warehouse pattern’leri daha uygun)
  • Microservice’lerde service boundary’leri (servis başına ayrı table’lar)
  • Farklı entity’lerin access pattern örtüşmesi yok

Açıkça söylenmesi gereken iki sınır var: configuration verisini operational table’ın dışında tut ve tek tabloyu servis sınırlarına yayma.

Type-Safe Implementation Kütüphaneleri#

Single-table design pattern’lerini TypeScript ile implement ederken, raw AWS SDK yerine type-safe kütüphaneler kullanmak development hızını artırıyor ve runtime hatalarını önlüyor. İki popüler seçenek var:

DynamoDB Toolbox#

DynamoDB Toolbox, AWS SDK v3 ile uyumlu, modern bir TypeScript kütüphanesi:

  • Type Safety: Entity tanımlarından otomatik TypeScript tipleri
  • Schema Validation: Runtime’da veri doğrulama
  • Query Builder: Type-safe query ve update expression’ları
  • Single-Table Desteği: GSI ve composite key pattern’leri için built-in support
  • AWS SDK v3: Son SDK versiyonu ile tam uyumluluk

Raw AWS SDK ile yazdığın karmaşık AttributeValue mapping’leri yerine, temiz ve bakımı kolay entity tanımları kullanabilirsin. Detaylı implementation örnekleri ve production best practice’leri için DynamoDB Toolbox rehberini incele.

OneTable#

OneTable (yeni sekmede açılır), single-table design için özel olarak tasarlanmış alternatif bir kütüphane:

  • Schema-Driven: JSON schema ile model tanımlama
  • Migration Support: Built-in schema migration desteği
  • TypeScript Generation: Schema’dan otomatik type generation
  • Developer Experience: Minimal boilerplate, sezgisel API
  • Validation: JSON Schema standardı ile güçlü validation

OneTable, özellikle büyük ve karmaşık single-table design’larda schema evolution ve migration ihtiyaçları için güçlü araçlar sunuyor.

Seçim Kriterleri#

DynamoDB Toolbox tercih et:

  • AWS SDK v3’e migrate ediyorsan veya yeni başlıyorsan
  • AWS ekosistemi ile daha sıkı entegrasyon istiyorsan
  • Daha fazla AWS-native pattern kullanacaksan

OneTable tercih et:

  • Schema migration’lar sık yapıyorsan
  • JSON Schema standardını tercih ediyorsan
  • Daha fazla abstraction ve convention over configuration istiyorsan
  • Hızlı prototyping yapıyorsan

Her iki kütüphane de production-ready ve aktif olarak maintain ediliyor. Seçim, team preference ve proje gereksinimlerine bağlı.

İlgili Konular#

DynamoDB ile çalışmak keşfetmeye değer birkaç alana bağlanıyor:

Varsayılan şu koşullarda geçerli: entity’ler birlikte okunuyorsa ve access pattern’ler table oluşmadan önce biliniyorsa tek table, generic key’ler ve az sayıda overload edilmiş index yeterli. Entity type’ları farklı servislere aitse, raporlama ad-hoc ise ya da ekibin modelleme öğrenme eğrisine ayıracak alanı yoksa bu varsayılanı bırak. Ayrı table’lar fazladan round-trip demek; bu, kimsenin değiştiremediği bir key şemasından daha ucuz bir problem.

Kaynaklar#

İlgili yazılar