Ir al contenido

Blog


Intercambio en caliente de tablas de producción para rellenar bases de datos de forma segura

21 de octubre de 2020

|
Justin Lee

Justin Lee

En DoorDash utilizamos diversos almacenes de datos para impulsar nuestro negocio, pero una de nuestras herramientas principales son los datos relacionales clásicos basados en Postgres. A medida que nuestro negocio crece y nuestra oferta de productos se amplía, nuestros modelos de datos evolucionan, lo que requiere cambios de esquema y rellenos de las bases de datos existentes.

Cuando DoorDash era más pequeño y estaba en menos zonas horarias, era razonable tomarse unos minutos de inactividad por la noche para realizar este tipo de operaciones de datos. Pero a medida que hemos crecido para incluir comerciantes, clientes y Dashers en más de 4.000 ciudades en dos continentes, ya no es aceptable tener tiempo de inactividad en nuestro sistema. Nuestro alcance global significa que tenemos que diseñar soluciones para realizar operaciones importantes en tablas enormes sin interrumpir el negocio.

Durante nuestra fase de precrecimiento, la forma más obvia de rellenar una nueva columna era simplemente añadir la columna como anulable y, a continuación, iniciar un proceso en segundo plano para rellenar las filas por lotes. Pero algunas de nuestras tablas se han hecho tan grandes e incluyen tantos índices que este proceso es demasiado lento para ceñirse a cualquier tipo de calendario de producto razonable.

Recientemente, nuestro equipo de almacenamiento ha perfeccionado una técnica de backfilling para nuestras bases de datos Postgres que nos permite reconstruir completamente una tabla -cambiando los tipos y las restricciones de varias columnas a la vez- sin afectar a nuestros sistemas de producción. La ventaja inesperada de esta técnica es que podemos intercambiar transaccionalmente las dos tablas tanto hacia delante como hacia atrás, lo que nos permite probar con seguridad la nueva tabla y volver a cambiarla si surgen problemas, manteniendo la integridad de los datos.

Este método redujo un proyecto previsto de tres meses a menos de una semana, al tiempo que nos permitía actualizar tablas en un entorno de producción. No solo pudimos añadir nuestro nuevo campo, sino que también tuvimos la oportunidad de limpiar todo el modelo de datos, corrigiendo incoherencias de datos y añadiendo restricciones.

Necesidad de rellenar los datos

No todos los cambios de esquema requieren un relleno. A menudo, es más fácil añadir una columna anulable y crear un comportamiento predeterminado para los valores NULL en el código de la aplicación. Aunque es rápido, este proceso tiene desventajas, como no poder añadir restricciones a nivel de base de datos. Si el código de la aplicación olvida por error establecer el valor, obtendrá un comportamiento por defecto, que puede no ser el deseado.

Sin embargo, algunos cambios de esquema requieren un backfill. Por ejemplo, cambiar los tipos de datos de las claves primarias requiere que se actualicen todos los datos históricos. Del mismo modo, la desnormalización por razones de rendimiento requiere rellenar los datos históricos si no se puede implementar un comportamiento por defecto razonable.

Dificultades del relleno in situ de tablas de gran tamaño

Intentar actualizar todas las filas de una tabla de producción de gran tamaño presenta varios problemas. 

Uno de los problemas es la velocidad. Actualizar una columna en mil millones de filas en Postgres equivale a borrar mil millones de filas e insertar mil millones de filas, gracias al funcionamiento encubierto del Control de concurrencia multiversión (MVCC). Las filas antiguas tendrán que ser recogidas por el proceso VACUUM. Todo esto ejerce una gran presión sobre la infraestructura de datos, utiliza ciclos de cálculo y puede sobrecargar los recursos, lo que provoca ralentizaciones en los sistemas de producción.

El número de índices en la tabla amplifica esta presión. Cada índice de una tabla requiere efectivamente otro par de inserción/borrado. La base de datos también tendrá que buscar la tupla en el montón, lo que requiere leer esa parte del índice en la caché. En DoorDash, nuestros accesos a datos de producción tienden a concentrarse en la cola de nuestros datos, por lo que las lecturas serializadas de un backfill ejercen presión sobre las cachés de la base de datos.

El segundo problema es que si las escrituras se producen demasiado rápido, nuestras réplicas de lectura pueden quedarse atrás con respecto al escritor principal. Este problema de retraso de réplica ocurre en DoorDash porque hacemos un uso intensivo de las réplicas de lectura de AWS Aurora para dar servicio al tráfico de base de datos de solo lectura para nuestros sistemas de producción. En Postgres estándar, las réplicas de lectura se mantienen actualizadas con la primaria mediante la lectura del registro de escritura anticipada (WAL), que es un flujo de páginas actualizadas que fluye desde el escritor primario a las réplicas de lectura. Aurora Postgres utiliza un mecanismo diferente para mantener las réplicas actualizadas, pero también sufre un problema análogo de retraso en la replicación. Las réplicas de Aurora suelen retrasarse menos de 100 milisegundos, lo que es suficiente para la lógica de nuestra aplicación. Pero sin una monitorización cuidadosa, descubrimos que es bastante fácil que el retraso de las réplicas supere los 10 segundos, lo que, como era de esperar, causa problemas de producción.

El tercer gran problema es que incluso los cambios de esquema "seguros", como la ampliación de un archivo INT columna a BIGINTpuede descubrir errores inesperados en el código de producción que no son triviales de localizar por simple inspección. Puede ser desesperante alterar un esquema en uso sin un plan de respaldo.

La solución a todos estos problemas es evitar alterar la tabla de producción in situ por completo. En su lugar, la copiamos a una tabla sombra ligeramente indexada, reconstruimos los índices después e intercambiamos las tablas.

Creación de una tabla sombra

El primer paso consiste en crear una tabla sombra con un esquema idéntico al de la tabla de origen:

CREATE TABLE shadow_table (LIKE source_table); 

La nueva tabla sombra tiene el mismo esquema que la fuente, pero sin ninguno de los índices. Necesitamos un índice en la clave primaria para poder realizar búsquedas rápidas durante el proceso de relleno:

ALTER TABLE shadow_table ADD PRIMARY KEY (id);

La última etapa consiste en modificar el esquema de la nueva tabla. Dado que estamos reescribiendo toda la tabla, esta es una buena oportunidad para descargar cualquier deuda técnica acumulada a lo largo del tiempo. Las columnas que antes se añadían a la tabla como anulables por comodidad ahora pueden rellenarse con datos reales, lo que permite añadir un elemento NOT NULL restricción. También podemos ampliar los tipos, por ejemplo tomando INT columnas a BIGINT columnas.

ALTER TABLE shadow_table ALTER COLUMN id type BIGINT;
ALTER TABLE shadow_table ALTER COLUMN uuid SET NOT NULL;
ALTER TABLE shadow_table ALTER COLUMN late_added_column SET NOT NULL;

Escribir la función de copia

A continuación crearemos una función Postgres que copiará y rellenará filas al mismo tiempo. Utilizaremos esta función tanto en el trigger, que mantendrá las filas nuevas y actualizadas sincronizadas con la tabla sombra, como en el script de backfill, que copiará los datos históricos. 

La función es esencialmente un INSERT emparejado con un SELECT utilizando COALESCE para rellenar las columnas nulas. En este ejemplo, no hemos añadido ninguna columna, por lo que nos basamos en el hecho de que las dos tablas tienen columnas en el mismo orden, pero si esta operación hubiera añadido columnas, podríamos tratarlas aquí enumerándolas explícitamente en la sentencia INSERT.

CREATE OR REPLACE FUNCTION copy_from_source_to_shadow(INTEGER, INTEGER)
RETURNS VOID AS $$

INSERT INTO shadow_table
SELECT
  id,
  COALESCE(uuid, uuid_generate_v4())
  created_at,
  COALESCE(late_added_column, true),
  ...
FROM source_table
WHERE id BETWEEN $1 AND $2
ON CONFLICT DO NOTHING

$$ LANGUAGE SQL SECURITY DEFINER;

Esos COALESCE son las partes esenciales--el efecto aquí es "mira a ver si un valor es NULL y, si es así, sustituirlo por esto otro". El uso de COALESCE() nos ha permitido reparar más de una docena de columnas al mismo tiempo.

En INT a BIGINT La conversión es gratuita con esta técnica. Basta con modificar el esquema de la tabla sombra antes de iniciar el procedimiento y el INSERT se encarga de la promoción de tipos.

Por último, queremos asegurarnos de no causar ningún daño, por lo que esta función está escrita de forma que se minimice el riesgo de que el script de relleno sobrescriba accidentalmente datos más recientes con datos antiguos. La característica de seguridad clave aquí es la función ON CONFLICT DO NOTHINGlo que significa que es seguro ejecutar esta función varias veces sobre el mismo rango. Más adelante veremos cómo tratar las actualizaciones en el disparador.

Ajuste del activador

Es posible que incluso los desarrolladores de aplicaciones muy versados en los entresijos de SQL no hayan tenido la oportunidad de utilizar un disparador de base de datos, ya que esta característica de las bases de datos no suele integrarse en los marcos de trabajo del lado de la aplicación. Un disparador es una potente función que nos permite adjuntar SQL arbitrario a varias acciones de una forma transaccionalmente segura. En nuestro caso, adjuntaremos nuestra función de copia a cada tipo de sentencia de modificación de datos (INSERT, UPDATEy DELETE) para asegurarnos de que todos los cambios realizados en la base de datos de producción se reflejarán en la copia sombra.

El desencadenante real es sencillo, salvo que para UPDATE realiza una DELETE y INSERT dentro de una transacción. Borrar y reinsertar manualmente de esta forma nos permite reutilizar la función principal de relleno (que de otra forma no haría nada debido a la función ON CONFLICT DO NOTHING). También asegura que no cometeremos un error y sobrescribiremos los datos más recientes porque la función de relleno no puede realizar un UPDATE.

CREATE OR REPLACE FUNCTION shadow_trigger()
RETURNS TRIGGER AS
$$
BEGIN
  IF ( TG_OP = 'INSERT') THEN
      PERFORM copy_from_source_to_shadow(NEW.id, NEW.id);
      RETURN NEW;
  ELSIF ( TG_OP = 'UPDATE') THEN
      DELETE FROM shadow_table WHERE id = OLD.id;
      PERFORM copy_from_source_to_shadow(NEW.id, NEW.id);
      RETURN NEW;
  ELSIF ( TG_OP = 'DELETE') THEN
     DELETE FROM shadow_table WHERE id = OLD.id;
      RETURN OLD;
  END IF;
END;
$$ LANGUAGE PLPGSQL SECURITY DEFINER;

CREATE TRIGGER shadow_trigger
AFTER INSERT OR UPDATE OR DELETE ON source_table
FOR EACH ROW EXECUTE PROCEDURE shadow_trigger();

Realización del relleno

Para el backfill real, utilizamos un script Python personalizado que utiliza una conexión directa a la base de datos en un shell de producción. La ventaja es que el desarrollo es interactivo, podemos hacer pruebas en un entorno de ensayo y podemos detenerlo al instante si algo va mal. La desventaja es que sólo el ingeniero que tiene acceso a ese shell de producción puede detenerlo, por lo que debe ejecutarse mientras alguien puede supervisarlo y detenerlo si algo va mal.

En nuestra primera ronda de rellenado, la velocidad fue varios órdenes de magnitud más rápida que en nuestros intentos anteriores de modificación in situ de la tabla de producción original. Alcanzamos unas 10.000 filas por segundo.

De hecho, el verdadero problema es que estábamos escribiendo demasiado rápido para que nuestras réplicas pudieran mantener el ritmo bajo carga de producción. Nuestras réplicas Postgres suelen tener un retardo de replicación inferior a 20 milisegundos, incluso con una carga elevada.

Con una arquitectura de microservicios, es habitual que se inserte o actualice un registro y que otro servicio lo lea inmediatamente. La mayor parte de nuestro código es resistente a un ligero retraso en la replicación, pero si el retraso es demasiado grande, nuestro sistema puede empezar a fallar.

Esto es exactamente lo que nos ocurrió justo al final del backfill: el retardo de replicación alcanzó los 10 segundos. Nuestra sospecha es que, dado que Aurora Postgres solo transmite las páginas almacenadas en caché en las réplicas, solo tuvimos problemas cuando empezamos a tocar datos más recientes que residían en páginas calientes.

Independientemente de la causa, resulta que Aurora Postgres expone el retardo de replicación instantánea mediante la siguiente consulta: 

SELECT max(replica_lag_in_msec) as replica_lag FROM
aurora_replica_status();

Ahora utilizamos esta comprobación en nuestros scripts de relleno entre INSERT declaraciones. Si el retardo es demasiado alto, simplemente dormimos hasta que desciende por debajo de niveles aceptables. Comprobando el retardo, es posible mantener el relleno durante todo el día, incluso con mucha carga, y tener la seguridad de que este problema no volverá a surgir.

Hacer el canje

Postgres puede realizar cambios de esquema en una transacción, incluyendo el renombrado de tablas y la creación y eliminación de triggers. Se trata de una herramienta extremadamente potente para realizar cambios en un sistema de producción en funcionamiento, ya que podemos intercambiar dos tablas de forma transaccional. Esto significa que ninguna transacción entrante verá nunca la tabla en un estado inconsistente: las consultas empezarán a fluir de la tabla antigua a la nueva al instante.

Y lo que es aún mejor, la función de copia y disparo puede ajustarse para que fluya en sentido inverso. El sitio COALESCE Por supuesto, hay que suprimir las declaraciones y, si hay diferencias en las columnas, hay que tenerlas en cuenta, pero estructuralmente el activador inverso es la misma idea.

De hecho, cuando intercambiamos las tablas por primera vez durante esta operación en particular, descubrimos un error en un código heredado de Python que comprobaba expresamente el tipo de una columna. Al tener el trigger inverso en su lugar y tener a mano un swap inverso, volvimos instantáneamente a la tabla antigua sin pérdida de datos para darnos tiempo a preparar nuestro código para los cambios de esquema. El procedimiento de doble intercambio mantuvo ambas tablas sincronizadas en ambas direcciones y no causó ninguna interrupción en nuestro sistema de producción.

Esta capacidad de alternar entre dos mesas manteniéndolas sincronizadas es el superpoder de esta técnica.

Conclusión

Todos los esquemas de bases de datos evolucionan con el tiempo, pero en DoorDash, tenemos un conjunto de demandas de productos en constante evolución, y tenemos que satisfacer esas demandas siendo fluidos y dinámicos con nuestras bases de datos. Los periodos de inactividad o mantenimiento no son aceptables, por lo que esta técnica no sólo nos permite realizar cambios de esquema con seguridad y confianza, sino que también nos permite hacerlos mucho más rápido que los backfills in situ tradicionales.

Aunque esta solución en particular está adaptada para Postgres y utiliza algunas características específicas de AWS Aurora, en general esta técnica debería funcionar en casi cualquier base de datos relacional. Aunque no todas las bases de datos tienen características DDL transaccionales, esta técnica sigue minimizando el periodo de interrupción al tiempo que se tarda en realizar el intercambio. 

En el futuro, podemos considerar el uso de esta técnica para otros tipos de cambios de esquema que ni siquiera impliquen un backfill, como la eliminación de índices poco utilizados. Dado que recrear un índice puede llevar más de una hora, la eliminación de cualquier índice conlleva un riesgo considerable. Pero al tener dos versiones de la misma tabla sincronizadas al mismo tiempo, podemos probar con seguridad este tipo de cambios con un riesgo mínimo para nuestro sistema de producción.

Agradecimientos

Muchas personas han colaborado en este proyecto. Muchas gracias a Sean Chittenden, Robert Treat, Payal Singh, Alessandro Salvatori, Kosha Shah y Akshat Nair.

Sobre el autor

Trabajos relacionados

Ubicación
San Francisco, CA; Mountain View, CA; Nueva York, NY; Seattle, WA
Departamento
Ingeniería
Ubicación
San Francisco, CA; Sunnyvale, CA
Departamento
Ingeniería
Ubicación
San Francisco, CA; Sunnyvale, CA; Seattle, WA
Departamento
Ingeniería
ID de trabajo: 3013456
Ubicación
Pune, India
Departamento
Ingeniería
Ubicación
San Francisco, CA; Seattle, WA; Sunnyvale, CA
Departamento
Ingeniería