Cómo trabajar con Postgres en Go: prácticas, características, matices


El comportamiento inesperado de la aplicación en relación con el trabajo con la base de datos conduce a una guerra entre el DBA y los desarrolladores: DBA grita: "Su aplicación deja caer la base de datos", los desarrolladores - "¡Pero todo funcionó antes!" Lo peor de todo es que DBA y los desarrolladores no pueden ayudarse entre sí: algunos no conocen los matices de la aplicación y el controlador, otros no conocen las características relacionadas con la infraestructura. Sería bueno evitar tal situación.


Tienes que entender, a menudo no es suficiente mirar a través de go-database-sql.org . Es mejor armarse con la experiencia de otras personas. Aún mejor si es una experiencia ganada por sangre y dinero perdido.



Mi nombre es Ryabinkov Artemy y este artículo es una interpretación gratuita de mi informe de la conferencia Saints HighLoad 2019 .


Las herramientas


Puede encontrar la información mínima necesaria sobre cómo trabajar con cualquier base de datos similar a SQL en Go en go-database-sql.org . Si no lo ha leído, léalo.


sqlx


En mi opinión, el poder de Go es la simplicidad. Y esto se expresa, por ejemplo, en que es habitual que Go escriba consultas en SQL simple (ORM no es un honor). Esto es tanto una ventaja como una fuente de dificultades adicionales.


Por lo tanto, tomando el paquete estándar de database/sql lenguaje database/sql , querrá expandir sus interfaces. Una vez que eso suceda, eche un vistazo a github.com/jmoiron/sqlx . Déjame mostrarte algunos ejemplos de cómo esta extensión puede simplificar tu vida.


El uso de StructScan elimina la necesidad de cambiar manualmente los datos de las columnas a las propiedades de la estructura.


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

El uso de NamedQuery le permite usar propiedades de estructura como marcadores de posición en una consulta.


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

El uso de Get and Select le permite deshacerse de la necesidad de escribir manualmente bucles que obtienen líneas de la base de datos.


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

Conductores


database/sql es un conjunto de interfaces para trabajar con la base de datos, y sqlx es su extensión. Para que estas interfaces funcionen, necesitan una implementación. Los conductores son responsables de la implementación.


Conductores más populares:


  • github.com/lib/pq - controlador pure Go Postgres driver for database/sql. Este controlador ha permanecido durante mucho tiempo el estándar predeterminado. Pero hoy ha perdido su relevancia y no está siendo desarrollado por el autor.
  • github.com/jackc/pgx : PostgreSQL driver and toolkit for Go. Hoy es mejor elegir esta herramienta.

github.com/jackc/pgx : este es el controlador que desea utilizar. Por qué


  • Activamente apoyado y desarrollado .
  • Puede ser más productivo si se usa sin interfaces de database/sql .
  • Soporte para más de 60 tipos de PostgreSQL que PostgreSQL implementa fuera del estándar SQL .
  • La capacidad de implementar convenientemente el registro de lo que sucede dentro del controlador.
  • pgx errores legibles por humanos , mientras que solo lib/pq lanza ataques de pánico. Si no se asusta, el programa se bloqueará. ( No debe usar el pánico en Go, esto no es lo mismo que la excepción ) .
  • Con pgx , tenemos la capacidad de configurar de forma independiente cada conexión .
  • Hay soporte para el protocolo de replicación lógica PostgreSQL .

4KB


Por lo general, escribimos este bucle para obtener datos de la base de datos:


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

Dentro del controlador, obtenemos datos almacenándolos en un búfer de 4KB . rows.Next() genera un viaje de red y llena el búfer. Si el búfer no es suficiente, entonces vamos a la red para obtener los datos restantes. Más visitas a la red, menos velocidad de procesamiento. Por otro lado, dado que el límite del búfer es 4KB, no olvidemos toda la memoria del proceso.


Pero, por supuesto, quiero desenroscar el volumen del búfer al máximo para reducir el número de solicitudes a la red y reducir la latencia de nuestro servicio. Agregamos esta oportunidad e intentamos descubrir la aceleración esperada en las pruebas sintéticas :


 $ 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 

Se puede ver que no hay una gran diferencia en la velocidad de procesamiento. Por qué


Resulta que estamos limitados por el tamaño del búfer para enviar datos dentro de Postgres. Este búfer tiene un tamaño fijo de 8 KB . Con strace puede ver que el sistema operativo devuelve 8192 bytes en la llamada al sistema de lectura . Y tcpdump confirma esto con el tamaño de los paquetes.


Tom Lane ( uno de los principales desarrolladores del núcleo de Postgres ) comenta esto así:


Tradicionalmente, al menos, ese era el tamaño de los tampones de tuberías en máquinas Unix, por lo que, en principio, este es el tamaño de fragmento más óptimo para enviar datos a través de un zócalo Unix.

Andres Freund ( desarrollador de Postgres de EnterpriseDB ) cree que un búfer de 8 KB no es la mejor opción de implementación hasta la fecha, y debe probar el comportamiento en diferentes tamaños y con una configuración de socket diferente.


También debemos recordar que PgBouncer también tiene un búfer y su tamaño se puede configurar con el parámetro pkt_buf .


OID


Otra característica del controlador pgx ( v3 ): para cada conexión, realiza una solicitud a la base de datos para obtener información sobre el ID de objeto ( OID ).


Estos identificadores se agregaron a Postgres para identificar de manera única los objetos internos: filas, tablas, funciones, etc.


El controlador utiliza el conocimiento de los OIDs para comprender qué columna de la base de datos en qué idioma primitivo agregar datos. Para esto, pgx compatible con dicha tabla (la clave es el nombre del tipo, el valor es el ID del objeto )


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

Esta implementación lleva al hecho de que el controlador para cada conexión establecida con la base de datos realiza aproximadamente tres solicitudes para formar una tabla con un Object ID . En el modo normal de funcionamiento de la base de datos y la aplicación, el grupo de conexiones en Go le permite no generar nuevas conexiones a la base de datos. Pero a la menor degradación de la base de datos, el conjunto de conexiones en el lado de la aplicación se agota y el número de conexiones generadas por unidad de tiempo aumenta significativamente. Las solicitudes de OIDs bastante pesadas, como resultado, el controlador puede llevar la base de datos a un estado crítico.


Este es el momento en que tales solicitudes se vertieron en una de nuestras bases de datos:



15 transacciones por minuto en modo normal, un salto de hasta 6500 transacciones durante la degradación.


Que hacer


En primer lugar, limite el tamaño de su piscina desde arriba.


Para la database/sql esto se puede hacer con la función DB.SetMaxOpenConns . Si abandona las interfaces de database/sql y usa pgx.ConnPool (el grupo de conexiones implementado por el controlador ), puede especificar MaxConnections (el valor predeterminado es 5 ).


Por cierto, al usar pgx.ConnPool controlador reutilizará la información sobre los OIDs recibidos y no realizará consultas a la base de datos para cada nueva conexión.


Si no desea rechazar la database/sql , puede almacenar en caché la información sobre los OIDs usted mismo.


 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 } }) 

Este es un método de trabajo, pero usarlo puede ser peligroso en dos condiciones:


  • usa enum o tipos de dominio en Postgres;
  • Si el asistente falla, cambia la aplicación a la réplica, que se vierte mediante la replicación lógica.

El cumplimiento de estas condiciones lleva al hecho de que los OIDs en caché se vuelven inválidos. Pero no podremos limpiarlos, porque no sabemos el momento de cambiar a una nueva base.


En el mundo de Postgres , la replicación física generalmente se usa para organizar una alta disponibilidad, que copia las instancias de la base de datos poco a poco, por lo que los problemas con el almacenamiento en caché de OIDs rara vez se ven en la naturaleza. ( Pero es mejor consultar con su DBA cómo funciona el modo de espera para usted ).


En la próxima versión principal del controlador pgx - v4 , no habrá campañas para OIDs . Ahora el controlador se basará solo en la lista de OIDs en el código. Para los tipos personalizados, tendrá que tomar el control de la deserialización en el lado de su aplicación: el controlador simplemente entregará un trozo de memoria como una matriz de bytes.


Registro y Monitoreo


El monitoreo y el registro ayudarán a notar problemas antes de que la base se bloquee.


database/sql proporciona el método DB.Stats () . La instantánea de estado devuelta le dará una idea de lo que está sucediendo dentro del controlador.


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

Si usa el grupo en pgx directamente, el método ConnPool.Stat () le dará información similar:


 type ConnPoolStat struct { MaxConnections int CurrentConnections int AvailableConnections int } 

El registro es igualmente importante, y pgx permite hacer esto. El controlador acepta la interfaz Logger , al implementar el cual, obtiene todos los eventos que ocurren dentro del controlador.


 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{}) } 

Lo más probable es que ni siquiera tenga que implementar esta interfaz usted mismo. En pgx para pgx , hay un conjunto de adaptadores para los registradores más populares, por ejemplo, uber-go / zap , sirupsen / logrus , rs / zerolog .


La infraestructura


Casi siempre que Postgres con Postgres utilizarás un agrupador de conexiones , y será PgBouncer ( u odisea , si eres Yandex ).


Por eso, puedes leer en el excelente artículo brandur.org/postgres- connections . En resumen, cuando el número de clientes supera los 100, la velocidad de procesamiento de las solicitudes comienza a degradarse. Esto sucede debido a las características de la implementación de Postgres: el lanzamiento de un proceso separado para cada conexión, el mecanismo para eliminar instantáneas y el uso de memoria compartida para la interacción, todo esto afecta.


Aquí está el punto de referencia de varias implementaciones de agrupadores de conexiones:


Y ancho de banda de referencia con y sin PgBouncer.



Como resultado, su infraestructura se verá así:



Donde Server es el proceso que procesa las solicitudes de los usuarios. Este proceso gira en kubernetes en 3 copias ( al menos ). Por separado, en un servidor de hierro, hay Postgres , cubierto por PgBouncer' . PgBouncerPgBouncer solo subproceso, por lo que lanzamos varios HAProxy cuyo tráfico equilibramos con HAProxy . Como resultado, obtenemos dicha cadena de ejecución de consultas en la base de datos: → HAProxy → PgBouncer → Postgres .


PgBouncer puede funcionar en tres modos:


  • Agrupación de sesiones : para cada sesión, se emite una conexión y se le asigna durante toda la vida útil.
  • Agrupación de transacciones : la conexión vive mientras se ejecuta la transacción. Tan pronto como se complete la transacción, PgBouncer toma esta conexión y la devuelve a otra transacción. Este modo permite una muy buena eliminación de compuestos.
  • Agrupación de instrucciones : modo obsoleto . Fue creado solo para soportar PL / Proxy .

Puede ver la matriz de qué propiedades están disponibles en cada modo. Elegimos Transaction Pooling , pero tiene limitaciones para trabajar con Prepared Statements .


Agrupación de transacciones + extractos preparados


Imaginemos que queremos preparar una solicitud y luego ejecutarla. En algún momento, comenzamos una transacción en la que enviamos una solicitud de preparación, y obtenemos la identificación de la solicitud preparada de la base de datos.



Luego, en cualquier otro momento, generamos otra transacción. En él, pasamos a la base de datos y queremos cumplir con la solicitud utilizando el identificador con los parámetros especificados.



En el modo de Agrupación de transacciones, se pueden ejecutar dos transacciones en diferentes conexiones, pero el ID de estado de cuenta solo es válido dentro de una conexión. Obtenemos una prepared statement does not exist error al intentar ejecutar una solicitud.


Lo más desagradable: dado que durante el desarrollo y las pruebas la carga es pequeña, PgBouncer menudo emite la misma conexión y todo funciona correctamente. Pero tan pronto como nos lanzamos a la producción, las solicitudes comienzan a caer con un error.


Ahora encuentre Prepared Statements en este código:


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

¡No lo verás! La preparación de la consulta se producirá implícitamente dentro de la Query() . Al mismo tiempo, la preparación y ejecución de la solicitud se realizará en diferentes transacciones y recibiremos completamente todo lo que describí anteriormente.


Que hacer


La primera opción más fácil es cambiar PgBouncer a la Session pooling . Se asigna una conexión a la sesión, todas las transacciones comienzan a realizarse en esta conexión y las solicitudes preparadas funcionan correctamente. Pero en este modo, la eficiencia de la utilización de compuestos deja mucho que desear. Por lo tanto, esta opción no se considera.


La segunda opción es preparar una solicitud en el lado del cliente . No quiero hacer esto por dos razones:


  • Posibles vulnerabilidades de SQL. El desarrollador puede olvidar o hacer un escape incorrecto.
  • Escapar de los parámetros de consulta cada vez que tenga que escribir con las manos.

Otra opción es ajustar explícitamente cada solicitud en una transacción . Después de todo, mientras dure la transacción, PgBouncer no retoma la conexión. Esto funciona, pero, además de la verbosidad en nuestro código, también recibimos más llamadas de red: Comenzar, Preparar, Ejecutar, Confirmar. Total de 4 llamadas de red por solicitud. La latencia está creciendo.


Pero lo quiero de manera segura, conveniente y eficiente. ¡Y hay tal opción! Puede decirle explícitamente al controlador que desea usar el modo Consulta simple . En este modo, no habrá preparación y toda la solicitud pasará en una llamada de red. En este caso, el controlador realizará el blindaje de cada uno de los parámetros (las cadenas de conformación standard_conforming_strings deben activarse en el nivel base o al establecer una conexión ).


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

Cancelar solicitudes


Los siguientes problemas están relacionados con la cancelación de solicitudes en el lado de la aplicación.


Echa un vistazo a este código. ¿Dónde están las trampas?


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

Go tiene un método para controlar el flujo de ejecución del programa: context.Context . En este código, pasamos el ctx controlador para que cuando se cierre el contexto, el controlador cancele la solicitud en el nivel de la base de datos.


Al mismo tiempo, se espera que ahorremos recursos cancelando solicitudes que nadie está esperando. Pero al cancelar una solicitud, PgBouncer versión 1.7 envía información a la conexión de que esta conexión está lista para su uso, y luego la devuelve al grupo. Este comportamiento de PgBouncer' error al controlador que, al enviar la siguiente solicitud, recibe al instante ReadyForQuery en respuesta. Al final, detectamos errores inesperados de ReadyForQuery .


A PgBouncer versión 1.8 de PgBouncer , este comportamiento se ha solucionado . Use la versión actual de PgBouncer .


Y, aunque, en este caso, los errores desaparecerán, permanecerá un comportamiento interesante. En algunos casos, nuestra aplicación puede recibir respuestas no a su solicitud, sino a la vecina (lo principal es que las solicitudes coinciden con el tipo y el orden de los datos solicitados). Es decir, por ejemplo, a la consulta where user_id = 2 , se devolverá la respuesta de la consulta where user_id = 42 . Esto se debe al procesamiento de solicitudes de cancelación en diferentes niveles: a nivel del grupo de controladores y el grupo de rebotes.


Cancelación retrasada


Para cancelar la solicitud, necesitamos crear una nueva conexión a la base de datos y solicitar una cancelación. Postgres crea un proceso separado para cada conexión. Enviamos un comando para cancelar la solicitud actual en un proceso específico. Para hacer esto, cree una nueva conexión y transfiera la ID de proceso (PID) que nos interese. Pero mientras el comando de cancelación vuela a la base, la solicitud cancelada puede finalizar por sí sola.



Postgres ejecutará el comando y cancelará la solicitud actual en el proceso dado. Pero la solicitud actual no será la que queríamos cancelar inicialmente. Debido a este comportamiento cuando se trabaja con Postgres con PgBouncer más seguro no cancelar la solicitud en el nivel del controlador. Para hacer esto, puede configurar la CustomCancel , que no cancelará la solicitud, incluso si context.Context utiliza context.Context .


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

Lista de verificación de Postgres


En lugar de conclusiones, decidí hacer una lista de verificación para trabajar con Postgres. Esto debería ayudar al artículo a caber en mi cabeza.


  • Use github.com/jackc/pgx como controlador para trabajar con Postgres.
  • Limite el tamaño del grupo de conexiones desde arriba.
  • OIDs caché o use pgx.ConnPool si está trabajando con pgx versión 3.
  • Recopile métricas del grupo de conexiones utilizando DB.Stats () o ConnPool.Stat () .
  • Registre lo que está sucediendo en el controlador.
  • Use el modo Consulta simple para evitar problemas con la preparación de consultas en el modo transaccional PgBouncer .
  • Actualiza PgBouncer a la última versión.
  • Tenga cuidado con la cancelación de solicitudes de la aplicación.

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


All Articles