Shopify reemplaza Redis por MySQL en su sistema de reservas de inventario
(shopify.engineering)- El sistema de reservas de inventario es una infraestructura clave para evitar la sobreventa, es decir, que el mismo producto se venda dos veces durante el procesamiento del pago, y Shopify lo operó durante años sobre Redis
- Aprovechando la función
SKIP LOCKEDde MySQL 8, rediseñó el sistema con una estructura de 1 fila por unidad vendible en lugar de una columna de cantidad por ítem, logrando alto rendimiento sin Redis - Combinó técnicas de optimización en MySQL como claves primarias compuestas, nivel de aislamiento
READ COMMITTED, orden consistente de bloqueos y procesamiento por lotes conUNION ALLpara resolver la contención de locks y los deadlocks - El cuello de botella real no estaba en las consultas de reserva sino en la ocupación de conexiones, y al instrumentar toda la ruta de checkout logró reducir 50% las lecturas a la base de datos y 33% las transacciones
- En el pico del Black Friday 2025, procesó ventas por US$5.1 millones por minuto, manteniendo la CPU del writer por debajo de 50% y la del reader por debajo de 16%, superando el rendimiento objetivo
Contexto: requisitos de un sistema de prevención de sobreventa
- Se necesita un sistema de prevención de sobreventa (Oversell Protection) que garantice que todavía hay inventario real disponible al momento de completar el checkout
- Reserve: al iniciar el pago, bloquea temporalmente ese ítem por algunos minutos
- Claim: al completarse el pago, descuenta permanentemente la cantidad del libro mayor de inventario
- No hay margen de error en ninguna de las dos direcciones
- Si algo falla, dos personas podrían comprar el mismo producto o un artículo podría marcarse como agotado aunque sí haya inventario, causando pérdida de ventas
- Requisito de escala: Shopify representa más del 14% del ecommerce en Estados Unidos y en el Black Friday 2025 registró ventas por US$5.1 millones por minuto, 11% más que el año anterior
- Los requisitos clave son inventario multiubicación (Multi-location inventory), garantías ACID, alto rendimiento y priorizar la exactitud
Límites del modelo anterior con Redis
- En Redis, cada ítem tenía una clave de cantidad, y las reservas se procesaban con
DECR, mientras que la liberación se hacía conINCR - Problema central: los datos de reserva (Redis) y el libro mayor de inventario (MySQL) vivían en sistemas distintos
- En la etapa de Claim no se podía agrupar en una sola transacción atómica la actualización en MySQL y la limpieza en Redis
- Según el orden de ejecución, podían ocurrir sobreventas (el producto se vendió pero no se descontó del libro mayor) o subventas/infrautilización (se descontó del libro mayor pero seguía marcado como reservado)
- Tampoco había soporte para conciencia de inventario multiubicación, y además implicaba el costo operativo de mantener un clúster de Redis aparte
Solución clave: rediseño en MySQL basado en SKIP LOCKED
Estructura base: una fila por unidad (One Row Per Unit)
- En lugar de una columna de cantidad por ítem, se adoptó una estructura de 1 fila por unidad vendible
- Un ítem con 10 unidades de inventario → 10 filas; si se reservan 3 unidades, se seleccionan y mueven 3 filas dentro de una sola transacción
- Al poner las reservas y el libro mayor de inventario en la misma base de datos MySQL, reserve y claim pueden ejecutarse como transacciones ACID, eliminando la clase de bugs que aparecía con Redis
SKIP LOCKED: omite las filas bloqueadas por otras transacciones y devuelve de inmediato filas disponibles, reduciendo la contención sin esperar por la misma fila
Límite de tamaño del pool: máximo 1,000 filas por ubicación
- Se limitó a un máximo de 1,000 filas disponibles por combinación ítem/ubicación para mantener el tamaño de las tablas y el rendimiento de escaneo
- Ejemplo: evitar escenarios de 50,000 unidades de inventario × 10 ubicaciones = 500,000 filas
- Cuando el pool se agota, se activa una reposición en línea (replenishment); se aplica un lock para que solo una transacción reponga, evitando un thundering herd donde muchas transacciones insertan filas al mismo tiempo
- Si el pool llega a vaciarse por completo, solo se retrasa esa reserva; no ocurre que un comprador con inventario real disponible vea el producto como agotado
Cuatro decisiones técnicas clave
1. Reducir locks con clave primaria compuesta
- En el prototipo inicial, al usar un ID autoincremental como clave primaria, InnoDB bloqueaba tanto el índice secundario como el índice clustered, generando 2 locks de fila por reserva
- Se aplicó una clave primaria compuesta formada por
shop_id, inventory_item_id, inventory_group_id, id→ como las columnas de filtrado ya estaban en la clave primaria, los locks se redujeron a 1 - En un entorno con miles de reservas por segundo, el diseño de índices y claves primarias impacta directamente la cantidad de locks y el throughput
2. Eliminar gap locks con READ COMMITTED
- Al ejecutar
SELECT ... FOR UPDATE SKIP LOCKEDsobre una tabla vacía, se producían gap locks (incluido supremum), bloqueando losINSERTde la transacción de reposición y provocando deadlocks - Se cambió el nivel de aislamiento del valor por defecto de MySQL,
REPEATABLE READ, aREAD COMMITTED→ con eso cambió la forma en que aparecen los gap locks y la transacción de reposición pudo avanzar normalmente - Fue el primer uso en ese código base de un nivel de aislamiento no predeterminado, por lo que se necesitó un pequeño soporte en el framework para configurar el aislamiento por transacción
3. Evitar deadlocks con orden consistente de locks
- Reserve y Claim accedían a dos tablas en distinto orden, lo que causaba deadlocks
- reserve:
reserved_quantitiesINSERT→reservation_unitsDELETE - claim:
reserved_quantitiesDELETE
- reserve:
- La solución fue estandarizar el orden: reserve siempre hace primero
DELETEen la tabla de units y despuésINSERTenreserved_quantities, eliminando la espera circular (circular wait)
4. Menos round trips con lotes usando UNION ALL
- Cuando el carrito tiene varios line items, se procesan las consultas de reserva en un solo round trip usando
UNION ALL - Al reducir la cantidad total de round trips, mejoró la latencia bajo carga
El verdadero cuello de botella: no eran las consultas, sino la ocupación de conexiones
Cómo detectaron el problema
- En producción, el sistema alcanzaba un techo por debajo del throughput objetivo, aunque la latencia P90 era buena, la CPU no estaba al máximo y las consultas ya estaban optimizadas
- Síntomas observados en pruebas de carga:
- cola de threads dentro de MySQL
- al ejecutarse el trabajo acumulado en cola, la CPU se disparaba
- agotamiento de conexiones backend de MySQL en la capa de ProxySQL
Ganar visibilidad sobre las conexiones
- Capa de aplicación: se agregaron comentarios de identificación de proceso de negocio a todas las sentencias SQL con formato
/* conn_tag:checkout_completion */ - Capa de ProxySQL: se añadió análisis de esos tags y agregación del tiempo de ocupación de conexiones por llamador
- Resultado: se pudo ver de inmediato qué procesos retenían conexiones y durante cuánto tiempo
Qué encontraron y cómo lo resolvieron
- Había otro código dentro de la ruta de checkout, aparte de las reservas, que retenía conexiones mucho más tiempo del necesario
- Ese código había quedado fuera del foco de optimización porque no era el primero en llegar al límite
- Tras limpiar la ruta de checkout, el resultado fue: 50% menos lecturas en la base primaria y 33% menos transacciones
- También se eliminó otro cuello de botella al ajustar la configuración de concurrencia de threads en InnoDB, que se había definido de forma conservadora años atrás y nunca se había revisado
- Después de las mejoras, incluso en flash sales de alto volumen, la CPU del writer se mantuvo por debajo de 50% y la del reader por debajo de 16%
Método de migración: Shadow Mode
- En vez de cambiar de Redis a MySQL de una sola vez, operaron ambos sistemas en paralelo mediante Shadow Mode
- Todas las reservas se escribían simultáneamente en Redis y MySQL, mientras Redis seguía siendo la source of truth
- Así pudieron validar en paralelo la exactitud y el rendimiento de MySQL con tráfico real de producción
- Fue posible hacer la transición sin migrar reservas inflight (porque ambos sistemas seguían activos al mismo tiempo)
- Incluso después de convertir MySQL en la source of truth, se mantuvo un kill switch, y la ruta de doble escritura hizo que Redis permaneciera siempre actualizado
- El rollout se hizo gradualmente por pod, desde pods de bajo tráfico hasta los merchants de mayor volumen
Lecciones
1. Revisar decisiones antiguas
- Lo que hace 5 años no era posible con MySQL, hoy sí lo es gracias a funciones nuevas como
SKIP LOCKED - Configuraciones de “regla práctica”, como los límites de threads, deben revisarse cuando cambian la carga de trabajo y el hardware
- Si la CPU está baja pero hay queueing, hay que investigar la causa sí o sí
2. Empezar pequeño y observar
- En lugar de arrancar con todo el framework de Rails, construyeron un prototipo mínimo con un pequeño script en Ruby y MySQL
- Observar directamente el comportamiento de los locks desde una segunda terminal enseñó más que la teoría
- El patrón de instrumentación de ocupación de conexiones (tags en la capa de aplicación + agregación en el proxy) es simple de implementar y se puede poner en marcha de inmediato
1 comentarios
Hace tiempo que no aparecía un artículo que se sintiera como desarrollo de verdad.