Trabalhar com bancos de dados através dos olhos de um desenvolvedor



Quando você desenvolve uma nova funcionalidade usando um banco de dados, o ciclo de desenvolvimento geralmente inclui (mas não está limitado a) os seguintes estágios:



Gravação de migrações de SQL → gravação de código → teste → liberação → monitoramento.



Neste artigo, quero compartilhar alguns conselhos práticos sobre como você pode reduzir o tempo deste ciclo em cada etapa, sem reduzir a qualidade, mas sim aumentando-a. 



Como trabalhamos com PostgreSQL na empresa e escrevemos o código do servidor em Java, os exemplos serão baseados nesta pilha, embora a maioria das ideias não dependa do banco de dados e da linguagem de programação utilizada.



Migração SQL



O primeiro estágio de desenvolvimento após o design é escrever a migração SQL. O principal conselho - não faça alterações manuais no esquema de dados, mas sempre faça isso por meio de scripts e armazene-os em um só lugar. 



Em nossa empresa, os próprios desenvolvedores escrevem migrações SQL, portanto, todas as migrações são armazenadas em um repositório com o código principal. Em algumas empresas, os administradores de banco de dados estão envolvidos na alteração do esquema e, nesse caso, o registro de migração está em algum lugar com eles. De uma forma ou de outra, essa abordagem traz as seguintes vantagens:



  • Você sempre pode criar facilmente uma nova base do zero ou atualizar uma existente para a versão atual. Isso permite que você implante rapidamente novos ambientes de teste e ambientes de desenvolvimento local.
  • Todas as bases têm o mesmo layout - sem surpresas no serviço.
  • Existe um histórico de todas as mudanças (controle de versão).


Existem muitas ferramentas prontas para automatizar esse processo, tanto comerciais como livres: flyway , liquibase , sqitch , etc. Neste artigo eu não comparar e escolher a melhor ferramenta - este é um tópico separado grande, e você pode encontrar muitos artigos sobre isso ... 



Usamos flyway, então aqui estão algumas informações sobre ele:



  • Existem 2 tipos de migrações: baseado em SQL e Java-based
  • As migrações de SQL são imutáveis ​​(imutáveis). Após a primeira execução, a migração SQL não pode ser alterada. O Flyway calcula uma soma de verificação para o conteúdo do arquivo de migração e verifica em cada execução. Manipulações manuais adicionais são necessárias para tornar as migrações Java imutáveis .
  • flyway_schema_history ( schema_version). , , , .


De acordo com nossos acordos internos, todas as alterações do esquema de dados são feitas apenas por meio de migrações SQL. Sua imutabilidade garante que sempre possamos obter um esquema real que seja completamente idêntico a todos os ambientes. 



As migrações Java são usadas apenas para DML , quando é impossível escrever em SQL puro. Para nós, um exemplo típico de tal situação são as migrações para transferir dados para o Postgres de outro banco de dados (estamos mudando do Redis para o Postgres, mas esta é uma história completamente diferente). Outro exemplo é a atualização dos dados de uma grande tabela, que é realizada em diversas transações para minimizar o tempo de bloqueio da tabela. Vale a pena dizer que a partir da 11ª versão do Postgres isso pode ser feito usando procedimentos SQL em plpgsql.



Quando o código Java está desatualizado, a migração pode ser removida para não produzir legado (a própria classe de migração Java permanece, mas por dentro está vazia). Em nosso país, isso não pode acontecer antes de um mês após a migração para produção - acreditamos que é tempo suficiente para que todos os ambientes de teste e de desenvolvimento local sejam atualizados. Deve-se observar que, como as migrações Java são usadas apenas para DML, sua remoção não afeta de forma alguma a criação de novos bancos de dados do zero.



Uma nuance importante para quem usa o pg_bouncer



O Flyway aplica um bloqueio durante a migração para evitar a execução simultânea de múltiplas migrações. Simplificado, funciona assim:



  • captura de bloqueio ocorre 
  • realizando migrações em transações separadas
  • desbloqueio. 


Para o Postgres, utiliza travas consultivas em modo de sessão, o que significa que para funcionar corretamente, é necessário que o servidor de aplicação esteja rodando na mesma conexão durante a captura e liberação do travamento. Se você usar o pg_bouncer no modo transacional (que é o mais comum) ou no modo de solicitação única, então para cada transação ele pode retornar uma nova conexão e o flyway não será capaz de liberar um bloqueio estabelecido. 



Para resolver este problema, usamos um pequeno pool de conexão separado no pg_bouncer no modo de sessão, que se destina apenas a migrações. Do lado do aplicativo, também existe um pool separado que contém 1 conexão e é fechado por tempo limite após a migração, para não desperdiçar recursos.



Codificação



A migração foi criada, agora estamos escrevendo o código.



Existem 3 abordagens para trabalhar com o banco de dados do lado do aplicativo:



  • Usando ORM (se falamos sobre Java, então hibernar é de fato o padrão)
  • Usando sql + jdbcTemplate etc.
  • Usando bibliotecas DSL.


Usar ORM permite reduzir os requisitos de conhecimento de SQL - muito é gerado automaticamente: 

  • esquema de dados pode ser criado de xml-description ou Java-entity disponível no código
  • relacionamentos de objeto são definidos usando uma descrição declarativa - ORM fará junções para você
  • ao usar Spring Data JPA, consultas ainda mais complicadas também podem ser geradas automaticamente com base na assinatura do método do repositório .


Outro "bônus" é a presença de caches de dados prontos para uso (para hibernar, são 3 níveis de caches).



Mas é importante notar que ORM, como qualquer outra ferramenta poderosa, requer certas qualificações ao usá-lo. Sem a configuração adequada, o código provavelmente funcionará, mas está longe de ser o ideal.



O oposto é escrever o SQL manualmente. Isso permite que você tenha controle total sobre as solicitações - exatamente o que você escreveu é executado, sem surpresas. Mas, obviamente, isso aumenta a quantidade de trabalho manual e aumenta os requisitos para a qualificação dos desenvolvedores.



Bibliotecas DSL



Aproximadamente no meio dessas abordagens há outra, que consiste no uso de bibliotecas DSL ( jOOQ , Querydsl , etc.). Eles geralmente são muito mais leves do que os ORMs, mas são mais convenientes do que o trabalho totalmente manual com o banco de dados. O uso de DSLs é menos comum, portanto, este artigo examinará rapidamente essa abordagem. 



Falaremos sobre uma das bibliotecas - jOOQ . O que ela oferece:



  • inspeção de banco de dados e geração automática de classes
  • API fluente para escrever solicitações.


jOOQ não é um ORM - não há geração automática de consultas, nem cache, mas, ao mesmo tempo, alguns dos problemas de uma abordagem totalmente manual são encerrados:

  • classes para tabelas, visualizações, funções, etc. objetos de banco de dados são gerados automaticamente 
  • as solicitações são escritas em Java, o que garante a segurança do tipo - uma solicitação sintaticamente incorreta ou uma solicitação com um parâmetro do tipo errado não será compilada - seu IDE solicitará imediatamente um erro e você não terá que perder tempo iniciando o aplicativo para verificar a exatidão da solicitação. Isso acelera o processo de desenvolvimento e reduz a probabilidade de erros.


No código, as solicitações se parecem com isto :



BookRecord book = dslContext.selectFrom(BOOK)
                        .where(BOOK.LANGUAGE.eq("DE"))
                        .orderBy(BOOK.TITLE)
                        .fetchAny();


Você pode usar sql simples se quiser:



Result<Record> records = dslContext.fetch("SELECT * FROM BOOK WHERE LANGUAGE = ? ORDER BY TITLE LIMIT 1", "DE");


Obviamente, neste caso, a correção da consulta e a análise dos resultados estão inteiramente sobre seus ombros.



Registro jOOQ e POJO



BookRecord no exemplo acima é um wrapper sobre uma linha na tabela de livros e implementa o padrão de registro ativo . Como essa classe faz parte da camada de acesso a dados (além de sua implementação específica), você pode não querer transferi-la para outras camadas do aplicativo, mas usar algum tipo de objeto pojo de sua preferência. Para a conveniência da conversão de registros, <–> o pojo jooq oferece vários mecanismos: automático e manual . A documentação para os links acima tem uma variedade de exemplos de uso de leitura, mas nenhum exemplo para inserir novos dados e atualizar. Vamos preencher essa lacuna: 



private static final RecordUnmapper<Book, BookRecord> unmapper = 
    book -> new BookRecord(book.getTitle(), ...); // - 

public void create(Book book) {
    context.insertInto(BOOK)
            .set(unmapper.unmap(book))
            .execute();
}


Como você pode ver, tudo é muito simples.



Essa abordagem permite ocultar detalhes de implementação dentro da classe da camada de acesso a dados e evitar "vazamento" em outras camadas do aplicativo. 



Além disso, jooq pode gerar classes DAO com um conjunto de métodos básicos para simplificar o trabalho com dados da tabela e reduzir a quantidade de código manual (isso é muito semelhante ao Spring Data JPA):



public interface DAO<R extends TableRecord<R>, P, T> {
    void insert(P object) throws DataAccessException;    
    void update(P object) throws DataAccessException;
    void delete(P... objects) throws DataAccessException;
    void deleteById(T... ids) throws DataAccessException;
    boolean exists(P object) throws DataAccessException;
    ...
}


Na empresa, não usamos geração automática de classes DAO - apenas geramos wrappers sobre objetos de banco de dados e escrevemos consultas nós mesmos. A geração de wrappers ocorre sempre que um módulo maven separado é reconstruído, no qual as migrações são armazenadas. Um pouco mais tarde, haverá detalhes de como isso é implementado.



Testando



Escrever testes é uma parte importante do processo de desenvolvimento - bons testes garantem a qualidade do seu código e economizam tempo ao mantê-lo. Ao mesmo tempo, é justo dizer que o contrário também é verdadeiro - testes ruins podem criar a ilusão de código de qualidade, ocultar erros e desacelerar o processo de desenvolvimento. Assim, não basta apenas decidir que você vai escrever testes, é preciso fazer certo . Ao mesmo tempo, o conceito de correção dos testes é muito vago e cada um tem um pouco. 



O mesmo vale para a questão da classificação do teste. Este artigo sugere o uso da seguinte opção de divisão:



  • teste de unidade (teste de unidade) 
  • teste de integração
  • teste ponta a ponta (ponta a ponta).


O teste de unidade envolve a verificação da funcionalidade de módulos individuais isolados uns dos outros. O tamanho do módulo é novamente uma coisa indefinida, para alguns é um método separado, para alguns é uma classe. O isolamento significa que todos os outros módulos são mocks ou stubs (em russo são imitações ou stubs, mas de alguma forma eles não soam muito bem). Siga este link para ler o artigo de Martin Fowler sobre a diferença entre os dois. Os testes de unidade são pequenos, rápidos, mas só podem garantir a correção da lógica de uma unidade individual.



Testes de integraçãoao contrário dos testes de unidade, eles verificam a interação de vários módulos entre si. Trabalhar com banco de dados é um bom exemplo de quando testes de integração fazem sentido, pois é muito difícil "travar" um banco de dados com alta qualidade, levando em consideração todas as suas nuances. Os testes de integração, na maioria dos casos, são um bom meio-termo entre a velocidade de execução e a garantia de qualidade ao testar um banco de dados em comparação com outros tipos de teste. Portanto, neste artigo falaremos mais sobre esse tipo de teste.



O teste de ponta a ponta é o mais extenso. Para realizá-lo, é necessário elevar todo o meio ambiente. Ele garante o mais alto nível de confiança na qualidade do produto, mas é o mais lento e mais caro.



Teste de integração



Quando se trata de teste de integração de código que funciona com um banco de dados, a maioria dos desenvolvedores se pergunta: como iniciar o banco de dados, como inicializar seu estado com os dados iniciais e como fazer isso o mais rápido possível?



Algum tempo atrás, h2 era uma prática bastante comum em testes de integração . É um banco de dados na memória escrito em Java que possui modos de compatibilidade com os bancos de dados mais populares. A ausência da necessidade de instalação de banco de dados e a versatilidade do h2 tornam-no uma substituição muito conveniente para bancos de dados reais, principalmente se a aplicação não depende de um banco de dados específico e usa apenas o que está incluso no padrão SQL (o que nem sempre é o caso). 



Mas os problemas começam no momento em que você usa alguma funcionalidade complicada de banco de dados (ou uma completamente nova de uma versão nova), cujo suporte não está implementado em h2. Em geral, por se tratar de uma “simulação” de um SGBD específico, sempre pode haver algumas diferenças de comportamento.



Outra opção é usar postgres embutidos . Este é um Postgres real, enviado como um arquivo e não requer instalação. Ele permite que você trabalhe como uma versão normal do Postgres. 



Existem várias implementações, a mais popular de Yandex e openTable... Nós, da empresa, usamos a versão do Yandex. Das desvantagens - é bastante lento na inicialização (toda vez que o arquivo é descompactado e o banco de dados é iniciado - leva de 2 a 5 segundos, dependendo da potência do computador), também há um problema com o atraso em relação à versão oficial de lançamento. Também enfrentamos o problema de que após uma tentativa de interromper o código, ocorreu algum erro e o processo do Postgres permaneceu travado no sistema operacional - você tinha que matá-lo manualmente. 



testcontainers



A terceira opção é usar o docker. Para Java, há uma biblioteca testcontainers que fornece uma API para trabalhar com contêineres docker a partir do código. Portanto, qualquer dependência em seu aplicativo que tenha uma imagem docker pode ser substituída em testes usando testcontainers. Além disso, para muitas tecnologias populares, existem classes prontas separadas que fornecem uma API mais conveniente, dependendo da imagem usada:



  • bancos de dados (Postgres, Oracle, Cassandra, MongoDB, etc.), 
  • nginx
  • kafka, etc.


A propósito, quando o projeto tescontainers se tornou bastante popular, os desenvolvedores do yandex anunciaram oficialmente que estavam parando o desenvolvimento do projeto de postgres embutido e aconselharam a troca para testcontainers.



Quais são os prós:



  • testcontainers são rápidos (iniciar o Postgres vazio leva menos de um segundo)
  • A comunidade postgres lança imagens oficiais do docker para cada nova versão
  • testcontainers tem um processo especial que mata contêineres pendentes após desligar o jvm, a menos que você tenha feito isso de forma programática
  • com testcontainers, você pode usar uma abordagem uniforme para testar dependências externas de seu aplicativo, o que obviamente torna as coisas mais fáceis.


Teste de exemplo usando Postgres:



@Test
public void testSimple() throws SQLException {
    try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>()) {
        postgres.start();
        ResultSet resultSet = performQuery(postgres, "SELECT 1");
        int resultSetInt = resultSet.getInt(1);
        assertEquals("A basic SELECT query succeeds", 1, resultSetInt);
    }
}


Se não existe uma classe separada para imagens no testcontainers, em seguida, criar um recipiente olhares como este :



public static GenericContainer redis = new GenericContainer("redis:3.0.2")
            .withExposedPorts(6379);


Se você estiver usando JUnit4, JUnit5 ou Spock, então testcontainers tem extras. suporte para esses frameworks, o que torna mais fácil escrever testes.



Acelerando os testes com testcontainers



Embora a troca de postgres embutidos para testcontainers tenha tornado nossos testes mais rápidos ao executar o Postgres com mais rapidez, com o tempo os testes começaram a ficar lentos novamente. Isso se deve ao aumento do número de migrações de SQL que o flyway executa na inicialização. Quando o número de migrações ultrapassava cem, o tempo de execução era de cerca de 7 a 8 segundos, o que tornava os testes significativamente mais lentos. Funcionou mais ou menos assim:



  1. antes da próxima aula de teste, um contêiner "limpo" com Postgres foi lançado
  2. migrações realizadas flyway
  3. testes desta classe foram executados
  4. o contêiner foi parado e removido
  5. repita a partir do item 1 para a próxima aula de teste.


Obviamente, com o tempo, a segunda etapa demorou cada vez mais.



Tentando resolver este problema, percebemos que basta realizar migrações apenas uma vez antes de todos os testes, salvar o estado do container e depois utilizar este container em todos os testes. Portanto, o algoritmo mudou:



  1. um contêiner "limpo" com Postgres é lançado antes de todos os testes
  2. flyway realiza migrações
  3. o estado do contêiner persiste
  4. antes da próxima aula de teste, um contêiner previamente preparado é lançado
  5. testes desta classe são executados
  6. o contêiner para e é removido
  7. repita a partir da etapa 4 para a próxima aula de teste.


Agora, o tempo de execução de um teste individual não depende do número de migrações e, com o número atual de migrações (mais de 200), o novo esquema economiza vários minutos em cada execução de todos os testes.



Aqui estão alguns detalhes técnicos sobre como implementar isso.



O Docker tem um mecanismo integrado para criar uma nova imagem de um contêiner em execução usando o comando commit . Ele permite que você personalize as imagens, por exemplo, alterando quaisquer configurações. 



Uma advertência importante é que o comando não salva os dados das partições montadas. Mas se você pegar a imagem docker oficial do Postgres, o diretório PGDATA, no qual os dados são armazenados, está localizado em uma seção separada (para que, depois que o contêiner seja reiniciado, os dados não sejam perdidos), portanto, quando o commit é executado, o estado do próprio banco de dados não é salvo. 



A solução é simples - não use a seção para PGDATA, mas mantenha os dados na memória, o que é normal para testes. Existem 2 maneiras de fazer isso - use seu dockerfile (algo como este) sem criar uma seção ou sobrescrever a variável PGDATA ao iniciar o contêiner oficial (a seção permanecerá, mas não será usada). A segunda maneira parece muito mais simples:



PostgreSQLContainer<?> container = ...
container.addEnv("PGDATA", "/var/lib/postgresql/data-no-mounted");
container.start();


Antes de confirmar, é recomendado que você faça um checkpoint postgres para liberar as alterações dos buffers compartilhados para o "disco" (que corresponde à variável PGDATA substituída):



container.execInContainer("psql", "-c", "checkpoint");


O próprio commit é mais ou menos assim:



CommitCmd cmd = container.getDockerClient().commitCmd(container.getContainerId())
                .withMessage("Container for integration tests. ...")
                .withRepository(imageName)
                .withTag(tag);
String imageId = cmd.exec();


Deve-se notar que esta abordagem usando imagens preparadas pode ser aplicada a muitas outras imagens, o que também economizará tempo na execução de testes de integração.



Mais algumas palavras sobre como otimizar o tempo de construção



Conforme mencionado anteriormente, ao montar um módulo maven separado com migrações, entre outras coisas, os wrappers java são gerados sobre os objetos de banco de dados. Para isso, um plugin maven autoescrito é usado, que é iniciado antes de compilar o código principal e executa 3 ações:



  1. Executa um contêiner docker "limpo" com postgres
  2. Lança Flyway, que realiza migrações sql para todos os bancos de dados, verificando assim sua validade
  3. Executa o Jooq, que inspeciona o esquema do banco de dados e gera classes java para tabelas, visualizações, funções e outros objetos de esquema.


Como você pode ver facilmente, as 2 primeiras etapas são idênticas às executadas quando os testes são executados. Para economizar tempo ao iniciar o contêiner e executar as migrações antes dos testes, movemos o salvamento do estado do contêiner para um plugin. Assim, agora, imediatamente após a reconstrução do módulo, imagens prontas para testes de integração de todos os bancos de dados usados ​​no código aparecem no repositório local de imagens docker.



Exemplo de código mais detalhado
@ThreadSafe
public class PostgresContainerAdapter implements PostgresExecutable {
  private static final String ORIGINAL_IMAGE = "postgres:11.6-alpine";

  @GuardedBy("this")
  @Nullable
  private PostgreSQLContainer<?> container; // not null if it is running

  @Override
  public synchronized String start(int port, String db, String user, String password) 
  {
    Preconditions.checkState(container == null, "postgres is already running");

    PostgreSQLContainer<?> newContainer = new PostgreSQLContainer<>(ORIGINAL_IMAGE)
        .withDatabaseName(db)
        .withUsername(user)
        .withPassword(password);

    newContainer.addEnv("PGDATA", "/var/lib/postgresql/data-no-mounted");

    // workaround for using fixed port instead of random one chosen by docker
    List<String> portBindings = new ArrayList<>(newContainer.getPortBindings());
    portBindings.add(String.format("%d:%d", port, POSTGRESQL_PORT));
    newContainer.setPortBindings(portBindings);
    newContainer.start();

    container = newContainer;
    return container.getJdbcUrl();
  }

  @Override
  public synchronized void saveState(String name) {
    try {
      Preconditions.checkState(container != null, "postgres isn't started yet");

      // flush all changes
      doCheckpoint(container);

      commitContainer(container, name);
    } catch (Exception e) {
      stop();
      throw new RuntimeException("Saving postgres container state failed", e);
    }
  }

  @Override
  public synchronized void stop() {
    Preconditions.checkState(container != null, "postgres isn't started yet");

    container.stop();
    container = null;
  }

  private static void doCheckpoint(PostgreSQLContainer<?> container) {
    try {
      container.execInContainer("psql", "-c", "checkpoint");
    } catch (IOException | InterruptedException e) {
      throw new RuntimeException(e);
    }
  }

  private static void commitContainer(PostgreSQLContainer<?> container, String image)
  {
    String tag = "latest";
    container.getDockerClient().commitCmd(container.getContainerId())
        .withMessage("Container for integration tests. It uses non default location for PGDATA which is not mounted to a volume")
        .withRepository(image)
        .withTag(tag)
        .exec();
  }
  // ...
}


( «start»):

@Mojo(name = "start")
public class PostgresPluginStartMojo extends AbstractMojo {
  private static final Logger logger = LoggerFactory.getLogger(PostgresPluginStartMojo.class);

  @Nullable
  static PostgresExecutable postgres;

  @Parameter(defaultValue = "5432")
  private int port;
  @Parameter(defaultValue = "dbName")
  private String db;
  @Parameter(defaultValue = "userName")
  private String user;
  @Parameter(defaultValue = "password")
  private String password;

  @Override
  public void execute() throws MojoExecutionException {
    if (postgres != null) { 
      logger.warn("Postgres already started");
      return;
    }
    logger.info("Starting Postgres");
    if (!isDockerInstalled()) {
      throw new IllegalStateException("Docker is not installed");
    }
    String url = start();
    testConnection(url, user, password);
    logger.info("Postgres started at " + url);
  }

  private String start() {
    postgres = new PostgresContainerAdapter();
    return postgres.start(port, db, user, password);
  }

  private static void testConnection(String url, String user, String password) throws MojoExecutionException {
    try (Connection conn = DriverManager.getConnection(url, user, password)) {
      conn.createStatement().execute("SELECT 1");
    } catch (SQLException e) {
      throw new MojoExecutionException("Exception occurred while testing sql connection", e);
    }
  }

  private static boolean isDockerInstalled() {
    if (CommandLine.executableExists("docker")) {
      return true;
    }
    if (CommandLine.executableExists("docker.exe")) {
      return true;
    }
    if (CommandLine.executableExists("docker-machine")) {
      return true;
    }
    if (CommandLine.executableExists("docker-machine.exe")) {
      return true;
    }
    return false;
  }
}


save-state stop .



:



<build>
  <plugins>
    <plugin>
      <groupId>com.miro.maven</groupId>
      <artifactId>PostgresPlugin</artifactId>
      <executions>
        <!-- running a postgres container -->
        <execution>
          <id>start-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>start</goal>
          </goals>
          
          <configuration>
            <db>${db}</db>
            <user>${dbUser}</user>
            <password>${dbPassword}</password>
            <port>${dbPort}</port>
          </configuration>
        </execution>
        
        <!-- applying migrations and generation java-classes -->
        <execution>
          <id>flyway-and-jooq</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>execute-mojo</goal>
          </goals>
          
          <configuration>
            <plugins>
              <!-- applying migrations -->
              <plugin>
                <groupId>org.flywaydb</groupId>
                <artifactId>flyway-maven-plugin</artifactId>
                <version>${flyway.version}</version>
                <executions>
                  <execution>
                    <id>migration</id>
                    <goals>
                      <goal>migrate</goal>
                    </goals>
                    
                    <configuration>
                      <url>${dbUrl}</url>
                      <user>${dbUser}</user>
                      <password>${dbPassword}</password>
                      <locations>
                        <location>filesystem:src/main/resources/migrations</location>
                      </locations>
                    </configuration>
                  </execution>
                </executions>
              </plugin>

              <!-- generation java-classes -->
              <plugin>
                <groupId>org.jooq</groupId>
                <artifactId>jooq-codegen-maven</artifactId>
                <version>${jooq.version}</version>
                <executions>
                  <execution>
                    <id>jooq-generate-sources</id>
                    <goals>
                      <goal>generate</goal>
                    </goals>
                      
                    <configuration>
                      <jdbc>
                        <url>${dbUrl}</url>
                        <user>${dbUser}</user>
                        <password>${dbPassword}</password>
                      </jdbc>
                      
                      <generator>
                        <database>
                          <name>org.jooq.meta.postgres.PostgresDatabase</name>
                          <includes>.*</includes>
                          <excludes>
                            #exclude flyway tables
                            schema_version | flyway_schema_history
                            # other excludes
                          </excludes>
                          <includePrimaryKeys>true</includePrimaryKeys>
                          <includeUniqueKeys>true</includeUniqueKeys>
                          <includeForeignKeys>true</includeForeignKeys>
                          <includeExcludeColumns>true</includeExcludeColumns>
                        </database>
                        <generate>
                          <interfaces>false</interfaces>
                          <deprecated>false</deprecated>
                          <jpaAnnotations>false</jpaAnnotations>
                          <validationAnnotations>false</validationAnnotations>
                        </generate>
                        <target>
                          <packageName>com.miro.persistence</packageName>
                          <directory>src/main/java</directory>
                        </target>
                      </generator>
                    </configuration>
                  </execution>
                </executions>
              </plugin>
            </plugins>
          </configuration>
        </execution>

        <!-- creation an image for integration tests -->
        <execution>
          <id>save-state-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>save-state</goal>
          </goals>
          
          <configuration>
            <name>postgres-it</name>
          </configuration>
        </execution>

        <!-- stopping the container -->
        <execution>
          <id>stop-postgres</id>
          <phase>generate-sources</phase>
          <goals>
            <goal>stop</goal>
          </goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>




Lançamento



O código foi escrito e testado - é hora de lançar. Em geral, a complexidade de uma versão depende dos seguintes fatores:



  • no número de bancos de dados (um ou mais)
  • no tamanho do banco de dados
  • no número de servidores de aplicativos (um ou mais)
  • liberação contínua ou não (se o tempo de inatividade do aplicativo é permitido).


Os itens 1 e 3 impõem um requisito de compatibilidade com versões anteriores no código, uma vez que na maioria dos casos é impossível atualizar simultaneamente todos os bancos de dados e todos os servidores de aplicativos - sempre haverá um ponto no tempo em que os bancos de dados terão esquemas diferentes e os servidores terão versões diferentes do código.



O tamanho do banco de dados afeta o tempo de migração - quanto maior o banco de dados, maior a probabilidade de você precisar realizar uma migração longa.



A perfeição é parcialmente um fator resultante - se a liberação for realizada com desligamento (tempo de inatividade), os primeiros 3 pontos não são tão importantes e afetam apenas o tempo em que o aplicativo fica indisponível.



Se falarmos sobre nosso serviço, então são:



  • cerca de 30 clusters de banco de dados


  • tamanho de uma base 200 - 400 GB
  • ( 100),
  • .


Usamos versões canário : uma nova versão do aplicativo é exibida primeiro em um pequeno número de servidores (chamamos de pré-lançamento) e, depois de um tempo, se nenhum erro for encontrado no pré-lançamento, ela será lançada para outros servidores. Assim, os servidores de produção podem ser executados em diferentes versões.



Ao iniciar, cada servidor de aplicativos verifica a versão do banco de dados com as versões dos scripts que estão no código-fonte (em termos de flyway, isso é chamado de validação ). Se eles forem diferentes, o servidor não iniciará. Isso garante a compatibilidade do código e do banco de dados . Tal situação não pode ocorrer quando, por exemplo, o código trabalha com uma tabela que ainda não foi criada, pois a migração está em uma versão diferente do servidor.



Mas é claro que isso não resolve o problema quando, por exemplo, na nova versão do aplicativo há uma migração que exclui uma coluna da tabela que pode ser usada na versão antiga do servidor. Agora verificamos tais situações apenas na fase de revisão (é obrigatório), mas de forma amigável é necessário introduzir adicionais. estágio com tal verificação no ciclo de CI / CD.  



Às vezes, as migrações podem levar muito tempo (por exemplo, ao atualizar dados de uma grande tabela) e para não retardar os lançamentos ao mesmo tempo, usamos a técnica de migrações combinadas... A combinação consiste em executar manualmente a migração em um servidor em execução (através do painel de administração, sem flyway e, portanto, sem registrar no histórico de migração), e então a saída "regular" da mesma migração na próxima versão do servidor. Essas migrações estão sujeitas aos seguintes requisitos:



  • Em primeiro lugar, deve ser escrito de forma a não bloquear a aplicação durante uma longa execução (o ponto principal aqui é não adquirir bloqueios de longo prazo ao nível do BD). Para fazer isso, temos diretrizes internas para desenvolvedores sobre como escrever migrações. No futuro, também posso compartilhá-los no Habré.
  • Em segundo lugar, a migração em um início "normal" deve determinar que já foi realizada no modo manual e não fazer nada neste caso - apenas enviar um novo registro no histórico. Para migrações SQL, essa verificação é realizada executando alguma consulta SQL para alterações. Outra abordagem para migrações Java é usar sinalizadores booleanos armazenados, que são definidos após uma execução manual.




Esta abordagem resolve 2 problemas:

  • o lançamento é rápido (embora com ações manuais)
  • ( ) - .




Uma vez lançado, o ciclo de desenvolvimento não termina. Para entender se a nova funcionalidade funciona (e como funciona), é necessário “encerrar” com métricas. Eles podem ser divididos em 2 grupos: negócios e sistema. 



O primeiro grupo depende fortemente da área de assunto: para um servidor de e-mail é útil saber o número de cartas enviadas, para um recurso de notícias - o número de usuários únicos por dia, etc.



As métricas do segundo grupo são aproximadamente as mesmas para todos - elas determinam o estado técnico do servidor: cpu, memória, rede, banco de dados, etc. 



O que exatamente precisa ser monitorado e como fazer - este é um tópico de um grande número de artigos separados e não será abordado aqui. Eu gostaria de lembrar apenas as coisas mais básicas (até mesmo do capitão):



definir métricas com antecedência



É necessário definir uma lista de métricas básicas. E deve ser feito com antecedência , antes do lançamento, e não após o primeiro incidente, quando você não entende o que está acontecendo com o sistema.



configurar alertas automáticos



Isso irá acelerar seu tempo de reação e economizar tempo no monitoramento manual. Idealmente, você deve saber sobre os problemas antes que os usuários os sintam e escrevam para você.



coletar métricas de todos os nós



Métricas, como logs, nunca são demais. A presença de dados de cada nó de seu sistema (servidor de aplicativos, banco de dados, extrator de conexão, balanceador, etc.) permite que você tenha uma visão completa de seu estado e, se necessário, pode localizar rapidamente o problema. 



Um exemplo simples: o carregamento dos dados de uma página da web começou a ficar lento. Pode haver muitos motivos:



  • o servidor da web está sobrecarregado e leva muito tempo para responder às solicitações


  • A consulta SQL leva mais tempo para ser executada
  • uma fila se acumulou no pool de conexão e o servidor de aplicativos não pode receber uma conexão por um longo tempo
  • problemas de rede
  • algo mais


Sem métricas, encontrar a causa de um problema não será fácil.



Em vez de conclusão



Eu gostaria de dizer uma frase muito banal sobre o fato de que não há bala de prata e a escolha de uma ou outra abordagem depende dos requisitos de uma tarefa específica, e o que funciona bem para outras pode não ser aplicável para você. Mas quanto mais abordagens diferentes você conhece, mais completa e qualitativamente você pode fazer essa escolha. Espero que com este artigo você tenha aprendido algo novo para si mesmo que o ajudará no futuro. Eu ficaria feliz em comentar sobre quais abordagens você usa para melhorar o processo de trabalho com o banco de dados.



All Articles