Кеш
Накратко: Кешът пази копие на често четени данни по-близо до кода; при cache-aside програмата първо гледа в кеша, а при промяна копието трябва да се изтрие, иначе показва стари данни.
Проблем
Страницата, която пита базата за едно и също
Магазинът пуска кампания за кафе. Страницата на продукта пита базата около 40 пъти: продукта, цената, наличността, отзивите, свързаните продукти. Ако страницата се отваря по 2 000 пъти в минута (пример, не измерване), това са 80 000 заявки в минута. А отговорите са едни и същи, защото цената на кафето не се мени всяка секунда.
Базата е на 100% процесор, страницата се зарежда за 4 секунди, а купувачите си тръгват. Ето същото в малко, с база, която брои заявките си:
def product_page(products, product_id): p = products.find(product_id) return f"{p.name}: {p.price:.2f} €"db = Database(CATALOG)popular = [7, 9, 12]for i in range(1000): product_page(db, popular[i % 3])print(f"отваряния: 1000, заявки към базата: {db.queries}")отваряния: 1000, заявки към базата: 1000Хиляда отваряния на три продукта: хиляда заявки за три различни отговора.
Идеята
Копие пред базата
Ако отговорът не се мени често, пази го. Програмата първо гледа в кеша. Ако там има копие, връща го (попадение). Ако няма (пропуск), пита базата, слага копие в кеша и връща отговора. Копието има срок, TTL: след него кешът го забравя и следващото четене пак отива до базата.
Командите на Redis за това, TTL и DEL видя в урока за key-value базите. Тук въпросът е друг: къде в кода живее кешът, така че останалият код да не знае за него.
Отговорът е обвивка около хранилището. CachedRepository има същия интерфейс като базата (Repository) и обвива базата като Decorator. Услугата и страницата получават хранилище и не знаят дали то е кеш. Решава кодът, който сглобява обектите.
Това се казва cache-aside: кешът стои встрани от базата, а кодът сам го пълни при пропуск.
Виж го
Кешът в код
Продуктът, хранилището и базата, която брои заявките си:
@dataclass(frozen=True)class Product: id: int name: str price: float# базата: брои заявките си, за да ги видимclass Database: def __init__(self, products): self.rows = {p.id: p for p in products} self.queries = 0
def find(self, product_id): self.queries += 1 return self.rows[product_id]
def save(self, product): self.queries += 1 self.rows[product.id] = productinterface: Database и CachedRepository просто имат еднакви методи find и save (duck typing). Ако искаш проверка от mypy, описваш ги с typing.Protocol.Времето е брояч на тикове. Така изтичането на TTL е едно и също при всяко пускане:
# времето е брояч на тикове, не часовникclass Clock: def __init__(self): self.now = 0Кешът: първо копието, при пропуск базата, а save пише в базата и трие копието.
@dataclassclass Entry: product: Product expires: int
class CachedRepository: def __init__(self, db, clock, ttl): self.db = db self.clock = clock self.ttl = ttl self.cache = {} self.hits = 0 self.misses = 0
def find(self, product_id): entry = self.cache.get(product_id) if entry and self.clock.now < entry.expires: self.hits += 1 return entry.product # копие, не истината self.misses += 1 product = self.db.find(product_id) expires = self.clock.now + self.ttl self.cache[product_id] = Entry(product, expires) return product
def save(self, product): self.db.save(product) self.cache.pop(product.id, None) # трие копиетоХилядата отваряния, но през кеша, с TTL 100 тика:
clock = Clock()db = Database(CATALOG)products = CachedRepository(db, clock, 100)popular = [7, 9, 12]for i in range(1000): clock.now = i # всяко отваряне е един тик product_page(products, popular[i % 3])print(f"заявки към базата: {db.queries}")print(f"попадения: {products.hits}, пропуски: {products.misses}")заявки към базата: 30попадения: 970, пропуски: 30Тридесет заявки вместо хиляда. Всеки от трите продукта се чете веднъж на 100 тика, а останалите 970 отваряния отговаря копието.
Пробвай
Предвиди изхода
Базата и часовникът са като в програмата горе, TTL е 100 тика и часовникът стои на 0. Какво ще отпечата?
products = CachedRepository(db, clock, 100)products.find(7)products.find(7)products.find(9)products.save(Product(7, "кафе", 12.90))products.find(7)products.find(7)print(products.hits, products.misses, db.queries)Първото find(7) е пропуск (заявка 1), второто е попадение. find(9) е пропуск (заявка 2). save пише в базата (заявка 3) и трие копието на 7, затова следващото find(7) е пак пропуск (заявка 4), а последното е попадение. Две попадения, три пропуска, четири заявки.
Идеята
Старата цена
Кафето поевтинява от 14,90 € на 12,90 €. Нощният импорт на цените пише направо в базата, покрай CachedRepository:
clock = Clock()db = Database(CATALOG)products = CachedRepository(db, clock, 5)print("тик 0:", product_page(products, 7))# нощният импорт на цените пише направо в базатаdb.save(Product(7, "кафе", 12.90))for t in range(1, 7): clock.now = t print(f"тик {t}:", product_page(products, 7))тик 0: кафе: 14.90 €тик 1: кафе: 14.90 €тик 2: кафе: 14.90 €тик 3: кафе: 14.90 €тик 4: кафе: 14.90 €тик 5: кафе: 12.90 €тик 6: кафе: 12.90 €Базата казва 12,90 €, а страницата показва 14,90 € до тик 5. Кешът е копие, не истината. Никой не му е казал, че истината се е сменила, затова той отговаря със старото, докато TTL не изтече. В истински магазин TTL е минути, а купувачът вижда една цена на страницата и друга в количката.
Затова всеки, който променя данните, трие и копието. Това се казва инвалидиране (cache invalidation). Същата промяна, но през save:
clock = Clock()db = Database(CATALOG)products = CachedRepository(db, clock, 5)print("тик 0:", product_page(products, 7))# същата промяна, но през saveproducts.save(Product(7, "кафе", 12.90))clock.now = 1print("тик 1:", product_page(products, 7))print(f"заявки към базата: {db.queries}")тик 0: кафе: 14.90 €тик 1: кафе: 12.90 €заявки към базата: 3Новата цена се вижда веднага. Трите заявки са четенето на тик 0, записът и новото четене след изтритото копие.
TTL остава като предпазна мрежа: ако някой път се пише покрай кеша, лъжата живее най-много един TTL.
Виж го
Копието, старата цена и инвалидирането
def find(self, product_id): entry = self.cache.get(product_id) if entry and self.clock.now < entry.expires: self.hits += 1 return entry.product # копие, не истината self.misses += 1 product = self.db.find(product_id) expires = self.clock.now + self.ttl self.cache[product_id] = Entry(product, expires) return product
def save(self, product): self.db.save(product) self.cache.pop(product.id, None) # трие копиетоСтъпка по стъпка, с думи
- Тик 0: страницата иска кафето (продукт 7). В кеша няма копие: пропуск.
- При пропуск кешът пита базата. Това е единствената заявка досега.
- Кешът пази копие до тик 5 (TTL от 5 тика), а страницата показва 14.90 €.
- Тик 1: попадение. Отговаря копието, а базата не чува нищо.
- Импортът на цените пише 12.90 € направо в базата. На тик 2 копието още казва 14.90 €: старата цена.
- Никой не е казал на кеша. Старото копие живее, докато TTL изтече на тик 5.
- Поправката: промяната минава през save. Той пише в базата и веднага трие копието.
- Следващото отваряне не намира копие, пита базата и взима 12.90 €.
- Кешът е копие. Истината е в базата, а който я променя, трие копието.
save не пише новата цена в кеша, а само трие копието. Следващото четене взима истината от базата. Защо това е по-безопасно, е в теста накрая.
Пробвай
Попълни
Кой метод трие копието, когато цената се смени?
def save(self, product):
self.db.save(product)
self.cache.(product.id, None)self.cache.pop(product.id, None) маха копието. None е стойността, която pop връща, ако копие няма. Без нея pop хвърля KeyError. Следващото find е пропуск и взима новата цена от базата.
Идеята
Кога кешът вреди
Кешът е бърз, защото лъже малко. Ето кога лъжата струва повече от скоростта:
- Стойности, които трябва да са точни. Цената и наличността при плащането се четат от базата, а не от кеша. Иначе продаваш последната бройка два пъти или таксуваш по старата цена.
- Промяна покрай кеша. Импорт, друга програма или ръчен
UPDATEв базата не трият копието. Колкото повече пътища пишат, толкова по-лесно някой забравя. - Състезание между четене и запис. Четене при пропуск взима 14,90 € от базата. Преди да сложи копието,
saveпише 12,90 € и трие (още празния) ключ. После четенето слага своето 14,90 € и то живее цял TTL. Това е race condition между две заявки. Затова TTL никога не е безкраен. - Всичко изтича наведнъж. Ако хиляди копия изтекат в един момент или кешът се рестартира празен, всички заявки отиват до базата наведнъж. Срокът се разпръсква с малко случайност, а най-четеното се зарежда предварително.
- Всеки сървър със свой кеш. Кеш в паметта на процеса е бърз, но при три копия на сървъра (в урока за мащабирането) всяко копие има своя стара цена. Затова общият кеш е отделен (Redis).
- Кеш, който не е измерен. Кешът добавя код, памет и грешки. Слагаш го, когато брояч или измерване покажат, че базата е тясното място, а не за всеки случай.
Пробвай
Намери грешката
Колега кешира цялата готова страница на продукта. Тестовете минават. На следващия ден английските купувачи виждат страницата на български. На кой ред е грешката?
Избери реда с грешката.
Ключът е само номерът на продукта, а страницата зависи и от езика. Който отвори продукта пръв, решава езика за всички до изтичането на копието. Ключът трябва да съдържа всичко, от което зависи отговорът: f"page:{product_id}:{lang}". Същото важи за валута, права и всичко друго, което прави два отговора различни.
По-точно, за любопитните
Други начини за кеш. При read-through кешът сам пита базата при пропуск (кодът говори само с кеша). При write-through записът минава през кеша и той пише в базата. При write-behind кешът пише в базата по-късно, на партиди: бързо е, но при срив губиш записи. Cache-aside е най-честият, защото кешът може да падне, без да спре магазина: тогава всичко отива до базата.
Инвалидиране със събитие. Ако цената се сменя в една услуга, а се кешира в друга, тя не може да трие чужд ключ. Тогава промяната пуска събитие „цената на 7 се смени“ и всеки, който кешира, трие своето копие. Това е урокът за събитията.
Истинските кешове: Redis и Valkey (общ кеш за всички сървъри), Memcached. В процеса: Caffeine в Java, IMemoryCache и HybridCache в ASP.NET Core, functools.lru_cache в Python (без TTL), пакетът lru-cache в Node. Рамките имат и обвивка като тук: @Cacheable и @CacheEvict в Spring, кешът на Django.
Кеш по пътя на заявката. Браузърът и CDN-ът също кешират отговори по заглавката Cache-Control. Настройката на CDN и proxy е инфраструктура и се учи в DevOps курса ZeroToOps.
Фил Карлтън казва, че в програмирането има две трудни неща: инвалидирането на кеша и имената. Сега знаеш защо първото е в списъка.
Упражнение
Цената за доставка
Реши задачата в редактора си.
Количката пита външния доставчик колко струва доставката до града на купувача. Доставчикът отговаря бавно и брои заявките. Цените: София 4,90 €, Пловдив 5,90 €, Варна 6,90 €. Напиши CachedQuotes: пази цената за всеки град 10 тика и пита доставчика само при пропуск. Пусни заявките на тикове 0, 1, 2, 5, 11, 12 и 13 за градовете София, Пловдив, София, София, София, Пловдив и Варна.
тик 0, София: 4.90 €тик 1, Пловдив: 5.90 €тик 2, София: 4.90 €тик 5, София: 4.90 €тик 11, София: 4.90 €тик 12, Пловдив: 5.90 €тик 13, Варна: 6.90 €заявки към доставчика: 5Пет заявки вместо седем. София на тик 11 е пропуск: копието от тик 0 е живяло до тик 10.
Още задачи
- Брой попаденията. Добави брояч на попаденията и пропуските и ги отпечатай накрая.
- Доставчикът сменя цената. Добави метод, с който доставчикът казва „цената за Варна се смени“, и изтрий само нейното копие.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Decorator · Repository · Race condition · Слоеве
- Продължава в: Мащабиране · ADR: записани архитектурни решения
- Задачи по темата: Софтуерни архитектури