Transcript
00:00:00Was kann ein einziges Byte eigentlich kosten?
00:00:02Nun, bei der Skalierung von Cloudflare kostet das Verschwenden eines einzigen Bytes pro Eintrag
00:00:04sie über 250 Gigabyte Arbeitsspeicher
00:00:06in ihrem gesamten System,
00:00:08und vor Kurzem haben sie diesen Speicherbedarf pro Eintrag halbiert,
00:00:11wodurch 100 Terabyte Arbeitsspeicher frei wurden.
00:00:13In der heutigen Zeit ist das eine Menge Geld.
00:00:15Sie haben dies tatsächlich durch fünf recht einfache Änderungen
00:00:17an ihrem Caching-System erreicht
00:00:18und gleichzeitig DNS sogar noch schneller gemacht.
00:00:21Schauen wir uns also an, wie sie das angestellt haben.
00:00:27Vielleicht habt ihr schon mal von 1.1.1.1 gehört.
00:00:30Es ist der öffentliche DNS-Resolver von Cloudflare
00:00:32und funktioniert, indem er die gewünschte Domain nimmt,
00:00:34wie etwa betterstack.com,
00:00:35und herausfindet, wie die echte IP für diese Domain lautet.
00:00:39Das Tool, das diese Arbeit erledigt,
00:00:40heißt Big Pineapple, ist in Rust geschrieben
00:00:42und, wie ihr euch denken könnt,
00:00:43ein unglaublich stark beanspruchtes Stück Software,
00:00:46das zu jedem Zeitpunkt über 250 Milliarden DNS-Cache-Einträge speichert.
00:00:50Das stellt sicher, dass wenn jemand betterstack.com aufrufen möchte,
00:00:52zwei Sekunden nach jemand anderem,
00:00:53nicht noch einmal die gesamte DNS-Hierarchie durchlaufen werden muss,
00:00:56sondern die Antwort im Speicher behalten und direkt zurückgeliefert wird.
00:00:59Aber wie ich eingangs erwähnte,
00:01:01bedeuten 250 Milliarden Cache-Einträge, dass ein verschwendetes Byte pro Eintrag
00:01:04etwa 250 Gigabyte RAM kostet,
00:01:07und genau deshalb hatten diese fünf Änderungen einen so großen Einfluss.
00:01:10Als Erstes haben wir die Kosten für die Kapazität.
00:01:12So sah ein Cache-Eintrag früher aus.
00:01:14Zeitstempel, Gültigkeitsdauer (TTL), Zugriffs zählen,
00:01:16und dann eine Reihe von Vektoren.
00:01:17Ein Vektor mit Antwortdatensätzen,
00:01:18ein Vektor mit Autoritätsdatensätzen,
00:01:20ein Vektor mit zusätzlichen Datensätzen,
00:01:21ein Vektor für Fehler
00:01:22und noch vieles mehr.
00:01:23Für diejenigen unter euch, die Rust nicht kennen:
00:01:25Ein Vektor ist einfach der Standard-Datentyp für eine wachsende Liste
00:01:27und besteht im Speicher aus drei Dingen:
00:01:29einem Zeiger, einer Länge und einer Kapazität.
00:01:31Das ist der Platz, der für weiteres Wachstum reserviert ist,
00:01:32damit nicht jedes Mal neu zugewiesen werden muss, wenn man Elemente hinzufügt.
00:01:35Das ist sehr nützlich, wenn die Daten tatsächlich wachsen.
00:01:38Aber sie haben sich reale DNS-Cache-Einträge angesehen
00:01:40und festgestellt, dass alles nur in den Cache geschrieben
00:01:42und danach nie wieder angefasst wurde.
00:01:44Es wurde ausschließlich gelesen,
00:01:45sodass diese Vektoren niemals wuchsen.
00:01:47Das bedeutet, dass übermäßig zugewiesener Heap-Speicher
00:01:49einfach ungenutzt dalag.
00:01:50Wenn ein Vektor Platz für acht Elemente hat,
00:01:52aber nur fünf gespeichert sind,
00:01:53bleiben drei Plätze ungenutzt,
00:01:55und das Kapazitätsfeld selbst ist ein usize,
00:01:57was acht Byte Arbeitsspeicher entspricht,
00:01:58die für sie schlicht nicht benötigt werden.
00:02:00Die Lösung hierfür war unglaublich einfach:
00:02:02Man ersetzt den Vektor einfach durch eine Box,
00:02:04da die Größe einer Box fest ist und der Größe bei der Erstellung entspricht.
00:02:07Sie benötigt daher kein Kapazitätsfeld
00:02:08und speichert exakt das, was vorhanden ist.
00:02:10Es muss kein überschüssiger Platz für die Zukunft reserviert werden.
00:02:13Sie erkannten auch, dass sie genau dieselbe Einsparung
00:02:15auf die String-Felder anwenden konnten,
00:02:16da diese im Wesentlichen auch nur Vektoren aus u8 sind.
00:02:18Normalerweise kann Text wachsen oder schrumpfen,
00:02:20aber wenn das nicht nötig ist,
00:02:21kann man einfach einen Box-String-Slice verwenden,
00:02:23nach dem Motto: Dieser String wird sich nicht mehr ändern.
00:02:26Insgesamt gab es in einem Cache-Eintrag
00:02:27acht Vektor- und String-Felder,
00:02:29sodass das Ersetzen durch eine Box acht Byte pro Feld einsparte,
00:02:31also 64 Byte pro Eintrag.
00:02:33Zudem wurde der überschüssige Heap-Speicher eliminiert,
00:02:35den ein Vektor für zukünftiges Wachstum reserviert.
00:02:36Skaliert auf 250 Milliarden Cache-Einträge summierten sich die Einsparungen
00:02:39somit auf über 15 Terabyte.
00:02:42Und das alles durch eine einfache Änderung des Datentyps.
00:02:45Für unsere nächste Änderung
00:02:46haben sie sich einige dieser Listen angesehen
00:02:48und schlicht gefragt: Brauchen wir die überhaupt?
00:02:50Eine DNS-Antwort enthält drei Einträge:
00:02:52Answer, Authority und Additional.
00:02:54Wie wir vorhin gesehen haben,
00:02:55wurden diese beim Eintreffen zwischengespeichert,
00:02:57in drei getrennten Listen,
00:02:58Obwohl wir das Kapazitätsfeld in Änderung eins entfernt hatten,
00:03:01besteht jede Liste immer noch aus einem Zeiger und einer Länge,
00:03:03also 8 Byte plus 8 Byte, mal drei,
00:03:05was insgesamt 48 Byte ausmacht
00:03:07sowie drei separate Heap-Zuweisungen.
00:03:09Als sie sich jedoch ansahen, wie diese Listen
00:03:10tatsächlich verwendet wurden,
00:03:12merkten sie, dass sie immer zusammen gelesen,
00:03:13immer zusammen geschrieben
00:03:14und stets in exakt derselben Reihenfolge verarbeitet werden.
00:03:16Anstelle von drei Listen
00:03:17könnten sie es also auch als eine Liste mit zwei Trennzeichen behandeln.
00:03:20Aus den drei Listen wird also eine einzige Liste von Datensätzen
00:03:22plus zwei kleinen Offsets, die besagen: Antworten enden hier
00:03:25und Authority endet hier.
00:03:26und da eine DNS-Antwort niemals vier Milliarden Einträge haben wird,
00:03:29reicht für die Offsets eine 16-Bit-Ganzzahl ohne Vorzeichen,
00:03:33was gerade einmal zwei Byte pro Stück sind.
00:03:34Das bedeutet insgesamt,
00:03:35dass sie zwei vollständige 16-Byte-Header durch zwei 2-Byte-Zahlen ersetzt haben,
00:03:39wodurch 28 Byte pro Eintrag eingespart wurden.
00:03:41Dies erlaubt es Rust zudem, zusätzlichen Leerraum zwischen Feldern zu entfernen,
00:03:44wodurch die Struktur noch kleiner wird als nur durch die bloß entfernten Felder.
00:03:47Sie haben dieses Konzept sogar noch weitergeführt
00:03:49und mehrere Boolesche Felder zu einem einzigen Bit-Flag zusammengefasst.
00:03:52Kommen wir zu Änderung Nummer drei:
00:03:53Was wäre, wenn wir denselben Namen nicht mehr doppelt nennen?
00:03:55Jeder DNS-Datensatz besitzt einen Inhaber,
00:03:57also schlicht den Domainnamen, zu dem dieser Datensatz gehört.
00:04:00Wenn ihr also betterstack.com abfragt und eure Datensätze zurückerhaltet,
00:04:03steht auf jedem einzelnen betterstack.com drauf.
00:04:05Aber diese Information ist im Grunde redundant.
00:04:08Ihr seid schließlich die Person, die die Frage gestellt hat,
00:04:09der Domainname ist also bereits bekannt.
00:04:11Cloudflare speicherte diese Abfrage-Domain ohnehin bereits als Cache-Schlüssel,
00:04:14warum also mussten sie ihn noch einmal als Inhaber speichern?
00:04:17Nun, das mussten sie nicht.
00:04:17Cloudflare änderte dieses Feld daher in einen optionalen Box-Namen,
00:04:20sodass, wenn der Inhaber mit der Abfrage übereinstimmt,
00:04:22einfach none gespeichert wird,
00:04:23während er in den wenigen Fällen, in denen er wirklich abweicht,
00:04:24wie etwa bei CNAME-Chains,
00:04:27wie zuvor als Inhaber hinterlegt wird.
00:04:29Dadurch sparen sie in den Normalfällen, in denen sie übereinstimmen,
00:04:32eine ganze Heap-Zuweisung pro Datensatz ein.
00:04:34Diese Einsparung basierte schlicht darauf, sich anzusehen, welche Daten im Cache liegen,
00:04:37und zu erkennen, dass dort doppelte Werte vorhanden waren.
00:04:39Bei Einsparung Nummer vier haben wir die 144 Byte für die IP-Adresse.
00:04:43Ihre Datensatzdaten bestanden aus einem Rust-Enum aus A-Records, AAAA-Records, Text,
00:04:47SVCB und NAPTR – ein einziger Typ, der all das abdeckte.
00:04:51Aber hier liegt das Problem mit Enums:
00:04:52Sie sind immer so groß wie ihre größte Variante.
00:04:54Jeder Wert dieses Typs belegt exakt denselben Platz,
00:04:57egal ob er ihn benötigt oder nicht.
00:04:58In diesem Fall ist NAPTR der größte Wert mit 136 Bytes,
00:05:03und wenn man Tag und Padding zum Enum hinzurechnet, kommt man auf 144 Bytes.
00:05:07Vergleicht man das mit dem, was ein A-Record benötigt, nämlich eine einfache IPv4-Adresse,
00:05:12bräuchte diese nur vier Bytes.
00:05:13Das bedeutet, jeder A-Record in diesem Cache saß in einer 144-Byte-Box,
00:05:17obwohl er nur vier davon nutzte,
00:05:19und A- sowie 4A-Records machen den Großteil des echten Traffics aus.
00:05:22In Cloudflares Benchmark-Mix sind es 56 % A-Records und 25 % 4A, weshalb der Großteil des Caches nur aus Padding bestand.
00:05:29Die Lösung hierfür war unsere bewährte Box.
00:05:32Sie packten die großen Varianten in Boxen und ließen A und 4A inline, da diese klein und häufig sind,
00:05:36sodass text, SVCB und NAPTR nun hinter einem Zeiger liegen,
00:05:40sodass das Enum nur noch so groß sein muss wie die größte verbleibende Variante, nämlich die 16-Byte-IPv6-Adresse.
00:05:45Insgesamt werden dadurch bei einem A- oder 4A-Record jeweils 120 Bytes eingespart.
00:05:50Diese Änderung bringt jedoch tatsächlich einen Nachteil mit sich.
00:05:52Wenn man eine Variante in eine Box packt, wandert ihr Datensatz aus dem Cache-Eintrag heraus
00:05:55sondern landet irgendwo ganz woanders in einem eigenen Heap-Bereich,
00:05:58was zwei neue Kostenpunkte nach sich zieht.
00:06:00Der erste ist der Allokator.
00:06:02Cloudflare verwendet Jemalloc, und Jemalloc gibt dir nicht exakt die Anzahl Bytes, nach der du fragst,
00:06:06sondern fasst Allokationen in feste Bins zusammen und rundet zur nächsten Größe auf.
00:06:10Ein Text-Record, der 32 Bytes anfordert, landet also in einem 32-Byte-Bin und verschwendet nichts,
00:06:15aber ein MX-Record fragt nach 40, wird auf 48 Bytes aufgerundet und verliert klammheimlich 8 Bytes.
00:06:21Die zweiten Kosten betreffen die Lokalität.
00:06:22Vor dem Boxen befanden sich alle Datensätze eines Eintrags in einem zusammenhängenden Speicherblock,
00:06:27und nach dem Boxen lebt jeder an einem anderen Ort, sodass das Lesen das Folgen eines Zeigers erfordert,
00:06:31und wenn dieser Zeiger weit entfernt vom Rest des Eintrags landet,
00:06:34muss die CPU eine völlig neue Cache-Zeile abrufen, nur um ihn zu lesen.
00:06:37Das Boxen hat zwar das Padding-Problem gelöst, aber ein eigenes Problem geschaffen,
00:06:41und das war der Moment, in dem sich Cloudflare dachte: Was, wenn wir sie gar nicht als Rust-Typen speichern?
00:06:45Nun, das ist Änderung Nummer fünf.
00:06:46Cloudflare beschrieb dies als Mittelweg: Man behält den Rest des Cache-Eintrags als normal strukturierte
00:06:50Felder bei, nimmt aber die eigentlichen Record-Daten und speichert sie als Rohe Bytes.
00:06:54Anstelle eines Enums oder einer Box pro Record erhält der gesamte Eintrag ein einziges, boxed u8-Array,
00:07:00wobei jeder Record als ein 2-Byte-Längenpräfix gefolgt von seinen Daten geschrieben wird.
00:07:03Dadurch werden beide Kostenfaktoren rückgängig gemacht, über die wir gerade gesprochen haben.
00:07:06All diese separaten Box-Allokationen verschmelzen zu einer einzigen Allokation für alle Record-Daten,
00:07:10sodass kein Aufrunden auf ein Jemalloc-Bin pro Record mehr stattfindet
00:07:13und alles wieder zusammenhängend gepackt ist, wodurch man die Cache-Lokalität zurückerhält, die das Boxen weggenommen hatte.
00:07:18Und als zusätzlicher Bonus werden dadurch auch Lookups schneller.
00:07:21Zuvor befand sich bei jedem Cache-Treffer ein geparster Record im Speicher,
00:07:25und man musste ihn Feld für Feld wieder in das DNS-Wire-Format serialisieren, bevor man ihn irgendwohin senden konnte.
00:07:30Jetzt liegt es jedoch bereits im DNS-Wire-Format vor, sodass die meisten Record-Typen direkt aus dem Puffer
00:07:35in die ausgehende Nachricht kopiert werden.
00:07:37Die einzigen, die noch geparst werden müssen, sind Records, die Domainnamen enthalten,
00:07:40also CNAME, NS, MX und SOA,
00:07:43und das auch nur, weil die DNS-Namenkomprimierung bedeutet, dass diese Namen ohnehin neu geschrieben werden müssen.
00:07:47Diese letzte Änderung reduziert also den Speicher und entfernt jede Menge Arbeit aus dem kritischen Pfad.
00:07:51Das sind also 5 Low-Level-Änderungen am Cache,
00:07:54die in Kombination den Fußabdruck pro Eintrag von 953 Bytes auf 420 senken,
00:08:00was einer Verringerung um 56 % entspricht, während der pro Eintrag allokierte Speicher von 1,1 Kilobytes
00:08:05auf 461 Bytes sank, also eine Kürzung um 58 %.
00:08:09Darüber hinaus stieg auch ihr Einfüge-Durchsatz um 43 %,
00:08:12von 625.000 Einträgen pro Sekunde auf 893.000,
00:08:17und auch Lookups wurden 19 % schneller, runter von 828 Nanosekunden auf 670.
00:08:23Sie haben diese Änderungen dieses Jahr in der Produktion ausgerollt,
00:08:25und ihr P99-Resident-Speicher sank von 9,3 Gigabyte auf 5,3,
00:08:29was einer Reduzierung von 43 % im echten Traffic entspricht,
00:08:32was fleetweit etwa 100 Terabyte freigemachten Speicher bedeutet,
00:08:35oder der RAM-Menge von 130 Servern der 13. Generation.
00:08:38Nun planen sie, diesen zusätzlichen Platz für einen größeren Cache zu nutzen,
00:08:41um die Dinge noch weiter zu beschleunigen.
00:08:43Mir gefällt dieser Blogbeitrag wirklich sehr, weil er eine Entscheidung hervorhebt, die wir treffen müssen.
00:08:46Hätten wir uns vor dem Bau hinsetzen und all diese Optimierungen ausarbeiten sollen,
00:08:50oder wäre das eine verfrühte Optimierung gewesen?
00:08:52Und ich schätze, damals im Jahr 2018, als das gebaut wurde,
00:08:55waren die RAM-Kosten für Cloudflare nicht so signifikant wie heute.
00:08:59Der vollständige Beitrag ist ein großartiger Artikel, den ich unten verlinken werde.
00:09:02Lasst mich in den Kommentaren wissen, was ihr darüber denkt,
00:09:03abonniert den Kanal,
00:09:04und wie immer bis zum nächsten Mal.
00:09:05Bis zum nächsten Mal.
Community Posts
No posts yet. Be the first to write about this video!
Write about this video