So arbeiten Sie mit Postgres in Go: Praktiken, Funktionen, Nuancen


Das unerwartete Verhalten der Anwendung in Bezug auf die Arbeit mit der Datenbank führt zu einem Krieg zwischen dem DBA und den Entwicklern: DBA-Ruf: "Ihre Anwendung löscht die Datenbank", die Entwickler - "Aber alles hat vorher funktioniert!" Am schlimmsten ist, dass DBA und Entwickler sich nicht gegenseitig helfen können: Einige kennen die Nuancen der Anwendung und des Treibers nicht, andere kennen die Funktionen der Infrastruktur nicht. Es wäre schön, eine solche Situation zu vermeiden.


Sie müssen verstehen, dass es oft nicht ausreicht, go-database-sql.org zu durchsuchen . Es ist besser, sich mit den Erfahrungen anderer zu bewaffnen. Noch besser, wenn es sich um eine Erfahrung handelt, die durch Blut und Geldverlust gewonnen wurde.



Mein Name ist Ryabinkov Artemy und dieser Artikel ist eine freie Interpretation meines Berichts von der Saints HighLoad 2019- Konferenz.


Die Werkzeuge


Die minimal erforderlichen Informationen zum Arbeiten mit einer SQL-ähnlichen Datenbank finden Sie in Go unter go-database-sql.org . Wenn Sie es nicht gelesen haben, lesen Sie es.


sqlx


Meiner Meinung nach ist die Kraft von Go die Einfachheit. Dies wird zum Beispiel dadurch ausgedrückt, dass es für Go üblich ist, Abfragen in Bare-SQL zu schreiben (ORM ist nicht in Ehren). Dies ist sowohl ein Vorteil als auch eine Quelle zusätzlicher Schwierigkeiten.


Wenn Sie das Standard- database/sql Sprachpaket verwenden, möchten Sie daher die Schnittstellen erweitern. Schauen Sie sich in diesem Fall github.com/jmoiron/sqlx an . Lassen Sie mich Ihnen einige Beispiele zeigen, wie diese Erweiterung Ihr Leben vereinfachen kann.


Durch die Verwendung von StructScan müssen Daten aus Spalten nicht mehr manuell in Struktureigenschaften verschoben werden.


 type Place struct { Country string City sql.NullString TelephoneCode int `db:"telcode"` } var p Place err = rows.StructScan(&p) 

Mit NamedQuery können Sie Struktureigenschaften als Platzhalter in einer Abfrage verwenden.


 p := Place{Country: "South Africa"} sql := `.. WHERE country=:country` rows, err := db.NamedQuery(sql, p) 

Mit Get and Select können Sie die Notwendigkeit beseitigen, Schleifen, die Zeilen aus der Datenbank abrufen, manuell zu schreiben.


 var p Place var pp []Place // Get   p     err = db.Get(&p, ".. LIMIT 1") // Select   pp   . err = db.Select(&pp, ".. WHERE telcode > ?", 50) 

Treiber


database/sql ist eine Reihe von Schnittstellen für die Arbeit mit der Datenbank, und sqlx ist ihre Erweiterung. Damit diese Schnittstellen funktionieren, benötigen sie eine Implementierung. Die Treiber sind für die Implementierung verantwortlich.


Beliebteste Treiber:


  • github.com/lib/pq - pure Go Postgres driver for database/sql. Dieser Treiber ist seit langem der Standard. Aber heute hat es seine Relevanz verloren und wird vom Autor nicht weiterentwickelt.
  • github.com/jackc/pgx - PostgreSQL driver and toolkit for Go. Heute ist es besser, dieses Tool zu wählen.

github.com/jackc/pgx - Dies ist der Treiber, den Sie verwenden möchten. Warum?


  • Aktiv unterstützt und weiterentwickelt .
  • Es kann produktiver sein, wenn es ohne database/sql Schnittstellen verwendet wird.
  • Unterstützung für über 60 PostgreSQL-Typen , die PostgreSQL außerhalb des SQL Standards implementiert.
  • Die Möglichkeit, die Protokollierung der Vorgänge im Treiber bequem zu implementieren.
  • pgx Menschen lesbare Fehler , während nur lib/pq Panikattacken pgx . Wenn Sie nicht in Panik geraten, stürzt das Programm ab. ( Sie sollten in Go keine Panik verwenden, dies ist nicht dasselbe wie eine Ausnahme. )
  • Mit pgx können wir jede Verbindung unabhängig konfigurieren .
  • Das logische Replikationsprotokoll von PostgreSQL wird unterstützt.

4 KB


Normalerweise schreiben wir diese Schleife, um Daten aus der Datenbank abzurufen:


 rows, err := s.db.QueryContext(ctx, sql) for rows.Next() { err = rows.Scan(...) } 

Im Treiber erhalten wir Daten, indem wir sie in einem 4-KB-Puffer speichern. rows.Next() erzeugt eine Netzwerkreise und füllt den Puffer. Wenn der Puffer nicht ausreicht, gehen wir zum Netzwerk, um die verbleibenden Daten zu erhalten. Mehr Netzwerkbesuche - weniger Verarbeitungsgeschwindigkeit. Vergessen wir andererseits nicht den gesamten Prozessspeicher, da die Puffergrenze 4 KB beträgt.


Aber natürlich möchte ich das Puffervolumen maximal abschrauben, um die Anzahl der Anforderungen an das Netzwerk und die Latenz unseres Dienstes zu verringern. Wir fügen diese Gelegenheit hinzu und versuchen, die erwartete Beschleunigung bei synthetischen Tests herauszufinden:


 $ go test -v -run=XXX -bench=. -benchmem goos: linux goarch: amd64 pkg: github.com/furdarius/pgxexperiments/bufsize BenchmarkBufferSize/4KB 5 315763978 ns/op 53112832 B/op 12967 allocs/op BenchmarkBufferSize/8KB 5 300140961 ns/op 53082521 B/op 6479 allocs/op BenchmarkBufferSize/16KB 5 298477972 ns/op 52910489 B/op 3229 allocs/op BenchmarkBufferSize/1MB 5 299602670 ns/op 52848230 B/op 50 allocs/op PASS ok github.com/furdarius/pgxexperiments/bufsize 10.964s 

Es ist ersichtlich, dass es keinen großen Unterschied in der Verarbeitungsgeschwindigkeit gibt. Warum so?


Es stellt sich heraus, dass wir durch die Größe des Puffers zum Senden von Daten innerhalb von Postgres selbst begrenzt sind. Dieser Puffer hat eine feste Größe von 8 KB . Mit strace Sie sehen, dass das Betriebssystem beim Lesen des Systemaufrufs 8192 Byte zurückgibt. Und tcpdump bestätigt dies mit der Größe der Pakete.


Tom Lane ( einer der Hauptentwickler des Postgres-Kernels ) kommentiert dies folgendermaßen:


Zumindest war dies traditionell die Größe von Pipe-Puffern in Unix-Maschinen. Im Prinzip ist dies also die optimalste Blockgröße für das Senden von Daten über einen Unix-Socket.

Andres Freund ( Postgres-Entwickler von EnterpriseDB ) ist der Ansicht, dass ein 8-KB-Puffer nicht die bisher beste Implementierungsoption ist, und Sie müssen das Verhalten auf verschiedenen Größen und mit einer anderen Socket-Konfiguration testen.


Wir müssen uns auch daran erinnern, dass PgBouncer auch einen Puffer hat und seine Größe mit dem Parameter pkt_buf konfiguriert werden kann.


OIDs


Ein weiteres Merkmal des pgx ( v3 ) -Treibers: Für jede Verbindung fordert er die Datenbank an, Informationen über die Objekt-ID ( OID ) abzurufen .


Diese Bezeichner wurden Postgres hinzugefügt, um interne Objekte eindeutig zu identifizieren : Zeilen, Tabellen, Funktionen usw.


Der Treiber verwendet Kenntnisse über OIDs zu verstehen, welche Datenbankspalte in welchem ​​Sprachprimitiv Daten hinzugefügt werden sollen. Zu diesem pgx unterstützt pgx eine solche Tabelle (der Schlüssel ist der pgx , der Wert ist die Objekt-ID ).


 map[string]Value{ "_aclitem": 2, "_bool": 3, "_int4": 4, "_int8": 55, ... } 

Diese Implementierung führt dazu, dass der Treiber für jede hergestellte Verbindung mit der Datenbank ungefähr drei Anforderungen stellt, um eine Tabelle mit einer Object ID . Im normalen Betriebsmodus der Datenbank und der Anwendung können Sie mit dem Verbindungspool in Go keine neuen Verbindungen zur Datenbank generieren. Bei der geringsten Verschlechterung der Datenbank ist der Verbindungspool auf der Anwendungsseite jedoch erschöpft und die Anzahl der generierten Verbindungen pro Zeiteinheit nimmt erheblich zu. Die Anforderungen an OIDs sehr OIDs Daher kann der Treiber die Datenbank in einen kritischen Zustand versetzen.


Hier ist der Moment, in dem solche Anfragen in eine unserer Datenbanken eingespeist wurden:



15 Transaktionen pro Minute im normalen Modus, ein Sprung von bis zu 6500 Transaktionen während der Verschlechterung.


Was zu tun ist?


Begrenzen Sie in erster Linie die Größe Ihres Pools von oben.


Für database/sql kann dies mit der Funktion DB.SetMaxOpenConns erfolgen . Wenn Sie die database/sql Schnittstellen verlassen und pgx.ConnPool (den vom Treiber selbst implementierten Verbindungspool ) verwenden, können Sie MaxConnections angeben ( Standard ist 5 ).


Übrigens verwendet pgx.ConnPool Treiber bei Verwendung von pgx.ConnPool Informationen zu empfangenen OIDs und stellt nicht bei jeder neuen Verbindung Abfragen an die Datenbank.


Wenn Sie database/sql nicht ablehnen database/sql , können Sie Informationen zu OIDs selbst zwischenspeichern.


 github.com/jackc/pgx/stdlib.OpenDB(pgx.ConnConfig{ CustomConnInfo: func(c *pgx.Conn) (*pgtype.ConnInfo, error) { cachedOids = //  OIDs   . info := pgtype.NewConnInfo() info.InitializeDataTypes(cachedOids) return info, nil } }) 

Dies ist eine Arbeitsmethode, deren Verwendung jedoch unter zwei Bedingungen gefährlich sein kann:


  • Sie verwenden Enum- oder Domain-Typen in Postgres.
  • Wenn der Assistent fehlschlägt, wechseln Sie die Anwendung zum Replikat, das durch logische Replikation übertragen wird.

Die Erfüllung dieser Bedingungen führt dazu, dass zwischengespeicherte OIDs ungültig werden. Wir können sie jedoch nicht reinigen, da wir den Zeitpunkt des Wechsels zu einer neuen Basis nicht kennen.


In der Postgres Welt wird die physische Replikation normalerweise verwendet, um eine hohe Verfügbarkeit zu organisieren, bei der die Datenbankinstanzen Stück für Stück kopiert werden, sodass Probleme mit dem OIDs Caching in freier Wildbahn selten auftreten. ( Es ist jedoch besser, mit Ihrem DBA zu klären, wie Standby für Sie funktioniert .)


In der nächsten Hauptversion des pgx Treibers - v4 - gibt es keine Kampagnen für OIDs . Jetzt verlässt sich der Treiber nur noch auf die Liste der OIDs , die im Code fest OIDs sind. Bei benutzerdefinierten Typen müssen Sie die Deserialisierung auf Ihrer Anwendungsseite steuern: Der Treiber gibt einfach ein Stück Speicher als Array von Bytes auf.


Protokollierung und Überwachung


Durch Überwachung und Protokollierung können Probleme erkannt werden, bevor die Basis abstürzt.


database/sql bietet die DB.Stats () -Methode. Der zurückgegebene Status-Snapshot gibt Ihnen eine Vorstellung davon, was im Treiber vor sich geht.


 type DBStats struct { MaxOpenConnections int // Pool State OpenConnections int InUse int Idle int // Counters WaitCount int64 WaitDuration time.Duration MaxIdleClosed int64 MaxLifetimeClosed int64 } 

Wenn Sie den Pool in pgx direkt verwenden, erhalten Sie mit der ConnPool.Stat () -Methode ähnliche Informationen:


 type ConnPoolStat struct { MaxConnections int CurrentConnections int AvailableConnections int } 

Die Protokollierung ist ebenso wichtig, und mit pgx können Sie dies tun. Der Treiber akzeptiert die Logger Schnittstelle, indem er implementiert, dass Sie alle Ereignisse erhalten, die im Treiber auftreten.


 type Logger interface { // Log a message at the given level with data key/value pairs. // data may be nil. Log(level LogLevel, msg string, data map[string]interface{}) } 

Höchstwahrscheinlich müssen Sie diese Schnittstelle nicht einmal selbst implementieren. In pgx out of the box gibt es eine Reihe von Adaptern für die gängigsten Logger, zum Beispiel uber-go / zap , sirupsen / logrus , rs / zerolog .


Die Infrastruktur


Fast immer, wenn Sie mit Postgres Sie den Verbindungspooler und es wird PgBouncer ( oder Odyssee - wenn Sie Yandex sind ) sein.


Warum so, können Sie in dem ausgezeichneten Artikel brandur.org/postgres-connections lesen . Kurz gesagt, wenn die Anzahl der Clients 100 überschreitet, beginnt sich die Geschwindigkeit der Verarbeitungsanforderungen zu verschlechtern. Dies geschieht aufgrund der Funktionen der Implementierung von Postgres selbst: dem Start eines separaten Prozesses für jede Verbindung, dem Mechanismus zum Entfernen von Snapshots und der Verwendung des gemeinsam genutzten Speichers für die Interaktion - all dies wirkt sich aus.


Hier ist der Benchmark verschiedener Implementierungen von Verbindungspoolern:


Und Benchmark- Bandbreite mit und ohne PgBouncer.



Infolgedessen sieht Ihre Infrastruktur folgendermaßen aus:



Wobei Server der Prozess ist, der Benutzeranforderungen verarbeitet. Dieser Prozess dreht sich in kubernetes in 3 Kopien ( mindestens ). Auf einem PgBouncer' befindet sich PgBouncer' Postgres , das von PgBouncer' abgedeckt wird. PgBouncer selbst PgBouncer Single-Threaded, daher starten wir mehrere Bouncer, deren Datenverkehr wir mit HAProxy ausgleichen. Als Ergebnis erhalten wir eine solche Kette der Abfrageausführung in der Datenbank: → HAProxy → PgBouncer → Postgres .


PgBouncer kann in drei Modi arbeiten:


  • Sitzungspooling - Für jede Sitzung wird eine Verbindung ausgegeben und ihr für die gesamte Lebensdauer zugewiesen.
  • Transaktionspooling - Die Verbindung bleibt bestehen, während die Transaktion ausgeführt wird. Sobald die Transaktion abgeschlossen ist, nimmt PgBouncer diese Verbindung und gibt sie an eine andere Transaktion zurück. Dieser Modus ermöglicht eine sehr gute Entsorgung von Verbindungen.
  • Anweisungspooling - veralteter Modus. Es wurde nur zur Unterstützung von PL / Proxy erstellt .

Sie können die Matrix sehen, welche Eigenschaften in jedem Modus verfügbar sind. Wir wählen Transaktionspooling , aber es gibt Einschränkungen beim Arbeiten mit Prepared Statements .


Transaktionspooling + vorbereitete Anweisungen


Stellen wir uns vor, wir möchten eine Anfrage vorbereiten und dann ausführen. Irgendwann starten wir eine Transaktion, in der wir eine Vorbereitungsanforderung senden, und wir erhalten die ID der vorbereiteten Anforderung aus der Datenbank.



Nachdem wir zu einem anderen Zeitpunkt eine weitere Transaktion generiert haben. Darin wenden wir uns der Datenbank zu und möchten die Anforderung unter Verwendung des Bezeichners mit den angegebenen Parametern erfüllen.



Im Transaktionspooling- Modus können zwei Transaktionen in unterschiedlichen Verbindungen ausgeführt werden, die Anweisungs-ID ist jedoch nur innerhalb einer Verbindung gültig. Wir erhalten eine prepared statement does not exist beim Versuch, eine Anforderung auszuführen, prepared statement does not exist Fehler prepared statement does not exist .


Das Unangenehmste: Da während der Entwicklung und des Testens die Last gering ist, stellt PgBouncer häufig dieselbe Verbindung her und alles funktioniert ordnungsgemäß. Sobald wir uns jedoch auf den Weg machen, beginnen die Anfragen mit einem Fehler zu fallen.


Finden Sie nun Prepared Statements in diesem Code:


 sql := `select * from places where city = ?` rows, err := s.db.Query(sql, city) 

Du wirst ihn nicht sehen! Die Abfragevorbereitung erfolgt implizit in Query() . Gleichzeitig erfolgt die Vorbereitung und Ausführung der Anfrage in verschiedenen Transaktionen, und wir erhalten alles, was ich oben beschrieben habe, vollständig.


Was zu tun ist?


Die erste und einfachste Option besteht darin , PgBouncer auf Session pooling PgBouncer . Der Sitzung wird eine Verbindung zugewiesen, alle Transaktionen beginnen mit dieser Verbindung und vorbereitete Anforderungen funktionieren ordnungsgemäß. In diesem Modus lässt die Effizienz der Verwendung von Verbindungen jedoch zu wünschen übrig. Daher wird diese Option nicht berücksichtigt.


Die zweite Möglichkeit besteht darin, eine Anfrage auf der Clientseite vorzubereiten . Ich möchte dies aus zwei Gründen nicht tun:


  • Mögliche SQL-Schwachstellen. Der Entwickler kann das Entkommen vergessen oder falsch machen.
  • Entkommen Sie den Abfrageparametern jedes Mal, wenn Sie mit Ihren Händen schreiben müssen.

Eine andere Möglichkeit besteht darin, jede Anforderung explizit in eine Transaktion einzuschließen . Schließlich nimmt PgBouncer die Verbindung nicht auf, solange die Transaktion PgBouncer ist. Dies funktioniert, aber zusätzlich zur Ausführlichkeit in unserem Code erhalten wir auch mehr Netzwerkanrufe: Beginnen, Vorbereiten, Ausführen, Festschreiben. Insgesamt 4 Netzwerkanrufe pro Anfrage. Die Latenz wächst.


Aber ich möchte es sowohl sicher als auch bequem und effizient. Und es gibt eine solche Option! Sie können dem Treiber explizit mitteilen, dass Sie den einfachen Abfragemodus verwenden möchten. In diesem Modus erfolgt keine Vorbereitung und die gesamte Anforderung wird in einem Netzwerkanruf weitergeleitet. In diesem Fall nimmt der Treiber die Abschirmung für jeden der Parameter selbst vor ( standard_conforming_strings muss auf der Basisebene oder beim Herstellen einer Verbindung aktiviert werden ).


 cfg := pgx.ConnConfig{ ... RuntimeParams: map[string]string{ "standard_conforming_strings": "on", }, PreferSimpleProtocol: true, } 

Anfragen abbrechen


Die folgenden Probleme beziehen sich auf das Abbrechen von Anforderungen auf der Anwendungsseite.


Schauen Sie sich diesen Code an. Wo sind die Fallstricke?


 rows, err := s.db.QueryContext(ctx, ...) 

Go verfügt über eine Methode zur Steuerung des Programmablaufs - context.Context . In diesem Code übergeben wir den ctx Treiber, sodass ctx Treiber beim Schließen des Kontexts die Anforderung auf Datenbankebene abbricht.


Gleichzeitig wird erwartet, dass wir Ressourcen sparen, indem wir Anfragen stornieren, auf die niemand wartet. Wenn Sie jedoch eine Anforderung PgBouncer sendet PgBouncer Version 1.7 Informationen an die Verbindung, dass diese Verbindung zur Verwendung bereit ist, und gibt sie anschließend an den Pool zurück. Dieses Verhalten von PgBouncer' führt den Treiber in die Irre, der beim Senden der nächsten Anforderung sofort ReadyForQuery als Antwort erhält. Am Ende stellen wir unerwartete ReadyForQuery-Fehler fest .


Ab PgBouncer Version 1.8 wurde dieses Verhalten behoben . Verwenden Sie die aktuelle Version von PgBouncer .


Und obwohl in diesem Fall die Fehler verschwinden, bleibt ein interessantes Verhalten bestehen. In einigen Fällen erhält unsere Anwendung möglicherweise Antworten nicht auf ihre Anfrage, sondern auf die benachbarte (Hauptsache, die Anfragen stimmen mit der Art und Reihenfolge der angeforderten Daten überein). where user_id = 2 ist beispielsweise für die Abfrage where user_id = 2 die Antwort der Abfrage where user_id = 42 . Dies ist auf die Verarbeitung von Stornierungsanforderungen auf verschiedenen Ebenen zurückzuführen: auf der Ebene des Treiberpools und des Bouncer-Pools.


Verspätete Stornierung


Um die Anfrage abzubrechen, müssen wir eine neue Verbindung zur Datenbank herstellen und eine Stornierung anfordern. Postgres erstellt für jede Verbindung einen eigenen Prozess. Wir senden einen Befehl, um die aktuelle Anfrage in einem bestimmten Prozess abzubrechen. Erstellen Sie dazu eine neue Verbindung und übertragen Sie darin die interessierende Prozess-ID (PID) an uns. Während der Stornierungsbefehl zur Basis fliegt, kann die stornierte Anforderung von selbst enden.



Postgres führt den Befehl aus und bricht die aktuelle Anforderung im angegebenen Prozess ab. Die aktuelle Anfrage ist jedoch nicht die, die wir ursprünglich stornieren wollten. Aufgrund dieses Verhaltens bei der Arbeit mit Postgres mit PgBouncer sicherer, die Anforderung nicht auf Treiberebene abzubrechen. Dazu können Sie die CustomCancel , die die Anforderung auch dann nicht context.Context , wenn context.Context verwendet wird.


 cfg := pgx.ConnConfig{ ... CustomCancel: func(_ *pgx.Conn) error { return nil }, } 

Postgres Checkliste


Anstelle von Schlussfolgerungen habe ich beschlossen, eine Checkliste für die Arbeit mit Postgres zu erstellen. Dies sollte dazu beitragen, dass der Artikel in meinen Kopf passt.


  • Verwenden Sie github.com/jackc/pgx als Treiber für die Arbeit mit Postgres.
  • Begrenzen Sie die Größe des Verbindungspools von oben.
  • Cache- OIDs oder verwende pgx.ConnPool, wenn du mit pgx Version 3 pgx .
  • Sammeln Sie Metriken aus dem Verbindungspool mit DB.Stats () oder ConnPool.Stat () .
  • Protokollieren Sie, was im Treiber passiert.
  • Verwenden Sie den einfachen Abfragemodus, um Probleme bei der PgBouncer im PgBouncer Transaktionsmodus zu vermeiden.
  • Aktualisieren Sie PgBouncer auf die neueste Version.
  • Seien Sie vorsichtig beim Abbrechen von Anfragen aus der Anwendung.

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


All Articles