Zum Hauptinhalt springen

Große Werte, kleine Pausen: Large-Object-Chunking in Zaris

· 6 Minuten Lesezeit
Clustron Team
Engineering für verteilte Systeme

Large-Object-Chunking hält große Werte vom LOH fern

Es gibt eine Zahl, die sich jeder .NET-Performance-Ingenieur irgendwann merkt: 85.000 Bytes. Jede einzelne Allokation ab etwa dieser Größe landet nicht auf dem normalen Heap für kleine Objekte, sondern auf dem Large Object Heap (LOH). Der LOH ist ein anderes Tier. Er wird nur bei einer Gen-2-(vollständigen) Garbage Collection eingesammelt und standardmäßig nicht kompaktiert. Diese Kombination ist in Ordnung für einen großen Puffer, den man einmal allokiert und behält. Sie ist nicht in Ordnung für einen latenzarmen In-Memory-Store, der wiederholt große Werte schreibt und überschreibt.

Stellen Sie sich die Last vor: große serialisierte Blobs, umfangreiche JSON-Dokumente, Bilder, Protokoll-Payloads — gespeichert, aktualisiert, ersetzt, immer wieder. Jeder dieser großen Schreibvorgänge legt ein frisches Objekt auf dem LOH ab. Da der LOH nicht kompaktiert wird, verschmelzen freigegebene Bereiche nicht zu zusammenhängendem Speicher, sondern fragmentieren. Und weil jede Rückgewinnung eine vollständige Gen-2-Collection bedeutet, werden die Pausen länger und treten häufiger auf, genau dann, wenn der Working Set wächst. Für einen Store, dessen ganzes Versprechen niedrige Latenz ist, ist das die falsche Art von Überraschung.

Zaris liefert ein optionales Feature, das den LOH für solche Werte komplett umgeht. Es ist standardmäßig aus, und dieser Beitrag erklärt, was es tut, wie es funktioniert und — ebenso wichtig — wann Sie es einschalten sollten und wann nicht.

Die Idee: Das große Objekt nie allokieren​

Ist Large-Object-Chunking aktiviert, speichert Zaris einen großen einfachen Wert nicht mehr als ein zusammenhängendes byte[]. Stattdessen teilt es den Wert in ein Jagged Array kleinerer Stücke auf — ein byte[][], bei dem jeder Chunk bequem unter der LOH-Schwelle von ~85 KB liegt.

Die Konsequenz ist der ganze Zweck: Kein einzelnes Stück ist groß genug, um ein Large Object zu sein, also landet keines davon auf dem LOH. Der Wert lebt vollständig auf dem normalen Heap für kleine Objekte, der kompaktiert wird und in günstigen Gen-0/Gen-1-Durchläufen weit häufiger eingesammelt wird als in Gen-2. Aus einem Wert, der zuvor eine wiederkehrende Quelle von LOH-Fragmentierung und Gen-2-Druck war, wird gewöhnlicher, gutartiger Heap-Verkehr.

without chunking:  value ──▶ one 400 KB byte[]  ──▶ Large Object Heap (gen-2 only, no compaction)

with chunking: value ──▶ byte[][] { 64KB, 64KB, 64KB, ... } ──▶ small-object heap (gen-0/1, compacted)

Lesezugriffe zahlen dafür fast nichts. Das Zusammensetzen beim Lesen ist ein einzelner Durchlauf: Zaris fügt die Stücke wieder zu dem zusammenhängenden Wert zusammen, den der Aufrufer erwartet, sodass der aufrufende Code exakt den Wert sieht, den er gespeichert hat. Das Chunk-Layout ist ein internes Speicherdetail und dringt nicht in Ihre API durch.

Die Speicherberichte bleiben ehrlich​

Eine berechtigte Sorge: Wenn ein Wert heimlich als ein Dutzend Teil-Arrays samt Buchhaltung gespeichert wird, wird dann die Speicherberichterstattung plötzlich verwirrend? Nein. Zaris meldet weiterhin die logische Größe eines Werts — das, was Sie tatsächlich gespeichert haben —, nicht das physische Chunk-Layout. Ein Wert mit 400 KB erscheint in Ihrer Buchhaltung als Wert mit 400 KB. Das Chunking ist eine Garbage-Collection-Taktik, keine Änderung der Buchhaltung, und wir haben es bewusst für die Zahlen unsichtbar gehalten, mit denen Sie arbeiten.

Einschalten​

Zwei Konfigurationsparameter steuern das Feature:

{
"memory": {
"chunking": {
"enabled": true
},
"lohThresholdBytes": 85000
}
}
  • memory.chunking.enabled schaltet das Verhalten ein. Beim Default speichert Zaris Werte auf die gewöhnliche, zusammenhängende Weise.
  • memory.lohThresholdBytes legt die Größe fest, ab der ein Wert in Chunks zerlegt wird. Setzen Sie ihn auf oder unter die LOH-Grenze der Runtime, damit alles, was groß genug wäre, um ein Large Object zu werden, geteilt wird, bevor es dazu kommt.

Aus Sicht des nativen Clients ändert sich an Ihren Aufrufen nichts — Sie schreiben einen großen Wert wie bisher, und das Chunking (falls aktiviert) geschieht darunter:

// Same call whether or not chunking is enabled — the split happens in the store.
byte[] bigPayload = SerializeSnapshot(order); // e.g. ~400 KB
await client.PutAsync("snapshot:order:42", bigPayload);

// Read reassembles the pieces in a single pass; caller sees the whole value.
ReadResult<byte[]> result = await client.GetAsync<byte[]>("snapshot:order:42");

Eine ehrliche Einschränkung zum Geltungsbereich​

Dies ist ein gezieltes Werkzeug, und man sollte seine Grenzen genau benennen. Chunking zielt auf große einfache Byte-Werte. Ein Jagged byte[][] übersteht den Speicherpfad sauber, weshalb große einfache Byte-Payloads der Idealfall sind. Es ist kein allgemeines Kompressionsverfahren — die Bytes werden nicht kleiner, sie werden nur anders angeordnet — und es ist keine Änderung daran, wie benutzerdefinierte typisierte Objekte behandelt werden. Betrachten Sie es als ein spezielles Instrument für ein spezielles Problem: GC-Druck durch häufig genutzte, große, einfache Werte. Greifen Sie danach, wenn das Ihr Problem ist, und lassen Sie es ansonsten in der Schublade.

Warum es standardmäßig aus ist​

Wenn es hilft, warum nicht immer an? Weil die meisten Werte in den meisten Stores klein sind — weit unter der LOH-Schwelle — und Chunking für diese Werte nichts Nützliches bewirkt. Was es tatsächlich hinzufügt, ist real: Das Zerteilen beim Schreiben, das Verwalten der Stücke und das Zusammenfügen beim Lesen kosten pro Wert ein wenig CPU und ein wenig Buchhaltung. Dieser Aufwand lohnt sich ohne Weiteres für einen wirklich großen Wert, den Sie ständig überschreiben, und ist reine Verschwendung bei einem 200-Byte-Session-Token.

Deshalb macht Zaris es zu einer bewussten Entscheidung statt zu einem stillen Default. Es einzuschalten ist eine Aussage: „Dieses Deployment hat häufig genutzte, große Werte, und ich tausche gern etwas Buchhaltung pro Wert gegen weniger Gen-2-Pausen und keine LOH-Fragmentierung." Das ist ein hervorragender Tausch für einen Cache großer serialisierter Aggregate oder einen Bildspeicher. Für einen Store voller kleiner Keys ist es ein schlechter Tausch, weshalb der Default den Normalfall annimmt.

Wann Sie es aktivieren sollten​

Greifen Sie zu Large-Object-Chunking, wenn beides zutrifft:

  • Ihre Werte sind wirklich groß — routinemäßig auf oder über der LOH-Schwelle von ~85 KB (große Blobs, umfangreiches JSON, Bilder, serialisierte Snapshots).
  • Sie werden häufig aktualisiert — häufig genutzte Werte, die überschrieben werden, nicht einmal geschrieben und vergessen. Hier sammeln sich LOH-Fragmentierung und Gen-2-Druck tatsächlich an.

Sehen Sie bei einer solchen Last länger werdende Gen-2-Pausen und eine wachsende LOH-Größe, schalten Sie memory.chunking.enabled ein und setzen Sie memory.lohThresholdBytes, um Werte vom LOH fernzuhalten. Sind Ihre Werte klein, lassen Sie es aus — Sie würden nur Buchhaltung für ein Problem bezahlen, das Sie nicht haben. Wie bei den meisten guten Performance-Parametern liegt sein Wert ganz darin, zu wissen, wann man es nicht einschaltet.