
Le comportement inattendu de l'application par rapport à l'utilisation de la base de données entraîne une guerre entre le DBA et les développeurs: DBA crie: "Votre application supprime la base de données", les développeurs - "Mais tout fonctionnait avant!" Pire encore, DBA et les développeurs ne peuvent pas s'entraider: certains ne connaissent pas les nuances de l'application et du pilote, d'autres ne connaissent pas les fonctionnalités liées à l'infrastructure. Ce serait bien d'éviter une telle situation.
Vous devez comprendre, il ne suffit souvent pas de parcourir go-database-sql.org . Il vaut mieux s'armer de l'expérience des autres. Encore mieux si c'est une expérience acquise par le sang et l'argent perdu.
Je m'appelle Ryabinkov Artemy et cet article est une interprétation gratuite de mon rapport de la conférence Saints HighLoad 2019 .
Les outils
Vous pouvez trouver les informations minimales nécessaires sur la façon de travailler avec Go avec n'importe quelle base de données de type SQL sur go-database-sql.org . Si vous ne l'avez pas lu, lisez-le.
sqlx
À mon avis, le pouvoir de Go est la simplicité. Et cela s'exprime, par exemple, en ce qu'il est habituel pour Go d'écrire des requêtes en SQL nu (ORM n'est pas à l'honneur). C'est à la fois un avantage et une source de difficultés supplémentaires.
Par conséquent, en prenant le package de langue standard de database/sql
, vous souhaiterez étendre ses interfaces. Une fois que cela se produit, consultez github.com/jmoiron/sqlx . Permettez-moi de vous montrer quelques exemples de la façon dont cette extension peut vous simplifier la vie.
L'utilisation de StructScan élimine le besoin de déplacer manuellement les données des colonnes dans les propriétés de la structure.
type Place struct { Country string City sql.NullString TelephoneCode int `db:"telcode"` } var p Place err = rows.StructScan(&p)
L'utilisation de NamedQuery vous permet d'utiliser les propriétés de structure comme espaces réservés dans une requête.
p := Place{Country: "South Africa"} sql := `.. WHERE country=:country` rows, err := db.NamedQuery(sql, p)
L'utilisation de Get et Select vous permet de vous débarrasser de la nécessité d'écrire manuellement des boucles qui récupèrent les lignes de la base de données.
var p Place var pp []Place
Pilotes
database/sql
est un ensemble d'interfaces pour travailler avec la base de données, et sqlx
est leur extension. Pour que ces interfaces fonctionnent, elles ont besoin d'une implémentation. Les pilotes sont responsables de la mise en œuvre.
Pilotes les plus populaires:
- github.com/lib/pq - Pilote
pure Go Postgres driver for database/sql.
Ce pilote est resté longtemps la norme par défaut. Mais aujourd'hui, il a perdu sa pertinence et n'est pas développé par l'auteur. - github.com/jackc/pgx -
PostgreSQL driver and toolkit for Go.
Aujourd'hui, il vaut mieux choisir cet outil.
github.com/jackc/pgx - c'est le pilote que vous souhaitez utiliser. Pourquoi?
- Soutenu et développé activement.
- Il peut être plus productif s'il est utilisé sans interfaces de
database/sql
. - Prise en charge de plus de 60 types de PostgreSQL que
PostgreSQL
implémente en dehors du standard SQL
. - La possibilité d'implémenter facilement la journalisation de ce qui se passe à l'intérieur du pilote.
pgx
des erreurs lisibles par l'homme , tandis que juste lib/pq
lance des attaques de panique. Si vous n'attrapez pas de panique, le programme se bloquera. ( Vous ne devez pas utiliser la panique dans Go, ce n'est pas la même chose que l'exception. )- Avec
pgx
, nous avons la possibilité de configurer indépendamment chaque connexion . - Il existe un support pour le protocole de réplication logique
PostgreSQL
.
4Ko
En règle générale, nous écrivons cette boucle pour obtenir des données de la base de données:
rows, err := s.db.QueryContext(ctx, sql) for rows.Next() { err = rows.Scan(...) }
À l'intérieur du pilote, nous obtenons des données en les stockant dans une mémoire tampon de 4 Ko . rows.Next()
génère un voyage réseau et remplit le tampon. Si le tampon n'est pas suffisant, nous allons sur le réseau pour les données restantes. Plus de visites sur le réseau - moins de vitesse de traitement. En revanche, comme la limite de mémoire tampon est de 4 Ko, n'oublions pas la totalité de la mémoire du processus.
Mais, bien sûr, je veux dévisser le volume de tampon au maximum pour réduire le nombre de requêtes vers le réseau et réduire la latence de notre service. Nous ajoutons cette opportunité et essayons de découvrir l'accélération attendue sur les tests synthétiques :
$ 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
On peut voir qu'il n'y a pas de grande différence de vitesse de traitement. Pourquoi
Il s'avère que nous sommes limités par la taille du tampon pour l'envoi de données à l'intérieur même de Postgres. Ce tampon a une taille fixe de 8 Ko . À l'aide de strace
vous pouvez voir que le système d'exploitation renvoie 8192
octets dans l'appel système en lecture . Et tcpdump
confirme avec la taille des paquets.
Tom Lane (l' un des principaux développeurs du noyau Postgres ) commente ceci comme ceci:
Traditionnellement, au moins, c'était la taille des tampons de tuyau dans les machines Unix, donc en principe c'est la taille de bloc la plus optimale pour envoyer des données via un socket Unix.
Andres Freund ( développeur Postgres de EnterpriseDB ) estime qu'un tampon de 8 Ko n'est pas la meilleure option d'implémentation à ce jour, et vous devez tester le comportement sur différentes tailles et avec une configuration de socket différente.
Nous devons également nous rappeler que PgBouncer possède également un tampon et sa taille peut être configurée avec le paramètre pkt_buf
.
OID
Autre caractéristique du pilote pgx ( v3 ): pour chaque connexion, il demande à la base de données d'obtenir des informations sur l' ID d'objet ( OID ).
Ces identifiants ont été ajoutés à Postgres pour identifier de manière unique les objets internes: lignes, tableaux, fonctions, etc.
Le pilote utilise la connaissance des OIDs
pour comprendre quelle colonne de base de données dans quelle langue primitive ajouter des données. Pour cela, pgx
prend en charge une telle table (la clé est le nom du type, la valeur est l'ID d'objet )
map[string]Value{ "_aclitem": 2, "_bool": 3, "_int4": 4, "_int8": 55, ... }
Cette implémentation conduit au fait que le pilote pour chaque connexion établie avec la base de données fait environ trois demandes pour former une table avec un Object ID
. Dans le mode de fonctionnement normal de la base de données et de l'application, le pool de connexions dans Go vous permet de ne pas générer de nouvelles connexions à la base de données. Mais à la moindre dégradation de la base de données, le pool de connexions côté application est épuisé et le nombre de connexions générées par unité de temps augmente considérablement. Les demandes d' OIDs
assez lourdes, par conséquent, le pilote peut mettre la base de données dans un état critique.
Voici le moment où de telles demandes ont été versées sur l'une de nos bases de données:

15 transactions par minute en mode normal, soit un saut jusqu'à 6500 transactions lors de la dégradation.
Que faire
Tout d'abord, limitez la taille de votre piscine par le haut.
Pour la database/sql
cela peut être fait avec la fonction DB.SetMaxOpenConns . Si vous abandonnez les interfaces database/sql
et utilisez pgx.ConnPool
(le pool de connexions implémenté par le pilote lui-même ), vous pouvez spécifier MaxConnections
(la valeur par défaut est 5 ).
Par ailleurs, lors de l'utilisation de pgx.ConnPool
pilote réutilisera les informations sur les OIDs
reçus et n'effectuera pas de requêtes dans la base de données pour chaque nouvelle connexion.
Si vous ne souhaitez pas refuser la database/sql
, vous pouvez mettre en cache vous-même les informations sur les OIDs
.
github.com/jackc/pgx/stdlib.OpenDB(pgx.ConnConfig{ CustomConnInfo: func(c *pgx.Conn) (*pgtype.ConnInfo, error) { cachedOids =
Il s'agit d'une méthode de travail, mais son utilisation peut être dangereuse dans deux conditions:
- vous utilisez des types d'énumération ou de domaine dans Postgres;
- si l'assistant échoue, vous basculez l'application vers la réplique, qui est versée par réplication logique.
Le respect de ces conditions conduit au fait que les OIDs
mis en cache deviennent invalides. Mais nous ne pourrons pas les nettoyer, car nous ne savons pas à quel moment passer à une nouvelle base.
Dans le monde Postgres
, la réplication physique est généralement utilisée pour organiser la haute disponibilité, qui copie les instances de base de données petit à petit, de sorte que les problèmes de mise en cache OIDs
rarement vus dans la nature. ( Mais il vaut mieux vérifier avec votre administrateur de base de données le fonctionnement de la veille pour vous ).
Dans la prochaine version majeure du pilote pgx
- v4
, il n'y aura pas de campagnes pour les OIDs
. Désormais, le pilote ne s'appuiera que sur la liste des OIDs
en OIDs
dans le code. Pour les types personnalisés, vous devrez prendre le contrôle de la désérialisation du côté de votre application: le pilote abandonnera simplement un morceau de mémoire en tant que tableau d'octets.
Journalisation et surveillance
La surveillance et la journalisation aideront à remarquer les problèmes avant que la base ne plante.
database/sql
fournit la méthode DB.Stats () . L'instantané d'état renvoyé vous donnera une idée de ce qui se passe à l'intérieur du pilote.
type DBStats struct { MaxOpenConnections int
Si vous utilisez directement le pool dans pgx
, la méthode ConnPool.Stat () vous donnera des informations similaires:
type ConnPoolStat struct { MaxConnections int CurrentConnections int AvailableConnections int }
La journalisation est tout aussi importante et pgx
vous permet de le faire. Le pilote accepte l'interface Logger
, en implémentant laquelle, vous obtenez tous les événements qui se produisent à l'intérieur du pilote.
type Logger interface {
Très probablement, vous n'avez même pas à implémenter cette interface vous-même. Dans pgx
, il existe un ensemble d'adaptateurs pour les enregistreurs les plus populaires, par exemple, uber-go / zap , sirupsen / logrus , rs / zerolog .
L'infrastructure
Presque toujours, lorsque vous travaillez avec Postgres
vous utiliserez le pool de connexions , et ce sera PgBouncer ( ou odyssée - si vous êtes Yandex ).
Pourquoi donc, vous pouvez lire dans l'excellent article brandur.org/postgres-connections . En bref, lorsque le nombre de clients dépasse 100, la vitesse de traitement des demandes commence à se dégrader. Cela se produit en raison des caractéristiques de l'implémentation de Postgres: le lancement d'un processus distinct pour chaque connexion, le mécanisme de suppression des instantanés et l'utilisation de la mémoire partagée pour l'interaction - tout cela affecte.
Voici la référence de diverses implémentations de pool de connexions:

Et comparer la bande passante avec et sans PgBouncer.

Par conséquent, votre infrastructure ressemblera à ceci:

Où Server
est le processus qui traite les demandes des utilisateurs. Ce processus tourne dans kubernetes
en 3 exemplaires ( au moins ). Séparément, sur un serveur de fer, il y a Postgres
, couvert par PgBouncer'
. PgBouncer
lui PgBouncer
même PgBouncer
un thread unique, nous lançons donc plusieurs videurs, le trafic pour lequel nous équilibrons en utilisant HAProxy
. En conséquence, nous obtenons une telle chaîne d'exécution de requête dans la base de données: → HAProxy → PgBouncer → Postgres
.
PgBouncer
peut fonctionner en trois modes:
- Regroupement de sessions - pour chaque session, une connexion est émise et affectée à celle-ci pour toute la durée de vie.
- Pool de transactions - la connexion est active pendant l'exécution de la transaction. Dès que la transaction est terminée,
PgBouncer
prend cette connexion et la rend à une autre transaction. Ce mode permet une très bonne élimination des composés. - Mise en commun des instructions - mode obsolète . Il a été créé uniquement pour prendre en charge PL / Proxy .
Vous pouvez voir la matrice des propriétés disponibles dans chaque mode. Nous choisissons le regroupement de transactions , mais il a des limites sur l'utilisation des Prepared Statements
.
Regroupement de transactions + états préparés
Imaginons que nous voulons préparer une demande, puis l'exécuter. À un moment donné, nous démarrons une transaction dans laquelle nous envoyons une demande de préparation et nous obtenons l'ID de la demande préparée dans la base de données.

Après, à tout autre moment, nous générons une autre transaction. Dans ce document, nous nous tournons vers la base de données et voulons répondre à la demande en utilisant l'identifiant avec les paramètres spécifiés.

En mode Transaction Pooling , deux transactions peuvent être exécutées dans des connexions différentes, mais l' ID d'instruction n'est valide que dans une seule connexion. Nous obtenons une prepared statement does not exist
erreur lors de la tentative d'exécution d'une demande.
Le plus désagréable: puisque lors du développement et des tests la charge est faible, PgBouncer
émet souvent la même connexion et tout fonctionne correctement. Mais dès que nous nous déployons vers prod, les demandes commencent à tomber avec une erreur.
Trouvez maintenant les Prepared Statements
dans ce code:
sql := `select * from places where city = ?` rows, err := s.db.Query(sql, city)
Vous ne le verrez pas! La préparation des requêtes se fera implicitement dans Query()
. Dans le même temps, la préparation et l'exécution de la demande se feront dans différentes transactions et nous recevrons pleinement tout ce que j'ai décrit ci-dessus.
Que faire
La première option, la plus simple, consiste à basculer PgBouncer
vers le PgBouncer
de Session pooling
. Une connexion est allouée à la session, toutes les transactions commencent à se connecter à cette connexion et les requêtes préparées fonctionnent correctement. Mais dans ce mode, l'efficacité de l'utilisation des composés laisse beaucoup à désirer. Par conséquent, cette option n'est pas considérée.
La deuxième option consiste à préparer une demande côté client . Je ne veux pas le faire pour deux raisons:
- Vulnérabilités SQL potentielles. Le développeur peut oublier ou faire échapper incorrectement.
- Échapper aux paramètres de la requête chaque fois que vous devez écrire avec vos mains.
Une autre option consiste à encapsuler explicitement chaque demande dans une transaction . Après tout, tant que la transaction est en cours, PgBouncer
ne reprend pas la connexion. Cela fonctionne, mais, en plus de la verbosité de notre code, nous recevons également plus d'appels réseau: Begin, Prepare, Execute, Commit. Total 4 appels réseau par demande. La latence augmente.
Mais je le veux à la fois en toute sécurité et de manière pratique et efficace. Et il y a une telle option! Vous pouvez indiquer explicitement au pilote que vous souhaitez utiliser le mode de requête simple . Dans ce mode, il n'y aura pas de préparation et la demande entière passera en un seul appel réseau. Dans ce cas, le pilote effectuera lui-même le blindage de chacun des paramètres ( standard_conforming_strings
doit être activé au niveau de la base ou lors de l'établissement d'une connexion ).
cfg := pgx.ConnConfig{ ... RuntimeParams: map[string]string{ "standard_conforming_strings": "on", }, PreferSimpleProtocol: true, }
Annuler les demandes
Les problèmes suivants sont liés à l'annulation des demandes côté application.
Jetez un oeil à ce code. Où sont les pièges?
rows, err := s.db.QueryContext(ctx, ...)
Go a une méthode pour contrôler le flux d'exécution du programme - context.Context . Dans ce code, nous transmettons le ctx
pilote de sorte que lorsque le contexte est fermé, le pilote annule la demande au niveau de la base de données.
En même temps, nous prévoyons économiser des ressources en annulant les demandes que personne n'attend. Mais lors de l'annulation d'une demande, PgBouncer
version 1.7 envoie à la connexion des informations indiquant que cette connexion est prête à être utilisée, puis la renvoie au pool. Ce comportement de PgBouncer'
induit en erreur le pilote qui, lors de l'envoi de la prochaine demande, reçoit instantanément ReadyForQuery
en réponse. Au final, nous détectons des erreurs ReadyForQuery inattendues .
PgBouncer
version 1.8 de PgBouncer
, ce comportement a été corrigé . Utilisez la version actuelle de PgBouncer
.
Et, bien que, dans ce cas, les erreurs disparaissent - un comportement intéressant restera. Dans certains cas, notre application peut recevoir des réponses non pas à sa demande, mais à la voisine (l'essentiel est que les demandes correspondent au type et à l'ordre des données demandées). C'est-à-dire, par exemple, à la requête where user_id = 2
, la réponse de la requête where user_id = 42
sera retournée. Cela est dû au traitement des demandes d'annulation à différents niveaux: au niveau du pool de pilotes et du pool de videurs.
Annulation retardée
Pour annuler la demande, nous devons créer une nouvelle connexion à la base de données et demander une annulation. Postgres
crée un processus distinct pour chaque connexion. Nous envoyons une commande pour annuler la demande en cours dans un processus spécifique. Pour ce faire, créez une nouvelle connexion et transférez-y l'ID de processus (PID) qui nous intéresse. Mais tandis que la commande d'annulation vole vers la base, la demande annulée peut se terminer d'elle-même.

Postgres
exécutera la commande et annulera la demande en cours dans le processus donné. Mais la demande en cours ne sera pas celle que nous souhaitions initialement annuler. En raison de ce comportement lorsque vous travaillez avec Postgres
avec PgBouncer
plus sûr de ne pas annuler la demande au niveau du pilote. Pour ce faire, vous pouvez définir la CustomCancel
, qui n'annulera pas la demande, même si context.Context
utilisé.
cfg := pgx.ConnConfig{ ... CustomCancel: func(_ *pgx.Conn) error { return nil }, }
Liste de contrôle Postgres
Au lieu de conclusions, j'ai décidé de faire une liste de contrôle pour travailler avec Postgres. Cela devrait aider l'article à s'insérer dans ma tête.
- Utilisez github.com/jackc/pgx comme pilote pour travailler avec Postgres.
- Limitez la taille du pool de connexions par le haut.
- Cachez les
OIDs
ou utilisez pgx.ConnPool si vous travaillez avec pgx
version 3. - Collectez les métriques du pool de connexions à l'aide de DB.Stats () ou ConnPool.Stat () .
- Enregistrez ce qui se passe dans le pilote.
- Utilisez le mode de requête simple pour éviter les problèmes de préparation des requêtes en mode transactionnel
PgBouncer
. - Mettez à jour
PgBouncer
vers la dernière version. - Soyez prudent avec l'annulation des demandes de l'application.