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ă RU —
response.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 rezervate —
ORDER 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.