Cuando DoorDash pasó del reparto de comida a domicilio a la venta de comestibles y ultramarinos, tuvimos que encontrar la forma de gestionar un inventario en línea por comerciante y tienda que pasó de decenas de artículos a decenas de miles. Tener varios comerciantes de CnG en la plataforma significa actualizar constantemente sus ofertas, un enorme problema de gestión de inventario que tendría que funcionar a escala. Para resolver este problema de escalado, nuestro equipo creó una plataforma de inventario de escritura pesada capaz de seguir el ritmo de todos los cambios de la plataforma.
Before we dive in, let's define some important terminology. In simple terms, "inventory" refers to the list of items present in a specific store of a Convenience and Grocery (CnG) merchant. This list also comes with store-specific information, most importantly price and availability. Along with inventory, "catalog" refers to the information about an item that is typically common across all stores of a business. The combined information from inventory and catalog make up the view that customers see when they land on a store page on doordash.com.
Este artículo describe los retos a los que nos enfrentamos al crear la plataforma de inventario y cómo los resolvimos tras múltiples iteraciones de experimentación y análisis.
Nota: Hace unos meses, nuestros compañeros del equipo de ingeniería de DashMart publicaron un artículo en el que explicaban cómo utilizaban CockroachDB changefeed para soportar cambios de inventario en tiempo real. Desde el punto de vista organizativo, el equipo de DashMart funciona actualmente con una arquitectura ligeramente diferente a la del resto de la plataforma CnG. Mientras que ese equipo se centró más en un aspecto específico del inventario (la propagación de cambios en tiempo real), nosotros nos centraremos en los escollos generales y en cómo sortearlos mientras se construye una plataforma de ingesta de inventario con mucha escritura. Además, el equipo de ingeniería de Fulfillment publicó recientemente este artículo sobre cómo utilizan CockroachDB para escalar su sistema. También obtuvimos beneficios similares al utilizar CockroachDB en nuestro sistema.
Desafíos asociados a la gestión de inventarios de GNC
DoorDash actualiza el inventario de los comerciantes de CnG varias veces al día de tres formas distintas:
- las actualizaciones se realizan automáticamente ingiriendo los ficheros planos de inventario recibidos del comerciante
- nuestro equipo de Operaciones carga los datos de inventario mediante herramientas internas
- el inventario se actualiza mediante señales de la aplicación Dasher utilizada por un Dasher que compra en una tienda CnG
Dado que el número de tiendas CnG asciende a decenas de miles y que cada una de ellas puede contener decenas de miles de artículos, la actualización podría implicar más de mil millones de artículos en un día. Para respaldar estas operaciones, creamos una plataforma de inventario que procesara estos artículos de forma fiable, escalable y tolerante a fallos. Este sistema garantizaría que pudiéramos ofrecer a nuestros clientes una visión precisa y actualizada de todos los artículos vendibles en las tiendas.
Los requisitos técnicos de nuestra plataforma de inventario ideal
Cualquier plataforma de inventario tiene que cumplir una serie de requisitos: debe procesar un volumen muy elevado de artículos al día para ofrecer una visión actualizada del inventario en tienda de un comerciante. Tenemos que satisfacer los siguientes requisitos técnicos:
- Gran escalabilidad
- A medida que crece nuestro negocio, la plataforma de inventario necesita soportar más artículos que se añaden al sistema.
- Es necesario apoyar las actualizaciones frecuentes para mantener la frescura del inventario
- Alta fiabilidad
- Nuestro proceso debe ser fiable, de modo que todas las solicitudes válidas de actualización de inventario de los comerciantes se procesen con éxito.
- Baja latencia
- Los datos de los artículos son sensibles al tiempo, especialmente los atributos de precio y disponibilidad. Por lo tanto, debemos asegurarnos de procesar todos los artículos del comerciante con una latencia razonable. El intervalo entre la recepción de los datos del comerciante y la visualización de los datos al cliente debe ser lo más pequeño posible.
- Alta observabilidad
- Los operadores internos deben poder ver la información detallada e histórica a nivel de artículo en el sistema de gestión de inventario.
- El proceso debe tener muchas validaciones y barreras de seguridad. No todos los artículos proporcionados por el comerciante pueden mostrarse al cliente y el sistema debe ser capaz de responder por qué un artículo proporcionado por el comerciante no se muestra al cliente.
Arquitectura funcional
Para iniciar el debate técnico, comenzaremos con la arquitectura de alto nivel de nuestro canal de ingesta de inventario, esbozando el flujo y la transformación de los datos de inventario. A continuación, repasaremos los componentes principales en las secciones siguientes.
La figura 1 muestra un diseño de alto nivel de nuestro proceso de ingestión de inventarios, que es un sistema asíncrono que ingiere inventarios de múltiples fuentes diferentes, los procesa y los transmite a los sistemas posteriores, donde la vista se sirve a las entidades de cara al cliente.
Controlador API
Nuestro controlador API basado en gRPC actúa como punto de entrada de los datos de inventario a la plataforma, y se encarga de recibir y validar las señales de inventario procedentes de múltiples fuentes: comerciantes, operadores internos, Dashers, etc.
Persistencia de los alimentos crudos
La mayor parte del procesamiento del inventario tras el controlador de la API es asíncrono y se ejecuta mediante flujos de trabajo de Cadence. Almacenamos la entrada bruta recibida de un comerciante para servir a casos de uso de lectura posteriores para otros flujos de trabajo (por ejemplo, Self-Serve Inventory Manager es una herramienta orientada al comerciante que proporciona información de inventario en tiempo real a los comerciantes que se obtiene de la entrada bruta persistente). También ayuda a la observabilidad y a la resolución de problemas cuando aparecen datos inesperados en las vistas de los clientes.
Hidratación
Como se ha mencionado anteriormente, la vista detallada de un artículo de la tienda incluye atributos de inventario y de catálogo. El proceso de ingestión de inventario en DoorDash es responsable de hidratar (es decir, enriquecer) la información de inventario sin procesar con atributos de catálogo. Obtenemos estos atributos de un servicio que mantiene un equipo hermano independiente.
Cálculo del precio
De forma similar a la hidratación, también nos basamos en la configuración externa obtenida de un servicio dependiente para realizar el cálculo del precio por artículo cuando sea necesario. Por ejemplo, el precio de un artículo ponderado, como un racimo de plátanos o una bolsa de patatas, se obtiene a partir de su precio unitario y su peso medio.
Clasificación predictiva de falta de existencias
Ofrecer un estado de disponibilidad preciso para un artículo de una tienda es un problema difícil, ya que las entradas de los comerciantes no siempre son las más precisas, ni muy frecuentes. Las señales de disponibilidad de los vendedores cuando no encuentran artículos en una tienda cubren el vacío, pero no del todo. Finalmente implementamos un modelo predictivo que aprende de los datos históricos de pedidos e INF (artículo no encontrado) y clasifica si un artículo puede estar disponible en la tienda o no. Este clasificador también es un componente importante del proceso de ingestión.
Barandillas
Ninguna cadena de suministro está exenta de errores causados por fallos de código en sus propios sistemas y/o problemas en sistemas anteriores. Por ejemplo, podría haber un problema con un módulo anterior responsable de estandarizar las entradas de inventario de un comerciante antes de enviarlas a nuestra plataforma de inventario, lo que podría dar lugar a precios incorrectos por exceso o por defecto. Estos errores son importantes, ya que repercuten directamente en el cliente. Por ejemplo, un racimo de plátanos puede aparecer como 300 $ en lugar de 3,00 $ en la aplicación del cliente debido a errores en la conversión de precios. Para protegerse de estas subidas de precios, la plataforma de inventario debe establecer barandillas de seguridad (y mecanismos de alerta) que puedan detectar y restringir las actualizaciones cuando se cumplan determinadas condiciones.
Observabilidad
La plataforma de inventario ejecuta el proceso de ingesta de inventario para decenas de miles de tiendas de comestibles y de conveniencia, cada una de ellas con decenas de miles de artículos. Es muy importante tener una visibilidad completa de este proceso tanto a nivel de artículo como de tienda (estadísticas agregadas). Necesitamos saber si un artículo se ha eliminado debido a algún error en el proceso, ya que esto está directamente relacionado con el hecho de que el artículo no esté disponible en la página de la tienda. Las opciones de observabilidad que ofrece la plataforma de inventario ayudan al personal comercial y de productos a supervisar el estado del inventario de cada tienda, así como a calcular los OKR comerciales:
- Los detalles de procesamiento a nivel de artículo se pasan a una capa de Kafka que es leída por un trabajo de Flink y rellenada en Snowflake (que puede consultarse por separado).
- Los detalles a nivel de tienda se agregan y persisten en una de nuestras tablas Cockroach DB prod directamente desde el pipeline
- Las cargas útiles de entrada y salida hacia y desde la tubería se almacenan en S3 para la resolución de problemas
Fiabilidad
Debido a la gran cantidad de cálculos y servicios dependientes, nuestro inventario debe ser asíncrono. Cadence es un orquestador de flujo de trabajo sin fallos y con estado que satisface esta responsabilidad para nosotros. Ejecutamos gran parte de nuestra lógica empresarial en trabajos de Cadence de larga duración. Afortunadamente, Cadence nos proporciona características de fiabilidad y durabilidad desde el primer momento.
- Reintento: Cadence tiene la capacidad de reintentar automáticamente un trabajo si falla bruscamente. También proporciona un buen control sobre el mecanismo de reintento con la capacidad de establecer el número máximo de intentos.
- Latido: También enviamos latidos desde nuestro trabajador al servidor Cadence para indicar que nuestro trabajo está vivo. Esta es una capacidad que viene incluida con el propio Cadence.
Estas características de durabilidad gracias a Cadence permiten que la plataforma sea más fiable y tolerante a fallos.
Cambios incrementales en la solución tras el MVP
Our MVP was focused on doing as much as necessary to get the functional architecture up and running, i.e. we were initially more focused on functional correctness than scalability. When updating our MVP to the next iteration, we made incremental improvements as we gradually rolled out more merchants onto our system and/or identified performance bottlenecks in the evolving system. Let's go over a few of those incremental changes.
Cambiar la API a nivel de artículo por la API por lotes
Inicialmente, nuestro objetivo era crear un sistema basado en elementos. Para la versión MVP (mostrada en la Figura 2), creamos una API a nivel de artículo, y para crear/actualizar un artículo, el usuario tiene que llamar a nuestra API una vez. Si una tienda tiene N artículos, el usuario tendrá que llamar a la API N veces, lo que puede ocurrir en paralelo. Pero cuando tenemos que actualizar la fuente de servicio (también conocido como servicio de menú) para un gran comerciante, que podría tener decenas de miles de tiendas con cada tienda potencialmente la venta de decenas de miles de artículos, el rendimiento puede llegar a ser demasiado alto para manejar. Podríamos haber escalado añadiendo más recursos, pero pensamos en esto de otra manera.
Let's think about the use case again: when we update one store, the caller already knows the complete list of items and they could simply send us the complete list of items in one API call. The most common use case would make it possible to batch the items and send them to our service within one request so our service will take much fewer API requests. Our service could save the payload in S3 and consume it asynchronously through a Cadence job.
Of course, it is important to note that we wouldn't want to increase the batch size indefinitely because of network bandwidth limitations as well as durability concerns. So we have to find the right balance between sending enough data in a batch but also not too much.
Después de convertirla en una API por lotes (como se muestra en la Figura 3), observamos mejoras en la velocidad de procesamiento, pero seguía estando lejos de lo que deseábamos.
Optimización de tablas de bases de datos
A medida que añadíamos más métricas en cada paso, descubrimos que el acceso a la base de datos era un cuello de botella importante. Utilizamos CockroachDB, una base de datos SQL distribuida y ampliamente utilizada en DoorDash. Después de más investigación y discusión con nuestro equipo interno de almacenamiento, tomamos las siguientes medidas para optimizar nuestra base de datos:
Elija una clave primaria natural en lugar de autoincrementar una clave primaria
The tables that we were working with had been created some time ago with an artificial primary key which auto-increments. This primary key had no relation to DoorDash's business parameters. One could argue that such a key makes sense for some use cases, but looking at our query and insert/update patterns, we realized that we can reduce the load by changing the primary key to be a combined primary key naturally constructed from business parameters. Using a natural composite key helped us reduce columns and query more efficiently because all our queries are mostly centered around those business parameters. A discussion about primary key best practices for CockroachDB can be found in this documentation.
Limpieza de índices de BD
- Añadir los índices que faltan para todas las consultas. Con el tiempo habíamos añadido nuevas columnas, y con el rápido ritmo de desarrollo, se nos había pasado añadir índices que eran necesarios para los tipos de consultas que estábamos haciendo. Recopilamos cuidadosamente todas nuestras consultas y añadimos los índices de base de datos que faltaban.
- Remove unnecessary indexes. We had changed parts of our implementation with time and hadn't removed the unused indexes. Also, our natural primary key was combined and constructed from a few different fields and we did not need indexes for each of them separately, as the combined index also serves the case for querying one or more of the columns in the combined index. For example, if there is already an index for columns (A, B, C), we don't need a separate index for querying with (A, B). Note that we would need an index for (B, C), however.
Reducir el número de columnas
Nuestra tabla tenía originalmente alrededor de 40 columnas y todas las columnas se pueden actualizar al mismo tiempo en la mayoría de los casos. Así que decidimos poner algunas de las columnas que se actualizan con frecuencia en una sola columna JSONB. Hay pros y contras para mantenerlos separados frente a ponerlos juntos. Para nuestro caso de uso de texto simple y atributos enteros fusionados en un JSON, funciona muy bien.
Configurar el tiempo de vida de las tablas de crecimiento rápido
Para mantener bajo control el volumen de la base de datos y la consiguiente carga de consultas, hemos finalizado algunas tablas de alta intensidad de escritura que no necesitan tener datos durante demasiado tiempo y hemos añadido configuraciones TTL (time-to-live) para ellas en CockroachDB.
These database optimizations improved the system significantly, but we weren't quite there yet.
Modificación de la lógica de recuperación de la base de datos y las dependencias para pasar del nivel de artículo al de almacén.
Para actualizar un artículo, tendremos que obtener mucha información de la tienda y del artículo, como la tasa de inflación de la tienda y los datos del catálogo del artículo. Podríamos elegir obtener esa información bajo demanda a medida que procesamos cada artículo. O bien, antes de empezar a procesar, podríamos obtener toda la información necesaria a nivel de tienda/artículo por lotes (como se muestra en la Figura 4), y pasarla a cada uno de los artículos para procesarlos. De este modo, ahorraríamos mucho QPS a los servicios y bases de datos posteriores y mejoraríamos el rendimiento de nuestros sistemas y los suyos.
Manténgase informado con las actualizaciones semanales
Suscríbase a nuestro blog de ingeniería para estar al día de los proyectos más interesantes en los que trabaja nuestro equipo.
Please enter a valid email address.
Gracias por suscribirse.
Inserción por lotes de la base de datos en una petición a CockroachDB
Cada vez que finalizábamos el procesamiento a nivel de artículo, guardábamos el resultado en la base de datos utilizando un único upsert de artículo, lo que provocaba un QPS muy elevado en la base de datos. Tras discutirlo con nuestro equipo de almacenamiento, nos sugirieron agrupar las peticiones SQL. Así que ajustamos la arquitectura: una vez finalizado el procesamiento de cada elemento, recogemos el resultado y lo guardamos en la memoria del procesador. A continuación, agregamos las consultas con 1.000 por lote y enviamos el lote en una sola petición SQL (véase la figura 5).
Antes de la consulta por lotes
Solicitud 1
USPERT INTO nombre_tabla (columna1, columna2, columna3, ...)VALUES (valor11, valor12, valor13, ...); |
Solicitud 2
USPERT INTO nombre_tabla (columna1, columna2, columna3, ...)VALUES (valor21, valor22, valor23, ...); |
Solicitud 3
USPERT INTO nombre_tabla (columna1, columna2, columna3, ...)VALUES (valor31, valor32, valor33, ...); |
Después de la consulta por lotes
Solicitud 1
UPSERT INTO nombre_tabla (columna1, columna2, columna3, ...) VALUES (valor11, valor12, valor13, ...); UPSERT INTO nombre_tabla (columna1, columna2, columna3, ...) VALUES (valor21, valor22, valor23, ...); UPSERT INTO nombre_tabla (columna1, columna2, columna3, ...) VALUES (valor31, valor32, valor33, ...); |
Tras el cambio anterior, observamos que el QPS de solicitud de BD descendía significativamente, y nos acercábamos a un resultado deseable.
Reescribir el lote upsert de múltiples consultas a una consulta
Al cabo de unos meses, fuimos incorporando cada vez más comerciantes a este nuevo sistema y observamos que el consumo de recursos de hardware de la base de datos aumentaba considerablemente. Investigamos más a fondo y nos sugirieron que reescribiéramos las consultas por lotes. Estamos utilizando JDBI como nuestra capa de interfaz con la base de datos, y habíamos asumido incorrectamente que proporcionar la anotación @SqlBatch nos daría automáticamente el mejor rendimiento.
Antes de reescribir la consulta
Previously we batched the queries by using a JDBI built-in batch function, and that function batched the queries by adding them line by line but didn't rewrite the queries into one query. This reduced the number of connections needed by the service to write to the DB, but on reaching the DB layer, the queries were still item-level.
UPSERT INTO nombre_tabla VALUES (v11, v12, v13,...v1n);UPSERT INTO nombre_tabla VALUES (v21, v22, v23,...v2n);UPSERT INTO nombre_tabla VALUES (v31, v32, v33,...v3n);...UPSERT INTO nombre_tabla VALUES (vm1, vm2, vm3,...vmn); |
Tras la reescritura de la consulta
Ahora estamos personalizando la lógica de reescritura para fusionar esas consultas upsert en una sola consulta y CockroachDB sólo necesita ejecutar una consulta para upsert todos esos valores.
UPSERT INTO nombre_tabla VALUES (v11, v12, v13,...v1n), (v21, v22, v23,...v2n), (v31, v32, v33,...v3n), (v41, v42, v43,...v4n), (v51, v52, v53,...v5n); |
Tras el cambio en la reescritura de consultas, observamos que el rendimiento de nuestro servicio mejoró significativamente en la capa de aplicación y en la capa de almacenamiento.
- El tiempo de procesamiento por artículo se redujo en un 75% (como muestra la Figura 6).
- El QPS de almacenamiento bajó un 99% (como muestra la figura 7).
- La utilización de la CPU de almacenamiento se redujo en un 50% (como muestra la figura 8).
Conclusión
Construir y ampliar un inventario digital es difícil, ya que el tamaño de los datos del inventario digital puede ser gigantesco y, al mismo tiempo, tiene que ser preciso para ofrecer la visión correcta del inventario en la tienda. Además, el tiempo apremia, porque tenemos que mostrar al cliente el precio correcto y la disponibilidad de un artículo en cuanto recibimos esa información del comerciante. Hemos aprendido mucho sobre cómo mantener un sistema de este tipo, que requiere muchas escrituras y es escalable y fiable, lo que podría aplicarse a problemas similares en otros ámbitos. Nos gustaría destacar algunos puntos clave.
Al principio de la implantación, esfuérzate en crear un cuadro de mandos de métricas exhaustivo, de modo que cuando surjan problemas de rendimiento, sea fácil acotar el cuello de botella del sistema. En general, tener una gran visibilidad del sistema en tiempo real desde el principio puede ser muy útil.
Guarde los datos de forma que puedan ayudar al patrón de lectura y escritura. Los datos de inventario pueden no ser una lista plana de datos, pueden tener un cierto nivel de jerarquía. Podrían guardarse a nivel de artículo o a nivel de tienda, todo depende de determinar el patrón de lectura y escritura para el servicio. En nuestra capa de servicio, almacenamos el menú como un árbol porque frecuentemente leemos a nivel de menú, mientras que en la capa de ingestión, los almacenamos como basados en ítems debido a la frecuente escritura a nivel de ítem.
Batch whenever possible in API and DB. Most of the time, when we update inventory, we would update a whole store's or geolocation's inventory. Either way, there are multiple items to update, so it's best to try to batch the update instead of updating single items for each request or query.
If the business unit allows asynchronous processing, make computations asynchronous and establish a strong SLA for job time per unit (i.e. store or item). Time for single-item processing includes time spent in network communication, which adds up when there are potentially billions of items to process. Instead, if we send the entire store's inventory via one request, and on the server side use a blob storage to save the request payload, and process it asynchronously, then the client side can save the waiting time and the service can have high throughput. On this note, also establish the idea that content will be updated in near-real time instead of real-time. Cadence is a good tool for processing near-real-time jobs and has many built-in features to improve system reliability and efficiency.
Follow best practices for the applicable DB - each database will provide best practices guidance on the performance, such as CockroachDB performance best practice. Reading these carefully can help us determine the anti-patterns in the services.
Make sure to keep indexes simple and concise based on the relevant queries - no more, no less.