← bütün yazılar

Nº02 // YAZILAR

Redis-in xilas edə bilmədiyi N+1

Cache əlavə edib heç nəyin dəyişmədiyini izləməyin özünəməxsus bir məyusluğu var. Qrafikin uçurumdan düşməsini gözləyirsən. Əvəzində o, sanki cache heç qoşulmayıbmış kimi, düz və laqeyd halda dayanır. Bir neçə həftə əvvəl mən belə idim — öz kommersiya Booknetic addonlarımdan birində (addons.itahir.com-da satdığım marketplace üslublu siyahı interfeysi) tenant-direktoriya axtarışına baxırdım və tam sağlam Redis object cache-in məhz ehtiyacı olan səhifə üçün niyə tamamilə heç nə etmədiyini başa düşməyə çalışırdım.

Axtarışın özü ən yaxşı mənada adidir: shortcode bir direktoriya render edir, bir neçə söz yazırsan, form tam yenidən yüklənir və uyğun siyahıların grid-ini alırsan. Hər klavişdə işləyən hoqqa yoxdur, debounce edəcəyin autocomplete yoxdur. Sadəcə klassik, serverdə render olunan axtarış. Və o, mənim diqqətimi çəkəcək qədər ləng idi — belə bir səhifədə bu o deməkdir ki, müştərilərin də diqqətini çəkəcək qədər ləng idi.

Redis orada idi. Sadəcə onu dəvət etməmişdilər.

İlk instinkt — mənim də, yəqin ki sənin də — budur: “object cache bunu tutmalıdır.” Redis işləyirdi. WordPress onu davamlı object cache kimi istifadə edirdi. Admin panelinin digər hissələri cəld hiss olunurdu. Bəs bu səhifə niyə hər yüklənmədə verilənlər bazasını döyəcləyirdi?

Çünki isti yol cache-dən bircə sual da soruşmurdu.

Bu hissəni ümumiləşdirməyə dəyər, çünki söhbət hər hansı bir platformadan getmir. Bütöv bir WordPress plugin sinfi — öz cədvəlləri olan, məlumat ağırlıqlı olanlar da daxil — birbaşa xam $wpdb sorğuları göndərir. WordPress-in object cache-i yalnız onun cache API-lərindən, yaxud özü cache edən sorğu qatlarından keçən kodu sürətləndirir. Əldə yazılmış SQL bütün bunları dizayn etibarilə yan keçir. Sorğu işləyir, nəticə qayıdır, object cache isə bu gediş-gəlişi heç görmür belə. Dünyanın ən sürətli Redis-ini quraşdıra bilərsən — o, ona verilənləri sədaqətlə cache edəcək və verilməyən hər şeydən xəbərsiz, dünyadan bixəbər qalacaq.

Deməli, birinci dərs, ən cazibəsizi: object cache yalnız ona müraciət edən koda kömək edir. İsti yolun əldə yazılmış SQL-dirsə, yaddaş-anbarının heç bir tənzimlənməsi səni xilas etməyəcək. Əvvəlcə sorğunu düzəltməlisən.

Səhifəni təxmin etmək yox, profilləmək

Nəzəriyyə qurmaq əvəzinə sorğunu həqiqətən profillədikdə, mənzərə az qala gülməli dərəcədə dərslik kitabından çıxmışdı.

Səhifə uyğun siyahıları tapmaq üçün bir axtarış sorğusu işlədirdi və sonra — hər nəticə sətri üçün — grid-də gördüyün kartı yığmaq üçün daha bir neçə sorğu atırdı: məkan, iş saatları, media, ən yaxşı xidmətlər, siyahının tipi, arxasındakı tenant qeydi. Sətir başına təxminən altı əlavə sorğu. Bir sətirdə kifayət qədər zərərsiz. Amma bu bir direktoriyadır; bütün məqsədi çoxlu sətir qaytarmaqdır.

Rəqəmlər tam olaraq N+1-in miqyaslandığı kimi miqyaslandı — yəni xətti və amansız şəkildə:

11 nəticə  →  77 sorğu
37 nəticə  → 235 sorğu

Budur imza. Sorğu sayının sətir sayı ilə ayaqlaşaraq artması — sətir-başına axtarış edən döngənin barmaq izidir. Bu sorğuların heç biri ayrılıqda ləng deyildi. Onlar topluca ləng idilər, və onların sayı çox olduğu üçün, eyni cüzi axtarışı təkrar-təkrar etməyin verilənlər bazası bağlantı yükü üzündən ləng idilər.

Axtarış sorğusunun özünün ikinci, ayrıca problemi vardı. O, uyğunluğu tapmaq üçün açar sözlər (keywords) cədvəlinə join edirdi, və həmin join sütununda index yox idi. EXPLAIN bütün hekayəni öz qısa-quru dili ilə danışdı:

type: ALL          -- tam cədvəl skanı, yararlı index yoxdur
rows: 496          -- bir ovuc tapmaq üçün hər şeyi oxuyur
Extra: Using where; Using join buffer (Block Nested Loop)

type: ALL üstəgəl Block Nested Loop join — verilənlər bazasının nəzakətlə sənə bildirməsidir ki, ağıllı olmaqdan əl çəkib bütün cədvəli buferə oxuyub onun içindən keçəcək. Bunun üstünə, sorğudakı GROUP BY müvəqqəti cədvəl və filesort-a məcbur edirdi — Using temporary; Using filesort — yəni optimizator aralıq nəticələri diskə-ya-yaddaşa materiallaşdırıb sonra onları sıralayır, və bütün bunlar onsuz skan etməyə məcbur olmamalı olduğu sətirləri təkrarsızlaşdırmaq üçündür.

Deməli: tam skan nested-loop join-u qidalandırır, o da müvəqqəti cədvəli, o da filesort-u qidalandırır, və sonra sətir-başına döngə bütün bunu nəticə sayına vurur. Eyni sorğunun üstündə üst-üstə yığılmış iki müstəqil performans baqı.

Tərpədişi həqiqətən edən düzəliş

Burda dürüst olmaq istədiyim hissə budur: aşağı-riskli düzəlişi qəsdən birinci buraxdım, və bu düzgün qərar idi.

N+1 daha böyük, daha qorxulu rəqəmdir, amma onu düzəltmək döngəni yenidən yazmaq və məlumatın səhifədən necə axdığını dəyişmək deməkdir — real reqressiya səthi olan real kod dəyişiklikləri. Join isə, əksinə, üç index və sıfır tətbiq kodu ilə bağlaya biləcəyim bir yaradan qan itirirdi.

Üç hədəfli index:

-- keywords əlaqə cədvəlindəki xarici-açar join sütunları
ADD INDEX idx_lp (landing_page_id);
ADD INDEX idx_kw (keyword_id);

-- siyahı axtarışının filtrlədiyi status + tenant cütü
ADD INDEX idx_status_tenant (status, tenant_id);

İlk ikisi join-a əsl giriş yolu verir. Üçüncüsü siyahı sorğusunun filtrlədiyi tam (status, tenant_id) predikatına uyğun gələn kompozit index-dir, beləcə verilənlər bazası skan etmək əvəzinə seek edə bilir. Hər üçü kiçik cədvəllərə, online DDL kimi tətbiq olundu — ani, kilid dramı yoxdur, canlı saytda işlətmək təhlükəsizdir.

EXPLAIN tam ümid etdiyin kimi çevrildi:

lpk:  type ALL, rows 496, Block Nested Loop
  →   type ref, key idx_lp, rows 4

ALL ref oldu. Dörd yüz doxsan altı sətir dörd oldu. Block Nested Loop yox oldu, çünki nəhayət join-u sürəcək bir index vardı. Müvəqqəti cədvəl və filesort da onunla birlikdə yoxa çıxdı. Baş-ayaq, axtarış təxminən 62ms-dən təxminən 35ms-ə düşdü — yarıdan bir az yaxşı — üç sətir DDL və tətbiq məntiqinə sıfır risk müqabilində.

Mən bu cür düzəlişləri sevirəm. Zərif olduğu üçün yox, ucuz, təhlükəsiz olduğu və bu gün buraxıla bildiyi üçün.

Növbəti dəfəyə qəsdən saxladığım şey

Index-lər sorğunu düzəltdi. Onlar N+1-i düzəltmədi və açıq gözlə görürəm ki, daha böyük qazanc hələ də masada qalıb.

N+1 bir kod dəyişikliyidir, və açıq olanıdır: nəticələrin üzərində döngə vurub sətir başına altı sorğu atmaq əvəzinə, nəticə dəstindən ID-ləri toplayıb hər şeyi paketlənmiş IN(...) sorğuları ilə gətirmək — bütün məkanlar üçün bir sorğu, bütün media üçün bir, bütün xidmətlər üçün bir, və beləcə — sonra onları PHP-də yenidən tikmək. Təkcə bu, 37-nəticəli halı ~235 sorğudan ~12–15 ətrafına salmalıdır. Sorğu sayı sətir sayını izləməyi dayandırır ki, N+1-i öldürməyin bütün mənası elə budur.

Və yalnız bundan sonra — yalnız həqiqətən sahib olduğum qatda, öz addonumun kodunda — cache nəhayət öz haqqını qazanır. Plan budur: normallaşdırılmış axtarış parametrlərinin hash-i ilə açarlanan transient — eyni axtarış sözləri, eyni filtrlər, eyni cache olunmuş nəticə dəsti. Transient API-nin arxasında Redis ilə, həmin yığılmış nəticə dəsti platformanın xam sorğularının heç vaxt toxunmadığı object cache-ə düşür, çünki indi müraciət edən mənim kodumdur. Yeganə istisna tarix və ya əlçatanlıq filtrləridir — onlar mahiyyətcə dəyişkən və vaxta həssasdır, ona görə tarix seçildikdə birisinə köhnəlmiş təqvim təqdim etməkdənsə cache-i tamamilə yan keçirəm.

Bu sıralama önəmlidir, və bütün tezis elə budur. Cache-ə birinci əl atmadım. Ona axırda əl atdım — sorğu ağıllı olduqdan və N+1 paketləndikdən sonra — çünki sınıq sorğunun üstünə qoyulmuş cache sadəcə ləng cavabı əzbərləyir. Əvvəlcə işin formasını düzəlt, sonra cache etməyə dəyər işin nəticəsini cache et.

Redis bu səhifəni xilas edə bilməzdi. Heç vaxt edə bilməyəcəkdi də. Səhifə ondan heç nə soruşmurdu.