Trabajar con bases de datos a través de los ojos de un desarrollador



Cuando desarrolla una nueva funcionalidad usando una base de datos, el ciclo de desarrollo generalmente incluye (pero no se limita a) las siguientes etapas:



Escribir migraciones SQL → escribir código → probar → liberar → monitorear.



En este artículo, quiero compartir algunos consejos prácticos sobre cómo se puede reducir el tiempo de este ciclo en cada etapa, sin reducir la calidad, sino incluso aumentarla. 



Dado que trabajamos con PostgreSQL en la empresa y escribimos el código del servidor en Java, los ejemplos estarán basados ​​en esta pila, aunque la mayoría de las ideas no dependen de la base de datos y el lenguaje de programación utilizado.



Migración SQL



La primera etapa de desarrollo después del diseño es escribir la migración SQL. El consejo principal: no realice ningún cambio manual en el esquema de datos, pero hágalo siempre a través de scripts y guárdelos en un solo lugar. 



En nuestra empresa, los desarrolladores escriben ellos mismos las migraciones SQL, por lo que todas las migraciones se almacenan en un repositorio con el código principal. En algunas empresas, los administradores de bases de datos están involucrados en cambiar el esquema, en cuyo caso el registro de migración está en algún lugar con ellos. De una forma u otra, este enfoque trae las siguientes ventajas:



  • Siempre puede crear fácilmente una nueva base desde cero o actualizar una existente a la versión actual. Esto le permite implementar rápidamente nuevos entornos de prueba y entornos de desarrollo local.
  • Todas las bases tienen el mismo diseño, sin sorpresas en el servicio.
  • Hay un historial de todos los cambios (versiones).


Hay muchas herramientas confeccionadas para la automatización de este proceso, tanto comerciales como libres: ruta de vuelo , Liquibase , sqitch , etc. En este artículo no voy a comparar y elegir la mejor herramienta - este es un tema muy amplia independiente, y se pueden encontrar muchos artículos en los que ... 



Usamos la ruta migratoria, así que aquí hay un poco de información al respecto:



  • Hay 2 tipos de migraciones: basadas en SQL y Java basado en
  • Las migraciones de SQL son inmutables (inmutables). Después de la primera ejecución, la migración de SQL no se puede cambiar. Flyway calcula una suma de comprobación para el contenido del archivo de migración y lo verifica en cada ejecución. Se requieren manipulaciones manuales adicionales para que las migraciones de Java sean inmutables .
  • flyway_schema_history ( schema_version). , , , .


De acuerdo con nuestros acuerdos internos, todos los cambios en el esquema de datos se realizan solo mediante migraciones de SQL. Su inmutabilidad asegura que siempre podamos obtener un esquema real que sea completamente idéntico a todos los entornos. 



Las migraciones de Java se utilizan solo para DML , cuando es imposible escribir en SQL puro. Para nosotros, un ejemplo típico de tal situación son las migraciones para transferir datos a Postgres desde otra base de datos (nos estamos mudando de Redis a Postgres, pero esta es una historia completamente diferente). Otro ejemplo es la actualización de los datos de una tabla grande, que se realiza en varias transacciones para minimizar el tiempo de bloqueo de la tabla. Vale la pena decir que a partir de la undécima versión de Postgres, esto se puede hacer usando procedimientos SQL en plpgsql.



Cuando el código de Java está desactualizado, la migración se puede eliminar para no producir un legado (la clase de migración de Java en sí permanece, pero por dentro está vacía). En nuestro país, esto no puede suceder antes de un mes después de la migración a producción; creemos que es tiempo suficiente para que se actualicen todos los entornos de prueba y de desarrollo local. Cabe señalar que, dado que las migraciones de Java se utilizan solo para DML, su eliminación no afecta la creación de nuevas bases de datos desde cero de ninguna manera.



Un matiz importante para quienes usan pg_bouncer



Flyway aplica un bloqueo durante la migración para evitar la ejecución simultánea de varias migraciones. Simplificado, funciona así:



  • se produce la captura de bloqueo 
  • realizar migraciones en transacciones separadas
  • desbloqueo. 


Para Postgres, utiliza bloqueos de aviso en modo sesión, lo que significa que para que funcione correctamente, es necesario que el servidor de aplicaciones se ejecute en la misma conexión durante la captura y liberación del bloqueo. Si usa pg_bouncer en modo transaccional (que es el más común) o en modo de solicitud única, entonces para cada transacción puede devolver una nueva conexión y la ruta aérea no podrá liberar un bloqueo establecido. 



Para resolver este problema, usamos un grupo de conexiones pequeño separado en pg_bouncer en modo de sesión, que está destinado solo para migraciones. Desde el lado de la aplicación, también hay un grupo separado que contiene 1 conexión y se cierra por tiempo de espera después de la migración, para no desperdiciar recursos.



Codificación



Se creó la migración, ahora estamos escribiendo el código.



Hay 3 enfoques para trabajar con la base de datos desde el lado de la aplicación:



  • Usando ORM (si hablamos de Java, hibernar es de facto el estándar)
  • Usando sql + jdbcTemplate simple, etc.
  • Usando bibliotecas DSL.


El uso de ORM le permite reducir los requisitos de conocimiento de SQL; mucho se genera automáticamente: 

  • El esquema de datos se puede crear a partir de la descripción xml o la entidad Java disponible en el código.
  • las relaciones de objeto se definen mediante una descripción declarativa: ORM hará uniones por usted
  • Cuando se usa Spring Data JPA, la firma del método del repositorio también puede generar automáticamente consultas aún más complicadas .


Otra "ventaja" es la presencia de almacenamiento en caché de datos desde el primer momento (para hibernar, estos son 3 niveles de cachés).



Pero es importante tener en cuenta que ORM, como cualquier otra herramienta poderosa, requiere ciertas calificaciones cuando se utiliza. Sin la configuración adecuada, lo más probable es que el código funcione, pero lejos de ser óptimo.



Lo contrario es escribir el SQL a mano. Esto le permite tener un control total sobre las solicitudes: se ejecuta exactamente lo que escribió, sin sorpresas. Pero, obviamente, esto aumenta la cantidad de trabajo manual y aumenta los requisitos para las calificaciones de los desarrolladores.



Bibliotecas DSL



Aproximadamente en el medio entre estos enfoques hay otro, que consiste en utilizar librerías DSL ( jOOQ , Querydsl , etc.). Suelen ser mucho más ligeros que los ORM, pero más convenientes que la manipulación de bases de datos completamente manual. El uso de DSL es menos común, por lo que en este artículo se analizará rápidamente este enfoque. 



Hablaremos de una de las bibliotecas: jOOQ . Que ofrece ella:



  • inspección de bases de datos y generación automática de clases
  • API fluida para escribir solicitudes.


jOOQ no es un ORM: no hay generación automática de consultas, ni almacenamiento en caché, pero al mismo tiempo, algunos de los problemas de un enfoque completamente manual están cerrados:

  • clases para tablas, vistas, funciones, etc.Los objetos de la base de datos se generan automáticamente 
  • Las solicitudes están escritas en Java, esto garantiza que el tipo sea seguro: una solicitud sintácticamente incorrecta o una solicitud con un parámetro del tipo incorrecto no se compilará; su IDE le indicará inmediatamente un error y no tendrá que perder tiempo iniciando la aplicación para verificar la exactitud de la solicitud. Esto acelera el proceso de desarrollo y reduce la probabilidad de errores.


En el código, las solicitudes se parecen a esto :



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


Puede usar sql simple si lo desea:



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


Evidentemente, en este caso, la veracidad de la consulta y el análisis de los resultados están completamente sobre sus hombros.



jOOQ Record y POJO



El BookRecord en el ejemplo anterior es un contenedor sobre una fila en la tabla del libro e implementa el patrón de registro activo . Dado que esta clase es parte de la capa de acceso a datos (además de su implementación específica), es posible que no desee transferirla a otras capas de la aplicación, sino que utilice algún tipo de objeto pojo propio. Para la conveniencia de convertir el registro <–> pojo jooq ofrece varios mecanismos: automático y manual . La documentación de los enlaces anteriores tiene una variedad de ejemplos de lectura y uso, pero no ejemplos para insertar nuevos datos y actualizarlos. Llenemos este vacío: 



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 ves, todo es bastante sencillo.



Este enfoque le permite ocultar los detalles de implementación dentro de la clase de capa de acceso a datos y evitar la "filtración" a otras capas de la aplicación. 



También jooq puede generar clases DAO con un conjunto de métodos básicos para simplificar el trabajo con datos de tabla y reducir la cantidad de código manual (esto es muy similar a 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;
    ...
}


En la empresa no utilizamos la generación automática de clases DAO, solo generamos contenedores sobre objetos de base de datos y escribimos consultas nosotros mismos. La generación de envoltorios ocurre cada vez que se reconstruye un módulo maven separado, en el que se almacenan las migraciones. Un poco más adelante, habrá detalles sobre cómo se implementa.



Pruebas



Escribir pruebas es una parte importante del proceso de desarrollo: las buenas pruebas garantizan la calidad de su código y ahorran tiempo mientras lo mantienen. Al mismo tiempo, es justo decir que lo contrario también es cierto: las malas pruebas pueden crear la ilusión de un código de calidad, ocultar errores y ralentizar el proceso de desarrollo. Por lo tanto, no basta con decidir que escribirás pruebas, debes hacerlo bien . Al mismo tiempo, el concepto de corrección de las pruebas es muy vago y cada uno tiene un poco de lo suyo. 



Lo mismo ocurre con la cuestión de la clasificación de las pruebas. Este artículo sugiere usar la siguiente opción de división:



  • prueba unitaria (prueba unitaria) 
  • pruebas de integración
  • pruebas de extremo a extremo (de extremo a extremo).


Las pruebas unitarias implican verificar la funcionalidad de los módulos individuales de forma aislada entre sí. El tamaño del módulo es nuevamente algo indefinido, para algunos es un método separado, para algunos es una clase. Aislamiento significa que todos los demás módulos son simulacros o stubs (en ruso son imitaciones o stubs, pero de alguna manera no suenan muy bien). Siga este enlace para leer el artículo de Martin Fowler sobre la diferencia entre los dos. Las pruebas unitarias son pequeñas, rápidas, pero solo pueden garantizar la exactitud de la lógica de una unidad individual.



Pruebas de integracióna diferencia de las pruebas unitarias, verifican la interacción de varios módulos entre sí. Trabajar con una base de datos es un buen ejemplo cuando las pruebas de integración tienen sentido, porque es muy difícil "bloquear" una base de datos con alta calidad, teniendo en cuenta todos sus matices. Las pruebas de integración en la mayoría de los casos son un buen compromiso entre la velocidad de ejecución y la garantía de calidad cuando se prueba una base de datos en comparación con otros tipos de pruebas. Por eso, en este artículo hablaremos con más detalle sobre este tipo de pruebas.



Las pruebas de extremo a extremo son las más extensas. Para llevarlo a cabo, es necesario levantar todo el entorno. Garantiza el más alto nivel de confianza en la calidad del producto, pero es el más lento y el más caro.



Pruebas de integración



Cuando se trata de pruebas de integración de código que funciona con una base de datos, la mayoría de los desarrolladores se hacen preguntas: ¿cómo iniciar la base de datos, cómo inicializar su estado con los datos iniciales y cómo hacerlo lo más rápido posible?



Hace algún tiempo, h2 era una práctica bastante común en las pruebas de integración . Es una base de datos en memoria escrita en Java que tiene modos de compatibilidad con las bases de datos más populares. La ausencia de la necesidad de instalar una base de datos y la versatilidad de h2 lo convirtió en un reemplazo muy conveniente para las bases de datos reales, especialmente si la aplicación no depende de una base de datos específica y usa solo lo que está incluido en el estándar SQL (que no siempre es el caso). 



Pero los problemas comienzan en el momento en que utiliza alguna funcionalidad de base de datos complicada (o una completamente nueva de una versión nueva), cuyo soporte no está implementado en h2. Y en general, dado que se trata de una "simulación" de un DBMS específico, siempre puede haber algunas diferencias de comportamiento.



Otra opción es usar postgres incrustado . Este es Postgres real, enviado como un archivo y no requiere instalación. Le permite trabajar como una versión normal de Postgres. 



Hay varias implementaciones, las más populares de Yandex y openTable... En la empresa usamos la versión de Yandex. De las desventajas: es bastante lento al inicio (cada vez que se desempaqueta el archivo y se inicia la base de datos, toma de 2 a 5 segundos, dependiendo de la potencia de la computadora), también hay un problema con el retraso de la versión de lanzamiento oficial. También nos encontramos con el problema de que después de un intento de detener el código, se produjo algún error y el proceso de Postgres permaneció colgado en el sistema operativo; tenía que eliminarlo manualmente. 



contenedores de prueba



La tercera opción es usar Docker. Para Java, hay una biblioteca testcontainers que proporciona una api para trabajar con contenedores docker desde el código. Por lo tanto, cualquier dependencia en su aplicación que tenga una imagen de la ventana acoplable se puede reemplazar en las pruebas con testcontainers. Además, para muchas tecnologías populares, hay clases separadas listas para usar que proporcionan una API más conveniente, según la imagen utilizada:



  • bases de datos (Postgres, Oracle, Cassandra, MongoDB, etc.), 
  • nginx
  • kafka, etc.


Por cierto, cuando el proyecto tescontainers se hizo bastante popular, los desarrolladores de yandex anunciaron oficialmente que iban a detener el desarrollo del proyecto postgres integrado y recomendaron cambiar a testcontainers.



Cuáles son las ventajas:



  • Los contenedores de prueba son rápidos (iniciar Postgres vacío toma menos de un segundo)
  • La comunidad de Postgres publica imágenes de Docker oficiales para cada nueva versión.
  • testcontainers tiene un proceso especial que mata los contenedores colgantes después de cerrar jvm, a menos que lo haya hecho mediante programación
  • con testcontainers, puede utilizar un enfoque uniforme para probar las dependencias externas de su aplicación, lo que obviamente facilita las cosas.


Prueba de ejemplo 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);
    }
}


Si no hay una clase separada para la imagen en testcontainers, a continuación, la creación de la apariencia del envase como este :



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


Si está utilizando JUnit4, JUnit5 o Spock, testcontainers tiene un extra. soporte para estos marcos, lo que facilita la escritura de pruebas.



Acelerar las pruebas con contenedores de prueba



Aunque cambiar de Postgres integrado a contenedores de prueba hizo que nuestras pruebas fueran más rápidas al ejecutar Postgres más rápido, con el tiempo las pruebas comenzaron a ralentizarse nuevamente. Esto se debe al mayor número de migraciones SQL que realiza la ruta aérea al inicio. Cuando el número de migraciones excedió las cien, el tiempo de ejecución fue de unos 7-8 segundos, lo que ralentizó significativamente las pruebas. Funcionó algo como esto:



  1. antes de la siguiente clase de prueba, se lanzó un contenedor "limpio" con Postgres
  2. migraciones realizadas por la ruta migratoria
  3. se ejecutaron pruebas de esta clase
  4. el contenedor fue detenido y retirado
  5. repita desde el punto 1 para la siguiente clase de prueba.


Obviamente, con el tiempo el segundo paso tomó cada vez más tiempo.



Intentando solucionar este problema, nos dimos cuenta de que basta con realizar migraciones una sola vez antes de todas las pruebas, guardar el estado del contenedor y luego usar este contenedor en todas las pruebas. Entonces el algoritmo ha cambiado:



  1. se lanza un contenedor "limpio" con Postgres antes de todas las pruebas
  2. la ruta migratoria realiza migraciones
  3. el estado del contenedor persiste
  4. antes de la siguiente clase de prueba, se lanza un contenedor preparado previamente
  5. se ejecutan las pruebas de esta clase
  6. el contenedor se detiene y se retira
  7. repita desde el paso 4 para la siguiente clase de prueba.


Ahora, el tiempo de ejecución de una prueba individual no depende del número de migraciones, y con el número actual de migraciones (más de 200), el nuevo esquema ahorra varios minutos en cada ejecución de todas las pruebas.



Aquí hay algunos detalles técnicos sobre cómo implementar esto.



Docker tiene un mecanismo incorporado para crear una nueva imagen desde un contenedor en ejecución usando el comando de confirmación . Le permite personalizar las imágenes, por ejemplo, cambiando cualquier configuración. 



Un matiz importante es que el comando no guarda los datos de las particiones montadas. Pero si toma la imagen oficial de la ventana acoplable de Postgres, entonces el directorio PGDATA en el que se almacenan los datos se ubica en una sección separada (para que después de reiniciar el contenedor, los datos no se pierdan), por lo tanto, cuando se ejecuta la confirmación, el estado de la base de datos en sí no se guarda. 



La solución es simple: no use la sección para PGDATA, pero mantenga los datos en la memoria, lo cual es bastante normal para las pruebas. Hay 2 formas de hacer esto: use su dockerfile (algo como esto) sin crear una sección, o anular la variable PGDATA al iniciar el contenedor oficial (la sección permanecerá, pero no se utilizará). La segunda forma parece mucho más simple:



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


Antes de comprometerse, se recomienda hacer un checkpoint postgres para vaciar los cambios de los búferes compartidos al "disco" (que corresponde a la variable PGDATA anulada):



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


El compromiso en sí es algo como esto:



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


Cabe señalar que este enfoque que utiliza imágenes preparadas se puede aplicar a muchas otras imágenes, lo que también ahorrará tiempo al ejecutar pruebas de integración.



Algunas palabras más sobre cómo optimizar el tiempo de compilación



Como se mencionó anteriormente, al ensamblar un módulo maven separado con migraciones, entre otras cosas, se generan envoltorios java sobre los objetos de la base de datos. Para ello, se utiliza un complemento maven autoescrito, que se inicia antes de compilar el código principal y realiza 3 acciones:



  1. Ejecuta un contenedor acoplable "limpio" con postgres
  2. Lanza Flyway, que realiza migraciones sql para todas las bases de datos, verificando así su validez
  3. Ejecuta Jooq, que inspecciona el esquema de la base de datos y genera clases Java para tablas, vistas, funciones y otros objetos de esquema.


Como puede ver fácilmente, los primeros 2 pasos son idénticos a los que se realizan cuando se ejecutan las pruebas. Para ahorrar tiempo al iniciar el contenedor y ejecutar las migraciones antes de las pruebas, trasladamos el almacenamiento del estado del contenedor a un complemento. Por lo tanto, ahora, inmediatamente después de reconstruir el módulo, las imágenes listas para usar para las pruebas de integración de todas las bases de datos utilizadas en el código aparecen en el repositorio local de imágenes de Docker.



Ejemplo de código más detallado
@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>




Lanzamiento



El código está escrito y probado, es hora de publicarlo. En general, la complejidad de una versión depende de los siguientes factores:



  • en el número de bases de datos (una o más)
  • en el tamaño de la base de datos
  • en el número de servidores de aplicaciones (uno o más)
  • lanzamiento sin problemas o no (si se permite el tiempo de inactividad de la aplicación).


Los elementos 1 y 3 imponen un requisito de compatibilidad con versiones anteriores del código, ya que en la mayoría de los casos es imposible actualizar simultáneamente todas las bases de datos y todos los servidores de aplicaciones; siempre habrá un momento en el que las bases de datos tendrán diferentes esquemas y los servidores tendrán diferentes versiones del código.



El tamaño de la base de datos afecta el tiempo de migración: cuanto más grande sea la base de datos, es más probable que deba realizar una migración prolongada.



La fluidez es en parte un factor resultante: si la liberación se realiza con apagado (tiempo de inactividad), los primeros 3 puntos no son tan importantes y solo afectan el tiempo en que la aplicación no está disponible.



Si hablamos de nuestro servicio, estos son:



  • alrededor de 30 clústeres de bases de datos


  • tamaño de una base 200 - 400 GB
  • ( 100),
  • .


Usamos versiones de Canary : una nueva versión de la aplicación se muestra primero en una pequeña cantidad de servidores (lo llamamos una versión preliminar), y después de un tiempo, si no se encuentran errores en la versión preliminar, se lanza a otros servidores. Por lo tanto, los servidores de producción pueden ejecutarse en diferentes versiones.



Al iniciar, cada servidor de aplicaciones verifica la versión de la base de datos con las versiones de los scripts que están en el código fuente (en términos de ruta de vuelo, esto se llama validación ). Si son diferentes, el servidor no se iniciará. Esto asegura la compatibilidad del código y la base de datos . Esta situación no puede surgir cuando, por ejemplo, el código funciona con una tabla que aún no se ha creado, porque la migración se encuentra en una versión diferente del servidor.



Pero esto, por supuesto, no resuelve el problema cuando, por ejemplo, en la nueva versión de la aplicación hay una migración que borra una columna de la tabla que se puede utilizar en la versión anterior del servidor. Ahora verificamos tales situaciones solo en la etapa de revisión (es obligatorio), pero de manera amistosa es necesario introducir más. etapa con tal control en el ciclo CI / CD.  



A veces, las migraciones pueden llevar mucho tiempo (por ejemplo, cuando se actualizan datos de una tabla grande) y para no ralentizar los lanzamientos al mismo tiempo, utilizamos la técnica de migraciones combinadas.... La combinación consiste en ejecutar manualmente la migración en un servidor en ejecución (a través del panel de administración, sin flyway y, en consecuencia, sin registrar en el historial de migración), y luego la salida "regular" de la misma migración en la próxima versión del servidor. Estas migraciones están sujetas a los siguientes requisitos:



  • En primer lugar, debe estar escrito de tal manera que no bloquee la aplicación durante una ejecución prolongada (el punto principal aquí es no adquirir bloqueos a largo plazo en el nivel de la base de datos). Para hacer esto, tenemos pautas internas para desarrolladores sobre cómo escribir migraciones. En el futuro, también puedo compartirlos en Habré.
  • En segundo lugar, la migración durante un lanzamiento "regular" debe determinar que ya se ha realizado en modo manual y no hacer nada en este caso, simplemente confirmar un nuevo registro en el historial. Para las migraciones de SQL, dicha verificación se realiza mediante la ejecución de alguna consulta SQL para cambios. Otro enfoque para las migraciones de Java es utilizar indicadores booleanos almacenados, que se establecen después de una ejecución manual.




Este enfoque resuelve 2 problemas:

  • el lanzamiento es rápido (aunque con acciones manuales)
  • ( ) - .




Después del lanzamiento, el ciclo de desarrollo no termina. Para comprender si la nueva funcionalidad funciona (y cómo funciona), es necesario "encerrar" con métricas. Se pueden dividir en 2 grupos: empresa y sistema. 



El primer grupo depende en gran medida del área temática: para un servidor de correo es útil saber el número de cartas enviadas, para un recurso de noticias, el número de usuarios únicos por día, etc.



Las métricas del segundo grupo son aproximadamente las mismas para todos: determinan el estado técnico del servidor: CPU, memoria, red, base de datos, etc.Qué es 



exactamente lo que se debe monitorear y cómo hacerlo: este es un tema de una gran cantidad de artículos separados y no se tratará aquí. Me gustaría recordar solo las cosas más básicas (incluso las del capitán):



definir métricas de antemano



Es necesario definir una lista de métricas básicas. Y debe hacerse con anticipación , antes del lanzamiento, y no después del primer incidente, cuando no se comprende lo que está sucediendo con el sistema.



configurar alertas automáticas



Esto acelerará su tiempo de reacción y ahorrará tiempo en el monitoreo manual. Idealmente, debería conocer los problemas antes de que los usuarios los sientan y le escriban.



recopilar métricas de todos los nodos



Las métricas, como los registros, nunca son demasiadas. La presencia de datos de cada nodo de su sistema (servidor de aplicaciones, base de datos, extractor de conexiones, balanceador, etc.) le permite tener una imagen completa de su estado y, si es necesario, puede localizar rápidamente el problema. 



Un ejemplo sencillo: la carga de datos de una página web empezó a ralentizarse. Puede haber muchas razones:



  • el servidor web está sobrecargado y tarda mucho en responder a las solicitudes


  • La consulta SQL tarda más en ejecutarse
  • se ha acumulado una cola en el grupo de conexiones y el servidor de aplicaciones no puede recibir una conexión
  • problemas de red
  • algo más


Sin métricas, no será fácil encontrar la causa de un problema.



En lugar de completar



Me gustaría decir una frase muy banal sobre el hecho de que no existe una fórmula mágica y la elección de uno u otro enfoque depende de los requisitos de una tarea específica, y lo que funciona bien para otros puede no serlo para usted. Pero cuantos más enfoques diferentes conozca, más completa y cualitativamente podrá tomar esta decisión. Espero que de este artículo hayas aprendido algo nuevo que te ayude en el futuro. Me complacería comentar los enfoques que utiliza para mejorar el proceso de trabajo con la base de datos.



All Articles