Migraciones de bases de datos: convertir orugas en mariposas

Publicado: 2022-03-11

A los usuarios no les importa lo que hay dentro del software que usan; solo que funciona sin problemas, de forma segura y discreta. Los desarrolladores se esfuerzan para que eso suceda, y uno de los problemas que intentan resolver es cómo garantizar que el almacén de datos esté en un estado apropiado para la versión actual del producto. El software evoluciona y su modelo de datos también puede cambiar con el tiempo, por ejemplo, para corregir errores de diseño. Para complicar aún más el problema, es posible que tenga varios entornos de prueba o clientes que migran a versiones más nuevas del producto a diferentes ritmos. No puede simplemente documentar la estructura de la tienda y qué manipulaciones se necesitan para usar la nueva versión brillante desde una sola perspectiva.

Migraciones de bases de datos: convertir orugas en mariposas

Una vez me uní a un proyecto con algunas bases de datos con estructuras que se actualizaban a pedido, directamente por los desarrolladores. Esto significaba que no había una manera obvia de averiguar qué cambios debían aplicarse para migrar la estructura a la última versión y ¡no había ningún concepto de control de versiones en absoluto! Esto fue durante la era anterior a DevOps y hoy en día se consideraría un desastre total. Decidimos desarrollar una herramienta que se usaría para aplicar cada cambio a la base de datos dada. Tenía migraciones y documentaría los cambios de esquema. Esto nos hizo confiar en que no habría cambios accidentales y que el estado del esquema sería predecible.

En este artículo, veremos cómo aplicar migraciones de esquemas de bases de datos relacionales y cómo superar los problemas concomitantes.

En primer lugar, ¿qué son las migraciones de bases de datos? En el contexto de este artículo, una migración es un conjunto de cambios que deben aplicarse a una base de datos. Crear o eliminar una tabla, columna o índice son ejemplos comunes de migraciones. La forma de su esquema puede cambiar drásticamente con el tiempo, especialmente si el desarrollo se inició cuando los requisitos aún eran vagos. Entonces, en el transcurso de varios hitos en el camino hacia un lanzamiento, su modelo de datos habrá evolucionado y puede haberse vuelto completamente diferente de lo que era al principio. Las migraciones son solo pasos hacia el estado de destino.

Para comenzar, exploremos lo que tenemos en nuestra caja de herramientas para evitar reinventar lo que ya se hizo bien.

Herramientas

En todos los idiomas ampliamente utilizados, existen bibliotecas que ayudan a facilitar las migraciones de bases de datos. Por ejemplo, en el caso de Java, las opciones populares son Liquibase y Flyway. Usaremos Liquibase más en los ejemplos, pero los conceptos se aplican a otras soluciones y no están vinculados a Liquibase.

¿Por qué molestarse en usar una biblioteca de migración de esquemas separada si algunos ORM ya brindan una opción para actualizar automáticamente un esquema y hacer que coincida con la estructura de las clases asignadas? En la práctica, tales migraciones automáticas solo realizan cambios de esquema simples, por ejemplo, crear tablas y columnas, y no pueden hacer cosas potencialmente destructivas como descartar o cambiar el nombre de los objetos de la base de datos. Por lo tanto, las soluciones no automáticas (pero aún así automatizadas) suelen ser una mejor opción porque se ve obligado a describir la lógica de migración usted mismo y sabe qué sucederá exactamente con su base de datos.

También es una muy mala idea mezclar modificaciones de esquema automáticas y manuales porque puede producir esquemas únicos e impredecibles si los cambios manuales se aplican en el orden incorrecto o no se aplican en absoluto, incluso si son necesarios. Una vez elegida la herramienta, utilícela para aplicar todas las migraciones de esquema.

Migraciones típicas de bases de datos

Las migraciones típicas incluyen la creación de secuencias, tablas, columnas, claves primarias y externas, índices y otros objetos de bases de datos. Para los tipos de cambios más comunes, Liquibase proporciona distintos elementos declarativos para describir lo que se debe hacer. Sería demasiado aburrido leer acerca de cada cambio trivial soportado por Liquibase u otras herramientas similares. Para tener una idea de cómo se ven los conjuntos de cambios, considere el siguiente ejemplo donde creamos una tabla (las declaraciones de espacio de nombres XML se omiten por brevedad):

 <?xml version="1.0" encoding="UTF-8"?> <databaseChangeLog> <changeSet author="demo"> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> </changeSet> </databaseChangeLog>

Como puede ver, el registro de cambios es un conjunto de conjuntos de cambios, y los conjuntos de cambios consisten en cambios. Los cambios simples como createTable se pueden combinar para implementar migraciones más complejas; por ejemplo, suponga que necesita actualizar el código de producto para todos los productos. Se puede lograr fácilmente con el siguiente cambio:

 <sql>UPDATE product SET code = 'new_' || code</sql>

El rendimiento se verá afectado si tiene millones de productos. Para acelerar la migración, podemos reescribirla en los siguientes pasos:

  1. Cree una nueva tabla para productos con createTable , tal como vimos anteriormente. En esta etapa, es mejor crear la menor cantidad posible de restricciones. Llamemos a la nueva tabla PRODUCT_TMP .
  2. Rellene PRODUCT_TMP con SQL en forma de INSERT INTO ... SELECT ... usando el cambio de sql .
  3. Cree todas las restricciones ( addNotNullConstraint , addUniqueConstraint , addForeignKeyConstraint ) e índices ( createIndex ) que necesite.
  4. Cambie el nombre de la tabla PRODUCT a algo como PRODUCT_BAK . Liquibase puede hacerlo con renameTable .
  5. Cambie el nombre de PRODUCT_TMP a PRODUCT (nuevamente, usando renameTable ).
  6. Opcionalmente, elimine PRODUCT_BAK con dropTable .

Por supuesto, es mejor evitar este tipo de migraciones, pero es bueno saber cómo implementarlas en caso de que te encuentres con uno de esos raros casos en los que lo necesitas.

Si considera que XML, JSON o YAML son demasiado extraños para la tarea de describir los cambios, simplemente use SQL simple y utilice todas las características específicas del proveedor de la base de datos. Además, puede implementar cualquier lógica personalizada en Java simple.

La forma en que Liquibase lo exime de escribir SQL específico de la base de datos real puede generar un exceso de confianza, pero no debe olvidarse de las peculiaridades de su base de datos de destino; por ejemplo, cuando crea una clave externa, se puede crear o no un índice, según el sistema de gestión de base de datos específico que se utilice. Como resultado, es posible que te encuentres en una situación incómoda. Liquibase le permite especificar que un conjunto de cambios debe ejecutarse solo para un tipo particular de base de datos, por ejemplo, PostgreSQL, Oracle o MySQL. Hace que esto sea posible utilizando los mismos conjuntos de cambios independientes del proveedor para diferentes bases de datos y para otros conjuntos de cambios, utilizando sintaxis y características específicas del proveedor. El siguiente conjunto de cambios se ejecutará solo si se utiliza una base de datos Oracle:

 <changeSet dbms="oracle" author="..."> ... </changeSet>

Además de Oracle, Liquibase admite algunas otras bases de datos listas para usar.

Nombrar objetos de base de datos

Cada objeto de base de datos que cree debe tener un nombre. No es necesario que proporcione explícitamente un nombre para algunos tipos de objetos, por ejemplo, para restricciones e índices. Pero eso no significa que esos objetos no tendrán nombres; sus nombres serán generados por la base de datos de todos modos. El problema surge cuando necesita hacer referencia a ese objeto para eliminarlo o modificarlo. Así que es mejor darles nombres explícitos. Pero, ¿hay alguna regla sobre qué nombres dar? La respuesta es corta: Sea consistente; por ejemplo, si decidió nombrar índices como este: IDX_<table>_<columns> , entonces un índice para la columna CODE antes mencionada debe llamarse IDX_PRODUCT_CODE .

Las convenciones de nomenclatura son increíblemente controvertidas, por lo que no pretendemos dar instrucciones completas aquí. Sea consistente, respete las convenciones de su equipo o proyecto, o simplemente invéntelas si no las hay.

Organización de conjuntos de cambios

Lo primero que debe decidir es dónde almacenar los conjuntos de cambios. Básicamente hay dos enfoques:

  1. Mantenga los conjuntos de cambios con el código de la aplicación. Es conveniente hacerlo porque puede confirmar y revisar los conjuntos de cambios y el código de la aplicación juntos.
  2. Mantenga los conjuntos de cambios y el código de la aplicación separados , por ejemplo, en repositorios VCS separados. Este enfoque es adecuado cuando el modelo de datos se comparte entre varias aplicaciones y es más conveniente almacenar todos los conjuntos de cambios en un repositorio dedicado y no dispersarlos en varios repositorios donde vive el código de la aplicación.

Donde sea que almacene los conjuntos de cambios, generalmente es razonable dividirlos en las siguientes categorías:

  1. Migraciones independientes que no afectan al sistema en ejecución. Por lo general, es seguro crear nuevas tablas, secuencias, etc., si la aplicación implementada actualmente aún no las conoce.
  2. Modificaciones de esquema que alteran la estructura de la tienda , por ejemplo, agregar o eliminar columnas e índices. Estos cambios no deben aplicarse mientras una versión anterior de la aplicación todavía esté en uso, ya que hacerlo puede provocar bloqueos o comportamientos extraños debido a cambios en el esquema.
  3. Migraciones rápidas que insertan o actualizan pequeñas cantidades de datos. Si se implementan varias aplicaciones, los conjuntos de cambios de esta categoría se pueden ejecutar simultáneamente sin degradar el rendimiento de la base de datos.
  4. Migraciones potencialmente lentas que insertan o actualizan una gran cantidad de datos. Es mejor aplicar estos cambios cuando no se están ejecutando otras migraciones similares.

representación gráfica de las cuatro categorías

Estos conjuntos de migraciones deben ejecutarse consecutivamente antes de implementar una versión más nueva de una aplicación. Este enfoque se vuelve aún más práctico si un sistema se compone de varias aplicaciones independientes y algunas de ellas utilizan la misma base de datos. De lo contrario, vale la pena separar solo aquellos conjuntos de cambios que podrían aplicarse sin afectar las aplicaciones en ejecución, y los conjuntos de cambios restantes pueden aplicarse juntos.

Para aplicaciones más simples, el conjunto completo de migraciones necesarias se puede aplicar al inicio de la aplicación. En este caso, todos los conjuntos de cambios caen en una sola categoría y se ejecutan cada vez que se inicializa la aplicación.

Independientemente de la etapa que se elija para aplicar las migraciones, vale la pena mencionar que el uso de la misma base de datos para varias aplicaciones puede causar bloqueos cuando se aplican las migraciones. Liquibase (como muchas otras soluciones similares) utiliza dos tablas especiales para registrar sus metadatos: DATABASECHANGELOG y DATABASECHANGELOGLOCK . El primero se usa para almacenar información sobre conjuntos de cambios aplicados y el segundo para evitar migraciones simultáneas dentro del mismo esquema de base de datos. Por lo tanto, si varias aplicaciones deben usar el mismo esquema de base de datos por alguna razón, es mejor usar nombres no predeterminados para las tablas de metadatos para evitar bloqueos.

Ahora que la estructura de alto nivel está clara, debe decidir cómo organizar los conjuntos de cambios dentro de cada categoría.

ejemplo de organización de conjunto de cambios

Depende en gran medida de los requisitos específicos de la aplicación, pero los siguientes puntos suelen ser razonables:

  1. Mantenga los registros de cambios agrupados por lanzamientos de su producto. Cree un nuevo directorio para cada versión y coloque los archivos de registro de cambios correspondientes en él. Tenga un registro de cambios raíz e incluya registros de cambios que correspondan a los lanzamientos. En los registros de cambios de la versión, incluya otros registros de cambios que comprendan esta versión.
  2. Tenga una convención de nomenclatura para los archivos de registro de cambios y los identificadores de conjuntos de cambios, y sígala, por supuesto.
  3. Evite conjuntos de cambios con muchos cambios. Prefiere varios conjuntos de cambios a un solo conjunto de cambios largo.
  4. Si usa procedimientos almacenados y necesita actualizarlos, considere usar el runOnChange="true" del conjunto de cambios en el que se agrega ese procedimiento almacenado. De lo contrario, cada vez que se actualice, deberá crear un nuevo conjunto de cambios con una nueva versión del procedimiento almacenado. Los requisitos varían, pero a menudo es aceptable no realizar un seguimiento de dicho historial.
  5. Considere aplastar los cambios redundantes antes de fusionar las ramas de funciones. A veces, sucede que en una rama de características (especialmente en una de larga duración) los conjuntos de cambios posteriores refinan los cambios realizados en conjuntos de cambios anteriores. Por ejemplo, puede crear una tabla y luego decidir agregarle más columnas. Vale la pena agregar esas columnas al cambio inicial de createTable si esta rama de características aún no se fusionó con la rama principal.
  6. Utilice los mismos registros de cambios para crear una base de datos de prueba. Si intenta hacerlo, es posible que pronto descubra que no todos los conjuntos de cambios son aplicables al entorno de prueba, o que se necesitan conjuntos de cambios adicionales para ese entorno de prueba específico. Con Liquibase, este problema se resuelve fácilmente usando contextos . Simplemente agregue el atributo context="test" a los conjuntos de cambios que deben ejecutarse solo con pruebas, y luego inicialice Liquibase con el contexto de test habilitado.

Retrocediendo

Al igual que otras soluciones similares, Liquibase admite la migración de esquemas "hacia arriba" y "hacia abajo". Pero tenga cuidado: deshacer las migraciones puede no ser fácil y no siempre vale la pena el esfuerzo. Si decidió respaldar las migraciones de deshacer para su aplicación, sea coherente y hágalo para cada conjunto de cambios que deba deshacer. Con Liquibase, deshacer un conjunto de cambios se logra agregando una etiqueta de rollback que contiene los cambios necesarios para realizar una reversión. Considere el siguiente ejemplo:

 <changeSet author="..."> <createTable tableName="PRODUCT"> <column name="ID" type="BIGINT"> <constraints primaryKey="true" primaryKeyName="PK_PRODUCT"/> </column> <column name="CODE" type="VARCHAR(50)"> <constraints nullable="false" unique="true" uniqueConstraintName="UC_PRODUCT_CODE"/> </column> </createTable> <rollback> <dropTable tableName="PRODUCT"/> </rollback> </changeSet>

La reversión explícita es redundante aquí porque Liquibase realizaría las mismas acciones de reversión. Liquibase puede revertir automáticamente la mayoría de los tipos de cambios admitidos, por ejemplo, createTable , addColumn o createIndex .

arreglando el pasado

Nadie es perfecto, y todos cometemos errores. Algunos de ellos pueden descubrirse demasiado tarde cuando ya se han aplicado cambios estropeados. Exploremos qué se podría hacer para salvar el día.

Actualizar manualmente la base de datos

Implica jugar con DATABASECHANGELOG y su base de datos de las siguientes maneras:

  1. Si desea corregir conjuntos de cambios incorrectos y ejecutarlos nuevamente:
    • Elimine filas de DATABASECHANGELOG que correspondan a los conjuntos de cambios.
    • Eliminar todos los efectos secundarios que introdujeron los conjuntos de cambios; por ejemplo, restaurar una tabla si se eliminó.
    • Arregla los conjuntos de cambios incorrectos.
    • Vuelva a ejecutar las migraciones.
  2. Si desea corregir los conjuntos de cambios incorrectos pero omitir aplicarlos nuevamente:
    • Actualice DATABASECHANGELOG configurando el valor del campo MD5SUM en NULL para aquellas filas que corresponden a los conjuntos de cambios incorrectos.
    • Corrija manualmente lo que se hizo mal en la base de datos. Por ejemplo, si se agregó una columna con el tipo incorrecto, emita una consulta para modificar su tipo.
    • Arregla los conjuntos de cambios incorrectos.
    • Vuelva a ejecutar las migraciones. Liquibase calculará la nueva suma de verificación y la guardará en MD5SUM . Los conjuntos de cambios corregidos no se volverán a ejecutar.

Obviamente, es fácil hacer estos trucos durante el desarrollo, pero se vuelve mucho más difícil si los cambios se aplican a varias bases de datos.

Escribir conjuntos de cambios correctivos

En la práctica, este enfoque suele ser más apropiado. Quizás se pregunte, ¿por qué no simplemente editar el conjunto de cambios original? La verdad es que depende de lo que haya que cambiar. Liquibase calcula una suma de verificación para cada conjunto de cambios y se niega a aplicar nuevos cambios si la suma de verificación es nueva para al menos uno de los conjuntos de cambios aplicados anteriormente. Este comportamiento se puede personalizar por conjunto de cambios especificando el runOnChange="true" . La suma de comprobación no se ve afectada si modifica las condiciones previas o los atributos del conjunto de cambios opcionales ( context , runOnChange , etc.).

Ahora, puede que se pregunte, ¿cómo corrige eventualmente los conjuntos de cambios con errores?

  1. Si desea que esos cambios se sigan aplicando a los nuevos esquemas, simplemente agregue conjuntos de cambios correctivos. Por ejemplo, si se agregó una columna con el tipo incorrecto, modifique su tipo en el nuevo conjunto de cambios.
  2. Si desea fingir que esos conjuntos de cambios incorrectos nunca existieron, haga lo siguiente:
    • Elimine los conjuntos de cambios o agregue el atributo de context con un valor que garantice que nunca más intentará aplicar migraciones con dicho contexto, por ejemplo, context="graveyard-changesets-never-run" .
    • Agregue nuevos conjuntos de cambios que revertirán lo que se hizo mal o lo arreglarán. Estos cambios deben aplicarse solo si se aplicaron cambios incorrectos. Se puede lograr con condiciones previas, como con changeSetExecuted . No olvides agregar un comentario explicando por qué lo haces.
    • Agregue nuevos conjuntos de cambios que modifiquen el esquema de la manera correcta.

Como puede ver, arreglar el pasado es posible, aunque puede que no siempre sea sencillo.

Mitigar los dolores de crecimiento

A medida que su aplicación envejece, su registro de cambios también crece, acumulando cada cambio de esquema a lo largo de la ruta. Es por diseño, y no hay nada intrínsecamente malo en esto. Los registros de cambios largos se pueden acortar eliminando las migraciones con regularidad, por ejemplo, después de lanzar cada versión del producto. En algunos casos, haría que la inicialización de un nuevo esquema fuera más rápida.

ilustración de registros de cambios aplastados

El aplastamiento no siempre es trivial y puede causar regresiones sin traer muchos beneficios. Otra gran opción es usar una base de datos semilla para evitar ejecutar todos los conjuntos de cambios. Es muy adecuado para entornos de prueba si necesita tener una base de datos lista lo más rápido posible, tal vez incluso con algunos datos de prueba. Puede pensar en ello como una forma de aplastar conjuntos de cambios: en algún momento (por ejemplo, después de lanzar otra versión), hace un volcado del esquema. Después de restaurar el volcado, aplica las migraciones como de costumbre. Solo se aplicarán los cambios nuevos porque los más antiguos ya se aplicaron antes de realizar el volcado; por lo tanto, fueron restaurados del basurero.

ilustración de una base de datos semilla

Conclusión

Deliberadamente evitamos profundizar en las funciones de Liquibase para ofrecer un artículo breve y directo, centrado en los esquemas en evolución en general. Con suerte, está claro qué beneficios y problemas genera la aplicación automatizada de migraciones de esquemas de bases de datos y qué tan bien encaja todo en la cultura DevOps. Es importante no convertir incluso las buenas ideas en dogmas. Los requisitos varían y, como ingenieros de bases de datos, nuestras decisiones deben fomentar el avance de un producto y no solo seguir las recomendaciones de alguien en Internet.