Unix-часи в кешуванні та TTL — як насправді працює закінчення терміну дії

Якщо ви коли-небудь дивилися на застарілий запис у кеші й дивувалися, чому він не закінчується вчасно, відповідь зазвичай знаходиться десь у часовій мітці. Системи кешування — від Redis до CDN і HTTP-заголовків — активно використовують Unix-часи, і навіть помилка в математиці на кілька секунд може викликати баги, які важко відтворити.

Використовуйте конвертер Unix-часу, щоб перевести будь-яку часову мітку закінчення кеша у читабельну дату або отримати поточну часову мітку для налагодження розрахунків TTL.

Що означає TTL і як він виражається

TTL — це скорочення від time-to-live (час життя). Це період, протягом якого значення у кеші вважається допустимим до того, як його потрібно оновити або видалити.

TTL можна виражати двома способами залежно від системи:

Відносний TTL: кількість секунд, протягом яких запис у кеші повинен залишатися актуальним з моменту збереження. Redis і Memcached за замовчуванням працюють саме так. Якщо ви встановите ключ з TTL 3600, він закінчиться через 3600 секунд (одну годину) після встановлення, незалежно від того, коли це було.

Абсолютна часова мітка закінчення: Unix-час, в який значення у кеші закінчується. HTTP-заголовки Cache-Control з max-age працюють як відносний TTL, але заголовки Expires використовують абсолютне дату-час. CDN та деякі кеші на рівні застосунку зберігають абсолютну часову мітку закінчення — поточний час плюс TTL — і перевіряють, чи поточний_час > закінчення, щоб визначити, чи дані застаріли.

Обидва підходи зводяться до однієї логіки: порівняти поточний Unix-час з збереженою часовою міткою закінчення. Різниця лишає в тому, де відбувається це обчислення — під час запису чи під час читання.

Як Redis обробляє закінчення ключів

Redis — найпоширеніший кеш у пам'яті, і його поведінка щодо TTL варта детального вивчення.

Коли ви встановлюєте ключ з EXPIRE key 3600, Redis записує абсолютний Unix-час, в який ключ повинен закінчитися: поточний_час + 3600. Ви можете це побачити за допомогою EXPIRETIME key, яка повертає Unix-час закінчення (Redis 7.0+). TTL key повертає залишкові секунди.

Redis використовує ліниве закінчення (lazy expiry) в поєднанні з періодичними очистками. Ключ не видаляється в момент закінчення — він видаляється при наступному доступі (якщо буде виявлено, що він закінчився) або коли фонова очистка Redis його підхопить. Це означає, що ключ з TTL 0 все ще може з'явитися у сценаріях DEBUG SLEEP або коли очистка ще не запустилася.

Практичне наслідок: якщо ваш застосунок читає ключ, перевіряє, що він не nil, а потім його використовує — і навіть у невеликому часовому проміжку між GET і перевіркою TTL — ви можете прочитати значення, що щойно закінчилося. У системах з високою пропускною здатністю це може викликати шторм кеша (cache stampede), коли багато запитів одночасно виявляють закінчений ключ і намагаються його відновити.

HTTP-кешування і Unix-часи

HTTP-кешування використовує комбінацію відносних та абсолютних значень часу, і взаємодія між ними викликає багато плутанини.

Cache-Control: max-age=3600 говорить браузеру чи CDN, що відповідь актуальна протягом 3600 секунд з моменту її отримання. Це відносна величина — кожен клієнт відраховує час від себе з моменту отримання відповіді.

Expires: Thu, 17 Apr 2026 10:00:00 GMT — це абсолютна часова мітка. Вона говорить клієнту не використовувати кешовану відповідь після цієї точки. Проблема в тому, що це вимагає точності системного часу клієнта. Якщо системний час клієнта неправильний на годину, поведінка кеша буде зміщена на годину.

Age заголовок: коли CDN уже має кешовану відповідь протягом 600 секунд, він посилає Age: 600 клієнту. У поєднанні з max-age, клієнт знає, що залишковий час свіжості — це max-age - Age. Якщо Age перевищує max-age, відповідь уже застаріла.

Last-Modified та ETag: це заголовки валідації — вони дозволяють клієнту запитати «це все ще актуально?» замість того щоб просто використовувати TTL. Сервер повертає 304 Not Modified (без тіла, лише заголовки), якщо вміст не змінився, що набагато швидше, ніж відправка повної відповіді.

Логіка закінчення та очищення у CDN

CDN на кшталт Cloudflare, Fastly та CloudFront кешують відповіді на периферії і використовують TTL для визначення того, як довго обслуговувати кешовану копію без звернення до основного сервера.

CDN зазвичай зберігають абсолютну часову мітку закінчення, обчислену в момент кешування відповіді: час_кешування + max_age. На кожному запиті вони перевіряють поточний_час > закінчення. Якщо це правда, вони отримують свіжу копію з основного сервера.

Граничний випадок, який заплутує людей: CDN може мати кешовану відповідь у кількох прикордонних локаціях, закешовану в дещо різні часи. CDN у Франкфурті закешував відповідь на 12 секунд раніше, ніж у Сінгапурі. Їхні часові мітки закінчення відрізняються на 12 секунд. Якщо ви зробите «очищення кеша», воно анулює всі одночасно, незалежно від TTL. Але якщо ви покладаєтеся тільки на закінчення TTL, ви можете отримати застарілу відповідь з однієї прикордонної локації на кілька секунд пізніше, ніж інша вже оновила свою.

Налагодження застарілого кеша за допомогою часових міток

Коли ви підозрюєте, що кеш повертає застарілі дані, перша річ — отримати сирові часові мітки:

1. Отримайте поточний Unix-час — використовуйте конвертер Unix-часу або виконайте date +%s у терміналі.

2. Знайдіть часову мітку закінчення запису у кеші — у Redis EXPIRETIME key; у HTTP-відповідях розберіть заголовок Expires або обчисліть із Date + max-age - Age; у кеші вашого застосунку заведіть облік збереженого значення закінчення.

3. Порівняйте їх — якщо поточний_час > закінчення, запис повинен бути закінчений. Якщо він не видаляється, перевірте вашу політику витіснення (eviction policy), налаштування ліниого та нетерплячого закінчення, та чи обновляється запис до того, як закінчиться.

4. Перевірте різницю часу між системами — якщо ваш сервер застосунку та сервер кеша мають різні системні часи, розрахунки TTL будуть неправильними. Типова помилка в production — сервер кеша, у якого час відстав на 5 хвилин. Записи, які мали закінчитися 4 хвилини тому, все ще повертаються як свіжі.

Синхронізація NTP попереджує відставання часу, але не позбавляє його повністю. Для критичної логіки закінчення використовуйте часові мітки, згенеровані сервером, з одного джерела, замість того щоб розраховувати на те, що всі хости погодяться щодо поточного часу.

Збереження часових міток закінчення у вашому застосунку

Коли ви будуєте кешування на рівні застосунку — збереження обчислених результатів у базі даних або key-value сховищі з закінченням — ви повинні вирішити, як представити закінчення.

Варіант 1: збережіть TTL (відносний). Запис знає, що повинен жити 3600 секунд, але не знає, коли був створений. Вам потрібно окреме поле created_at, щоб обчислити, чи закінчився він.

Варіант 2: збережіть абсолютну часову мітку закінчення. При записі обчисліть expires_at = поточний_unix_час + ttl. При читанні перевірте поточний_unix_час > expires_at. Простіше розмірковувати — вам потрібно лише одне поле.

Підхід з абсолютною часовою міткою майже завжди чистіший. Збереження expires_at: 1775000000 яснішe, ніж збереження ttl: 3600 окремо від created_at: 1774996400. Це також добре переживає перезавантаження — відносний TTL нічого не значить, якщо ви не знаєте, коли запис був створений.

Шторм кеша та як часові мітки допомагають його запобігти

Шторм кеша (cache stampede) відбувається, коли багато запитів одночасно виявляють, що кешоване значення закінчилося, і всі намагаються його відновити одночасно. Для дорогого запиту до бази даних або зовнішнього API виклику 50 одночасних запитів на відновлення можуть перевантажити backend.

Рішення на основі часових міток — ймовірнісне раннє закінчення (також звана прогрівом кеша): замість закінчення запису точно в час_закінчення, почніть ймовірнісно його оновлювати раніше. Проста версія:

if поточний_час > (час_закінчення - випадковий_множник * залишковий_ttl):
    оновити кеш

Де випадковий_множник зазвичай 0–1. Це розподіляє оновлення кеша замість того щоб скупчувати їх у точний момент закінчення.

Простіший підхід: додайте випадковість до TTL. Замість того щоб встановити всі записи на закінчення рівно через 3600 секунд, встановіть їх на закінчення через 3600 + випадково(-300, 300) секунд. Це розсинхронізує часи закінчення різних записів і запобігає тому, щоб вони всі закінчилися одночасно.

Знакові та беззнакові часові мітки та проблема 2038 року

Більшість систем кешування зберігають значення TTL як 32-бітові цілі числа. Для значень TTL (відносних тривалостей) це нормально — ніхто не встановлює TTL кеша на 2 мільярди секунд. Але для абсолютних часових міток закінчення, знакове 32-бітове ціле число переповнюється 19 січня 2038 року.

Якщо ваша інфраструктура кешування зберігає абсолютні Unix-часи у 32-бітових цілих числах, записи встановлені на закінчення після січня 2038 матимуть неправильні значення закінчення. Це граничний випадок сьогодні — більшість TTL в production мають тривалість хвилин чи годин, не років — але системи, які кешують довгоживучі токени, сертифікати чи дані конфігурації з багаторічним закінченням, можуть на це натрапити.

Сучасні системи та мови справляються з цим правильно за допомогою 64-бітових цілих чисел. Варто перевірити, чи ви працюєте зі старішими вбудованими системами, старим кодом C, або будь-якою системою, що обробляє часові мітки як int замість int64 чи long long.

Схожі статті