Flujos de eventos
En resumen: Un flujo de eventos como Kafka es un registro en el que cada evento se escribe una vez y cada sistema lo lee a su ritmo.
Problema
Un pedido, cuatro sistemas
El cliente 7 hace un pedido de 12,40 €. El almacén tiene que prepararlo, el equipo de facturas tiene que emitir una factura, el servicio de correo tiene que enviar un mensaje y el análisis tiene que contar la venta.
Si el código del pedido llama a los cuatro sistemas uno tras otro, un servidor de correo que no responde hace fallar todo el pedido. O el pedido se hace y el correo se pierde. Cada sistema nuevo (digamos, los puntos de fidelidad) significa un cambio en el código del pedido.
La tabla orders guarda lo que el pedido es ahora. No guarda quién lo sabe ya ni qué pasó antes. Necesitas un lugar donde la tienda diga «el pedido se realizó» una vez, y todos a los que les importe lo lean cuando puedan.
La idea
Un registro de eventos
Un evento es algo que ya ha ocurrido: «el pedido 61 se realizó», «el pedido 61 se pagó». La tienda lo escribe al final de un registro y se olvida. El almacén, las facturas y el correo leen cada uno el registro a su ritmo. Si el servicio de correo está caído una hora, sigue desde donde se quedó, y no se pierde nada.
Un registro así es un flujo de eventos. Kafka lo guarda en temas, por ejemplo orders. Un tema se divide en particiones, para que muchas máquinas puedan escribir y leer a la vez. Cada registro recibe el siguiente número de su partición: el offset. Los números solo suben.
produce escribe un evento y te dice a qué partición fue y con qué número. read lo muestra todo: p1:0 es la partición 1, offset 0. En el dibujo cada fila es una partición, las celdas son los registros con sus números (bajo el número está el final de la clave), y la celda de trazos es el lugar al que irá el siguiente registro.
Los registros no se modifican ni se borran: comandos como update y delete dan un error. Si algo se corrige, eso es un evento nuevo.
Más precisamente, para curiosos
Los comandos del simulador son una versión más legible de las herramientas de Kafka (kafka-topics, kafka-console-producer, kafka-console-consumer, kafka-consumer-groups):
create topic orders partitions 3: un tema nuevo con 3 particiones;topicsenumera los temas;produce orders key=customer-7 {"order":61,"total":12.40}: escribe un evento; la clave es opcional;read orders [partition 1] [from 0]: muestra los registros y no recuerda nada;consume orders group billing [as billing-2] [max 5] [from-beginning] [no-commit]: lee como grupo y guarda hasta dónde llegó;offsets orders: el final de cada partición y la posición de cada grupo;describe group billing: la posición, el final y el retraso del grupo en cada partición;groupsenumera los grupos;reset group billing to 0(oearliest,latest): mueve la posición del grupo.
no-commit existe solo en el simulador: muestra a un consumidor que ha leído pero se cayó antes de guardar su posición. El simulador lee partición por partición (primero la 0, luego la 1, luego la 2). Un consumidor real recoge lotes de varias particiones a la vez, y el orden entre ellas no está definido.
Míralo
El orden se pierde entre particiones
Sin clave, los registros se reparten entre las particiones (el simulador las toma por turno: 0, 1, 2, 0). El pedido 61 se realiza, se paga y se envía. El servicio de correo lee:
El servicio de correo ve «shipped» del pedido 61 antes que «placed» y envía «tu paquete está en camino» para un pedido que aún no conoce. El orden solo está garantizado dentro de una partición.
Para eso sirve la clave. El programa que escribe calcula un número a partir de la clave (un hash) y toma el resto de dividirlo por el número de particiones. La misma clave da siempre la misma partición. El hash del simulador es la suma de los códigos de los caracteres: para customer-7 es 982, y 982 dividido por 3 deja de resto 1, o sea, la partición 1. Kafka usa otro hash (murmur2), pero la idea es la misma.
La clave es aquello cuyo orden hay que conservar: el número de pedido para los eventos de un pedido, o el cliente si importa el orden de todos sus pedidos.
Pruébalo
Los eventos de un pedido
El tema orders tiene 3 particiones. Escribe los tres eventos del pedido 61 en orden (placed, paid, shipped) con la clave order-61, para que cada consumidor los lea en ese orden.
Solución
produce orders key=order-61 {"order":61,"event":"placed"}
produce orders key=order-61 {"order":61,"event":"paid"}
produce orders key=order-61 {"order":61,"event":"shipped"}
Tres veces produce orders key=order-61 …. Los tres van a una partición (la 1) con los números 0, 1 y 2, y cada consumidor los lee en orden. Sin clave van a tres particiones distintas y el orden se pierde. La clave conserva el orden en que los escribes: si escribes «shipped» antes que «paid», así se leerán.
La idea
Grupos de consumidores
Cada sistema lee como un grupo de consumidores: billing, email, warehouse. El grupo recuerda hasta dónde ha llegado en cada partición. Después de leer guarda esa posición (un commit), así que sigue desde ahí incluso tras un reinicio. Dos grupos no se estorban: cada uno recibe todos los eventos.
billing ha leído todo, y email solo dos registros. La diferencia entre el final de la partición y la posición del grupo es el retraso (lag). En el dibujo cada grupo tiene su propia fila bajo cada partición: la parte coloreada está leída, y ▲ es donde leerá a continuación. Un retraso que crece significa que el grupo no da abasto.
Dentro de un grupo las particiones se reparten. Si la facturación va lenta, arrancas una segunda copia con el mismo grupo, y cada copia lee sus propias particiones:
Por eso el número de particiones es el límite superior: con 3 particiones una cuarta copia se queda sin hacer nada.
Un grupo nuevo sin from-beginning empieza por el final: ve solo los eventos de ahora en adelante. from-beginning vale solo para un grupo que todavía no tiene una posición guardada.
El equipo de análisis empieza hoy y quiere todos los pedidos hasta ahora. Léelos con un grupo nuevo analytics.
Solución
consume orders group analytics from-beginning
consume orders group analytics from-beginning. El grupo nuevo no tiene posición y empieza por el primer registro. Sin from-beginning empieza por el final y no lee nada. Y con el grupo billing el análisis se llevaría la posición de las facturas: ese grupo ya ha leído todo.
Míralo
Dos facturas para un pedido
El grupo guarda su posición después de leer. Si el consumidor se cae entre el procesamiento y el guardado, tras el reinicio recibe otra vez los mismos eventos:
Los pedidos 64 y 61 llegan dos veces. Si billing envía una factura cada vez que lee, el cliente recibe dos. Por eso el consumidor tiene que ser idempotente: aplicar el mismo evento una segunda vez no cambia nada. La forma más habitual es dejar que la base de datos rechace el segundo:
UNIQUE sobre el número de pedido y ON CONFLICT DO NOTHING hacen inofensivo el segundo evento.
Más precisamente, para curiosos
«Exactamente una vez» es difícil. El otro orden (guardar primero la posición, procesar después) da «como máximo una vez»: si hay una caída, el evento se pierde. Por eso lo habitual es «al menos una vez» más un consumidor idempotente. Kafka tiene un productor idempotente y transacciones, que mantienen el «exactamente una vez» para leer y escribir dentro del propio Kafka. Un correo que ya salió no lo puede recuperar ninguna transacción.
La idea
Otra vez desde el principio
El informe del análisis estaba mal: olvidó el coste de envío. La corrección está lista, pero los pedidos antiguos ya se han leído. El registro guarda los eventos, así que el grupo puede volver atrás y leerlos otra vez (reproducción, replay):
reset group analytics to 0 mueve la posición del grupo al principio de cada partición, y la siguiente lectura vuelve a pasar por todo. Los demás grupos no notan nada. from-beginning no ayuda aquí: el grupo ya tiene una posición, y la posición gana.
La reproducción solo es segura para un consumidor idempotente. Un informe que se vuelve a calcular no pasa nada. Unas facturas que se vuelven a enviar, sí.
Los registros tampoco se quedan para siempre. Un tema tiene un período de retención: los eventos se borran cuando se hacen demasiado antiguos (en Kafka, por defecto, a los 7 días) o cuando superan un tamaño fijado. Los borra la antigüedad, no los consumidores: lo que todos han leído se queda hasta entonces, y lo que nadie ha leído desaparece después. El simulador no modela la retención.
Más precisamente, para curiosos
En el Kafka real kafka-consumer-groups --reset-offsets necesita que el grupo esté parado y --execute, si no solo muestra lo que haría. También hay temas compactados (log compaction): de cada clave se conserva al menos el último registro, y los registros más antiguos con la misma clave se borran.
La idea
El estado es la suma de los eventos
En la tabla products el stock de queso blanco es un solo número:
El número es 18, pero no dice por qué. ¿Cuánto entró, cuánto se vendió, se devolvió algo? UPDATE escribe encima del valor antiguo y la historia desaparece.
Event sourcing guarda los eventos como la verdad, y el estado se calcula a partir de ellos: el stock es la suma de todos los cambios. Lo mismo en SQL, con el stock después de cada evento:
Y así se ve en el flujo. La clave es el producto, para que todos sus eventos estén en una partición y en orden:
Los eventos te dan la historia gratis: quién hizo qué y cuándo, el stock de cualquier día pasado y un nuevo cálculo sobre los eventos antiguos. El coste es que el estado actual significa recorrer todos los eventos. Por eso los programas guardan también un estado ya preparado (una instantánea) que actualizan desde el flujo. Los eventos se quedan para siempre, así que también su formato tiene que cambiar con cuidado.
Ejercicio
Rebobinar, una segunda copia y una corrección
1. Los grupos billing y analytics han leído todo en orders.
El cálculo del análisis está corregido. Mueve el grupo analytics de vuelta al principio de orders para que lea todo otra vez. No toques billing y no leas todavía.
Solución
reset group analytics to 0
reset group analytics to 0 (o to earliest). offsets orders muestra analytics en 0 en cada partición y billing al final. consume … from-beginning no ayuda: el grupo ya tiene una posición. reset group billing to 0 volvería a enviar las facturas.
2. billing ha leído todo, y entonces llega el pedido 65 (a la partición 2).
Arranca una segunda copia de la facturación llamada billing-2 en el mismo grupo, para que se quede con parte de las particiones y lea lo nuevo que haya en ellas.
Solución
consume orders group billing as billing-2
consume orders group billing as billing-2. Las dos copias se reparten las particiones: billing-1 toma la 0 y la 1, y billing-2 toma la 2 y lee el pedido 65. group billing-2 es otro grupo: recibiría cada evento por separado, y cada cliente recibiría dos facturas.
3. El tema stock tiene los eventos del queso blanco (producto 7): una entrega de 24, ventas de 4 y de 3.
El cliente del pedido 61 devolvió un queso. Añade el evento {"product":7,"change":1,"why":"returned"} para que esté en la historia del queso, después de las ventas.
Solución
produce stock key=product-7 {"product":7,"change":1,"why":"returned"}
produce stock key=product-7 {"product":7,"change":1,"why":"returned"}. El evento va a la partición 2 con los demás del producto 7, con el número 3. Sin clave va a otra partición y se pierde su orden respecto a las ventas. Cambiar la venta antigua no es posible: los registros no se tocan, y una corrección es un evento nuevo.
La idea
Cuándo un flujo y cuándo no
Un flujo de eventos es bueno cuando muchos sistemas tienen que enterarse de una cosa, cada uno a su ritmo, y cuando la historia vale algo: pedidos y pagos, movimientos de stock, clics y seguimiento de usuarios, registros de servidores. No es bueno cuando necesitas una respuesta al instante («¿hay queso en stock?», «¿cuál es el total?»), buscar, o cambiar un registro. Para eso está la base de datos SQL, que suele estar junto al flujo y se llena a partir de él.
Más precisamente, para curiosos
Kafka es el más usado: temas, particiones, grupos, y las posiciones de los grupos se guardan en el propio Kafka (en el tema __consumer_offsets). Redpanda habla el mismo protocolo que Kafka, así que los programas y las herramientas de Kafka funcionan con él. RabbitMQ es ante todo una cola, donde un mensaje desaparece cuando se confirma. Sus streams son otro tipo de cola: un registro que se puede leer desde cualquier posición y volver a leer. Amazon Kinesis Data Streams llama shards a las particiones y por defecto guarda los registros 24 horas.
Dentro de una página del navegador la misma idea es un bus de eventos (publicar/suscribirse): un botón anuncia «añadido al carrito», y el contador y el total se suscriben. La diferencia es que un bus no guarda nada: quien no está escuchando en ese momento se pierde el evento. Un registro conserva los eventos y te deja leerlos otra vez.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: INSERT: insertar filas · UPDATE: cambiar valores · Upsert: insertar o cambiar · Restricciones: reglas para los datos
- Continúa en: Cómo elegir una base de datos
- Ejercicios sobre el tema: Tipos de bases de datos