Asosiy qismga o'tish

Tez serving qatlami

19-bob 8 kod bloki 1986 so'z

Bu bobdagi kod shu fayllar bilan ishlaydio'z kompyuteringizda ishga tushiring

Sahifa ochilmayapti

RostUy saytida foydalanuvchi filtr bosadi: "Yunusobod, 2 xona, 5 milliongacha".

Sahifa 4 soniya aylanadi.

Siz so'rovga qaraysiz — u to'g'ri yozilgan, 18-bobdagi barcha qoidalarga rioya qilingan. Reja toza. Indeks bor. Lekin baribir sekin.

Sabab boshqa joyda: bu so'rov ombor uchun yozilgan ombordan kelmoqda. Ombor esa boshqa ish uchun qurilgan.

Ikki xil mijoz

Farqni tushunish uchun ikki xil mijozni solishtiring.

Tahlilchi. Kuniga 20 ta so'rov yuboradi. Har biri butun jadvalni o'qiydi. 10 soniya kutishga tayyor — u choy ichib keladi.

Sayt foydalanuvchisi. Sekundiga 500 ta so'rov keladi. Har biri bir nechta qator so'raydi. 300 millisekunddan ko'p kutsa — sahifani yopadi.

python8 qator
import pandas as pd
show(pd.DataFrame([
    {"": "So'rovlar soni", "Tahlil (OLAP)": "Kuniga o'nlab", "Serving": "Sekundiga minglab"},
    {"": "Har so'rov o'qiydi", "Tahlil (OLAP)": "Millionlab qator", "Serving": "O'nlab qator"},
    {"": "Kutish chegarasi", "Tahlil (OLAP)": "Soniyalar", "Serving": "Millisekundlar"},
    {"": "So'rov shakli", "Tahlil (OLAP)": "Oldindan noma'lum", "Serving": "Oldindan ma'lum"},
    {"": "Nima muhim", "Tahlil (OLAP)": "Moslashuvchanlik", "Serving": "Kechikish (p99)"},
]))
Tahlil (OLAP)Serving
So'rovlar soniKuniga o'nlabSekundiga minglab
Har so'rov o'qiydiMillionlab qatorO'nlab qator
Kutish chegarasiSoniyalarMillisekundlar
So'rov shakliOldindan noma'lumOldindan ma'lum
Nima muhimMoslashuvchanlikKechikish (p99)

Oxirgi qatorga e'tibor bering — so'rov shakli oldindan ma'lum. Sayt foydalanuvchisi istalgan so'rov yozmaydi; u faqat siz bergan filtrlarni bosadi.

Bu juda muhim, chunki ma'lum savolga oldindan tayyorlanish mumkin.

Oshxonadagi ikki usul

Restoranga qaytamiz.

Birinchi usul — buyurtma kelganda oshpaz noldan tayyorlaydi. Har taom yangi, istalgan o'zgartirish mumkin. Lekin 40 daqiqa kutasiz.

Ikkinchi usul — eng ko'p buyurtma qilinadigan beshta taom oldindan tayyorlab qo'yiladi. Buyurtma kelsa — 2 daqiqada chiqadi. Lekin menyudan tashqari narsa so'rasangiz, u yo'q.

Serving qatlami — ikkinchi usul. Siz ma'lum savollarga oldindan javob tayyorlaysiz.

Ma'lumotni tayyorlaymiz

python11 qator
python3 -c "
import duckdb, pandas as pd
d = pd.read_csv('../datasets/ijara.csv').drop_duplicates('elon_id')
con = duckdb.connect('serving.db')
con.execute('DROP TABLE IF EXISTS elon')
con.execute('CREATE TABLE elon AS SELECT * FROM d')
n = con.execute('SELECT count(*) FROM elon').fetchone()[0]
print(f'elonlar: {n:,}')
print(f'tumanlar: {con.execute(\"SELECT count(DISTINCT tuman) FROM elon\").fetchone()[0]}')
con.close()
"
Chiqish (qaytish kodi 0, 439 ms)
elonlar: 11,906
tumanlar: 10

Sayt so'rovini o'lchaymiz

Foydalanuvchining filtri — bu sayt uchun eng ko'p takrorlanadigan so'rov.

python30 qator
python3 -c "
import duckdb, time
import numpy as np
con = duckdb.connect('serving.db')

SORQV = '''
SELECT elon_id, tuman, xonalar, narx_mln, maydon
FROM elon
WHERE tuman = ? AND xonalar = ? AND narx_mln <= ?
ORDER BY narx_mln
LIMIT 20
'''

def olch(n=300):
    con.execute(SORQV, ['Yunusobod', 2, 5.0]).fetchall()
    v = []
    for _ in range(n):
        t0 = time.perf_counter()
        con.execute(SORQV, ['Yunusobod', 2, 5.0]).fetchall()
        v.append((time.perf_counter()-t0)*1000)
    return np.array(v)

v = olch()
print(f'p50 : {np.percentile(v,50):6.2f} ms')
print(f'p95 : {np.percentile(v,95):6.2f} ms')
print(f'p99 : {np.percentile(v,99):6.2f} ms')
print()
print(f'natija: {len(con.execute(SORQV, [\"Yunusobod\", 2, 5.0]).fetchall())} elon')
con.close()
"
Chiqish (qaytish kodi 0, 623 ms)
p50 :   1.08 ms
p95 :   1.25 ms
p99 :   1.47 ms

natija: 20 elon

Bu raqamlar yaxshi ko'rinadi. Lekin diqqat: bizda atigi 12 ming qator bor. RostUy o'ssa va 1 million e'lon bo'lsa nima bo'ladi?

python35 qator
python3 -c "
import duckdb, time
import numpy as np
con = duckdb.connect('serving.db')

con.execute('DROP TABLE IF EXISTS katta')
con.execute('''CREATE TABLE katta AS
    SELECT e.* FROM elon e, generate_series(1, 84) g''')
n = con.execute('SELECT count(*) FROM katta').fetchone()[0]
print(f'katta jadval: {n:,} qator')

SORQV = '''SELECT elon_id, tuman, xonalar, narx_mln FROM {t}
           WHERE tuman = ? AND xonalar = ? AND narx_mln <= ?
           ORDER BY narx_mln LIMIT 20'''

def olch(t, n=60):
    q = SORQV.format(t=t)
    con.execute(q, ['Yunusobod', 2, 5.0]).fetchall()
    v = []
    for _ in range(n):
        t0 = time.perf_counter()
        con.execute(q, ['Yunusobod', 2, 5.0]).fetchall()
        v.append((time.perf_counter()-t0)*1000)
    return np.array(v)

a, b = olch('elon'), olch('katta')
print()
print(f'{\"jadval\":10s} {\"qatorlar\":>12s} {\"p50\":>9s} {\"p99\":>9s}')
print('-' * 44)
print(f'{\"elon\":10s} {12000:>12,} {np.percentile(a,50):>8.2f}ms {np.percentile(a,99):>8.2f}ms')
print(f'{\"katta\":10s} {n:>12,} {np.percentile(b,50):>8.2f}ms {np.percentile(b,99):>8.2f}ms')
print()
print(f'{n/12000:.0f}x koproq malumot -> {np.percentile(b,50)/np.percentile(a,50):.1f}x sekin')
con.close()
"
Chiqish (qaytish kodi 0, 1821 ms)
katta jadval: 1,000,104 qator

jadval         qatorlar       p50       p99
--------------------------------------------
elon             12,000     1.09ms     1.65ms
katta         1,000,104     2.08ms    11.49ms

83x koproq malumot -> 1.9x sekin

Bu yerda to'xtang va raqamlarni diqqat bilan o'qing — men bu bobni yozayotganda adashgandim.

Men "ma'lumot 83 barobar o'ssa, kechikish ham shunga yaqin o'sadi" deb kutgandim. O'lchov buni rad etdi: 83 barobar ko'p ma'lumot atigi 2 barobar sekinlashtirdi.

Nega? Ikki sabab:

  1. DuckDB ustunli. U faqat kerakli to'rt ustunni o'qiydi, qolganini emas (9-bob).
  2. Filtr juda tanlovchi. "Yunusobod + 2 xona + 5 mln gacha" million qatordan bir necha yuztasini qoldiradi. Qolgan qatorlar statistika yordamida butunlay o'tkazib yuboriladi.

Demak muammo hajmda emas. Unda nimada?

Muammo so'rovlar sonida. 2 ms bitta so'rov uchun ajoyib. Lekin sekundiga 500 ta so'rov kelsa, bu sekundiga 1000 ms protsessor vaqti degani — bitta yadro to'liq band. Ming foydalanuvchi bo'lsa, server yiqiladi.

Serving qatlamining maqsadi shuning uchun "katta ma'lumotni tez o'qish" emas, balki bir xil ishni qayta-qayta qilmaslik.

Birinchi yechim: oldindan hisoblash

Foydalanuvchi filtrlari cheklangan: 10 tuman × 5 xona varianti × bir nechta narx oralig'i. Bularning hammasini oldindan hisoblab qo'yish mumkin.

python44 qator
python3 -c "
import duckdb, time
import numpy as np
con = duckdb.connect('serving.db')

t0 = time.perf_counter()
con.execute('DROP TABLE IF EXISTS tayyor')
con.execute('''CREATE TABLE tayyor AS
    SELECT tuman, xonalar,
           CAST(ceil(narx_mln / 2.5) * 2.5 AS DOUBLE) AS narx_chegara,
           count(*) AS elonlar,
           round(avg(narx_mln), 2) AS ort_narx,
           round(avg(maydon), 1) AS ort_maydon,
           round(avg(broker), 3) AS broker_ulushi
    FROM katta
    GROUP BY tuman, xonalar, narx_chegara''')
qurish = (time.perf_counter()-t0)*1000
n = con.execute('SELECT count(*) FROM tayyor').fetchone()[0]
print(f'oldindan hisoblash: {qurish:.0f} ms (bir marta)')
print(f'natija jadvali    : {n:,} qator')
print()

def olch(q, args, n=200):
    con.execute(q, args).fetchall()
    v = []
    for _ in range(n):
        t0 = time.perf_counter()
        con.execute(q, args).fetchall()
        v.append((time.perf_counter()-t0)*1000)
    return np.array(v)

xom_q = '''SELECT count(*), avg(narx_mln) FROM katta
           WHERE tuman = ? AND xonalar = ? AND narx_mln <= ?'''
tayyor_q = '''SELECT sum(elonlar), avg(ort_narx) FROM tayyor
              WHERE tuman = ? AND xonalar = ? AND narx_chegara <= ?'''

a = olch(xom_q, ['Yunusobod', 2, 5.0])
b = olch(tayyor_q, ['Yunusobod', 2, 5.0])
print(f'xom jadvaldan  : p50 {np.percentile(a,50):7.3f} ms | p99 {np.percentile(a,99):7.3f} ms')
print(f'tayyor jadvaldan: p50 {np.percentile(b,50):7.3f} ms | p99 {np.percentile(b,99):7.3f} ms')
print()
print(f'{np.percentile(a,50)/np.percentile(b,50):.0f}x tez')
con.close()
"
Chiqish (qaytish kodi 0, 810 ms)
oldindan hisoblash: 51 ms (bir marta)
natija jadvali    : 165 qator

xom jadvaldan  : p50   1.580 ms | p99   5.537 ms
tayyor jadvaldan: p50   0.398 ms | p99   0.696 ms

4x tez

Farq katta, va sababi oddiy: tayyor jadvalda bir necha yuz qator bor, xom jadvalda million.

Oldindan hisoblash — bepul tezlik emas

Uch narxi bor va ularni bilib turib to'lash kerak:

1. Eskirish. Tayyor jadval — bu suratga olingan holat. Yangi e'lon qo'shilsa, u yangilanmaguncha ko'rinmaydi. Har necha daqiqada qayta hisoblash kerak.

2. Moslashuvchanlik yo'qoladi. Biz narxni 2.5 million qadamga yaxlitladik. Foydalanuvchi "4.3 milliongacha" deb so'rasa — aniq javob yo'q.

3. Kombinatsiya portlashi. 10 tuman × 5 xona = 50 qator. Lekin yana maydon, qavat, rasmlar soni qo'shsangiz — minglab kombinatsiya. Har biri saqlanadi va yangilanadi.

Qoida: faqat haqiqatan tez-tez so'raladigan filtrlarni oldindan hisoblang. Qolganiga xom jadval javob bersin.

Ikkinchi yechim: kesh

Oldindan hisoblashning kamchiligini ko'rdik: kombinatsiya portlashi. Har yangi filtr qo'shsangiz, tayyorlash kerak bo'lgan javoblar soni ko'payib boradi.

Endi boshqa tomondan yondashamiz.

Oldindan hisoblash — bu hamma savolga javob tayyorlash, hatto hech kim so'ramaydiganiga ham. Restoran misolida: menyudagi 200 ta taomning hammasini oldindan pishirib qo'yish. Ko'pi sovib qoladi.

Kesh esa teskarisini qiladi: so'ralgan savolning javobini eslab qoladi.

Ya'ni siz oldindan hech narsa tayyorlamaysiz. Birinchi mijoz osh so'raydi — pishirasiz (sekin). Ikkinchi mijoz ham osh so'raydi — tayyorini berasiz (tez).

Nega bu ishlaydi? Chunki mijozlar tasodifiy tanlamaydi. Ular bir xil narsani so'rashadi.

RostUy da ham shunday: "Yunusobod, 2 xona" filtri kuniga minglab marta bosiladi, "Bektemir, 7 xona" esa oyiga bir marta.

Buni o'lchab ko'ramiz — haqiqiy trafikka o'xshash taqsimot bilan.

python42 qator
python3 -c "
import duckdb, time, functools
import numpy as np
con = duckdb.connect('serving.db')

Q = '''SELECT count(*), avg(narx_mln) FROM katta
       WHERE tuman = ? AND xonalar = ? AND narx_mln <= ?'''

def xom(tuman, xona, narx):
    return con.execute(Q, [tuman, xona, narx]).fetchall()

@functools.lru_cache(maxsize=256)
def keshli(tuman, xona, narx):
    return con.execute(Q, [tuman, xona, narx]).fetchall()

# haqiqiy trafik: bir nechta filtr juda ko'p takrorlanadi
rng = np.random.default_rng(42)
tumanlar = [r[0] for r in con.execute('SELECT DISTINCT tuman FROM elon').fetchall()]
# 80% sorov eng mashhur 3 ta filtrga tushadi
mashhur = [(tumanlar[0], 2, 5.0), (tumanlar[1], 3, 8.0), (tumanlar[2], 1, 4.0)]
sorovlar = []
for _ in range(400):
    if rng.random() < 0.8:
        sorovlar.append(mashhur[rng.integers(0, 3)])
    else:
        sorovlar.append((tumanlar[rng.integers(0, len(tumanlar))],
                         int(rng.integers(1, 5)), float(rng.integers(3, 12))))

for nom, f in (('keshsiz', xom), ('keshli', keshli)):
    if nom == 'keshli': keshli.cache_clear()
    t0 = time.perf_counter()
    for s in sorovlar: f(*s)
    dt = (time.perf_counter()-t0)*1000
    print(f'{nom:8s}: {len(sorovlar)} sorov {dt:8.1f} ms -> bitta sorov {dt/len(sorovlar):.3f} ms')

i = keshli.cache_info()
print()
print(f'kesh topildi : {i.hits}')
print(f'kesh topilmadi: {i.misses}')
print(f'topilish ulushi: {i.hits/(i.hits+i.misses)*100:.1f}%')
con.close()
"
Chiqish (qaytish kodi 0, 977 ms)
keshsiz : 400 sorov    708.4 ms -> bitta sorov 1.771 ms
keshli  : 400 sorov     84.1 ms -> bitta sorov 0.210 ms

kesh topildi : 332
kesh topilmadi: 68
topilish ulushi: 83.0%

Kesh ishladi, chunki haqiqiy trafik teng taqsimlanmagan: bir nechta filtr juda ko'p takrorlanadi, qolganlari kamdan-kam.

Bu qonuniyat deyarli hamma joyda uchraydi va uning nomi bor — uzun dum (long tail).

Keshni bekor qilish — eng qiyin qism

Dasturlashda mashhur gap bor: "kompyuter fanidagi ikki qiyin masala — keshni bekor qilish va nom tanlash".

Muammo: e'lon narxi o'zgardi. Kesh esa eski javobni saqlab turibdi. Foydalanuvchi eski narxni ko'radi — 11-bobdagi telefon qo'ng'irog'i qaytadi.

Uch yechim bor: - TTL — javob N soniyadan keyin o'z-o'zidan o'chadi. Oddiy, lekin N soniya davomida ma'lumot eski. - O'zgarishda o'chirish — e'lon yangilanganda tegishli kesh o'chiriladi. Aniq, lekin "tegishli" qaysi ekanini bilish qiyin. - Versiya kaliti — kesh kalitiga ma'lumot versiyasi qo'shiladi. Yangi versiya — yangi kalit, eskisi o'zi eskiradi.

Sayt uchun odatda TTL yetarli (30-60 soniya). Narx va qoldiq uchun — o'zgarishda o'chirish.

Qaysi vositani tanlash

Bu bobda DuckDB ishlatdik, chunki u mahalliy va sodda. Ishlab chiqarishda tanlov boshqacha:

python11 qator
import pandas as pd
show(pd.DataFrame([
    {"Vosita": "PostgreSQL + indeks", "Kuchli tomoni": "Oddiy, allaqachon bor",
     "Qachon": "Sekundiga yuzlab so'rov"},
    {"Vosita": "Redis", "Kuchli tomoni": "Xotirada, juda tez",
     "Qachon": "Kalit bo'yicha o'qish"},
    {"Vosita": "ClickHouse", "Kuchli tomoni": "Ustunli, yig'ish uchun tez",
     "Qachon": "Real vaqt tahlil paneli"},
    {"Vosita": "Apache Pinot / Druid", "Kuchli tomoni": "Oqimdan darhol o'qiydi",
     "Qachon": "Hodisa kelishi bilan panelda"},
]))
VositaKuchli tomoniQachon
PostgreSQL + indeksOddiy, allaqachon borSekundiga yuzlab so'rov
RedisXotirada, juda tezKalit bo'yicha o'qish
ClickHouseUstunli, yig'ish uchun tezReal vaqt tahlil paneli
Apache Pinot / DruidOqimdan darhol o'qiydiHodisa kelishi bilan panelda

Oddiydan boshlang

Ko'pchilik loyihada PostgreSQL + to'g'ri indeks + kesh yetarli. ClickHouse yoki Pinot qo'shishdan oldin uch savolga javob bering:

  1. Hozirgi p99 qancha? (o'lchadingizmi?)
  2. Maqsad qancha?
  3. 18-bobdagi oltita qadamni bajardingizmi?

Yangi baza qo'shish — yangi tizim, yangi nusxa, yangi nosozlik nuqtasi. U oxirgi chora bo'lsin.

Tozalash

python7 qator
python3 -c "
import duckdb, pathlib
con = duckdb.connect('serving.db')
con.execute('DROP TABLE IF EXISTS katta')
con.close()
print('katta jadval ochirildi, serving.db qoldi')
"
Chiqish (qaytish kodi 0, 95 ms)
katta jadval ochirildi, serving.db qoldi

Xulosa

Tushuncha Asosiy fikr
Ikki xil mijoz Tahlilchi soniya kutadi, foydalanuvchi kutmaydi
So'rov shakli Serving da u oldindan ma'lum
Chiziqli o'sish Ma'lumot o'ssa, xom so'rov sekinlashadi
Oldindan hisoblash Ma'lum savolga tayyor javob
Uning narxi Eskirish, moslashuvchanlik, kombinatsiya portlashi
Kesh So'ralgan javobni eslab qolish
Uzun dum Bir nechta so'rov trafikning ko'pini tashkil qiladi
Keshni bekor qilish TTL, o'zgarishda o'chirish yoki versiya kaliti
Vosita tanlash Avval Postgres + indeks + kesh; keyin o'ylang

Talaba ishi

  1. Kesh o'lchamini 256 dan 8 ga tushiring. Topilish ulushi qanday o'zgardi?
  2. Trafikni teng taqsimlang (mashhur filtrlarsiz). Kesh hali ham foyda beradimi?
  3. tayyor jadvalga maydon oralig'ini ham qo'shing. Qatorlar soni necha barobar oshdi?
  4. Yangi e'lon qo'shing va tayyor jadvalni yangilamang. Foydalanuvchi qanday noto'g'ri javob oladi?

Bu bobni bepul o'qidingiz.

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

Telegramda yozish Narxlar