
O comportamento inesperado do aplicativo em relação ao trabalho com o banco de dados leva a uma guerra entre o DBA e os desenvolvedores: DBA grita: "Seu aplicativo descarta o banco de dados", os desenvolvedores - "Mas tudo funcionou antes!" O pior de tudo é que o DBA e os desenvolvedores não podem ajudar um ao outro: alguns não sabem as nuances do aplicativo e do driver, outros não sabem os recursos relacionados à infraestrutura. Seria bom evitar tal situação.
Você precisa entender, geralmente não é suficiente procurar no go-database-sql.org . É melhor se armar com a experiência de outras pessoas. Ainda melhor se for uma experiência adquirida com sangue e dinheiro perdido.
Meu nome é Ryabinkov Artemy e este artigo é uma interpretação gratuita do meu relatório da conferência Saints HighLoad 2019 .
As ferramentas
Você pode encontrar as informações mínimas necessárias sobre como trabalhar com o Go com qualquer banco de dados semelhante ao SQL em go-database-sql.org . Se você ainda não leu, leia.
sqlx
Na minha opinião, o poder do Go é a simplicidade. E isso é expresso, por exemplo, no fato de que o Go costuma escrever consultas em SQL puro (o ORM não é uma honra). Isso é uma vantagem e uma fonte de dificuldades adicionais.
Portanto, usando o pacote padrão de database/sql
idioma database/sql
, você desejará expandir suas interfaces. Quando isso acontecer, dê uma olhada em github.com/jmoiron/sqlx . Deixe-me mostrar alguns exemplos de como essa extensão pode simplificar sua vida.
O uso do StructScan elimina a necessidade de mudar manualmente os dados das colunas para as propriedades da estrutura.
type Place struct { Country string City sql.NullString TelephoneCode int `db:"telcode"` } var p Place err = rows.StructScan(&p)
O uso do NamedQuery permite usar propriedades da estrutura como espaços reservados em uma consulta.
p := Place{Country: "South Africa"} sql := `.. WHERE country=:country` rows, err := db.NamedQuery(sql, p)
O uso de Get and Select permite que você se livre da necessidade de gravar manualmente loops que obtêm linhas do banco de dados.
var p Place var pp []Place
Drivers
database/sql
é um conjunto de interfaces para trabalhar com o banco de dados e sqlx
é sua extensão. Para que essas interfaces funcionem, elas precisam de uma implementação. Os drivers são responsáveis pela implementação.
Drivers mais populares:
- github.com/lib/pq -
pure Go Postgres driver for database/sql.
Esse driver permaneceu por muito tempo o padrão padrão. Hoje, porém, perdeu sua relevância e não está sendo desenvolvida pelo autor. - github.com/jackc/pgx -
PostgreSQL driver and toolkit for Go.
Hoje é melhor escolher essa ferramenta.
github.com/jackc/pgx - este é o driver que você deseja usar. Porque
- Ativamente apoiado e desenvolvido .
- Pode ser mais produtivo se usado sem interfaces de
database/sql
. - Suporte para mais de 60 tipos de PostgreSQL que o
PostgreSQL
implementa fora do padrão SQL
. - A capacidade de implementar convenientemente o log do que está acontecendo dentro do driver.
pgx
erros legíveis por humanos , enquanto apenas lib/pq
lança ataques de pânico. Se você não entrar em pânico, o programa falhará. ( Você não deve entrar em pânico no Go, isso não é o mesmo que exceção. )- Com o
pgx
, temos a capacidade de configurar independentemente cada conexão . - Há suporte para o protocolo de replicação lógica do
PostgreSQL
.
4KB
Normalmente, escrevemos esse loop para obter dados do banco de dados:
rows, err := s.db.QueryContext(ctx, sql) for rows.Next() { err = rows.Scan(...) }
Dentro do driver, obtemos dados armazenando-os em um buffer de 4KB . rows.Next()
gera uma viagem de rede e preenche o buffer. Se o buffer não for suficiente, vamos à rede para os dados restantes. Mais visitas à rede - menos velocidade de processamento. Por outro lado, como o limite do buffer é de 4KB, não vamos esquecer toda a memória do processo.
Mas, é claro, quero desaparafusar o volume do buffer ao máximo para reduzir o número de solicitações à rede e reduzir a latência do nosso serviço. Adicionamos essa oportunidade e tentamos descobrir a aceleração esperada em testes sintéticos :
$ 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
Pode-se ver que não há grande diferença na velocidade de processamento. Porque
Acontece que estamos limitados pelo tamanho do buffer para enviar dados dentro do próprio Postgres. Este buffer tem um tamanho fixo de 8 KB . Usando strace
você pode ver que o sistema operacional retorna 8192
bytes na chamada de leitura do sistema. E o tcpdump
confirma isso com o tamanho dos pacotes.
Tom Lane ( um dos principais desenvolvedores do kernel do Postgres ) comenta assim:
Tradicionalmente, pelo menos, esse era o tamanho dos buffers de tubo nas máquinas Unix, portanto, em princípio, esse é o tamanho de bloco mais ideal para enviar dados através de um soquete Unix.
Andres Freund ( desenvolvedor do Postgres do EnterpriseDB ) acredita que um buffer de 8 KB não é a melhor opção de implementação até o momento, e você precisa testar o comportamento em tamanhos diferentes e com uma configuração de soquete diferente.
Também devemos lembrar que o PgBouncer também possui um buffer e seu tamanho pode ser configurado com o parâmetro pkt_buf
.
OIDs
Outro recurso do driver pgx ( v3 ): para cada conexão, ele faz um pedido ao banco de dados para obter informações sobre o ID do Objeto ( OID ).
Esses identificadores foram adicionados ao Postgres para identificar exclusivamente objetos internos: linhas, tabelas, funções, etc.
O driver usa o conhecimento de OIDs
para entender qual coluna do banco de dados em qual idioma primitivo adicionar dados. Para isso, o pgx
suporta essa tabela (a chave é o nome do tipo, o valor é ID do objeto )
map[string]Value{ "_aclitem": 2, "_bool": 3, "_int4": 4, "_int8": 55, ... }
Essa implementação leva ao fato de que o driver para cada conexão estabelecida com o banco de dados faz cerca de três solicitações para formar uma tabela com um Object ID
. No modo normal de operação do banco de dados e do aplicativo, o conjunto de conexões no Go permite que você não gere novas conexões com o banco de dados. Porém, com a menor degradação do banco de dados, o conjunto de conexões no lado do aplicativo se esgota e o número de conexões geradas por unidade de tempo aumenta significativamente. As solicitações de OIDs
bastante pesadas; como resultado, o driver pode levar o banco de dados a um estado crítico.
Aqui é o momento em que essas solicitações foram despejadas em um de nossos bancos de dados:

15 transações por minuto no modo normal, um salto de até 6500 transações durante a degradação.
O que fazer
Em primeiro lugar, limite o tamanho da sua piscina de cima.
Para database/sql
isso pode ser feito com a função DB.SetMaxOpenConns . Se você abandonar as interfaces de database/sql
e usar pgx.ConnPool
(o conjunto de conexões implementado pelo próprio driver ), poderá especificar MaxConnections
(o padrão é 5 ).
A propósito, ao usar o pgx.ConnPool
driver reutilizará as informações sobre os OIDs
recebidos e não fará consultas ao banco de dados para cada nova conexão.
Se você não quiser recusar o database/sql
, poderá armazenar em cache as informações sobre os OIDs
.
github.com/jackc/pgx/stdlib.OpenDB(pgx.ConnConfig{ CustomConnInfo: func(c *pgx.Conn) (*pgtype.ConnInfo, error) { cachedOids =
Este é um método de trabalho, mas usá-lo pode ser perigoso sob duas condições:
- você usa tipos de enumeração ou domínio no Postgres;
- se o assistente falhar, você alterna o aplicativo para a réplica, que é derramada pela replicação lógica.
O cumprimento dessas condições leva ao fato de que os OIDs
armazenados em cache se tornam inválidos. Mas não conseguiremos limpá-los, porque não sabemos o momento de mudar para uma nova base.
No mundo do Postgres
, a replicação física geralmente é usada para organizar a alta disponibilidade, que copia as instâncias do banco de dados pouco a pouco; portanto, problemas com o cache do OIDs
raramente OIDs
vistos na natureza. ( Mas é melhor verificar com seu DBA como a espera funciona para você ).
Na próxima versão principal do driver pgx
- v4
, não haverá campanhas para OIDs
. Agora, o driver dependerá apenas da lista de OIDs
no código. Para tipos personalizados, você precisará assumir o controle da desserialização no lado do aplicativo: o driver simplesmente abrirá um pedaço de memória como uma matriz de bytes.
Registro e Monitoramento
O monitoramento e o registro ajudarão a detectar problemas antes que a base falhe.
database/sql
fornece o método DB.Stats () . O instantâneo de status retornado fornecerá uma idéia do que está acontecendo dentro do driver.
type DBStats struct { MaxOpenConnections int
Se você usar o pool no pgx
diretamente, o método ConnPool.Stat () fornecerá informações semelhantes:
type ConnPoolStat struct { MaxConnections int CurrentConnections int AvailableConnections int }
O registro é igualmente importante e o pgx
permite que você faça isso. O driver aceita a interface do Logger
, implementando qual, você obtém todos os eventos que ocorrem dentro do driver.
type Logger interface {
Provavelmente, você nem precisa implementar essa interface. No pgx pronto para uso , há um conjunto de adaptadores para os registradores mais populares, por exemplo, uber-go / zap , sirupsen / logrus , rs / zerolog .
A infraestrutura
Quase sempre, ao trabalhar com o Postgres
você usará o pool de conexões , e será o PgBouncer ( ou odisséia - se você é Yandex ).
Por isso, você pode ler o excelente artigo brandur.org/postgres-connections . Em resumo, quando o número de clientes excede 100, a velocidade das solicitações de processamento começa a diminuir. Isso acontece devido aos recursos da implementação do Postgres: o lançamento de um processo separado para cada conexão, o mecanismo para remover instantâneos e o uso de memória compartilhada para interação - tudo isso afeta.
Aqui está a referência de várias implementações do pool de conexões:

E compare a largura de banda com e sem o PgBouncer.

Como resultado, sua infraestrutura ficará assim:

Onde Server
é o processo que processa solicitações do usuário. Esse processo gira em kubernetes
em 3 cópias ( pelo menos ). Separadamente, em um servidor de ferro, existe o Postgres
, coberto pelo PgBouncer'
. PgBouncer
próprio PgBouncer
de thread único, então lançamos vários bouncers, cujo tráfego é equilibrado usando o HAProxy
. Como resultado, obtemos uma cadeia de execução de consulta no banco de dados: → HAProxy → PgBouncer → Postgres
.
PgBouncer
pode funcionar em três modos:
- Pool de sessões - para cada sessão, uma conexão é emitida e atribuída a ela por todo o tempo de vida.
- Pool de transações - a conexão permanece enquanto a transação está em execução. Assim que a transação é concluída, o
PgBouncer
pega essa conexão e a devolve para outra transação. Este modo permite uma disposição muito boa de compostos. - Conjunto de instruções - modo descontinuado . Foi criado apenas para dar suporte ao PL / Proxy .
Você pode ver a matriz de quais propriedades estão disponíveis em cada modo. Escolhemos o Pool de Transações , mas ele tem limitações para trabalhar com Prepared Statements
.
Pool de Transações + Instruções Preparadas
Vamos imaginar que queremos preparar uma solicitação e executá-la. Em algum momento, iniciamos uma transação na qual enviamos uma solicitação de Preparação e obtemos o ID da solicitação preparada no banco de dados.

Depois, em qualquer outro momento, geramos outra transação. Nele, nos voltamos para o banco de dados e queremos atender à solicitação usando o identificador com os parâmetros especificados.

No modo Pool de Transações , duas transações podem ser executadas em conexões diferentes, mas o ID da Instrução é válido apenas dentro de uma conexão. Recebemos uma prepared statement does not exist
erro ao tentar executar uma solicitação.
O mais desagradável: como durante o desenvolvimento e o teste a carga é pequena, o PgBouncer
geralmente emite a mesma conexão e tudo funciona corretamente. Mas assim que lançamos o produto, as solicitações começam a cair com um erro.
Agora encontre Prepared Statements
neste código:
sql := `select * from places where city = ?` rows, err := s.db.Query(sql, city)
Você não o verá! A preparação da consulta ocorrerá implicitamente dentro de Query()
. Ao mesmo tempo, a preparação e a execução da solicitação ocorrerão em diferentes transações e receberemos totalmente tudo o que descrevi acima.
O que fazer
A primeira opção mais fácil é alternar o PgBouncer
para o Session pooling
. Uma conexão é alocada para a sessão, todas as transações começam a ocorrer nessa conexão e as solicitações preparadas funcionam corretamente. Mas, nesse modo, a eficiência da utilização de compostos deixa muito a desejar. Portanto, esta opção não é considerada.
A segunda opção é preparar uma solicitação no lado do cliente . Não quero fazer isso por dois motivos:
- Potenciais vulnerabilidades SQL. O desenvolvedor pode esquecer ou fazer incorretamente escapar.
- Escapar dos parâmetros da consulta toda vez que você precisar escrever com as mãos.
Outra opção é agrupar explicitamente cada solicitação em uma transação . Afinal, enquanto a transação PgBouncer
, o PgBouncer
não PgBouncer
a conexão. Isso funciona, mas, além da verbosidade em nosso código, também recebemos mais chamadas de rede: Iniciar, Preparar, Executar, Confirmar. Total de 4 chamadas de rede por solicitação. Latência está crescendo.
Mas eu quero isso com segurança, conveniência e eficiência. E existe essa opção! Você pode dizer explicitamente ao driver que deseja usar o modo Consulta Simples . Nesse modo, não haverá preparação e toda a solicitação passará em uma chamada de rede. Nesse caso, o driver fará a blindagem de cada um dos parâmetros em si (as standard_conforming_strings
devem ser ativadas no nível base ou ao estabelecer uma conexão ).
cfg := pgx.ConnConfig{ ... RuntimeParams: map[string]string{ "standard_conforming_strings": "on", }, PreferSimpleProtocol: true, }
Cancelar solicitações
Os seguintes problemas estão relacionados ao cancelamento de solicitações no lado do aplicativo.
Dê uma olhada neste código. Onde estão as armadilhas?
rows, err := s.db.QueryContext(ctx, ...)
Go possui um método para controlar o fluxo de execução do programa - context.Context . Nesse código, passamos o ctx
driver para que, quando o contexto for fechado, o driver cancele a solicitação no nível do banco de dados.
Ao mesmo tempo, espera-se que economizemos recursos, cancelando solicitações pelas quais ninguém está esperando. Porém, ao cancelar uma solicitação, o PgBouncer
versão 1.7 envia informações para a conexão de que essa conexão está pronta para uso e depois a retorna para o pool. Esse comportamento do PgBouncer'
está enganando o driver, que, ao enviar a próxima solicitação, recebe instantaneamente o ReadyForQuery
em resposta. No final, detectamos erros inesperados do ReadyForQuery .
Começando com o PgBouncer
versão 1.8, esse comportamento foi corrigido . Use a versão atual do PgBouncer
.
E, embora, neste caso, os erros desapareçam - um comportamento interessante permanecerá. Em alguns casos, nosso aplicativo pode receber respostas não à sua solicitação, mas à vizinha (a principal é que as solicitações correspondam ao tipo e ordem dos dados solicitados). Isso é, por exemplo, para a consulta em where user_id = 2
, a resposta da consulta em where user_id = 42
será retornada. Isso ocorre devido ao processamento de solicitações de cancelamento em diferentes níveis: no nível do pool de drivers e do bouncer.
Cancelamento atrasado
Para cancelar a solicitação, precisamos criar uma nova conexão com o banco de dados e solicitar um cancelamento. Postgres
cria um processo separado para cada conexão. Enviamos um comando para cancelar a solicitação atual em um processo específico. Para isso, crie uma nova conexão e transfira a identificação do processo (PID) de seu interesse. Mas enquanto o comando de cancelamento voa para a base, a solicitação cancelada pode terminar sozinha.

Postgres
executará o comando e cancelará a solicitação atual no processo especificado. Mas a solicitação atual não será a que queremos cancelar inicialmente. Devido a esse comportamento ao trabalhar com o Postgres
com o PgBouncer
mais seguro não cancelar a solicitação no nível do driver. Para fazer isso, você pode definir a CustomCancel
, que não cancelará a solicitação, mesmo que context.Context
usado.
cfg := pgx.ConnConfig{ ... CustomCancel: func(_ *pgx.Conn) error { return nil }, }
Lista de verificação do Postgres
Em vez de conclusões, decidi fazer uma lista de verificação para trabalhar com o Postgres. Isso deve ajudar o artigo a se encaixar na minha cabeça.
- Use github.com/jackc/pgx como um driver para trabalhar com o Postgres.
- Limite o tamanho do pool de conexões de cima.
- Faça cache de
OIDs
ou use pgx.ConnPool se estiver trabalhando com a versão 3 do pgx . - Colete métricas do pool de conexões usando DB.Stats () ou ConnPool.Stat () .
- Registre o que está acontecendo no driver.
- Use o modo Consulta Simples para evitar problemas com a preparação de consultas no modo transacional
PgBouncer
. - Atualize o
PgBouncer
para a versão mais recente. - Cuidado ao cancelar solicitações do aplicativo.