It would be almost impossible to build a scalable backend without a scalable datastore. DoorDash's expansion from food delivery into new verticals like convenience and grocery introduced a number of new business challenges that would need to be supported by our technical stack. This business expansion not only increased the number of integrated merchants dramatically but also exponentially increased the number of menu items, as stores have much larger and more complicated inventories than typical restaurants. This increased volume of items created high latency and high failure rate in the fulfillment backend mainly caused by database scalability problems.
To address these scalability issues we did a major overhaul of the legacy architecture and adopted CockroachDB as its new storage engine. Our team's goal was to utilize CockroachDB to build a scalable and reliable backend service that could fully support our business. CockroachDB is a scalable, consistently-replicated, transactional datastore, and it's designed to run on the cloud with high fault tolerance.
Este artículo profundizará en los pasos que dimos para migrar a CockroachDB y cómo nos aseguramos de que la migración se realizara sin problemas y con éxito. Específicamente vamos a discutir:
- Cuestiones y problemas relacionados con la antigua aplicación store_items
- Por qué elegimos CockroachDB
- Cómo migramos con éxito a CockroachDB
- Resultados y enseñanzas
Los retos de la antigua implementación de store_items
store_items is a materialized view that contains catalog, inventory, and pricing data for all the convenience and grocery items. It's hosted in the PostgreSQL and used to serve item metadata to the Dasher, our name for delivery drivers, during order fulfillment.
Nuestra tabla store_items heredada había conseguido poner en marcha con éxito el nuevo negocio vertical, pero necesitaba ser mucho más escalable si queríamos soportar volúmenes 10x. Teníamos que resolver tres problemas principales:
- Problemas de rendimiento
- Problemas de mantenimiento
- Antipatrones que hay que corregir
Let's dive into each one of these:
Problemas de rendimiento:
A medida que nuestros casos de uso evolucionaron, el uso de nuestra base de datos OLTP subió a 500 GB muy rápidamente, lo que era problemático ya que nuestro tamaño de tabla PostgreSQL recomendado internamente es inferior a 500 GB. Las tablas por encima del límite pueden volverse poco fiables y empezamos a observar problemas de rendimiento. En particular, notamos inserciones SQL más lentas porque todas las actualizaciones pasaban por una sola instancia de escritor. Especialmente durante las horas punta, observamos que nuestras métricas de latencia se duplicaban en los servicios generales cuando hacíamos grandes cantidades de upserts sin partición y no por lotes, lo que aumentaba el uso de la CPU de la base de datos a más del 80%.
Problemas de mantenimiento:
El tipo de clúster de base de datos heredado de escritor único/réplica de lectura múltiple que hemos adoptado, su instancia de escritor principal se encuentra en una única zona de disponibilidad de una región, existe el riesgo de latencias percibidas por el cliente más altas a medida que continuamos expandiéndonos a diferentes georregiones y países, debido a la distancia entre los servidores y los usuarios. Tener una sola zona es también un único punto de fallo con una región de AWS que puede hacer caer todo el nuevo negocio vertical de DoorDash.
Antipatrones que hay que corregir
Un antipatrón en ingeniería de software es una respuesta común a un problema recurrente que suele ser ineficaz y corre el riesgo de ser altamente contraproducente. Detectar y abordar los antipatrones en el software desempeña un papel muy crítico en la fiabilidad y escalabilidad del sistema. Habíamos descubierto varios antipatrones en el flujo de trabajo store_items heredado:
- acceso directo a la base de datos desde la capa de negocio sin una capa de servicio de datos
- uniones de claves externas e índices secundarios excesivos que sobrecargan la base de datos
- lógica de negocio dentro de la propia consulta a la base de datos, y
- Las interfaces JDBC son de bloqueo de hilos.
- This means if you use a JDBC call, it will BLOCK the entire thread you are on (not just the coroutine - the entire underlying thread). This can have a disastrous effect on the number of requests you are able to handle simultaneously
De cada uno de ellos nos ocupamos junto con la migración, que explicaremos con más detalle a continuación.
Para los dos primeros problemas, hemos explorado soluciones:
- Dividir la tabla store_items en varias tablas para que cada una tenga menos de 500 GB.
- Utilizar el almacenamiento blob s3 para almacenar los datos reales mientras se mantienen las URL s3 en la tabla DB.
- Adoptar CockroachDB para resolver de una vez por todas el problema de la escalabilidad, manteniendo al mismo tiempo un modelo de datos estructurado y tabular basado en SQL.
Finalmente, el equipo decidió utilizar CockroachDB para resolver el problema de escalabilidad, que se explicará en detalle en la siguiente sección.
Por qué elegimos CockroachDB para sustituir a PostgreSQL
CockroachDB's distributed nature makes it perfect for our migration given its high reliability, additional features support, and a modern event-driven architecture that is more performant.
We decided on CockroachDB because it is based on a shared-nothing architecture, in which each node in the system is independent and has its own local storage. This distributed aspect of the technology can make CockroachDB more resilient to failures and more scalable, as nodes can be added or removed without disrupting the system. CockroachDB also supports distributed transactions. Cockroach DB's changefeeds enables modern event-driven architecture. Thus CockroachDB is naturally a better choice for applications requiring high resilience, scalability, and support for distributed data and transactions.
Cómo migramos a CockroachDB:
Migramos de la base de datos heredada a CockroachDB en cuatro grandes hitos.
- migrar todos los flujos de recuperación de datos heredados a una nueva capa de fachada de base de datos,
- realizar cambios en el esquema y rellenar el almacén de datos,
- ejecutar lecturas en la sombra y comparaciones de datos,
- hacer el corte y la limpieza de la base de datos.
Hablaremos de cada uno de estos hitos en las siguientes secciones.
Construcción de la capa de la fachada de la base de datos:
Lo primero que hicimos como parte de la migración fue reducir todo el acceso directo a la base de datos a través de una capa de fachada de servicios denominada Retail Fulfillment Data Service (RFDS). Identificamos tres o cuatro patrones de consulta predominantes y creamos API gRPC en un nuevo servicio específico para atender estos patrones de consulta clave.
Ejecutar la base de datos detrás de una fachada de servicio tenía múltiples ventajas:
- Callers of RFDS don't need to know the low-level database schema as long as we were able to keep the service API intact.
- Añadir cualquier lógica de almacenamiento en caché será más fácil para un mejor rendimiento.
- Data migration (which is the primary reason in our case) is easier because of CockroachDB's distributed nature.
- Leveraging DoorDash's standardized gRPC service offerings to improve the downstream query reliability and observability, e.g. health check, rate limiting, load shedding, metrics, logging, error handling, dashboards, etc.
Dado que diferentes consultas/clientes solicitaban diferentes columnas, utilizamos protobuf fieldmask para permitir a los clientes especificar los atributos en los que estaban interesados. Esta característica ayuda a mantener la API simplificada y a evitar errores y transferencias de datos innecesarias. Como parte de la primera fase de la migración, trabajamos con nuestros clientes para migrar a Retail Fulfillment Data Service (RFDS) mientras seguimos trabajando en el resto del proceso de migración.
Manténgase informado con las actualizaciones semanales
Suscríbase a nuestro blog de ingeniería para recibir actualizaciones periódicas sobre los proyectos más interesantes en los que trabaja nuestro equipo.
Introduzca una dirección de correo electrónico válida.
Gracias por suscribirse.
Opciones clave de diseño de bajo nivel:
El diablo está en los detalles. Aquí enumeramos las principales decisiones de diseño a bajo nivel que tomamos y que nos ayudaron:
- Mejor integración y aprovechamiento de CockroachDB
- Cambios de esquema
- Familias de columna
- Columnas JSONB
- Eliminar los antipatrones de implantación heredados
- Eliminar las uniones
- Reducir el uso de índices
- Manipulación de datos de varias filas
A continuación repasaremos todos estos atributos.
Cambios de esquema: La sabiduría convencional sugiere minimizar los cambios que hacemos como parte de migraciones complejas. Sin embargo, hemos optado conscientemente por modificar el esquema para aprovechar mejor los puntos fuertes de la base de datos subyacente. Esta elección también nos ayudó a saltarnos uno o dos pasos para llegar antes a nuestro estado final. Creemos que migrar a los clientes al nuevo Retail Fulfillment Data Service(RFDS), servicio de fachada interna, y aislarlos del esquema subyacente es la mejor razón para asumir un riesgo tan grande en la migración.
Adopt column families: When a CockroachDB table is created, all columns are stored as a single column family. This default approach ensures efficient key-value storage and performance in most cases. However, when frequently updated columns are grouped with seldom updated columns, the seldom updated columns are nonetheless rewritten on every update. Especially when the seldom updated columns are large, it's more performant to split them into a distinct family. We looked into legacy query patterns for store_items and found that there are certain columns in store_items such as price, availability, or flag that change often compared to the rest of the columns such as bar-code, image link, etc. After isolating frequently updated columns into separate column families, we can update only non-static columns instead of a full row replacement. This improved the SQL update performance by over 5X.
Leverage JSONB columns: While looking into CockroachDB feature list, we found that CockroachDB's JSONB columns can be very helpful for several reasons.
- Actualización parcial del nivel superior
- SerDes Protobuf schema <> JSON through JDBI codec to make the json schema versioned, backward compatible, and strongly typed
- Añadir/eliminar un campo de una columna JSONB no requiere una migración de tabla de base de datos
- Se admite la indexación de un campo en una columna JSONB
- La columna calculada se puede utilizar para persistir un campo de JSONB a una columna separada si es necesario.
- La restricción de comprobación puede utilizarse para la validación de campos json
Así que decidimos agrupar las columnas de la tabla store_items de PostgreSQL heredada en diferentes columnas JSONB de CockroachDB basadas en su correspondiente familia de columnas
Eliminación de las uniones SQL: Una de las decisiones conscientes que hemos tomado como parte de esta migración es evitar las uniones SQL para poder mejorar el rendimiento general del sistema. Las uniones SQL en tablas de gran tamaño son algunas de las operaciones más costosas que, por lo general, no se recomiendan en un servicio en línea. Además, acoplaban la lógica de negocio a la consulta SQL, lo que no es una convención de codificación preferida. Hemos sustituido la unión SQL por llamadas de servicio descendentes o simples consultas SQL select, de modo que el conjunto de datos resultante pueda unirse en memoria a través del código.
Reducción del uso de índices: Con la migración, redujimos drásticamente el número de índices secundarios de ocho (en la tabla original) a dos índices secundarios (en la nueva tabla). El uso de la clave única integrada formada por columnas compuestas en los propios datos nos permitió renunciar a la clave artificial de autoincremento y al UUID generado aleatoriamente como identificadores únicos y ahorrar en índices secundarios. Auditar los patrones de consulta después de segregar las uniones y convertirlos en una simple API Get nos permitió mantener el número de índices secundarios en dos.
Manipulación de datos en varias filas: Para las sentencias INSERT, UPSERT y DELETE, una única sentencia de varias filas es más rápida que varias sentencias de una sola fila. Por lo tanto, utilizamos sentencias de varias filas para las consultas DML en lugar de varias sentencias de una sola fila, lo que mejora drásticamente el rendimiento de escritura y el rendimiento.
Cambio definitivo a CockroachDB
Una vez que todos nuestros clientes migraron al RFDS para sus casos de uso de lectura y escritura, empezamos a rellenar las tablas en la instancia de CockroachDB como parte de cada operación de escritura en tiempo real en store_items. Paralelamente, también empezamos a realizar escrituras masivas, haciendo que nuestros trabajos de inyección de inventario comercial escribieran tanto en la instancia de base de datos heredada como en CockroachDB. Primero creamos CockroachDB como una sombra con la base de datos heredada como primaria. La sombra se rellena a través de llamadas asíncronas y esta es una elección consciente que hicimos para evitar mantener CockroachDB como parte del flujo crítico.
Para comprobar la equivalencia de ambas tablas, en la ruta de lectura leímos tanto de la instancia de la base de datos heredada como de la instancia de CockroachDB de la nueva tabla, como se muestra en la figura 1. Comparamos datos tanto sobre la existencia de los mismos elementos de almacén en ambas tablas como sobre la equivalencia de atributos en caso de que existieran ambos elementos de almacén. La API Mapdifference resultó especialmente útil para detectar sesgos en las tablas y nos ayudó a detectar cualquier ruta de actualización que faltara.
Con el tiempo, comparamos los resultados y corregimos los errores que causaban diferencias. Una vez que tuvimos la certeza de que nuestras lecturas eran coherentes en ambos almacenes de datos, cambiamos los papeles de primaria y sombra, convirtiendo CockroachDB en la base de datos primaria y la base de datos heredada en la sombra, de modo que la sombra fuera la alternativa cuando la primaria no estuviera disponible. Repetimos un análisis similar y esta vez incluimos también métricas empresariales clave para su validación.
Una vez que tuvimos en cuenta las diferencias observadas (filas antiguas/vacías, valores por defecto/faltantes, etc.) y corregimos las actualizaciones que faltaban, estuvimos listos para transferir el tráfico a la nueva tabla. Utilizamos un indicador de función para dirigir dinámicamente las consultas a la tabla heredada o al nuevo servicio. Desplegamos gradualmente el indicador de función para desviar el tráfico al nuevo servicio por completo y ampliamos el nuevo servicio y el clúster CockroachDB según fuera necesario.
Resultados
Además de eliminar el cuello de botella del escalado, también mejoramos considerablemente el rendimiento general de las consultas con la migración y gracias a las decisiones que tomamos sobre la marcha.
The first example here is as depicted in figure 2: select query performance based on store_id dropped by ~38%. The reason for that is that in CockroachDB, store_id is the first column in the composite primary key; however, it's just an ordinary secondary index in the PostgreSQL table.
Para el resto de consultas en línea, como se muestra en la figura 3, hay dos patrones principales:
- Consulta de dd_menu_item_ids
- It's designed to be a secondary index for both data stores, the performance is on par
- Consulta contra store_id + merchant_supplied_id
- It's the composite key in the CockroachDB table however it's just a composite secondary index in the PostgreSQL table, the query performance is significantly improved (10x faster) in the CockroachDB compared to the PostgreSQL
Conclusión
Un backend escalable debe estar respaldado por un almacén de datos escalable. Cada tecnología tiene sus pros y sus contras, y no existe una solución única. Lo que se presenta en este artículo es una de las formas de abordar el problema de la escalabilidad del almacén de datos OLTP, mientras tanto también compartimos la deuda tecnológica en el diseño heredado, la adopción de nuevas características en CockroachDB y cómo planificar una migración sin errores. He aquí el resumen:
- DoorDash tiene un límite de tamaño recomendado de 500 GB para una sola tabla PostgreSQL
- CockroachDB's shared-nothing architecture makes it a perfect fit for high resilience, scalability, and support for distributed data and transactions.
- Eliminar en el nuevo diseño la deuda tecnológica, como claves externas, índices secundarios redundantes, sentencias SQL join, etc.
- Primero escritura dual, luego lectura en la sombra y, por último, transición basada en banderas de características para una migración sin problemas y sin errores.
Quienes se encuentren en una situación similar de cuello de botella en la escalabilidad de la base de datos OLTP pueden seguir los siguientes pasos:
- Simplifique al máximo la consulta SQL, traslade la unión de datos del nivel SQL al código del servicio o cree una vista materializada para almacenar la unión precalculada.
- Utilizar la clave única integrada formada por columnas compuestas en los propios datos. Esto nos permitió renunciar a la clave artificial de autoincremento y al UUID generado aleatoriamente como identificadores únicos y ahorrar en índices secundarios
- Utilización de sentencias de varias filas en las consultas DML para sustituir a varias sentencias de una sola fila
- Crear una capa de fachada para unificar las interfaces de acceso a los datos y aislar la aplicación del motor de almacenamiento y facilitar la migración.
- Entienda que CockroachDB es naturalmente una mejor opción para aplicaciones que requieren alta resiliencia, escalabilidad y soporte para datos y transacciones distribuidas.
Esta migración nos ha permitido resolver muchos problemas de rendimiento y de deuda tecnológica heredada. Además, ahora estamos en una fase en la que podemos manejar una carga 10 veces mayor a medida que seguimos ampliando nuestro nuevo negocio vertical.