Asosiy qismga o'tish

Oqimli qayta ishlash (streaming)

14-bob 9 kod bloki 2175 so'z

Kechki smenadagi kassir

Kechqurun. Do'konda kassir Sardor ishlaydi. Uning oldida oddiy vazifa: har soatda qancha savdo bo'lganini yozib borish.

Soat 19:00 dan 20:00 gacha bo'lgan cheklarni sanaydi, yozadi. Keyin 20:00–21:00.

Soat 21:00 bo'ldi. Sardor 20:00–21:00 oralig'ini yopdi: 47 ta chek.

Shu payt kuryer kirib keladi va qo'lida bitta chek:

— Uzr, bu chek 20:45 da chiqarilgan edi, printerda qolib ketibdi.

Endi Sardorda muammo. U hisobni allaqachon yopgan. Nima qiladi?

  1. Chekni tashlab yuboradi — hisob noto'g'ri, lekin oson.
  2. Yopilgan hisobni ochib tuzatadi — to'g'ri, lekin kechagi hisobotlarni ham qayta yuborish kerak.
  3. Chekni keyingi soatga yozadi — hisob "to'g'ri" ko'rinadi, lekin 20:45 dagi savdo 21:00 dan keyin bo'lgandek chiqadi.

Uchala javob ham noqulay. Va bu — oqimli qayta ishlashdagi eng asosiy muammo. Uning rasmiy nomi bor: kech kelgan ma'lumot (late data).

Bu bobda Sardorning muammosini dastur darajasida hal qilamiz.

Batch va oqim: farqni eslaymiz

9-bobda ikkalasini o'lchagandik. Endi amaliy tomoniga o'tamiz.

Batch — Sardor kun oxirida o'tiradi va butun kunlik cheklarni sanaydi. Hammasi qo'lida, hech narsa kechikmaydi, hisob aniq. Lekin javob kun tugagandan keyin keladi.

Oqim — Sardor har chek chiqqanda hisobni yangilab boradi. Javob doim tayyor. Lekin hisob "tugadi" degan nuqta yo'q — har doim yana bitta chek kelishi mumkin.

python19 qator
diagramma(
    {
        "Hodisalar\n(to'xtovsiz)": (0, 1.6, 2.2, 1.1, "manba"),
        "Oyna\n(19:00-20:00)": (3.2, 2.9, 2.3, 0.9, "qayta"),
        "Oyna\n(20:00-21:00)": (3.2, 1.6, 2.3, 0.9, "qayta"),
        "Oyna\n(21:00-22:00)": (3.2, 0.3, 2.3, 0.9, "qayta"),
        "Watermark\n(chegarani belgilaydi)": (6.8, 1.6, 2.8, 1.1, "kuzatuv"),
        "Natija": (10.2, 1.6, 2.0, 1.1, "xizmat"),
    },
    [
        ("Hodisalar\n(to'xtovsiz)", "Oyna\n(19:00-20:00)"),
        ("Hodisalar\n(to'xtovsiz)", "Oyna\n(20:00-21:00)"),
        ("Hodisalar\n(to'xtovsiz)", "Oyna\n(21:00-22:00)"),
        ("Oyna\n(20:00-21:00)", "Watermark\n(chegarani belgilaydi)"),
        ("Watermark\n(chegarani belgilaydi)", "Natija"),
    ],
    nom="oqim_oyna.png", kenglik=14,
    izoh="Cheksiz oqimni oynalarga bo'lamiz. Watermark oynani qachon yopishni hal qiladi.",
)

Cheksiz oqimni oynalarga bo'lamiz. Watermark oynani qachon yopishni hal qiladi.

Cheksiz oqimni oynalarga bo'lamiz. Watermark oynani qachon yopishni hal qiladi.

Ikki xil vaqt

Mana eng ko'p chalkashlik keltiradigan joy. Har hodisada ikkita vaqt bor:

Hodisa vaqti (event time) — voqea haqiqatan sodir bo'lgan payt. Chekda yozilgani: 20:45.

Qayta ishlash vaqti (processing time) — hodisa sizga yetib kelgan payt. Kuryer chekni olib kirgani: 21:03.

Sardorning muammosi aynan shu ikkisi farq qilgani uchun chiqdi.

python7 qator
import pandas as pd
show(pd.DataFrame([
    {"Vaqt turi": "Hodisa vaqti", "Manbai": "Hodisaning o'zida yozilgan",
     "Afzalligi": "Haqiqatni aks ettiradi", "Kamchiligi": "Kechikib kelishi mumkin"},
    {"Vaqt turi": "Qayta ishlash vaqti", "Manbai": "Serverning soati",
     "Afzalligi": "Hech qachon kechikmaydi", "Kamchiligi": "Haqiqatni buzadi"},
]))
Vaqt turiManbaiAfzalligiKamchiligi
Hodisa vaqtiHodisaning o'zida yozilganHaqiqatni aks ettiradiKechikib kelishi mumkin
Qayta ishlash vaqtiServerning soatiHech qachon kechikmaydiHaqiqatni buzadi

Qoida: hisobot uchun doim hodisa vaqti ishlatiladi. Aks holda internet sekinlashgan kuni sizning butun statistikangiz siljib ketadi.

Brokerni ko'taramiz

bash18 qator
docker rm -f kitob-kafka > /dev/null 2>&1
docker run -d --name kitob-kafka -p 19092:9092 \
  -e KAFKA_NODE_ID=1 -e KAFKA_PROCESS_ROLES=broker,controller \
  -e KAFKA_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093 \
  -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://localhost:19092 \
  -e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \
  -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT \
  -e KAFKA_CONTROLLER_QUORUM_VOTERS=1@localhost:9093 \
  -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \
  -e KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 \
  -e KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 \
  -e KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0 \
  apache/kafka:3.9.0 > /dev/null
for i in $(seq 1 60); do
  python3 -c "from kafka import KafkaAdminClient; KafkaAdminClient(bootstrap_servers='localhost:19092')" > /dev/null 2>&1 \
    && { echo "broker ${i} soniyada tayyor"; break; }
  sleep 1
done
Chiqish (qaytish kodi 0, 2243 ms)
broker 1 soniyada tayyor

Oyna (window) nima

Sardorning vazifasini eslang: "har soatda qancha savdo bo'lganini yozib bor".

Nega har soatda? Nega shunchaki "qancha savdo bo'ldi?" emas?

Chunki do'kon yopilmaydi. Savdo davom etaveradi. "Jami qancha?" degan savolning javobi har soniyada o'zgaradi va hech qachon tayyor bo'lmaydi.

Oqim ham xuddi shunday — u cheksiz. Hodisalar kelaveradi. "Hammasining o'rtachasi" degan savol ma'nosiz, chunki "hammasi" degan nuqta yo'q.

Shuning uchun oqimni bo'laklarga bo'lamiz. Har bo'lak — oyna (window).

Oyna — bu vaqt bo'yicha kesma: "19:00 dan 20:00 gacha". Endi savol javob berilishi mumkin bo'ladi, chunki chegara bor.

Diqqat qiling, bu sun'iy chegara. Tabiatda soat 20:00 da hech narsa o'zgarmaydi — biz uni o'zimiz qo'yamiz, javob olish uchun.

Va bo'lish usuli bitta emas. Uch xil oyna bor:

python9 qator
import pandas as pd
show(pd.DataFrame([
    {"Oyna turi": "Tumbling (ketma-ket)", "Ta'rifi": "Ustma-ust tushmaydi",
     "Misol": "Har soat: 19-20, 20-21", "Qachon": "Soatlik hisobot"},
    {"Oyna turi": "Sliding (siljuvchi)", "Ta'rifi": "Ustma-ust tushadi",
     "Misol": "Oxirgi 1 soat, har 10 daqiqada", "Qachon": "Trend kuzatish"},
    {"Oyna turi": "Session (seans)", "Ta'rifi": "Jimlik bilan ajraladi",
     "Misol": "Foydalanuvchi 30 daq jim tursa", "Qachon": "Sayt tashrifi"},
]))
Oyna turiTa'rifiMisolQachon
Tumbling (ketma-ket)Ustma-ust tushmaydiHar soat: 19-20, 20-21Soatlik hisobot
Sliding (siljuvchi)Ustma-ust tushadiOxirgi 1 soat, har 10 daqiqadaTrend kuzatish
Session (seans)Jimlik bilan ajraladiFoydalanuvchi 30 daq jim tursaSayt tashrifi

Oynali hisobni yozamiz. Kafka ga NOVA VIVO hodisalarini yuboramiz va soatlik oynalar bo'yicha sanaymiz.

shu faylni yaratingsrc/quvur/oqim.py

python51 qator
"""Oqimli qayta ishlash: oyna, watermark, kech kelgan ma'lumot."""
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any


def oyna_boshi(vaqt: datetime, daqiqa: int) -> datetime:
    """Hodisa vaqtini oyna boshiga yaxlitlaydi."""
    jami = vaqt.hour * 60 + vaqt.minute
    boshi = (jami // daqiqa) * daqiqa
    return vaqt.replace(hour=boshi // 60, minute=boshi % 60, second=0, microsecond=0)


class OynaliHisob:
    """Oyna bo'yicha sanaydi va watermark bilan yopadi.

    kechikish: watermark hodisa vaqtidan qancha orqada yuradi.
    Katta qiymat — kam yo'qotish, ko'p kutish. Kichik — teskarisi.
    """

    def __init__(self, oyna_daqiqa: int = 60, kechikish_daqiqa: int = 10) -> None:
        self.oyna_daqiqa = oyna_daqiqa
        self.kechikish = timedelta(minutes=kechikish_daqiqa)
        self.oynalar: dict[datetime, int] = defaultdict(int)
        self.yopilgan: dict[datetime, int] = {}
        self.watermark: datetime | None = None
        self.tashlangan = 0

    def qabul(self, vaqt: datetime) -> None:
        """Bitta hodisani qabul qiladi."""
        if self.watermark is None or vaqt - self.kechikish > self.watermark:
            self.watermark = vaqt - self.kechikish
            self._yop()
        oyna = oyna_boshi(vaqt, self.oyna_daqiqa)
        if oyna in self.yopilgan:
            self.tashlangan += 1          # kech keldi, oyna allaqachon yopilgan
            return
        self.oynalar[oyna] += 1

    def _yop(self) -> None:
        """Watermark dan oldingi oynalarni yopadi."""
        chegara = self.watermark - timedelta(minutes=self.oyna_daqiqa)
        for oyna in [o for o in self.oynalar if o < chegara]:
            self.yopilgan[oyna] = self.oynalar.pop(oyna)

    def hammasi(self) -> dict[str, Any]:
        return {
            "yopilgan_oynalar": len(self.yopilgan),
            "ochiq_oynalar": len(self.oynalar),
            "tashlangan_hodisa": self.tashlangan,
        }
python18 qator
python3 -c "
import json
import pandas as pd
from kafka import KafkaProducer
from kafka.admin import KafkaAdminClient, NewTopic

a = KafkaAdminClient(bootstrap_servers='localhost:19092')
if 'hodisa' not in a.list_topics():
    a.create_topics([NewTopic(name='hodisa', num_partitions=1, replication_factor=1)])

d = pd.read_csv('../datasets/ballon.csv').head(5000)
p = KafkaProducer(bootstrap_servers='localhost:19092',
                  value_serializer=lambda v: json.dumps(v).encode())
for r in d.itertuples():
    p.send('hodisa', value={'vaqt': r.vaqt, 'ombor': r.ombor})
p.flush()
print(f'{len(d):,} hodisa yuborildi')
" 2>&1 | grep -v Warning
Chiqish (qaytish kodi 0, 799 ms)
5,000 hodisa yuborildi

Endi ularni oynalar bo'yicha hisoblaymiz:

python21 qator
python3 -c "
import sys, json; sys.path.insert(0, 'src')
from datetime import datetime
from kafka import KafkaConsumer
from quvur.oqim import OynaliHisob

h = OynaliHisob(oyna_daqiqa=60, kechikish_daqiqa=10)
c = KafkaConsumer('hodisa', bootstrap_servers='localhost:19092',
                  auto_offset_reset='earliest', group_id='oyna1',
                  consumer_timeout_ms=6000)
n = 0
for m in c:
    v = json.loads(m.value)
    h.qabul(datetime.fromisoformat(v['vaqt']))
    n += 1
c.close()

print(f'qayta ishlangan hodisa: {n:,}')
for k, val in h.hammasi().items():
    print(f'  {k:22s}: {val:,}')
" 2>&1 | grep -v Warning
Chiqish (qaytish kodi 0, 7451 ms)
qayta ishlangan hodisa: 5,000
  yopilgan_oynalar      : 90
  ochiq_oynalar         : 1
  tashlangan_hodisa     : 0

Watermark: oynani qachon yopish kerak

Mana Sardorning savoliga javob.

Watermark — bu "men shu vaqtgacha bo'lgan hamma narsani ko'rdim deb hisoblayman" degan chiziq. U hodisa vaqtidan biroz orqada yuradi.

Sardor uchun bu shunday bo'lardi: "Soat 21:00 bo'ldi, lekin men hisobni 21:10 da yopaman. O'n daqiqa kechikkan cheklarni hali qabul qilaman."

Diqqat qiling, watermark soat emas. U devordagi soatga qaramaydi.

U kelgan hodisalarga qaraydi. Agar eng yangi chek 21:05 da chiqarilgan bo'lsa, watermark 21:05 minus 10 daqiqa = 20:55 bo'ladi. Ya'ni "men 20:55 gacha bo'lgan hamma chekni ko'rdim deb hisoblayman".

Nega devordagi soat emas? Chunki hodisalar butunlay to'xtashi mumkin — kechasi do'kon yopiladi. Devor soati yuraveradi va barcha oynalarni yopib tashlaydi, holbuki ertalab kechikkan chek kelishi mumkin.

Watermark ma'lumot bilan birga yuradi, vaqt bilan emas.

Endi asosiy savol: kechikishni qancha qo'yish kerak? Bu savdo (trade-off):

  • Kichik kechikish (1 daqiqa) — natija tez tayyor, lekin ko'p hodisa yo'qoladi.
  • Katta kechikish (60 daqiqa) — deyarli hech narsa yo'qolmaydi, lekin natija bir soat kutadi.

O'lchab ko'ramiz. Bir xil ma'lumot, turli kechikish:

python33 qator
python3 -c "
import sys, json; sys.path.insert(0, 'src')
from datetime import datetime
from kafka import KafkaConsumer
from quvur.oqim import OynaliHisob

hodisalar = []
c = KafkaConsumer('hodisa', bootstrap_servers='localhost:19092',
                  auto_offset_reset='earliest', group_id='oyna2',
                  consumer_timeout_ms=6000)
for m in c:
    hodisalar.append(datetime.fromisoformat(json.loads(m.value)['vaqt']))
c.close()

# hodisalar tartibsiz kelsin (haqiqiy oqimdagidek)
import random
random.seed(42)
aralash = hodisalar[:]
for i in range(0, len(aralash) - 50, 50):
    j = i + random.randint(1, 49)
    aralash[i], aralash[j] = aralash[j], aralash[i]

sarlavha = ['kechikish', 'yopilgan', 'tashlangan', 'yoqotish']
print(f'{sarlavha[0]:>12s} {sarlavha[1]:>10s} {sarlavha[2]:>12s} {sarlavha[3]:>10s}')
print('-' * 48)
for k in (0, 1, 5, 10, 30, 60):
    h = OynaliHisob(oyna_daqiqa=60, kechikish_daqiqa=k)
    for v in aralash:
        h.qabul(v)
    r = h.hammasi()
    ulush = r['tashlangan_hodisa'] / len(aralash) * 100
    print(f'{k:>10d}daq {r[\"yopilgan_oynalar\"]:>10,d} {r[\"tashlangan_hodisa\"]:>12,d} {ulush:>9.2f}%')
" 2>&1 | grep -v Warning
Chiqish (qaytish kodi 0, 6579 ms)
   kechikish   yopilgan   tashlangan   yoqotish
------------------------------------------------
         0daq         90          758     15.16%
         1daq         90          696     13.92%
         5daq         90          586     11.72%
        10daq         90          285      5.70%
        30daq         90           81      1.62%
        60daq         89            0      0.00%

Jadval savdoni aniq ko'rsatadi: kechikish oshgani sari tashlangan hodisalar kamayadi.

Watermark ni "xavfsizlik uchun" katta qo'yish

"Ehtiyot shart 6 soat qo'yaman" degan qaror mantiqiy tuyuladi, lekin u oqimning butun ma'nosini yo'q qiladi.

Oyna 6 soat yopilmasa, natija ham 6 soat kechikadi. Siz batch quvurini qurdingiz — faqat ancha murakkab kod bilan.

To'g'ri yo'l: haqiqiy kechikishni o'lchang (hodisa vaqti va kelish vaqti farqini yozib boring), keyin 99-persentilga qarab qiymat tanlang.

Kech kelgan ma'lumot jimgina yo'qoladi

Yuqoridagi kodda tashlangan hisoblagichi bor. Ko'p tizimda bunday hisoblagich yo'q.

Natijada kechki hisobot 47 o'rniga 46 chiqadi va hech kim sezmaydi. Bir yildan keyin kimdir "bizning raqamlar har doim biroz kam" deb aytadi, lekin sababni topa olmaydi.

Qoida: tashlangan hodisalarni doim sanang va monitoringga chiqaring (34-bob).

Tartibsizlik qayerdan keladi

Nega hodisalar tartibsiz keladi? Uch sabab:

  1. Tarmoq. Bitta paket boshqa marshrut bilan ketdi va kechikdi.
  2. Partition. 10-bobni eslang: tartib faqat partition ichida kafolatlanadi. Uch partitiondan o'qiyotgan iste'molchi ularni aralash ko'radi.
  3. Qurilma oflayn edi. Kuryerning telefoni tunnel ichida internetsiz qoldi; u chiqqach yig'ilgan hodisalarni birdan yubordi.

Uchinchisi eng yomoni: kechikish daqiqalar emas, soatlar bo'lishi mumkin. Shuning uchun mobil ilovali tizimlarda kech kelgan ma'lumotni butunlay tashlamaydilar — alohida "tuzatish" quvuriga yuboradilar.

Tozalash

bash2 qator
docker rm -f kitob-kafka > /dev/null 2>&1 && echo "kitob-kafka o'chirildi"
docker ps --format '{{.Names}}'
Chiqish (qaytish kodi 0, 2473 ms)
kitob-kafka o'chirildi
novavivo-postgres
novavivo-redis
stratix-med-frontend-1

Xulosa

Tushuncha Asosiy fikr
Oqim Cheksiz, shuning uchun oynalarga bo'linadi
Hodisa vaqti Voqea sodir bo'lgan payt — hisobot shunga tayanadi
Qayta ishlash vaqti Hodisa yetib kelgan payt — haqiqatni buzadi
Tumbling oyna Ustma-ust tushmaydi, soatlik hisobot uchun
Sliding oyna Ustma-ust tushadi, trend uchun
Session oyna Jimlik bilan ajraladi, tashrif uchun
Watermark "Shu vaqtgacha hammasini ko'rdim" chizig'i
Kechikish qiymati Kichik — tez, lekin yo'qotish; katta — aniq, lekin sekin
Kech kelgan ma'lumot Doim sanang, aks holda jim yo'qoladi
Tartibsizlik sabablari Tarmoq, partition, oflayn qurilma

Talaba ishi

  1. Oyna 60 daqiqa emas, 10 daqiqa bo'lsa tashlangan hodisalar qanday o'zgaradi? Nega?
  2. Aralashtirish kuchini oshiring (random.randint(1, 200)). Qaysi kechikish qiymati endi yetarli bo'ladi?
  3. OynaliHisob ga sliding oyna qo'shing: har 10 daqiqada oxirgi 60 daqiqani hisoblasin.
  4. Tashlangan hodisalarni tashlab yubormasdan alohida ro'yxatga yozing. Ularning hodisa vaqtlari qanday taqsimlangan?

Bu bobni bepul o'qidingiz.

Kitob o'rgatadi, lekin sizning kodingiz nega ishlamaganini ayta olmaydi. Kursda uy vazifangiz tekshiriladi.

Telegramda yozish Narxlar