MVCC in PostgreSQL-2. Gabeln, Dateien, Seiten

Das letzte Mal haben wir über Datenkonsistenz gesprochen, den Unterschied zwischen den Ebenen der Transaktionsisolation aus Sicht des Benutzers untersucht und herausgefunden, warum dies wichtig ist. Jetzt beginnen wir zu untersuchen, wie PostgreSQL die Snapshot-Isolation und die Multiversion-Parallelität implementiert.

In diesem Artikel werden wir uns ansehen, wie Daten physisch in Dateien und Seiten angeordnet sind. Dies führt uns von der Diskussion über Isolation ab, aber ein solcher Exkurs ist notwendig, um das Folgende zu verstehen. Wir müssen herausfinden, wie der Datenspeicher auf niedriger Ebene organisiert ist.

Beziehungen


Wenn Sie sich Tabellen und Indizes ansehen, stellt sich heraus, dass sie ähnlich organisiert sind. Beide sind Datenbankobjekte, die einige Daten enthalten, die aus Zeilen bestehen.

Es besteht kein Zweifel, dass eine Tabelle aus Zeilen besteht, dies ist jedoch für einen Index weniger offensichtlich. Stellen Sie sich jedoch einen B-Baum vor: Er besteht aus Knoten, die indizierte Werte und Verweise auf andere Knoten oder Tabellenzeilen enthalten. Es sind diese Knoten, die als Indexzeilen betrachtet werden können, und tatsächlich sind sie es.

Tatsächlich sind einige weitere Objekte auf ähnliche Weise organisiert: Sequenzen (im Wesentlichen einzeilige Tabellen) und materialisierte Ansichten (im Wesentlichen Tabellen, die sich an die Abfrage erinnern). Und es gibt auch reguläre Ansichten, die keine Daten selbst speichern, sondern in allen anderen Sinnen Tabellen ähneln.

Alle diese Objekte in PostgreSQL werden als allgemeine Wortbeziehung bezeichnet . Dieses Wort ist äußerst unpassend, weil es ein Begriff aus der relationalen Theorie ist. Sie können eine Parallele zwischen einer Relation und einer Tabelle (Ansicht) ziehen, aber sicherlich nicht zwischen einer Relation und einem Index. Aber es ist einfach so passiert: Der akademische Ursprung von PostgreSQL manifestiert sich. Es scheint mir, dass es Tabellen und Ansichten sind, die zuerst so genannt wurden, und der Rest schwoll mit der Zeit an.

Zur Vereinfachung werden wir weiter auf Tabellen und Indizes eingehen, aber die anderen Beziehungen sind genauso organisiert.

Gabeln und Feilen


Normalerweise entsprechen mehrere Gabeln jeder Beziehung. Gabeln können verschiedene Typen haben, und jeder von ihnen enthält eine bestimmte Art von Daten.

Wenn es eine Gabel gibt, wird diese zuerst durch die einzige Datei dargestellt . Der Dateiname ist eine numerische Kennung, an die eine Endung angehängt werden kann, die dem Gabelnamen entspricht.

Die Datei wächst allmählich und wenn ihre Größe 1 GB erreicht, wird eine neue Datei mit demselben Fork erstellt (Dateien wie diese werden manchmal als Segmente bezeichnet ). Die Ordnungszahl des Segments wird am Ende des Dateinamens angehängt.

Die 1-GB-Beschränkung der Dateigröße trat in der Vergangenheit auf, um verschiedene Dateisysteme zu unterstützen, von denen einige nicht mit Dateien größerer Größe umgehen können. Sie können diese Einschränkung beim ./configure --with-segsize PostgreSQL ./configure --with-segsize ( ./configure --with-segsize ).

So können mehrere Dateien auf der Festplatte einer Beziehung entsprechen. Für einen kleinen Tisch gibt es beispielsweise drei davon.

Alle Dateien von Objekten, die zu einem Tabellenbereich und einer Datenbank gehören, werden in einem Verzeichnis gespeichert. Sie müssen dies berücksichtigen, da Dateisysteme normalerweise mit einer großen Anzahl von Dateien in einem Verzeichnis nicht einwandfrei funktionieren.

Beachten Sie hier, dass Dateien wiederum in Seiten (oder Blöcke ) unterteilt sind, normalerweise um 8 KB. Wir werden die interne Struktur der Seiten etwas weiter diskutieren.



Schauen wir uns nun die Gabeltypen an.

Die Hauptgabel sind die Daten selbst: die Tabellen- und Indexzeilen. Die Hauptgabel ist für alle Beziehungen verfügbar (außer für Ansichten, die keine Daten enthalten).

Die Namen der Dateien der Hauptgabel bestehen aus der einzigen numerischen Kennung. Dies ist beispielsweise der Pfad zu der Tabelle, die wir zuletzt erstellt haben:

 => SELECT pg_relation_filepath('accounts'); 
  pg_relation_filepath ---------------------- base/41493/41496 (1 row) 

Woher kommen diese Kennungen? Das Verzeichnis "base" entspricht dem Tabellenbereich "pg_default". Im nächsten Unterverzeichnis, das der Datenbank entspricht, befindet sich die interessierende Datei:

 => SELECT oid FROM pg_database WHERE datname = 'test'; 
  oid ------- 41493 (1 row) 

 => SELECT relfilenode FROM pg_class WHERE relname = 'accounts'; 
  relfilenode ------------- 41496 (1 row) 

Der Pfad ist relativ und wird ab dem Datenverzeichnis (PGDATA) angegeben. Darüber hinaus werden praktisch alle Pfade in PostgreSQL ab PGDATA angegeben. Dank dessen können Sie PGDATA sicher an einen anderen Speicherort verschieben - nichts schränkt es ein (außer es kann erforderlich sein, den Pfad zu Bibliotheken in LD_LIBRARY_PATH festzulegen).

Weiter in das Dateisystem schauen:

 postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41496 
 -rw------- 1 postgres postgres 8192 /var/lib/postgresql/11/main/base/41493/41496 

Der Initialisierungsgabel ist nur für nicht protokollierte Tabellen (erstellt mit UNLOGGED angegeben) und deren Indizes verfügbar. Objekte wie diese unterscheiden sich nicht von normalen Objekten, außer dass Operationen mit ihnen nicht im Write-Ahead-Protokoll (WAL) protokolliert werden. Aus diesem Grund ist es schneller, mit ihnen zu arbeiten, aber es ist unmöglich, die Daten im Falle eines Fehlers im konsistenten Zustand wiederherzustellen. Daher entfernt PostgreSQL während einer Wiederherstellung einfach alle Gabeln solcher Objekte und schreibt die Initialisierungsgabel anstelle der Hauptgabel. Dies führt zu einem leeren Objekt. Wir werden die Protokollierung im Detail besprechen, aber in einer anderen Serie.

Die Tabelle "Konten" wird protokolliert und verfügt daher nicht über eine Initialisierungsgabel. Aber um zu experimentieren, können wir die Abmeldung deaktivieren:

 => ALTER TABLE accounts SET UNLOGGED; => SELECT pg_relation_filepath('accounts'); 
  pg_relation_filepath ---------------------- base/41493/41507 (1 row) 

Das Beispiel verdeutlicht, dass mit dem Umschreiben der Daten in Dateien mit unterschiedlichen Namen eine Möglichkeit verbunden ist, die Protokollierung im laufenden Betrieb ein- und auszuschalten.

Eine Initialisierungsgabel hat denselben Namen wie die Hauptgabel, jedoch das Suffix "_init":

 postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_init 
 -rw------- 1 postgres postgres 0 /var/lib/postgresql/11/main/base/41493/41507_init 

Die Freiraumkarte ist eine Abzweigung, die die Verfügbarkeit von Freiraum innerhalb von Seiten verfolgt. Dieser Raum ändert sich ständig: Er nimmt ab, wenn neue Versionen von Zeilen hinzugefügt werden, und nimmt beim Staubsaugen zu. Die Freiraumkarte wird beim Einfügen neuer Zeilenversionen verwendet, um schnell eine geeignete Seite zu finden, auf die die hinzuzufügenden Daten passen.

Der Name der Freiraumkarte hat das Suffix "_fsm". Diese Datei wird jedoch nicht sofort angezeigt, sondern nur bei Bedarf. Der einfachste Weg, dies zu erreichen, ist das Staubsaugen eines Tisches (wir werden erklären, warum, wenn es soweit ist):

 => VACUUM accounts; 

 postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_fsm 
 -rw------- 1 postgres postgres 24576 /var/lib/postgresql/11/main/base/41493/41507_fsm 

Die Sichtbarkeitskarte ist eine Abzweigung, bei der Seiten, die nur aktuelle Zeilenversionen enthalten, mit einem Bit markiert sind. In etwa bedeutet dies, dass beim Versuch einer Transaktion, eine Zeile von einer solchen Seite zu lesen, die Zeile angezeigt werden kann, ohne ihre Sichtbarkeit zu überprüfen. In den nächsten Artikeln werden wir detailliert diskutieren, wie dies geschieht.

 postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_vm 
 -rw------- 1 postgres postgres 8192 /var/lib/postgresql/11/main/base/41493/41507_vm 

Seiten


Wie bereits erwähnt, werden Dateien logisch in Seiten unterteilt.

Eine Seite hat normalerweise die Größe von 8 KB. Die Größe kann innerhalb bestimmter Grenzen (16 KB oder 32 KB) geändert werden, jedoch nur während des ./configure --with-blocksize ( ./configure --with-blocksize ). Eine erstellte und ausgeführte Instanz kann nur mit Seiten derselben Größe arbeiten.

Unabhängig davon, wo sich Dateien befinden, verwendet der Server sie auf ähnliche Weise. Seiten werden zuerst in den Puffercache eingelesen, wo die Prozesse sie lesen und ändern können. Bei Bedarf werden sie dann wieder auf die Festplatte übertragen.

Jede Seite verfügt über eine interne Partitionierung und enthält im Allgemeinen die folgenden Partitionen:

        0 + ----------------------------------- +
           |  Header |
       24 + ----------------------------------- +
           |  Array von Zeigern auf Zeilenversionen |
    niedriger + ----------------------------------- +
           |  freier Speicherplatz |
    obere + ----------------------------------- +
           |  Zeilenversionen |
  spezielle + ----------------------------------- +
           |  besonderer Raum |
 Seitengröße + ----------------------------------- +

Sie können die Größe dieser Partitionen leicht mit der Erweiterungsseite "Forschung" kennenlernen:

 => CREATE EXTENSION pageinspect; => SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('accounts',0)); 
  lower | upper | special | pagesize -------+-------+---------+---------- 40 | 8016 | 8192 | 8192 (1 row) 

Hier sehen wir uns die Kopfzeile der allerersten (Null-) Seite der Tabelle an. Zusätzlich zu den Größen anderer Bereiche enthält die Kopfzeile andere Informationen über die Seite, an denen wir noch nicht interessiert sind.

Am Ende der Seite befindet sich der spezielle Bereich , der in diesem Fall leer ist. Es wird nur für Indizes verwendet und auch nicht für alle. "Unten" spiegelt hier wider, was auf dem Bild zu sehen ist. es kann genauer sein, "in hohen Adressen" zu sagen.

Nach dem speziellen Bereich befinden sich Zeilenversionen , dh genau die Daten, die wir in der Tabelle speichern, sowie einige interne Informationen.

Am oberen Rand einer Seite, direkt nach der Kopfzeile, befindet sich das Inhaltsverzeichnis: das Array von Zeigern auf Zeilenversionen, die auf der Seite verfügbar sind.

Zwischen Zeilenversionen und Zeigern kann freier Speicherplatz verbleiben (dieser freie Speicherplatz wird in der Freiraumkarte nachverfolgt). Beachten Sie, dass es innerhalb einer Seite keine Speicherfragmentierung gibt - der gesamte freie Speicherplatz wird durch einen zusammenhängenden Bereich dargestellt.

Zeiger


Warum werden die Zeiger auf Zeilenversionen benötigt? Die Sache ist, dass Indexzeilen irgendwie auf Zeilenversionen in der Tabelle verweisen müssen. Es ist klar, dass die Referenz die Dateinummer, die Nummer der Seite in der Datei und einige Angaben zur Zeilenversion enthalten muss. Wir könnten den Versatz vom Anfang der Seite als Indikator verwenden, aber es ist unpraktisch. Wir könnten eine Zeilenversion nicht innerhalb der Seite verschieben, da dadurch verfügbare Referenzen beschädigt würden. Dies würde zu einer Fragmentierung des Platzes innerhalb der Seiten und anderen problematischen Konsequenzen führen. Daher verweist der Index auf die Zeigernummer und der Zeiger auf die aktuelle Position der Zeilenversion auf der Seite. Und das ist indirekte Adressierung.

Jeder Zeiger belegt genau vier Bytes und enthält:

  • ein Verweis auf die Zeilenversion
  • die Größe dieser Zeilenversion
  • mehrere Bytes, um den Status der Zeilenversion zu bestimmen

Datenformat


Das Datenformat auf der Festplatte entspricht genau der Datendarstellung im RAM. Die Seite wird "wie sie ist" ohne Konvertierungen in den Puffercache eingelesen. Daher sind Datendateien von einer Plattform nicht mit anderen Plattformen kompatibel.

In der X86-Architektur ist die Bytereihenfolge beispielsweise von niedrigstwertigen zu höchstwertigen Bytes (Little-Endian), z / Architecture verwendet die umgekehrte Reihenfolge (Big-Endian), und in ARM kann die Reihenfolge vertauscht werden.

Viele Architekturen sehen eine Datenausrichtung an den Grenzen von Maschinenwörtern vor. Auf einem 32-Bit-x86-System werden beispielsweise Ganzzahlen (Typ "Ganzzahl", die 4 Byte belegt) an einer Grenze von 4-Byte-Wörtern ausgerichtet, genau wie Zahlen mit doppelter Genauigkeit (Typ "doppelte Genauigkeit"). , die 8 Bytes belegt). Bei einem 64-Bit-System werden Zahlen mit doppelter Genauigkeit an einer Grenze von 8-Byte-Wörtern ausgerichtet. Dies ist ein weiterer Inkompatibilitätsgrund.

Aufgrund der Ausrichtung hängt die Größe der Tabellenzeile von der Feldreihenfolge ab. Normalerweise ist dieser Effekt nicht sehr auffällig, aber manchmal kann er zu einem signifikanten Wachstum der Größe führen. Wenn beispielsweise Felder vom Typ "char (1)" und "integer" verschachtelt sind, werden normalerweise 3 Bytes zwischen ihnen verschwendet. Weitere Einzelheiten hierzu finden Sie in Nikolay Shaplovs Präsentation " Tuple internals ".

Zeilenversionen und TOAST


Wir werden das nächste Mal Details der internen Struktur von Zeilenversionen diskutieren. An dieser Stelle ist es nur wichtig zu wissen, dass jede Version vollständig auf eine Seite passen muss: PostgreSQL hat keine Möglichkeit, die Zeile auf die nächste Seite zu "erweitern". Stattdessen wird die OASTized Attributes Storage-Technik (TOAST) verwendet. Der Name selbst deutet darauf hin, dass eine Reihe in Toast geschnitten werden kann.

Im Scherz impliziert TOAST mehrere Strategien. Wir können lange Attributwerte an eine separate interne Tabelle übertragen, nachdem wir sie in kleine Toaststücke aufgeteilt haben. Eine andere Möglichkeit besteht darin, einen Wert so zu komprimieren, dass die Zeilenversion auf eine reguläre Seite passt. Und wir können beides tun: zuerst komprimieren und dann auflösen und übertragen.

Für jede Primärtabelle kann bei Bedarf eine separate TOAST-Tabelle erstellt werden, eine für alle Attribute (zusammen mit einem Index darauf). Die Verfügbarkeit potenziell langer Attribute bestimmt diesen Bedarf. Wenn eine Tabelle beispielsweise eine Spalte vom Typ "numerisch" oder "Text" enthält, wird die TOAST-Tabelle sofort erstellt, auch wenn keine langen Werte verwendet werden.

Da eine TOAST-Tabelle im Wesentlichen eine reguläre Tabelle ist, verfügt sie über denselben Satz Gabeln. Dies verdoppelt die Anzahl der Dateien, die einer Tabelle entsprechen.

Die anfänglichen Strategien werden durch die Spaltendatentypen definiert. Sie können sie mit dem Befehl \d+ in psql anzeigen. Da jedoch zusätzlich viele andere Informationen ausgegeben werden, werden wir den Systemkatalog abfragen:

 => SELECT attname, atttypid::regtype, CASE attstorage WHEN 'p' THEN 'plain' WHEN 'e' THEN 'external' WHEN 'm' THEN 'main' WHEN 'x' THEN 'extended' END AS storage FROM pg_attribute WHERE attrelid = 'accounts'::regclass AND attnum > 0; 
  attname | atttypid | storage ---------+----------+---------- id | integer | plain number | text | extended client | text | extended amount | numeric | main (4 rows) 

Die Namen der Strategien bedeuten:

  • plain - TOAST wird nicht verwendet (wird für Datentypen verwendet, von denen bekannt ist, dass sie kurz sind, z. B. "Ganzzahl").
  • erweitert - sowohl Komprimierung als auch Speicherung in einer separaten TOAST-Tabelle sind zulässig
  • extern - lange Werte werden ohne Komprimierung in der TOAST-Tabelle gespeichert.
  • main - long - Werte werden zuerst komprimiert und gelangen nur dann in die TOAST - Tabelle, wenn die Komprimierung nicht geholfen hat.

Im Allgemeinen ist der Algorithmus wie folgt. PostgreSQL zielt darauf ab, dass mindestens vier Zeilen auf eine Seite passen. Wenn die Zeilengröße einen Viertel der Seite überschreitet, muss TOAST auf einen Teil der Werte angewendet werden, wobei der Header berücksichtigt wird (2040 Byte für eine reguläre 8-KB-Seite). Wir folgen der unten beschriebenen Reihenfolge und halten an, sobald die Zeile den Schwellenwert nicht mehr überschreitet:

  1. Zuerst gehen wir die Attribute mit den Strategien "extern" und "erweitert" vom längsten Attribut zum kürzesten durch. "Erweiterte" Attribute werden komprimiert (sofern dies wirksam ist). Wenn der Wert selbst einen Viertel der Seite überschreitet, wird er sofort in die TOAST-Tabelle aufgenommen. "Externe" Attribute werden auf die gleiche Weise verarbeitet, jedoch nicht komprimiert.
  2. Wenn die Zeilenversion nach dem ersten Durchgang noch nicht auf die Seite passt, übertragen wir die verbleibenden Attribute mit den Strategien "extern" und "erweitert" an die Tabelle TOAST.
  3. Wenn dies auch nicht geholfen hat, versuchen wir, die Attribute mit der "Haupt" -Strategie zu komprimieren, lassen sie jedoch auf der Tabellenseite.
  4. Und nur wenn danach die Zeile nicht kurz genug ist, werden "Haupt" -Attribute in die TOAST-Tabelle aufgenommen.

Manchmal kann es nützlich sein, die Strategie für bestimmte Spalten zu ändern. Wenn beispielsweise im Voraus bekannt ist, dass die Daten in einer Spalte nicht komprimiert werden können, können wir die "externe" Strategie festlegen, mit der wir Zeit sparen können, indem wir unnötige Komprimierungsversuche vermeiden. Dies geschieht wie folgt:

 => ALTER TABLE accounts ALTER COLUMN number SET STORAGE external; 

Wenn Sie die Abfrage erneut ausführen, erhalten Sie:

  attname | atttypid | storage ---------+----------+---------- id | integer | plain number | text | external client | text | extended amount | numeric | main 

TOAST-Tabellen und -Indizes befinden sich im separaten Schema pg_toast und sind daher normalerweise nicht sichtbar. Für temporäre Tabellen wird das Schema "pg_toast_temp_N" ähnlich wie das übliche Schema "pg_temp_N" verwendet.

Wenn Sie möchten, wird Sie natürlich niemand daran hindern, die internen Mechanismen des Prozesses auszuspionieren. Angenommen, in der Tabelle "Konten" gibt es drei potenziell lange Attribute, und daher muss eine TOAST-Tabelle vorhanden sein. Hier ist es:

 => SELECT relnamespace::regnamespace, relname FROM pg_class WHERE oid = ( SELECT reltoastrelid FROM pg_class WHERE relname = 'accounts' ); 
  relnamespace | relname --------------+---------------- pg_toast | pg_toast_33953 (1 row) 

 => \d+ pg_toast.pg_toast_33953 
 TOAST table "pg_toast.pg_toast_33953" Column | Type | Storage ------------+---------+--------- chunk_id | oid | plain chunk_seq | integer | plain chunk_data | bytea | plain 

Es ist vernünftig, dass die "einfache" Strategie auf die Toasts angewendet wird, in die die Reihe geschnitten wird: Es gibt keinen TOAST der zweiten Ebene.

PostgreSQL versteckt den Index besser, aber es ist auch nicht schwer zu finden:

 => SELECT indexrelid::regclass FROM pg_index WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'pg_toast_33953' ); 
  indexrelid ------------------------------- pg_toast.pg_toast_33953_index (1 row) 

 => \d pg_toast.pg_toast_33953_index 
 Unlogged index "pg_toast.pg_toast_33953_index" Column | Type | Key? | Definition -----------+---------+------+------------ chunk_id | oid | yes | chunk_id chunk_seq | integer | yes | chunk_seq primary key, btree, for table "pg_toast.pg_toast_33953" 

Die Spalte "Client" verwendet die Strategie "Erweitert": Ihre Werte werden komprimiert. Lassen Sie uns überprüfen:

 => UPDATE accounts SET client = repeat('A',3000) WHERE id = 1; => SELECT * FROM pg_toast.pg_toast_33953; 
  chunk_id | chunk_seq | chunk_data ----------+-----------+------------ (0 rows) 

Die TOAST-Tabelle enthält nichts: Wiederholte Zeichen werden gut komprimiert, und nach der Komprimierung passt der Wert auf eine normale Tabellenseite.

Und jetzt soll der Client-Name aus zufälligen Zeichen bestehen:

 => UPDATE accounts SET client = ( SELECT string_agg( chr(trunc(65+random()*26)::integer), '') FROM generate_series(1,3000) ) WHERE id = 1 RETURNING left(client,10) || '...' || right(client,10); 
  ?column? ------------------------- TCKGKZZSLI...RHQIOLWRRX (1 row) 

Eine solche Sequenz kann nicht komprimiert werden und wird in die TOAST-Tabelle aufgenommen:

 => SELECT chunk_id, chunk_seq, length(chunk_data), left(encode(chunk_data,'escape')::text, 10) || '...' || right(encode(chunk_data,'escape')::text, 10) FROM pg_toast.pg_toast_33953; 
  chunk_id | chunk_seq | length | ?column? ----------+-----------+--------+------------------------- 34000 | 0 | 2000 | TCKGKZZSLI...ZIPFLOXDIW 34000 | 1 | 1000 | DDXNNBQQYH...RHQIOLWRRX (2 rows) 

Wir können sehen, dass die Daten in 2000-Byte-Blöcke aufgeteilt sind.

Wenn auf einen langen Wert zugegriffen wird, stellt PostgreSQL für die Anwendung automatisch und transparent den ursprünglichen Wert wieder her und gibt ihn an den Client zurück.

Sicherlich ist es ziemlich ressourcenintensiv, zu komprimieren, aufzubrechen und dann wiederherzustellen. Das Speichern massiver Daten in PostgreSQL ist daher nicht die beste Idee, insbesondere wenn sie häufig verwendet werden und für die Verwendung keine Transaktionslogik erforderlich ist (z. B. Scans von Original-Buchhaltungsdokumenten). Eine vorteilhaftere Alternative besteht darin, solche Daten in einem Dateisystem mit den im DBMS gespeicherten Dateinamen zu speichern.

Die TOAST-Tabelle wird nur verwendet, um auf einen langen Wert zuzugreifen. Außerdem wird für eine TOAST-Tabelle eine eigene Mutiversions-Parallelität unterstützt: Wenn eine Datenaktualisierung keinen langen Wert berührt, verweist eine neue Zeilenversion auf denselben Wert in der TOAST-Tabelle, was Platz spart.

Beachten Sie, dass TOAST nur für Tabellen funktioniert, nicht jedoch für Indizes. Dies begrenzt die Größe der zu indizierenden Schlüssel.
Weitere Einzelheiten zur internen Datenstruktur finden Sie in der Dokumentation .

Lesen Sie weiter .

Source: https://habr.com/ru/post/de469087/


All Articles