RO EN

Cosmos DB patterns (1) — partition key design pentru queries eficiente

Cosmos DB patterns (1) — partition key design pentru queries eficiente ✨ Imagine generată cu AI
Doru Bulubașa
27 iulie 2026
40 vizualizări

Aceasta e prima parte dintr-o serie de cinci articole despre pattern-uri avansate în Cosmos DB. Începem cu decizia cu cel mai mare impact și cel mai greu de schimbat ulterior: partition key-ul. Exemplele vin dintr-un scenariu real — un sistem de chat AI cu sesiuni, mesaje și query logs, similar cu ce rulează în spatele Oravio.


De ce partition key-ul e decizia critică

Cosmos DB scalează prin împărțirea datelor în partiții fizice, distribuite pe baza partition key-ului. Fiecare partiție logică are o limită de 20 GB și throughput-ul se distribuie între partițiile fizice. Consecințele practice:

  • Query-urile într-o singură partiție sunt ieftine — Cosmos știe exact unde să caute. Câțiva RU pentru un point read.
  • Query-urile cross-partition sunt scumpe — se execută pe toate partițiile fizice și adună costurile. Un query care costă 3 RU pe o partiție costă 3 × N pe N partiții.
  • Partition key-ul nu se poate schimba — odată creat containerul, singura cale e migrarea datelor într-un container nou.

De aceea regula de aur: alegi partition key-ul pornind de la pattern-urile de query, nu de la structura datelor.


Cele trei criterii

1. Cardinalitate mare

Partition key-ul trebuie să aibă multe valori distincte. Un key cu 5 valori posibile (ex. /status) înseamnă maximum 5 partiții logice — și limita de 20 GB per partiție devine un tavan real.

2. Distribuție uniformă a scrierilor

Evită hot partitions: dacă 80% din scrieri merg pe aceeași valoare de key, acea partiție fizică devine bottleneck, indiferent cât throughput ai provizionat. Exemplu clasic de greșeală: /date ca partition key — toate scrierile de azi lovesc aceeași partiție.

3. Alinierea cu query-urile dominante

Query-ul cel mai frecvent trebuie să conțină partition key-ul în filtru. Dacă 90% din queries sunt „dă-mi mesajele sesiunii X”, atunci /sessionId e candidatul natural.


Studiu de caz: sistem de chat AI

Să aplicăm pe un scenariu concret. Un widget de chat AI stochează:

  • Sesiuni de chat — una per conversație cu un vizitator
  • Mesaje —multe per sesiune, citite mereu împreună cu sesiunea
  • Query logs — pentru analytics per client (tenant)

Mesajele: /sessionId

Pattern-ul dominant: „afișează conversația” = toate mesajele unei sesiuni. Cu /sessionId ca partition key, întreaga conversație e o singură partiție logică — query ieftin, fără cross-partition:

// Query intr-o singura partitie -- ieftin si rapid
var query = new QueryDefinition(
    "SELECT * FROM c WHERE c.sessionId = @sessionId")
    .WithParameter("@sessionId", sessionId);

using var iterator = _container.GetItemQueryIterator<ChatMessage>(
    query,
    requestOptions: new QueryRequestOptions
    {
        PartitionKey = new PartitionKey(sessionId)  // explicit -- evita fan-out
    });

Detaliu care contează: setează PartitionKey explicit în QueryRequestOptions. Fără el, chiar dacă filtrul conține sessionId, SDK-ul poate face fan-out inutil.

Query logs: /tenantId... cu o capcană

/tenantId pare natural pentru analytics per client. Dar dacă un tenant mare generează 50% din trafic, ai hot partition. Și un tenant activ ani de zile poate depăși 20 GB.

Soluția: cheie sintetică — combini tenantId cu o componentă temporală:

public class QueryLog
{
    public string Id { get; set; } = Guid.NewGuid().ToString();

    // Cheie sintetica: tenant + luna -- distributie in timp, fara hot partition
    public string PartitionKey => $"{TenantId}-{CreatedAt:yyyy-MM}";

    public string TenantId { get; set; } = default!;
    public string Query { get; set; } = default!;
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

Analytics-ul lunar per tenant rămâne single-partition („query logs pentru tenant X în martie”), iar datele se distribuie natural în timp.


Hierarchical partition keys

Alternativa modernă la cheile sintetice: până la 3 niveluri de partition key (/tenantId, /sessionId). Query-urile pe prefix (doar tenantId) sunt rutate eficient către subsetul relevant de partiții, iar limita de 20 GB se aplică pe combinația completă, nu pe primul nivel:

// Creare container cu hierarchical partition keys
var containerProperties = new ContainerProperties(
    id: "chat-data",
    partitionKeyPaths: new List<string> { "/tenantId", "/sessionId" });

await database.CreateContainerIfNotExistsAsync(containerProperties);

// Scriere -- ambele niveluri
var partitionKey = new PartitionKeyBuilder()
    .Add(message.TenantId)
    .Add(message.SessionId)
    .Build();
await _container.CreateItemAsync(message, partitionKey);

Când alegi hierarchical vs. sintetic: hierarchical dacă ai queries pe ambele niveluri (per tenant ȘI per sesiune); cheie sintetică dacă pattern-ul e fix și vrei simplitate.


Costul real al cross-partition

Uneori cross-partition e inevitabil (ex. căutare globală în dashboard admin). Regulile de supraviețuire:

  • Măsoară RUresponse.RequestCharge îți spune exact cât costă fiecare query; loghează-l în development
  • Nu folosi ORDER BY cross-partition fără composite index — primești eroare; pentru cazuri simple, sortează în memorie după fetch
  • Atenție la cuvinte rezervateORDER BY c["order"] cu paranteze pentru proprietăți ca „order”
  • Limitează cu TOP/OFFSET — un cross-partition fără limită scanează tot
// Masurarea costului -- obligatoriu in development
var response = await iterator.ReadNextAsync();
_logger.LogInformation("Query a costat {RU} RU", response.RequestCharge);

Ce urmează

Partition key-ul stabilit corect face restul pattern-urilor posibile. În partea a doua: Change Feed — fluxul de modificări din container ca sursă de evenimente, cu Change Feed Processor în .NET și cazuri practice (indexare, cache invalidation, proiecții).

Întrebări? Scrie-mi la contact@ludoprogramming.com.