İçeriğe atla

DynamoDB Sayfalama: İmzalı Cursor, Sıralama ve Toplam Sayı Problemi

DynamoDB'de OFFSET, keyfi ORDER BY ve ucuz COUNT yok. İmzalı next/prev cursor sunun, sıralamayı sort key ile modelleyin, toplam sayıyı listeden çıkarın.

Ayhan Sipahi Ayhan Sipahi

DynamoDB’nin yerel Query API’sinde sayfalama ilkeli tektir: yanıttaki LastEvaluatedKey değerini bir sonraki istekte ExclusiveStartKey olarak geri verirsiniz (PartiQL ExecuteStatement aynı mekaniği bir NextToken arkasına sarar). OFFSET yok, keyfi bir attribute üzerinde ORDER BY yok, ucuz bir COUNT da yok. DynamoDB üzerine kurulu liste endpoint’leri için önerdiğim sözleşme şu: HMAC ile imzalanmış, kapsama bağlı, opak bir cursor; yalnızca ileri ve geri gezinme; yanıtta toplam sayı yok. Sıralama sort key’den gelir; her ek sıralama düzeni, maliyeti bilerek üstlenilmiş bir GSI demektir. Aşağıdaki mekanik TypeScript ve AWS SDK for JavaScript v3 kullanıyor. Desenler dokümante edilmiş API davranışından türüyor; henüz yük altında denemedim, bu yüzden son bölüm sözleşme yayına çıktığında nelerin ölçüleceğini listeliyor.

Limit ve LastEvaluatedKey Gerçekte Ne Vadediyor#

Sayfalama davranışının büyük kısmını resmî dokümantasyondaki birkaç cümle belirliyor. Query API referansı (yeni sekmede açılır) Limit parametresini, eşleşen öğe sayısı olarak değil, değerlendirilecek en fazla öğe sayısı olarak tanımlar. Tek bir çağrı, bu sınıra ya da 1 MB veriye ulaşana kadar okur (hangisi önce gelirse) ve FilterExpression ancak bu okumadan sonra uygulanır. İşin ne zaman bittiği konusunda geliştirici kılavuzu (yeni sekmede açılır) açık: dolu bir LastEvaluatedKey, sonuç kümesinde daha fazla veri olduğunu garanti etmez; sonuç kümesinin sonuna ulaştığınızı bilmenin tek yolu LastEvaluatedKey alanının boş dönmesidir.

Bundan iki sonuç çıkıyor. Birincisi, filtreyle birlikte Limit: 20, yanıtta 20 öğe üretmez. DynamoDB 20 öğe değerlendirir, filtre eşleşmeyenleri eler; sayfa 3 öğeyle de gelebilir, 11 öğeyle de, sıfır öğe ve dolu bir cursor ile de. Dolayısıyla herhangi bir sayfalama döngüsünün bitiş koşulu LastEvaluatedKey alanının yokluğudur; boş Items dizisi bitiş sinyali sayılmaz. while (items.length > 0) şeklinde yazılmış bir istemci döngüsü erken durur ve sonuçları sessizce budar.

İkincisi, filtre yükü küçültürken fatura aynı kalır. Filtre dokümantasyonuna (yeni sekmede açılır) göre filtre, okuma tamamlandıktan sonra çalışır; tüketilen kapasite, filtrenin elediği öğeler dahil taranan her şeyi kapsar. Query yanıtı (yeni sekmede açılır) iki sayıyı birden raporlar: ScannedCount okunanı, Count filtreden sağ çıkanı gösterir. Aradaki fark, parasını ödeyip çöpe attığınız okuma kapasitesidir.

TypeScript ile Cursor Tabanlı Bir Sayfa#

@aws-sdk/lib-dynamodb ile cursor tabanlı bir liste endpoint’i şöyle görünür. encodeCursor ve decodeCursor fonksiyonları bir sonraki bölümde geliyor.

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb';
import { encodeCursor, decodeCursor } from './cursor';

const doc = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const MAX_PAGE_SIZE = 100;

export async function listOrders(customerId: string, cursor?: string, pageSize = 20) {
  // İstemciden gelen sayfa boyutuna asla güvenmeyin: sunucu tarafında sınırlayın.
  const limit = Math.min(Math.max(pageSize, 1), MAX_PAGE_SIZE);
  // Kapsam, cursor'ı bu kullanıcıya ve tam olarak bu sorgu şekline bağlar:
  // mantıksal sorgu adı, index (burada ana tablo), yön, tenant.
  const scope = `orders:base:desc:${customerId}`;

  const res = await doc.send(
    new QueryCommand({
      TableName: 'app',
      KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
      ExpressionAttributeValues: {
        ':pk': `CUSTOMER#${customerId}`,
        ':prefix': 'ORDER#',
      },
      ScanIndexForward: false, // en yeniden eskiye; her sayfada aynı kalmalı
      Limit: limit,
      ExclusiveStartKey: cursor ? decodeCursor(cursor, scope) : undefined,
      ReturnConsumedCapacity: 'TOTAL',
    }),
  );

  return {
    items: res.Items ?? [],
    // null, "liste bitti" sinyalinin tek dürüst hâli
    nextCursor: res.LastEvaluatedKey ? encodeCursor(res.LastEvaluatedKey, scope) : null,
  };
}

Bu taslakta iki ayrıntı yük taşıyor. ScanIndexForward, bir sayfalama oturumundaki her istekte aynı kalmak zorunda; çünkü cursor, yönlü bir gezinmedeki konumu kodlar ve yönü oturum ortasında çevirmek öğelerin atlanmasına ya da yinelenmesine yol açar. nextCursor: null ise endpoint’in verebileceği tek güvenilir tamamlanma sinyalidir; nedenini yukarıdaki bölüm anlatıyor. Handler ayrıca yalnızca nextCursor döndürüyor: önceki sayfa gezinmesi, sondaki kırılma listesinde anlatılan kendi ters yönlü oturumudur ve cursor’ını son öğeden değil, mevcut sayfanın ilk öğesinden üretir.

Sorgu bir GSI’ı hedeflediğinde dönen LastEvaluatedKey, index anahtarlarından fazlasını içerir. GSI dokümantasyonuna (yeni sekmede açılır) göre index anahtar değerlerinin benzersiz olması gerekmez ve ana tablonun primary key attribute’ları her zaman index’e yansıtılır; bu yüzden anahtar, tablonun partition ve sort key attribute’larını da taşır. Cursor doğrulaması, attribute beyaz listeleri ve loglama bunu hesaba katmalı; GSI destekli bir endpoint’te cursor loglayan bir sistem, aslında tablo anahtarlarını logluyordur.

Sayfa sunmak yerine sonuç kümesini baştan sona tüketen iç işlerde elle döngü kurmayın: @aws-sdk/lib-dynamodb içindeki paginateQuery, LastEvaluatedKey çevrimini bir async iterator’a sarar; AWS’nin sayfalama örneği (yeni sekmede açılır) aynı şekli farklı SDK’larda gösterir. Veri katmanınız DynamoDB Toolbox üzerindeyse, cursor kodlayıcının o yığında nereye oturduğunu DynamoDB Toolbox rehberi gösteriyor; oradaki örnek çıplak base64url kullanıyor, ona da bir sonraki bölümdeki imzalama katmanını ekleyin.

Cursor’ı İmzalayın, Kapsama Bağlayın#

Cursor dizesinin kendisi ne olmalı? Ekosistemdeki kamuya açık duruşlar iki kutupta toplanıyor. En yaygın tipli DynamoDB istemcilerinden ElectroDB (yeni sekmede açılır), cursor’ını LastEvaluatedKey değerinin base64url kodlanmış bir kopyası olarak dokümante eder ve çoğu eğitim içeriği aynı çıplak kodlamayı kullanır. Öbür uçta @emdgroup/dynamodb-paginator (yeni sekmede açılır), JSON kodlu token’ların istemciye anahtar değerlerini okuma ve değiştirme imkânı verdiğini söyleyerek token’larını hem şifreler hem imzalar. AWS, kendi yönetilen GraphQL katmanında ikinci kampta duruyor: AppSync resolver referansı (yeni sekmede açılır), tablo verisinin çağırana sızmaması için AppSync’in DynamoDB’den dönen sayfalama token’ını şifreleyip gizlediğini ve bu token’ların farklı resolver’lar arasında kullanılamadığını belirtir.

Önerim orta konum: cursor’ı, base64url yük artı bir kapsam dizesi üzerinden HMAC-SHA256 ile imzalayın; şifrelemeyi ise anahtar değerlerinin kendisinin hassas olduğu duruma saklayın. İmzanın ne satın aldığı konusunda net olmak gerekiyor; çünkü implementasyonların abarttığı yer burası: HMAC bütünlük ve kimlik doğrulama sağlar, gizlilik sağlamaz. İmzalı bir cursor’ı elinde tutan herkes yükü base64url’den çözüp içindeki anahtarı okuyabilir. Yani buradaki “opak”, istemcinin token’ı yorumlamaması gereken ve kurcalayanların imza kontrolüyle reddedildiği bir sözleşmedir; içerik gizleme değildir. Seçenekler şöyle diziliyor:

Cursor biçimiKurcalamaya kapalıİçerik gizliSunucu tarafı durum
Çıplak base64urlHayırHayırYok
HMAC imzalı base64urlEvetHayırYok
Authenticated encryption (AEAD)EvetEvetYok
Sunucuda saklanan rastgele handleEvetEvetİşletilecek bir cursor deposu

Çıplak base64 ilk sütunda sınıfta kalır ve ciddi olan da budur: token istemci tarafından düzenlenebilir, yani ExclusiveStartKey veri katmanınıza giren saldırgan kontrollü bir girdiye dönüşür; okunabilir anahtar şeması da istemcilerin ayrıştırıp bağımlılık kuracağı fiilî bir açık API hâline gelir. İmza kurcalama deliğini kapatır; şemanın okunmasını engellemez, onu yalnızca son iki satır engeller. Tam şifreleme, çoğu şemada CUSTOMER#id gibi sır sayılmayacak bir öneki gizlemek için anahtar yönetimi ve ek kripto yüzeyi getirir; sunucu tarafı handle ise cursor’ların tam da kaçınmak için var olduğu sunucu durumunu geri getirir. Anahtarlarınız e-posta adresi ya da başka kişisel veri içeriyorsa şifreleme seçenek olmaktan çıkar; o noktada AES’i elle kurmak yerine denetlenmiş bir kütüphane ya da platformunuzun kripto katmanını kullanın. emdgroup paginator deseni iyi gösteriyor; ama geliştirmesi 2023’ten beri durgun, bu yüzden onu bağımlılık olarak değil referans implementasyon olarak okuyun.

Kapsam dizesi, çoğu implementasyonun atladığı parçadır ve AppSync’in resolver’lar arası kısıtının ima ettiği şeydir. Konumun anlamını belirleyen her şeyi dahil edin: tenant ya da kullanıcı, mantıksal sorgu adı, index ve gezinme yönü. Bir kullanıcının sipariş listesi için üretilmiş cursor, başka bir kullanıcının listesine karşı oynatıldığında doğrulamadan geçmemeli; A sorgusunun cursor’ı B sorgusunda reddedilmeli.

import { createHmac, timingSafeEqual } from 'node:crypto';

export class CursorError extends Error {}

const SECRET = process.env.CURSOR_SECRET;
if (!SECRET) throw new Error('CURSOR_SECRET is not set');

const MAX_AGE_MS = 15 * 60 * 1000; // bir gezinme oturumu kadar; sonsuza dek geçerli olmasın

function sign(payload: string, scope: string): Buffer {
  return createHmac('sha256', SECRET).update(`${scope}.${payload}`).digest();
}

export function encodeCursor(key: Record<string, unknown>, scope: string): string {
  const payload = Buffer.from(JSON.stringify({ key, iat: Date.now() })).toString('base64url');
  return `${payload}.${sign(payload, scope).toString('base64url')}`;
}

export function decodeCursor(token: string, scope: string): Record<string, string | number> {
  const [payload, mac] = token.split('.');
  if (!payload || !mac) throw new CursorError('malformed cursor');

  const given = Buffer.from(mac, 'base64url');
  const expected = sign(payload, scope);
  // `given` istemciden geliyor: uzunluğu saldırganın kontrolünde.
  // timingSafeEqual uzunluklar farklıysa hata fırlatır: önce uzunlukları karşılaştırın.
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
    throw new CursorError('invalid signature');
  }

  const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString()) as {
    key: Record<string, string | number>;
    iat: number;
  };
  if (typeof parsed.iat !== 'number' || Date.now() - parsed.iat > MAX_AGE_MS) {
    throw new CursorError('cursor expired');
  }
  return parsed.key;
}

iat zaman damgasının önemi şurada: cursor, index içinde bir konumu işaretler; veriyi dondurmaz ve imza tek başına onu sonsuza kadar sözdizimsel olarak geçerli tutar. İmzalı yükün içine üretim zamanını gömmek, bayatlamış ya da sızmış bir token’ın ne kadar süre iş göreceğini sınırlar. Bu sınır bir hijyen önlemidir, tutarlılık mekanizması değil: pencere içinde bile eşzamanlı yazmalar öğeleri sayfa sınırları arasında kaydırır; sayfalayan bir istemci aynı öğeyi iki kez görebilir ya da kaçırabilir. Canlı veri üzerinde cursor sayfalaması, süre sınırı olsun olmasın, yapısı gereği best-effort çalışır.

Bu varsayılanın sunucu tarafı maliyetleri gerçek ama küçük: istek başına gelen cursor için bir HMAC doğrulaması artı giden için bir imzalama, saklanıp döndürülecek bir secret ve istemcinin ilk sayfadan yeniden başlayarak yanıtlaması gereken yeni bir hata yolu (geçersiz cursor kodlu bir HTTP 400). UX maliyetleri de aynı dürüstlüğü hak ediyor: istenen sayfaya doğrudan atlama yok, kullanıcıya görünen bir sonuç büyüklüğü yok ve cursor’ın süresi dolduğunda tarayıcının geri tuşu listeyi ilk sayfadan başlatıyor. En keskin köşe secret rotasyonu; çünkü yeni secret, uçuştaki bütün cursor’ları aynı anda geçersiz kılar. Pratikte rotasyon penceresi boyunca hem güncel hem önceki anahtarla doğrulama yapar, imzayı yalnızca güncel anahtarla atarsınız; yüke eklenecek bir sürüm alanı da aynı esnekliği biçim değişiklikleri için sağlar.

Filtre Altında Sabit Boyutlu Sayfa Doldurmak#

Bazen API sözleşmesi sayfa boyutunu sabitler, filtre bugün key condition’a taşınamaz ve arayüz için 3, sonra 0, sonra 11 öğelik bir sayfa dizisi kabul edilemezdir. İşe yarayan hafifletme, tur sayısı katı biçimde sınırlanmış bir iç döngüdür:

import { QueryCommand, type QueryCommandInput } from '@aws-sdk/lib-dynamodb';

const MAX_ROUNDS = 5; // katı sınır: bu olmadan tek istek bütün partition'ı dolaşabilir

// `doc`, önceki bölümdeki ortak DynamoDBDocumentClient.
export async function fillPage(base: QueryCommandInput, pageSize: number) {
  const collected: Record<string, unknown>[] = [];
  let startKey = base.ExclusiveStartKey;
  let rounds = 0;

  while (collected.length < pageSize && rounds < MAX_ROUNDS) {
    const res = await doc.send(
      new QueryCommand({
        ...base,
        ExclusiveStartKey: startKey,
        Limit: (pageSize - collected.length) * 3, // fazladan çek: filtre bir kısmını eleyecek
      }),
    );
    collected.push(...(res.Items ?? []));
    startKey = res.LastEvaluatedKey;
    rounds += 1;
    if (!startKey) break; // aralık gerçekten tükendi
  }

  const page = collected.slice(0, pageSize);
  const truncated = collected.length > page.length;
  const last = page.at(-1);

  return {
    items: page,
    // Fazla öğeler kırpıldıysa LastEvaluatedKey, çağırana hiç ulaşmamış
    // satırların ötesini gösterir. Bunun yerine gerçekten döndürülen
    // son öğeden devam edin.
    // pk ve sk adlı string tablo anahtarları varsayılıyor: kendi şemanızdan
    // türetin, GSI'da index anahtar attribute'larını da ekleyin; binary
    // anahtarlar kendi serileştirmesini ister.
    nextKey: truncated && last ? { pk: last.pk, sk: last.sk } : startKey,
  };
}

Bu döngüye üç not düşmek gerekiyor. Fazladan çekme çarpanı doğru sabiti olmayan bir ayar tahminidir; yalnızca beklenen tur sayısını azaltır. İnce nokta devam anahtarı: fazla öğeler kırpıldıktan sonra LastEvaluatedKey, yanıtın hiç döndürmediği satırların ötesini gösterir; oradan devam etmek o satırları atlar, anahtarı gerçekten döndürülen son öğeden türetmek cursor’ı dürüst tutar. GSI üzerinde bu türetilmiş anahtar hem index anahtar attribute’larını hem tablo anahtar attribute’larını içermek zorundadır. Son olarak sınır süs olsun diye durmuyor: MAX_ROUNDS olmadan, elverişsiz bir filtreye denk gelen tek istek bütün partition’ı dolaşır ve faturası size yazılır.

Döngü bir hafifletmedir ve aynı zamanda bir teşhis aracıdır. Turlarını düzenli olarak tüketiyorsa filtre, bir index’in yapması gereken işi yapıyor demektir; koşul ya key condition’a ya da bir GSI’a taşınmalıdır.

Sıralama Sort Key’den Gelir#

Query sonuçları sort key değerine göre sıralı gelir: sayılar sayısal, dizeler UTF-8 bayt sırasına göre; ScanIndexForward: false ise gezinmeyi tersine çevirir. DynamoDB’nin sunduğu sıralama özelliğinin tamamı budur. Gerisi veri modellemedir:

  • Bayt sırasının keskin köşeleri var. "10" dizesi "9" dizesinden önce sıralanır; bu yüzden string sort key’lerde saklanan sayısal değerler sabit genişliğe sıfırla doldurulmalıdır. ISO-8601 zaman damgaları doğru sıralanır; çünkü sözlük sıraları kronolojik sırayla örtüşür. Onları UTC’de ve sabit formatta tutun.
  • Her ek sıralama düzeni bir index’tir. Sort key’i sıralamak istediğiniz attribute olan bir GSI size ikinci bir düzen verir; bedeli ek depolama ve ana öğenin her değişiminde index’e yazılan bir kayıttır. Her yeni düzen sözcük anlamıyla index istemez: ters yön ScanIndexForward ile bedavadır, STATUS#DATE#ID gibi birleşik bir sort key birden çok aralık şekline hizmet eder ve küçük, sınırlı bir koleksiyon uygulama katmanında sıralanabilir. Index’e mal olan tekrarlayan durum, büyük bir koleksiyonu sıralaması gereken bağımsız bir attribute’tur. Bu maliyet her yazmada tekrarlandığı için DynamoDB’de “sortBy parametresi ekleyelim” hiçbir zaman bedava bir özellik olmaz. Anahtarların baştan nasıl seçileceğini single-table tasarım rehberi anlatıyor.
  • Seyrek index’ler tek hamlede hem filtreler hem sıralar. AWS’nin seyrek index rehberi (yeni sekmede açılır), yalnızca ilgili olduğu sürece var olan ve değeri işe yarar bir düzen üreten bir attribute önerir; örneğin sipariş tamamlanınca silinen bir açılış tarihi attribute’u. Seyrek GSI böylece yalnızca açık siparişleri, üstelik tarihe göre sıralı tutar.
  • Aşırı yüklenmiş tek bir GSI birden çok şekle hizmet edebilir. GSI overloading (yeni sekmede açılır), tablonun sort key’ini index’in partition key’i, genel bir attribute’u da index’in sort key’i yapar; tek index birden çok sorgu desenini yanıtlar.
  • Eşit index anahtarlarının tanımlı bir sırası yok. Bir GSI, aynı partition ve sort key değerlerine sahip birden çok öğeyi kabul eder ve bunların sonuçlardaki göreli sırası garanti edilmez. Kararlı bir dizilim isteyen arayüz, sort key’e 2026-08-23T10:12:03Z#ORDER-7431 gibi benzersiz bir ayırıcı ekler.
  • Katı sınır: sıralama tek bir partition key içinde yaşar. Partition’ları aşan bir sonuç kümesini tek bir Query sıralayamaz. Sınırlı çözümler var: sabit tek partition değerli bir GSI (yazma kapasitesini tek partition’la sınırlar) ya da uygulamada birleştirilen shard’lanmış index partition’ları. Yani “bütün kullanıcıları soyadına göre sırala” imkânsız değildir; ama ucu açık bir gereksinim olarak aşağıda ele alınan arama index’i istisnasına işaret eder.

Toplam Sayı Problemi#

Sayfalayıcı bileşeni 42 sayfanın 7’ncisini göstermek istiyor ve 42 için bir toplam gerekiyor. DynamoDB’de toplam üretmenin beş yolu var ve her biri bedelini farklı ödüyor:

MekanizmaGüncellikMaliyetiUygun olduğu yer
İstek başına Select: 'COUNT'Okuma anında güncel, sayfalar arası anlık görüntü değilTaranan her öğe için okuma kapasitesi, çağrı başına 1 MBTek partition içinde sınırlı küçük koleksiyonlar
DescribeTable ItemCountYaklaşık altı saat bayatÜcretsizPanolar, kapasite planlama
Transaction’lı sayaç öğesiHer yazma yolu transaction’dan geçtiği sürece kesinHer değişimde ikinci bir yazma artı transaction maliyeti, sıcak öğe riskiÜrün özelliği olan sayılar
Streams beslemeli toplamSaniyeler geriden, gecikme değişkenİşletip izleyeceğiniz bir Lambda tüketicisiÖlçekte, yazma yoğun toplama
OpenSearch replikasıReplikasyon gecikmesiİkinci bir veri deposu ve bir ingestion hattıKeyfi sıralama, tam metin arama, kesin isabet sayıları

Select: 'COUNT' yerleşik çözüm gibi görünür ve en çok yanlış anlaşılan seçenektir. Öğeler yerine sayı döndürür; ama saydığı her öğeyi yine okur, çağrı başına 1 MB sınırında yine durur ve takip etmeniz gereken bir LastEvaluatedKey yine döndürür. Tüketilen kapasite, veriyi çekmekle aynıdır. Okumalar varsayılan olarak eventually consistent’tır ve bir GSI hiçbir zaman güçlü tutarlılıkla okunamaz; yani bu sayı da ancak arkasındaki okumalar kadar günceldir. Tek partition içinde sınırlı bir koleksiyon için sorun yok; tablo genelinde bir toplam olarak ise her sayfa açılışında tam bir Scan çalıştırmak demektir.

DescribeTable hiçbir şeye mal olmaz ama başka bir soruya cevap verir. API referansı (yeni sekmede açılır), DynamoDB’nin ItemCount değerini yaklaşık altı saatte bir güncellediğini ve son değişikliklerin yansımamış olabileceğini söyler. Yaklaşık öğe sayısı gösteren bir pano kutucuğu için iş görür; sayfalayıcı girdisi olarak sınıfta kalır.

Elle bakımı yapılan sayaçlar kesindir; ama ancak ürün özelliği olduklarında masraflarını çıkarır. ADD ile UpdateItem atomiktir, fakat geliştirici kılavuzu (yeni sekmede açılır) püf noktasını açıkça söyler: atomik sayaç güncellemeleri idempotent olmadığından tekrarlanan bir artırma iki kez sayar. Çözüm, öğe yazımı ile sayaç artırımını client request token’lı tek bir TransactWriteItems (yeni sekmede açılır) çağrısında gruplamaktır; token, transaction’ı on dakikalık geçerlilik penceresi boyunca idempotent yapar. Kesinliğin de bir çevresi var: her yazma yolu aynı transaction’dan geçmek zorundadır ve onu atlayan her şey, bir TTL silmesi, bir toplu import, elle yapılan bir düzeltme, sayacı bir mutabakat işi düzeltene kadar kaydırır. Artık her değişiklik iki yazma artı transaction maliyeti öder ve yüksek trafikli bir tenant’ın paylaştığı sayaç sıcak bir öğeye dönüşür; sıcak bir sayaç öğesinin ani yük altında ne yaptığını ve write-sharding’in yükü nasıl dağıttığını rate limit stratejileri yazısı anlatıyor.

Daha yüksek yazma hacimlerinde AWS’nin materialized aggregation deseni (yeni sekmede açılır) saymayı yazma yolundan çıkarır: DynamoDB Streams, toplam öğelerini güncel tutan bir Lambda’yı besler. Sayı artık tabloyu geriden izler; çoğu zaman saniyelerle, tüketici hata verdiğinde ya da biriktirdiğinde ise katı bir üst sınır olmadan. Dead-letter kuyruğu ile iterator-age izlemesi olan bir tüketici işletirsiniz; karşılığında yazma yolu yüksüz kalır.

Gereksinim keyfi sıralama artı kesin isabet sayısı artı tam metin eşleşmesi olduğunda ise cevap tek başına DynamoDB olmaktan çıkar. OpenSearch ile zero-ETL entegrasyonu (yeni sekmede açılır) tabloyu, başlangıçta bir point-in-time export ve devamında Streams değişiklikleriyle replike eder; arama biçimli soruları replika doğal olarak yanıtlar. Verdiği isabet sayıları, kaynak tabloyu replikasyon gecikmesi kadar geriden izleyen kendi index durumu için kesindir.

Yine de çoğu ürün listesi arayüzü için doğru hamle toplamı listeden çıkarmaktır. Arayüzün göstermesi gereken şey “devamı var” ve “önceki sayfa var” bilgisidir. Dolu bir cursor tek başına ilkini tam karşılamaz: en başta anlatıldığı gibi yalnızca gezinmenin sürebileceğini vadeder ve bir sonraki çağrı boş dönebilir. Gerçek bir hasNextPage tam olarak bir fazla öğeye mal olur: filtreden geçen pageSize + 1 öğe bulunana kadar okuyun, ilk pageSize öğeyi döndürün, fazlalık soruyu yanıtlasın; yukarıdaki doldurma döngüsü, hedefi bir artırıldığında bunu üretir. Relay tarzı sayfalamanın arkasındaki sözleşme olan GraphQL Cursor Connections Specification (yeni sekmede açılır) böyle çalışır: pageInfo içinde hasNextPage ve hasPreviousPage bulunur, zorunlu bir toplam alanı yoktur; toplama ihtiyaç duyan ürünler için spesifikasyon ek alana da izin verir. En güçlü karşı argüman Alex Reid’in numaralı sayfa URL’leri yazısından (yeni sekmede açılır) geliyor: ürün gerçekten kalıcı, paylaşılabilir sayfa bağlantıları istiyorsa sayfa kırılım anahtarlarını önceden hesaplayıp saklayabilir ve sunabilirsiniz. Mekanik çalışıyor. O yazıdaki bedel, sayfa kırılım index’ini tutan ikinci bir veri deposu ve onu tablonun Stream’inden besleyip güncel tutan bir Lambda tüketicisidir; yani biri gereksinimi kanıtlayana kadar reddedilmeyi hak eden türden bir mekanizma.

Varsayılandan Ne Zaman Sapmalı#

Evet

Hayır

Evet

Hayır

Evet

Hayır

Evet

Hayır

İmzalı cursor, ileri ve geri, toplam sayı yok

Koleksiyon tek partition içinde sınırlı mı?

İstek başına Select COUNT karşılanabilir, kesin sayı gösterilebilir

Sayı bir ürün özelliği mi?

Transaction'lı sayaç ya da Streams toplaması

Partition'lar arası keyfi sıralama gerekli mi?

OpenSearch'e replike edin

Paylaşılabilir numaralı sayfa URL'leri şart mı?

Sayfa kırılım anahtarlarını önceden hesaplayın

Varsayılanda kalın

Her istisna gerçek bir durumdur ve her birinin kapsamı ilk bakışta görünenden dardır. Bir kullanıcının ödeme yöntemleri ya da bir projenin üyeleri gibi tek partition içinde sınırlı bir koleksiyon, istek başına Select: 'COUNT' maliyetini karşılanabilir kılar; o zaman kesin sayı göstermek zararsızdır. Kota, faturalama sayacı ya da okunmamış rozeti gibi kendisi ürün özelliği olan bir sayı, transaction’lı yazmalarla tutulan bir sayacı; yazma hacmi transaction’ları pahalı hâle getirdiğinde ise Streams toplamasını hak eder. Önceden sayamayacağınız attribute’lara göre sıralama gereksinimi bir arama iş yüküdür; beşinci GSI’ı eklemek yerine OpenSearch’e replike edin. İncelemeden sağ çıkan paylaşılabilir numaralı URL gereksinimi de önceden hesaplanmış sayfa kırılımları yolunu tutar. Bu gereksinimin en sık ayakta kalan hâli back-office ve denetim ekranlarıdır: destek ekipleri gerçekten konuma ve sonuç büyüklüğüne göre gezinir ve bu iç koleksiyonlar çoğu zaman maliyeti sınırlı tutacak kadar küçüktür. Bu istisnaların hiçbiri cursor’ın kendisini ortadan kaldırmaz: OpenSearch yolu bile aynı imzalamayı ve kapsam bağlamayı hak eden kendi sayfalama durumunu döndürür.

Sayfalama Kodunun Kırıldığı Yerler#

  • Boş sayfada durmak. Sıfır öğeli ama cursor’ı dolu bir sayfa normaldir. Anahtar yok olana kadar döngüye devam edin.
  • Limit parametresini sayfa boyutu sözü sanmak. Limit değerlendirmeyi sınırlar ve filtre okumadan sonra çalışır. Sınırlamayı ve doldurmayı API katmanında yapın.
  • Ham LastEvaluatedKey değerini query string’e koymak. Partition anahtarları tarayıcı geçmişine, CDN ve proxy loglarına, Referer başlıklarına düşer; token’ı eline geçiren herkes düzenleyebilir. Cursor’ı imzalayıp kapsama bağlayın ve gelen her cursor’ı güvenilmez girdi olarak işleyin; imza yükü gizlemediği için anahtar değerlerinin bu loglara düşmemesi gerekiyorsa şifrelemeye geçin.
  • Tek cursor, iki sorgu. Cursor yalnızca onu üreten index, key condition, filtre ve yön için anlamlıdır. Sorgu şeklini imzalı kapsama gömün ve uyuşmazlıkları reddedin; yoksa sayfalamanın ortasında filtre parametresini değiştiren istemci sessizce yanlış sayfalar alır.
  • Önceki sayfayı aynı cursor üzerinde ScanIndexForward çevirerek yapmak. Oturum ortasında yön değiştirmek konumun anlamını değiştirir. Geriye gezinmeyi, sayfa sınırında yakalanmış bir cursor’dan başlayan ayrı bir oturum olarak çalıştırın ve dönmeden önce sayfa dizisini ters çevirin.
  • Canlı yazmalar altında Scan tabanlı dışa aktarım. Scan cursor’ı anahtar sırasında bir konumu işaretler ve anlık görüntü garantisi taşımaz; eşzamanlı yazmalar öğeleri bu konuma göre kaydırabilir ve dışa aktarım satır yineleyebilir ya da kaçırabilir. Tutarlı dışa aktarım için canlı Scan yerine S3’e point-in-time export kullanın.
  • Sınırsız istemci sayfa boyutu. Tavanı olmayan bir parametre, tek isteği keyfî pahalılıkta bir okumaya çevirir. Yukarıdaki handler’daki gibi sunucu tarafında sınırlayın.

Sözleşmeye Güvenmeden Önce Ölçülecekler#

Yukarıdaki desenlerin hiçbiri henüz yük testinden geçmedi; o iş sözleşme yayına çıktıktan sonra başlıyor. Sayfalama katmanını gözlemlenebilir kılan enstrümantasyon planı kısa. API sayfası başına ConsumedCapacity değerini p50 ve p99 ile izleyin: maliyeti istekten isteğe on kat oynayan bir sayfa, filtrenin bir index’in işini yaptığı anlamına gelir. Sorgu başına Count ile ScannedCount oranını izleyin; kaba bir kural olarak, taranan öğelerin yarısından çok daha azı filtreden geçiyorsa endpoint, çöpe attığı okumaların parasını ödüyordur. API sayfası başına iç tur sayısını izleyin; bu sayı 1’de durmalı, kalıcı olarak daha yüksekse doldurma döngüsü bir modelleme sorununu maskeliyordur. Bunlara boş sayfa oranını (sıfır öğe, dolu cursor) ve cursor reddetme oranını ekleyin; reddetmelerdeki ani bir sıçrama çoğunlukla unutulmuş bir secret rotasyonuna ya da endpoint’i yoklayan birine işaret eder.

Varsayılan, öğeleri bir partition key’in doğal düzeni altında yaşayan ürün listesi endpoint’leri için geçerli: imzalı, kapsama bağlı cursor’lar, ileri ve geri gezinme, toplam yok. Sayı bir ürün özelliğiyse, sıralama partition’ları aşmak zorundaysa ya da kalıcı numaralı URL’ler üründe gerçekten gerekliyse istisnalara uzanın. Hemen atılmaya değer tek adım, bir frontend DynamoDB’nin sunmadığı garantiler etrafında sayfalayıcı kurmadan önce cursor sözleşmesini (opak token, süre aşımı, geçersiz cursor hata kodu) API spesifikasyonuna yazmaktır.

Kaynaklar#

İlgili yazılar