Потоци от събития
Накратко: Потокът от събития, като Kafka, е дневник, в който всяко събитие се записва веднъж, а всяка система го чете със своето темпо.
Проблем
Една поръчка, четири системи
Клиент 7 поръчва за 12,40 €. Складът трябва да приготви пакета, счетоводството да издаде фактура, пощата да прати имейл, а анализите да преброят продажбата.
Ако кодът на поръчката вика четирите системи една след друга, пощенският сървър, който не отговаря, проваля цялата поръчка. Или поръчката минава, а имейлът се губи. Всяка нова система (например точки за лоялност) значи промяна в кода на поръчката.
Таблицата orders пази какво е поръчката сега. Тя не пази кой вече е разбрал за нея и какво е станало преди. Трябва място, където магазинът казва веднъж „поръчката е направена“, а всеки, който се интересува, го прочита, когато може.
Идеята
Дневник от събития
Събитие е нещо, което вече е станало: „поръчка 61 е направена“, „поръчка 61 е платена“. Магазинът го записва в края на дневник и забравя за него. Складът, фактурите и пощата четат дневника всеки със своето темпо. Ако пощата е спряла за час, тя продължава оттам, докъдето е стигнала, и нищо не се губи.
Такъв дневник е поток от събития (event stream). Kafka го пази в теми (topic), например orders. Темата е разделена на дялове (partition), за да се пише и чете от много машини наведнъж. Всеки запис получава пореден номер в своя дял: отместване (offset). Номерата само растат.
produce записва събитие и казва в кой дял и под кой номер е отишло. read показва всичко: p1:0 е дял 1, номер 0. На картинката всеки ред е дял, клетките са записите с номерата им (под номера е краят на ключа), а клетката с пунктир е мястото на следващия запис.
Записите не се променят и не се трият: команди като update и delete дават грешка. Ако нещо се поправи, това е ново събитие.
По-точно, за любопитните
Командите на симулатора са по-четим вариант на инструментите на Kafka (kafka-topics, kafka-console-producer, kafka-console-consumer, kafka-consumer-groups):
create topic orders partitions 3: нова тема с 3 дяла;topicsизброява темите;produce orders key=customer-7 {"order":61,"total":12.40}: записва събитие; ключът е по желание;read orders [partition 1] [from 0]: показва записите, без да помни нищо;consume orders group billing [as billing-2] [max 5] [from-beginning] [no-commit]: чете като група и записва докъде е стигнала;offsets orders: краят на всеки дял и мястото на всяка група;describe group billing: мястото, краят и изоставането на групата във всеки дял;groupsизброява групите;reset group billing to 0(илиearliest,latest): мести мястото на групата.
no-commit е само на симулатора: показва потребител, който е прочел, но е паднал, преди да запише мястото си. Симулаторът чете дял по дял (първо 0, после 1, после 2). Истинският потребител взима порции от няколко дяла наведнъж и редът между тях не е определен.
Виж го
Редът се губи между дяловете
Без ключ записите се разпределят между дяловете (симулаторът ги редува: 0, 1, 2, 0). Поръчка 61 е направена, платена и изпратена. Пощата чете:
Пощата вижда „shipped“ на поръчка 61 преди „placed“ и праща „пратката ти е на път“ за поръчка, за която още не знае. Редът е гарантиран само вътре в един дял.
Затова се дава ключ. Програмата, която пише, смята от ключа число (хеш) и взима остатъка при деление на броя дялове. Същият ключ винаги дава същия дял. Хешът на симулатора е сборът от кодовете на знаците: за customer-7 той е 982, а 982 при деление на 3 дава остатък 1, затова дял 1. Kafka ползва друг хеш (murmur2), но идеята е същата.
Ключът е това, чийто ред има значение: номерът на поръчката за събитията на поръчката или клиентът, ако е важен редът на всичките му поръчки.
Пробвай
Събитията на една поръчка
Темата orders има 3 дяла. Запиши трите събития на поръчка 61 подред (placed, paid, shipped) с ключ order-61, така че всеки потребител да ги прочете в този ред.
Решение
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"}
Три пъти produce orders key=order-61 …. Трите отиват в един дял (1) под номера 0, 1 и 2, и всеки потребител ги чете подред. Без ключ отиват в три различни дяла и редът се губи. Ключът пази реда, в който ги запишеш: ако запишеш „shipped“ преди „paid“, така ще ги прочетат.
Идеята
Групи потребители
Всяка система чете като група потребители (consumer group): billing, email, warehouse. Групата помни докъде е стигнала във всеки дял. След четене тя записва това място (commit), за да продължи оттам и след рестарт. Две групи не си пречат: всяка получава всяко събитие.
billing е прочела всичко, а email само два записа. Разликата между края на дяла и мястото на групата е изоставането (lag). На картинката всяка група има свой ред под всеки дял: оцветеното е прочетено, а ▲ е мястото, откъдето ще чете. Растящо изоставане значи, че групата не смогва.
Вътре в една група дяловете се поделят. Ако фактурите се бавят, пускаш второ копие със същата група, и всяко чете своите дялове:
Затова броят дялове е горната граница: при 3 дяла четвъртото копие стои без работа.
Нова група без from-beginning започва от края: вижда само събитията от този момент нататък. from-beginning важи само за група, която още няма записано място.
Отделът за анализи започва днес и иска всички поръчки досега. Прочети ги с нова група analytics.
Решение
consume orders group analytics from-beginning
consume orders group analytics from-beginning. Новата група няма място и започва от първия запис. Без from-beginning започва от края и не прочита нищо. А с групата billing анализите щяха да вземат мястото на фактурите: тя вече е прочела всичко.
Виж го
Две фактури за една поръчка
Групата записва мястото си след като е прочела. Ако потребителят падне между обработката и записа, след рестарта получава същите събития отново:
Поръчки 64 и 61 идват два пъти. Ако billing праща фактура при всяко четене, клиентът получава две. Затова потребителят трябва да е идемпотентен: второто прилагане на същото събитие не променя нищо. Най-честият начин е базата сама да откаже второто:
UNIQUE по номера на поръчката и ON CONFLICT DO NOTHING правят второто събитие безвредно.
По-точно, за любопитните
„Точно веднъж“ (exactly once) е трудно. Обратният ред (първо записваш мястото, после обработваш) дава „най-много веднъж“: при падане събитието се губи. Затова обикновено се избира „поне веднъж“ (at least once) плюс идемпотентен потребител. Kafka има идемпотентен производител и транзакции, които пазят „точно веднъж“ при четене и запис в самия Kafka. Имейл, който е излязъл, никоя транзакция не връща.
Идеята
Пак от началото
Отчетът на анализите е сбъркан: забравил е доставката. Поправката е готова, но старите поръчки вече са прочетени. Дневникът пази събитията, затова групата може да се върне назад и да ги прочете пак (replay):
reset group analytics to 0 мести мястото на групата в началото на всеки дял, а следващото четене минава всичко наново. Другите групи не усещат нищо. from-beginning тук не помага: групата вече има място и то печели.
Повторното четене е безопасно само за идемпотентен потребител. Отчет, който се смята наново, е наред. Фактури, които се пращат наново, не са.
Записите все пак не стоят вечно. Темата има срок на пазене (retention): събитията се трият, когато остареят (в Kafka по подразбиране след 7 дни) или когато надхвърлят зададен размер. Трие ги възрастта, а не потребителите: прочетеното от всички стои до срока, а непрочетеното изчезва след него. Симулаторът не моделира срока.
По-точно, за любопитните
В истинския Kafka kafka-consumer-groups --reset-offsets иска групата да е спряна и --execute, иначе само показва какво би направил. Има и теми с компактиране (log compaction): за всеки ключ се пази поне последният запис, а по-старите записи със същия ключ се трият.
Идеята
Състоянието е сборът от събитията
В таблицата products наличността на сиренето е едно число:
Числото е 18, но не казва защо. Колко са дошли, колко са продадени, имало ли е връщане? UPDATE пише върху старото и историята изчезва.
Event sourcing пази събитията като истина, а състоянието се смята от тях: наличността е сборът от всички промени. Същото в SQL, с текущата наличност след всяко събитие:
А така изглежда в потока. Ключът е продуктът, затова всичките му събития са в един дял и подред:
Събитията дават историята наготово: кой какво е направил и кога, наличността към всеки минал ден, и ново изчисление върху старите събития. Цената е, че за текущото състояние трябва да се минат всички събития. Затова програмите пазят и готово състояние (снимка), което обновяват от потока. Събитията остават завинаги, така че и форматът им трябва да се мени внимателно.
Упражнение
Връщане, копие и корекция
1. Групите billing и analytics са прочели всичко в orders.
Сметката на анализите е поправена. Върни групата analytics в началото на orders, за да прочете всичко наново. Не пипай billing и не чети още.
Решение
reset group analytics to 0
reset group analytics to 0 (или to earliest). offsets orders показва analytics на 0 във всеки дял, а billing в края. consume … from-beginning не помага: групата вече има място. reset group billing to 0 щеше да прати фактурите наново.
2. billing е прочела всичко, а после идва поръчка 65 (в дял 2).
Пусни второ копие на фактурите с име billing-2 в същата група, така че да поеме част от дяловете и да прочете новото в тях.
Решение
consume orders group billing as billing-2
consume orders group billing as billing-2. Двете копия делят дяловете: billing-1 взима 0 и 1, а billing-2 взима 2 и чете поръчка 65. group billing-2 е друга група: тя щеше да получи всяко събитие отделно, и всеки клиент щеше да вземе по две фактури.
3. В темата stock са събитията на сиренето (продукт 7): доставка 24, продажби 4 и 3.
Клиентът от поръчка 61 върна едно сирене. Добави събитието {"product":7,"change":1,"why":"returned"} така, че да е в историята на сиренето, след продажбите.
Решение
produce stock key=product-7 {"product":7,"change":1,"why":"returned"}
produce stock key=product-7 {"product":7,"change":1,"why":"returned"}. Събитието отива в дял 2 с останалите на продукт 7, под номер 3. Без ключ отива в друг дял и редът с продажбите се губи. Да промениш старата продажба не става: записите не се пипат, а поправката е ново събитие.
Идеята
Кога поток, кога не
Поток от събития е добър, когато за едно нещо трябва да научат много системи, всяка със свое темпо, и когато историята има стойност: поръчки и плащания, движения на склада, кликове и следене на поведение, дневници на сървърите. Той не е добър, когато искаш отговор веднага („има ли сирене на склад?“, „колко е общата сума?“), търсене и промяна на запис. За това е SQL базата, която обикновено стои до потока и се пълни от него.
По-точно, за любопитните
Kafka е най-разпространеният: теми, дялове, групи, мястото на групите се пази в самия Kafka (в темата __consumer_offsets). Redpanda приема същия протокол като Kafka, затова програмите и инструментите на Kafka работят с нея. RabbitMQ е преди всичко опашка, от която съобщението изчезва след потвърждение. Неговите streams са отделен вид опашка: дневник, който се чете от произволно място и може да се чете наново. Amazon Kinesis Data Streams нарича дяловете shards и по подразбиране пази записите 24 часа.
В една страница в браузъра същата идея е шината за събития (event bus, publish/subscribe): един бутон съобщава „добавено в количката“, а броячът и сумата се абонират. Разликата е, че шината не пази нищо: който не слуша в момента, пропуска събитието. Дневникът пази събитията и позволява да се прочетат пак.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: INSERT: нови редове · UPDATE: промяна на редове · UPSERT: вмъкни или обнови · Ограничения: правила за данните
- Продължава в: Как се избира база
- Задачи по темата: Видове бази данни