Asosiy kontentga o‘tish

Maqolalar 7 daqiqa 88 ko‘rish

Xatoni ham keshlab qo‘yish: shu saytda bo‘lgan real tuzoq

Saytdagi GitHub repolar soni bir kun ichida eskirib qotib qoldi — lokalda esa hammasi joyida. Sababi men bilib turib qazigan chuqur edi: xato natijani ham muvaffaqiyat bilan bir xil muddatga keshlab qo‘yganman. Tuzatdim, keyin raqam o‘zi bilan o‘zi kelishmay qoldi, keyin sayt «0 ta repo» deb e'lon qildi. Uch bosqichli tashxis.

performance backend

Mundarija

Bir kuni shu saytning o‘zida qiziq nosozlik yuz berdi. Saytda GitHub'dagi ochiq repolarim soni ko‘rsatiladi va u jonli olinadi — o‘sha paytda bu raqam bosh sahifada turardi. Production'da esa u eskirgan zaxira raqamni ko‘rsatib qotib qoldi, holbuki lokal kompyuterimda hammasi to‘g‘ri ishlardi. Xato loglari ham yo‘q, kod ham bexato ko‘rinadi — shunchaki raqam "muzlab" qolgan. Sabab esa men o‘zim, bilib turib qazib qo‘ygan chuqur bo‘lib chiqdi: men xato natijani ham muvaffaqiyat bilan bir xil muddatga keshlagan ekanman.

Kod qanday edi#

Python
def repo_count() -> int:
    cached = cache.get(CACHE_KEY)
    if cached is not None:
        return cached
    value = fetch_from_github()  # xato yoki timeout bo'lsa None qaytadi
    if value is None:
        value = FALLBACK  # zaxira qiymat
    cache.set(CACHE_KEY, value, 60 * 60 * 12)  # 12 soatga yozamiz
    return value

Bir qarashda mantiq to‘g‘ri ko‘rinadi: GitHub javob bermasa zaxira qiymatni olamiz, keshga yozamiz va o‘n ikki soat tinch yashaymiz. Lekin bu yerda bitta e'tibordan chetda qolgan narsa bor. Oxirgi cache.set qatori muvaffaqiyatli javobni ham, zaxira qiymatni ham bir xil o‘n ikki soatga yozadi.

Nima yuz berdi#

Server qayta ishga tushganda birinchi so‘rov GitHub'ga uch soniyalik timeout bilan ketdi. Aynan o‘sha lahzada tarmoq bir zum sekinroq edi, so‘rov o‘tmadi va funksiya None qaytardi. Demak zaxira qiymat keshga tushdi — va o‘n ikki soatga tushdi. Endi natija ravshan: keyingi o‘n ikki soat davomida hech kim GitHub'ga qayta so‘rov yubormaydi, chunki kesh "to‘la" turibdi. Bitta omadsiz soniya butun kunni eskirgan raqam bilan qoldirdi.

Bu holatning o‘z nomi bor: negative caching, ya'ni salbiy natijani — xato, bo‘sh javob yoki 404 ni — keshlab qo‘yish. O‘z-o‘zidan bu yomon narsa emas, hatto DNS ham shunday qiladi. Xato mening kodimda edi: men salbiy natijani ijobiy natija muddatiga keshlaganman.

To‘g‘ri yechim#

Yechim ham xuddi muammo kabi oddiy — ikkala holatga alohida muddat berish:

Python
TTL = 60 * 60 * 12  # muvaffaqiyat: 12 soat
FAILURE_TTL = 60 * 15  # xatolik: atigi 15 daqiqa, keyin qayta urinamiz


def repo_count() -> int:
    cached = cache.get(CACHE_KEY)
    if cached is not None:
        return cached
    value = fetch_from_github()
    if value is None:
        cache.set(CACHE_KEY, FALLBACK, FAILURE_TTL)
        return FALLBACK
    cache.set(CACHE_KEY, value, TTL)
    return value

Endi omadsiz so‘rov ko‘pi bilan o‘n besh daqiqa yashaydi. Foydalanuvchi zaxira qiymatni qisqa vaqt ko‘radi, keyin tizim o‘zini o‘zi tuzatadi va yana jonli raqamga qaytadi.

Shu bilan tugadi deb o‘ylagandim. Aslida bu hikoyaning faqat birinchi qismi ekan.

Ertasiga: raqam o‘zi bilan o‘zi kelishmay qoldi#

Tuzatishdan keyin yangi g‘alatilik boshlandi. Sahifani yangilayman — 60 ta repo; yana yangilayman — 93 ta; yana bosaman — yana 60. Bitta sayt, bitta lahza, ikki xil javob.

Sabab keshning o‘zida edi. Men cache.set deb yozganimda Django qayerga yozayotganini o‘ylab ko‘rmagan ekanman. Django'ning standart keshi — LocMemCache, ya'ni jarayonning o‘z xotirasi. Production'da esa gunicorn bir nechta worker bilan ishlaydi, demak har worker'da alohida, bir-biridan bexabar kesh bor. So‘rovga qaysi worker javob bersa, o‘sha worker'ning keshi ko‘rinadi: biri GitHub'dan jonli sonni olib ulgurgan, ikkinchisi zaxira qiymat bilan o‘tirgan.

Lokalda esa runserver bitta jarayon — shuning uchun bu nosozlikni lokalda umuman ko‘rib bo‘lmasdi. Yechim sifatida keshni hamma worker o‘qiy oladigan joyga, ya'ni fayl tizimiga ko‘chirdim:

Python
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
        "LOCATION": str(BASE_DIR / "var" / "cache"),
    }
}

Va eng achchig‘i: zaxira qiymatning o‘zi#

Uchinchi zarba esa eng uyaltirgani bo‘ldi. Zaxira qiymat .env dan olinardi va uning standart qiymati — nol:

Python
GITHUB_REPO_COUNT_FALLBACK = env.int("GITHUB_REPO_COUNT_FALLBACK", default=0)

Ya'ni GitHub bitta marta so‘rov chegarasiga urilsa, sayt "0 ta ochiq repo" deb e'lon qilardi. O‘n besh daqiqalik tuzatishim zararni qisqartirgan, lekin yo‘qotmagan edi — chunki men hali ham noto‘g‘ri javobni keshlayotgan edim, shunchaki qisqaroq muddatga.

To‘g‘ri javob boshqacha bo‘lib chiqdi: xato natijani umuman keshlamaslik. Xato bo‘lsa oxirgi haqiqiy son o‘z joyida qolaveradi va o‘sha ko‘rsatiladi. Bir kunlik eski, lekin haqiqiy raqam — yangi, lekin yolg‘on nolga har doim afzal:

Python
def repo_count() -> int:
    state = _read_state()  # oxirgi haqiqiy son, diskda
    known = state.get("count")
    fresh = known is not None and time.time() - state.get("fetched_at", 0) < _TTL

    if not fresh:
        fetched = _fetch()
        # Nolga tushib ketish deyarli har doim xato alomati (chegara, tarmoq,
        # akkaunt nomi noto'g'ri) — bunday javob eski sonni siqib chiqarmaydi.
        if fetched and (known is None or fetched > 0):
            _write_state(fetched)
            known = fetched

    return known if known is not None else settings.GITHUB_REPO_COUNT_FALLBACK

Diqqat qiling: bu yerda _write_state faqat haqiqiy son kelganda chaqiriladi. Zaxira qiymat hech qachon keshga yozilmaydi — u faqat "hali hech qachon muvaffaqiyatli so‘rov bo‘lmagan" holat uchun qoldi. Salbiy natija uchun muddat tanlash muammosi shu bilan butunlay yo‘qoldi: keshlanmagan narsaning muddati ham bo‘lmaydi.

Bu tuzoqdan chiqqan qoidalar#

Bu kichik nosozlikdan bir necha amaliy xulosa qoldi. Eng asosiysi — uni men ikki bosqichda o‘rgandim: muvaffaqiyat va xatolik hech qachon bir xil muddatga keshlanmasligi kerak, xatolik uchun qisqa, muvaffaqiyat uchun uzun muddat. Buni tushunganimdan keyin esa yana bir qadam qoldi: iloji bo‘lsa xatolikni umuman keshlamaslik va oxirgi haqiqiy qiymatni ushlab turish.

Ikkinchisi: zaxira qiymat ham javobning bir turi, demak u ham haqiqatga o‘xshashi kerak. Menikida u nol edi — texnik jihatdan "qiymat yo‘q" degani, foydalanuvchi uchun esa "bu odamning bitta ham ochiq repozitoriysi yo‘q" degani. Zaxira qiymat — vaqtinchalik yamoq, doimiy holat emas, va u ko‘rinishda uzoq qolsa, foydalanuvchi uchun asta-sekin "haqiqat"ga aylanadi.

Uchinchisi: kesh qayerda yashayotganini bilish kerak. cache.set bir qatorga o‘xshaydi, lekin uning ortida jarayon xotirasi, fayl yoki Redis turishi mumkin — va bu tanlov bir necha worker'li production'da to‘g‘ridan-to‘g‘ri ko‘rinadigan farq beradi. To‘rtinchisi: tashqi xizmatga qo‘yiladigan timeout'ni juda qisqa qilmaslik kerak, chunki uch soniya tashqi API uchun kam va bitta sekin javob butun keshni zaharlashi mumkin.

Bunday nosozlikning ayyorligi shundaki, uni topish qiyin: kod ishlayapti, xato loglari yo‘q, faqat bitta raqam qotib qolgan yoki sakrab turibdi. Shuning uchun endi men keshga yozadigan har bir joyda o‘zimga ikkita savol beraman: bu yerga xato qiymat tushib qolsa, u qachon tuzaladi? Va — uni bu yerga umuman qo‘yish shartmi? O‘sha ikki savol endi mening har bir kod ko‘rigimda turadi.

7 daqiqa 88 marta o‘qildi