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.
Antes de entrar en materia, definamos algunos términos importantes. En términos sencillos, "inventario" se refiere a la lista de artículos presentes en una tienda específica de un comerciante de conveniencia y comestibles (CnG). Esta lista también incluye información específica de la tienda, sobre todo el precio y la disponibilidad. Junto con el inventario, el "catálogo" se refiere a la información sobre un artículo que suele ser común a todas las tiendas de una empresa. La información combinada de inventario y catálogo conforma la vista que los clientes ven cuando aterrizan en la página de una tienda en 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.
Retos 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 nuestro negocio crece, 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 controles. 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 mencionó 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 Kafka que es leída por un trabajo 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
Nuestro MVP se centró en hacer todo lo necesario para poner en marcha la arquitectura funcional, es decir, al principio nos centramos más en la corrección funcional que en la escalabilidad. Al actualizar nuestro MVP a la siguiente iteración, realizamos mejoras incrementales a medida que íbamos incorporando más comerciantes a nuestro sistema y/o identificábamos cuellos de botella de rendimiento en el sistema en evolución. Repasemos algunos de esos cambios incrementales.
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.
Pensemos de nuevo en el caso de uso: cuando actualizamos una tienda, la persona que llama ya conoce la lista completa de artículos y podría simplemente enviarnos la lista completa de artículos en una llamada a la API. El caso de uso más común permitiría agrupar los artículos por lotes y enviarlos a nuestro servicio en una sola solicitud, de modo que nuestro servicio recibiría muchas menos solicitudes de API. Nuestro servicio podría guardar la carga útil en S3 y consumirla de forma asíncrona a través de un trabajo de Cadence.
Por supuesto, es importante tener en cuenta que no querríamos aumentar el tamaño del lote indefinidamente debido a las limitaciones de ancho de banda de la red, así como por cuestiones de durabilidad. Así que tenemos que encontrar el equilibrio adecuado entre enviar suficientes datos en un lote pero tampoco demasiados.
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
Las tablas con las que estábamos trabajando se habían creado hacía algún tiempo con una clave primaria artificial que se autoincrementaba. Esta clave primaria no tenía ninguna relación con los parámetros de negocio de DoorDash. Se podría argumentar que dicha clave tiene sentido para algunos casos de uso, pero al observar nuestros patrones de consulta e inserción/actualización, nos dimos cuenta de que podíamos reducir la carga cambiando la clave primaria por una clave primaria combinada construida de forma natural a partir de parámetros de negocio. El uso de una clave compuesta natural nos ayudó a reducir las columnas y a realizar consultas de forma más eficiente, ya que todas nuestras consultas se centran principalmente en esos parámetros de negocio. Se puede encontrar una discusión sobre las mejores prácticas de clave primaria para CockroachDB en esta documentación.
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.
- Eliminar índices innecesarios. Habíamos cambiado partes de nuestra implementación con el tiempo y no habíamos eliminado los índices no utilizados. Además, nuestra clave primaria natural estaba combinada y construida a partir de varios campos diferentes y no necesitábamos índices para cada uno de ellos por separado, ya que el índice combinado también sirve para consultar una o varias de las columnas del índice combinado. Por ejemplo, si ya existe un índice para las columnas (A, B, C), no necesitamos un índice separado para consultar con (A, B). Sin embargo, necesitaríamos un índice para (B, C).
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.
Estas optimizaciones de la base de datos mejoraron notablemente el sistema, pero aún no habíamos llegado al final.
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 a procesar. 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 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.
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 dentro de una petición SQL (como se muestra en la figura 5).
Antes de la consulta por lotes
Solicitud 1
USPERT INTO nombre_tabla (columna1, columna2, columna3, ...)VALUES (valor11, valor12, valor13, ...); |
Petición 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
Anteriormente agrupábamos las consultas por lotes utilizando una función por lotes incorporada en JDBI, y esa función agrupaba las consultas añadiéndolas línea por línea pero no reescribía las consultas en una sola consulta. Esto reducía el número de conexiones que necesitaba el servicio para escribir en la BD, pero al llegar a la capa de BD, las consultas seguían siendo a nivel de ítem.
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 cayó 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 escritura tan pesada, escalable y fiable, 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.
Lote siempre que sea posible en API y DB. La mayoría de las veces, cuando actualizamos el inventario, actualizamos todo el inventario de una tienda o geolocalización. De cualquier manera, hay varios elementos para actualizar, así que es mejor tratar de lote de la actualización en lugar de actualizar los elementos individuales para cada solicitud o consulta.
Si la unidad de negocio permite el procesamiento asíncrono, haga que los cálculos sean asíncronos y establezca un SLA sólido para el tiempo de trabajo por unidad (es decir, tienda o artículo). El tiempo de procesamiento de un solo artículo incluye el tiempo empleado en la comunicación de red, que se acumula cuando hay potencialmente miles de millones de artículos que procesar. En cambio, si enviamos todo el inventario de la tienda a través de una solicitud, y en el lado del servidor utilizamos un almacenamiento blob para guardar la carga útil de la solicitud, y la procesamos de forma asíncrona, entonces el lado del cliente puede ahorrar el tiempo de espera y el servicio puede tener un alto rendimiento. En este sentido, también establecer la idea de que el contenido se actualizará en tiempo casi real en lugar de en tiempo real. Cadence es una buena herramienta para procesar trabajos en tiempo casi real y tiene muchas características incorporadas para mejorar la fiabilidad y eficiencia del sistema.
Siga las mejores prácticas para la DB aplicable - cada base de datos proporcionará orientación sobre las mejores prácticas en el rendimiento, como las mejores prácticas de rendimiento de CockroachDB. Leerlas detenidamente puede ayudarnos a determinar los antipatrones en los servicios.
Asegúrese de que los índices sean sencillos y concisos en función de las consultas pertinentes, ni más ni menos.