Capítulo 01 · El suelo
Bases de datos
Dónde vive de verdad el pedido 456. Tablas, índices, réplicas, ids. Redis y la cola vienen después.
19 min intro
Alguien pulsa Comprar
Imaginate que estamos diseñando la tienda. Ana está en la ficha de las zapatillas, elige la talla 42 y pulsa Comprar. A partir de ahí el navegador no está pidiendo una página más: está esperando a que el sistema deje constancia de que ese par es suyo. Si la compra salió bien, en algún lado tiene que existir un pedido — lo vamos a llamar el 456 — que diga quién compró, qué producto, a qué precio, y que esa última 42 ya no está en la estantería para el siguiente. Hasta que eso no esté escrito, no hay compra: hay un spinner.
Ese registro no puede vivir solo en la memoria del proceso de la API, y tampoco alcanza con Redis. Redis es un caché: si la caja se reinicia un martes, las keys se van, Ana puede tener un mail de confirmación (o creer que lo tiene) y vos no tenés pedido para cumplir. Tirar un mensaje a una cola y «ya se verá» tampoco sirve como primera escritura. Si el worker todavía no corrió y alguien pregunta por el 456 — Ana refrescando «mis pedidos», soporte, el depósito — el pedido no existe. Lo primero que hace el checkout, entonces, es persistir. Después avisás a pago y a email.
El sitio del que todo lo demás es copia
Una base de datos no es «el lugar donde guardo JSON». Es un sistema que te promete cosas concretas: que un SELECT por id es rápido si hay un índice que coincide con esa query, que dos cajas no pueden vender el último par a la vez si envolvés stock y pedido en una transacción, y que si se quema un disco hay otra máquina con las mismas filas. El pedido 456, el precio 89,90 €, el stock de la talla 42 y el mail de Ana viven acá. Si Redis se cae, degradamos y leemos más lento. Si un broker se retrasa, el mail llega tarde. Si Postgres pierde el pedido, no hay tienda.
cliente → API → Postgres ← verdad
Redis ← copia (después)
cola ← pasillo (después)
Tablas, porque la tienda tiene forma
El modelo clásico para esta tienda es relacional: Postgres o MySQL, filas y columnas, tablas que se parecen a cómo pensás el negocio. orders, order_items, products, users. El 456 es una fila en orders. El 123 es una fila en products. Un order_items las une: cantidad 1, talla 42, y el precio en el momento de comprar, porque el precio de la ficha va a cambiar y el histórico del pedido no tiene que mentir.
Preguntás con SQL. SELECT * FROM orders WHERE user_id = 789 son los pedidos de Ana: ids, fechas, totales. Eso alcanza si solo necesitás la fila del 456. En «mis pedidos», sin embargo, también querés el nombre de las zapatillas, y ese nombre no vive en orders: vive en products. El puente es order_items, que dice que el 456 incluye el 123, talla 42, cantidad 1. Un join es el cruce: dos tablas se encuentran por una columna en común y cada fila del resultado mezcla datos que en disco están separados. En la práctica, orders se junta con order_items por order_id, y order_items con products por product_id; una sola query te devuelve el 456 con «Zapatillas X». Sin join, harías dos o tres round-trips y armarías el cruce en la API. Con join, lo hace la base. Ese cruce es el superpoder del modelo relacional y, cuando las tablas crecen y el join no está indexado, también es el cuello.
En una entrevista de system design no alcanza con decir «uso SQL porque hay relaciones». Mongo también relaciona, con documentos anidados o ids sueltos. Lo que se puede defender es una propiedad que esta tienda necesita en el checkout:
Postgres, because I want ACID for checkout — either the order and the stock move together or neither does.
Eso es un argumento. «Hay relaciones» no lo es.
Tres cosas que sí salen cuando dibujás este diseño en una pizarra.
Índices. Sin índice, buscar los pedidos de Ana es recorrer orders entera. Con un índice en user_id — casi siempre un B-tree — la base salta cerca de esas filas en lugar de escanearlas. El índice no es gratis: es otra estructura que hay que mantener en cada write, así que indexar todas las columnas «por si acaso» encarece los inserts y no te salva las queries que no pensaste. En la ficha 123, el PRIMARY KEY (id) ya cubre el GET por id. El listado «últimos pedidos de este user» pide otra forma, algo como (user_id, created_at). El índice es un mapa; un mapa que no coincide con la query es papel mojado.
Transacciones. Reservar stock y crear el 456 tienen que pasar juntas. Si bajás el stock y el insert del pedido falla, no podés dejar el par en el limbo: Ana cree que no compró y el 42 desapareció igual. BEGIN … COMMIT es exactamente eso: o las dos cosas o ninguna. Eso es ACID en la práctica, sin recitar el acrónimo. Cuando el stock y el cobro viven en dos mundos — Postgres de un lado, Stripe del otro — ya no hay un BEGIN que las cubra. Ahí aparece una saga, y eso es el capítulo de colas. La transacción de Postgres llega hasta la puerta de Postgres. Ni un milímetro más.
Constraints. Un UNIQUE sobre un event_id es la barrera contra procesar dos veces el mismo evento de pago. FOREIGN KEY, NOT NULL, un CHECK de cantidad positiva: la base como último adulto en la sala. Si la regla de negocio cabe en una constraint, no la dejes solo en el código del worker. El código se redeploya mal; la constraint no.
La copia que no es un USB
Hasta acá el 456 vive en una sola máquina de Postgres. Eso funciona hasta que no funciona: si en un Black Friday se quema el disco, no hay ficha 123, no hay «mis pedidos», no hay checkout. Una sola caja es un único punto de fallo. La replicación es otra máquina con las mismas filas, alimentada todo el tiempo por lo que el primario va escribiendo. No es un USB que copiaste a mano a las tres de la mañana: cada INSERT del 456, cada cambio de precio en el 123, viaja al otro disco en un flujo continuo.
Lo habitual es un primario que acepta writes y una o más réplicas que sirven lecturas. El checkout — crear el 456, bajar el stock de la 42 — va al primario, porque ahí está la verdad que se puede modificar. El catálogo, si todavía no lo servís de Redis, puede ir a réplicas: mil personas mirando la ficha 123 no tienen por qué pegarle todas a la misma caja que está cobrando.
Esa copia no es instantánea. Entre que el primario confirma el write y la réplica lo aplica hay un hueco, a veces milisegundos, a veces más si hay carga. El editor acaba de poner 79,90 € en el 123; alguien que lee la ficha de una réplica todavía ve 89,90. Es el mismo fenómeno que un caché stale, pero entre discos. El caso incómodo para Ana es otro: acabás de escribir el 456 en el primario y, en la misma request, leés «tu pedido» de una réplica. A veces el 456 todavía no llegó. Eso es read-your-writes: Ana tiene que ver lo que ella misma acaba de crear. Esa lectura va al primario, o configurás replicación síncrona y el COMMIT no vuelve hasta que la réplica también tiene la fila — más lento, más seguro, y no hace falta para quien solo está hojeando el catálogo.
Réplicas no son shards. Un shard parte los datos: el 456 en una caja, los pedidos de otro user en otra. Una réplica copia: las mismas filas, en otro disco. Sirven para no morir si cae una máquina y para repartir lecturas. No sirven cuando orders ya no cabe en una caja o cuando los writes del primario son el cuello — las réplicas no toman esos inserts. Ahí hay que partir, y es la sección que sigue.
En una entrevista de system design suele aparecer CAP. No es un triángulo para recitar. Es esta decisión, sobre esta operación: si la red se parte entre primario y réplica, ¿qué preferís? Dejar de escribir el 456 hasta saber que no vas a mentir, o seguir sirviendo la ficha 123 aunque el precio pueda estar viejo. El catálogo puede inclinarse a disponibilidad; el checkout, a no cobrar dos veces el último 42.
Cuando una caja ya no basta
Las réplicas copian las mismas filas. Eso alivia lecturas y te salva si se quema un disco; no alivia el checkout. Cada Comprar sigue siendo un INSERT y un UPDATE de stock en el primario. La tienda crece, orders llega a cientos de millones de filas, el primario escribe a tope, y añadir otra réplica no cambia nada: las réplicas no toman esos writes. Ahí hay que partir. Sharding no es «más copias»; es cortar orders en trozos y poner cada trozo en una máquina distinta. El 456 vive en una caja; el pedido de otro user, en otra. Ninguna tiene la tabla entera.
La pregunta de diseño no es «usamos shards, sí o no». Es la shard key: qué columna decide en qué caja cae cada fila. Si partes por order_id, el 456 y el 457 pueden ir a máquinas distintas aunque sean de Ana. Listar «mis pedidos» toca todos los shards, junta los resultados y los ordena: scatter-gather. Funciona hasta que no, y en entrevista se ve de lejos. Si partes por user_id, los pedidos de Ana están juntos y el 456 vive donde vive Ana. Esa query se vuelve local. El costo aparece en otro lado: el stock del 123, si products parte por product_id, está en otra caja. Cruzar «pedidos de Ana que incluyen el 123» ya no es un join en un solo Postgres; es ir a dos sitios o tener una tabla pensada para esa query.
Un hotspot es el Taylor Swift del disco: un user o un producto se lleva casi todos los writes al mismo shard. Partir por user_id parece uniforme — los users se reparte — hasta que un reseller dispara millones de pedidos. Esa máquina arde y las otras bostezan. Lo mismo si el 123 sale en oferta relámpago y shardeaste stock por product_id: todo el mundo pega en la misma caja.
Postgres puede partir tablas nativas (particiones por fecha, por rango) mucho tiempo antes de un cluster de 64 shards. En una entrevista de system design, no saltes a «shardeamos» en el minuto dos de un catálogo de diez mil productos. Salta cuando el primario ya no da, y di qué query se rompe: «mis pedidos» si la key es order_id, el join con stock si las tablas viven en mundos distintos, el reseller si la key es el user.
El id 456 ya no puede ser 457, 458, 459…
Hasta ahora el pedido era «el 456» porque una sola base tenía un SERIAL. Un autoincrement es un mostrador: la siguiente ficha, un número más. Una caja, un mostrador, cero colisiones. El día que hay dos shards, hay dos mostradores. Los dos pueden dar el 456. Colisión: dos compras distintas, el mismo id, y «dame el 456» ya no significa una sola cosa.
Hay otro problema aunque no partas todavía. Un id secuencial global es un cuello — todos los inserts pelean por el siguiente número — y a veces un leak: si Ana ve que su pedido es el 1000001, acaba de enterarse cuántos vendiste.
Hace falta un id único sin preguntarle a un único mostrador.
UUID. Lo genera el cliente o la API, sin llamar a nadie. Cero coordinación, sirve el día que hay veinte cajas generando ids a la vez. El v4 es random: cada insert cae en un sitio distinto del B-tree, las páginas se parten, los writes se vuelven más caros a medida que crece orders. El v7 va más o menos en orden de tiempo: los pedidos nuevos caen cerca unos de otros y el índice se porta mejor. Es la respuesta simple cuando no querés un servicio de ids.
Snowflake (el de Twitter, no la nube). Un entero de 64 bits armado con timestamp, un machine_id y una secuencia. Lo genera cada API sin round-trip a un contador, queda ordenado en el tiempo y cabe en un BIGINT. Hay que vigilar que los relojes no retrocedan y que dos máquinas no compartan el mismo machine_id. En una entrevista de system design es lo que suelen querer oír cuando decís «ids a escala».
Lo que no hacés: MAX(id)+1 en la aplicación. Dos requests leen el mismo máximo y ambas insertan el 457. Tampoco un autoincrement global a través de shards: volviste a un mostrador único, que era exactamente lo que el shard quería evitar. Antes de partir, SERIAL vale. Poner Snowflake en un monolito de una caja es teatro.
SQL, NoSQL, y el speech que no hay que hacer
DynamoDB, Cassandra, Mongo. Sirven. También sirve Postgres mucho más de lo que el folklore admite: JSONB, particiones, réplicas, un pool de conexiones delante. Los dos yellow flags de entrevista son simétricos. Uno: «SQL no escala, por eso NoSQL». El otro: «uso SQL porque hay relaciones». Mongo también relaciona, con documentos anidados o ids sueltos. La escala de SQL se trabaja con índices, réplicas y, más tarde, particiones o shards. Elegí una base que conozcas y hablá de esta tienda, no del género.
En el checkout de Ana, Postgres encaja porque el 456 y el stock de la 42 tienen que moverse juntos, hay constraints, y el admin va a querer joins. Un key-value como Dynamo tiene sentido en un carrito con access patterns fijos: siempre PK = USER#789, get y put, sin «zapatillas rojas en Valencia». Cassandra, si escribís como un loco y leés por clave, y aceptás que la consistencia sea más laxa que un COMMIT de Postgres.
La pregunta útil no es SQL versus NoSQL. Es cómo se accede. Si el camino feliz es «dame el 456» o «dame los pedidos del user 789», un modelo por clave funciona y te ahorra joins. Si el camino es «zapatillas rojas en Valencia en marzo», esa query no es una primary key: o la indexás, o te quedás en Postgres, o ponés un índice de búsqueda aparte (Elastic, lo que sea) y aceptás que el catálogo de búsqueda es otra copia, no la verdad del stock. NoSQL no es más rápido. Es más estrecho: brillante cuando acertás el access pattern, caro cuando aparece una query que no contemplaste.
Lo que no va en una fila
Las fotos de las zapatillas no se meten en Postgres. Un bytea de 8 MB por imagen, mil productos, es una base usada de almacén: backups eternos, réplicas que copian gigas que nadie querya, y el SELECT de la ficha 123 compitiendo con el disco que está sirviendo JPEGs. Blob storage (S3) guarda el archivo. En products, una URL. El CDN delante, para que la milésima vista de la ficha no viaje a S3 ni a la DB. La base guarda metadatos y punteros. El binario, fuera.
Images in object storage, metadata in Postgres. Es un patrón, no una nota al pie.
La tienda, con suelo
Volvemos a Ana. Pulsa Comprar. La API abre una transacción en Postgres: inserta orders (hoy un serial alcanza; Snowflake o UUID v7 el día que partas), inserta order_items, baja stock si todavía hay talla 42. Commit. Si no hay stock, rollback: no hay 456, y el par sigue en la estantería.
Las lecturas de ficha, por ahora, pueden ir a Postgres — primario si Ana acaba de comprar y tiene que verse el 456; réplica si solo está hojeando el 123. Las fotos, a S3. Si el catálogo cabe, un primario y réplicas de lectura alcanzan. Si orders ya no cabe, shard por user_id, ids únicos sin mostrador, y ojo al reseller que calienta un shard.
No hace falta nombrar cinco bases. Hace falta una de verdad para el cobro, un sitio para los bytes gordos, y honestidad sobre cuándo partes.
Comprar
→ BEGIN
→ INSERT order 456 + items
→ UPDATE stock talla 42
→ COMMIT
→ 201 al usuario
fotos 123 → S3 / CDN
ficha 123 → Postgres (el caché es el próximo capítulo)
Qué espera cada nivel
Un mid elige Postgres, nombra un índice para «mis pedidos», pone réplicas para lecturas y no shardea a los cinco minutos.
Un senior dice qué query se vuelve fea al partir, nombra el hotspot, elige ids cuando ya no hay un mostrador, y no mete las fotos en la fila. Si sale CAP, lo ata a una lectura o escritura, no al cluster entero.
Si te lo preguntan
¿Por qué Postgres? I need transactions for checkout: order row and stock have to commit together. I know how to index user_id and I can add replicas for reads.
¿SQL o NoSQL? No el speech. Postgres here because of ACID on the purchase path. If the access pattern were a single key at huge write volume, I’d consider Dynamo. I’m not picking NoSQL for scale as a slogan.
¿Cómo escalas lecturas? Replicas, then cache. Sharding is for when writes or data size break the primary.
¿Shard key? user_id so a customer’s orders live together. I’ll watch for a hot user.
¿Ids? Once we shard, no global autoincrement. Snowflake or UUID v7 so each API can mint ids without a central counter.
¿Las fotos? S3, URL in the product row. Not in Postgres.
El caché de la ficha 123 es el capítulo de caché. El hold de diez minutos mientras Ana paga — apartar el último 42 — no es esta transacción. Un BEGIN no vive diez minutos: eso es locks. El email y el cobro asíncrono, colas. Acá el suelo ya está: filas, índices, copias, trozos, y un id que no depende de un mostrador.