0% прочитано

Laravel Analytics: як я зробила атомарне накопичення без втрати переглядів при одночасних запитах

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

20 вересня 2026 р. 11 хв читанняЛаравель

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

Поки дивишся на один запит, рішення здається очевидним: прочитати поточне значення, додати 1 у PHP і зберегти результат назад.

Але вебзастосунок не обробляє перегляди строго по одному.

Якщо два запити приходять майже одночасно, проста схема може втратити один із переглядів без жодної помилки.

Лічильник може бути логічно неправильним навіть тоді, коли обидва HTTP-запити завершилися успішно і база даних не повернула жодної помилки.
Laravel analytics concurrency diagram comparing unsafe read increment save with an atomic database increment into a daily aggregate
Два одночасні запити можуть прочитати однакове старе значення й перезаписати один одного. Атомарна операція накопичує обидва збільшення в базі даних.

Небезпечна схема виглядає абсолютно нормально

У спрощеному вигляді такий код може виглядати так:

PHP - Прочитати, збільшити та зберегти
$aggregate = AnalyticsDailyAggregate::query()    ->where(...)    ->first(); $aggregate->views++; $aggregate->save();

Для одного запиту все правильно.

Проблема з’являється, коли два процеси виконують ці кроки одночасно.

Два запити можуть прочитати одне й те саме старе значення

Один із двох переглядів втрачено
Database: views = 100  Request A reads:100 Request B reads:100  Request A calculates:101 Request B calculates:

Фактично було два нові перегляди, тому правильне значення мало стати 102.

Але обидва запити працювали з однією старою копією значення.

Другий запис не додає ще одну одиницю. Він просто повторно записує 101.

Втрачене оновлення небезпечне тим, що система не обов’язково бачить помилку. Вона просто тихо зберігає неправильний результат.

Для лічильника збільшення має виконувати база даних

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

SQL - Спрощена атомарна операція збільшення
UPDATE analytics_daily_aggregatesSET views = views + 1WHERE id = ?;

Тут PHP не читає 100, щоб потім повернути 101.

База даних сама виконує операцію views = views + 1 над актуальним значенням рядка.

Але потрібний агрегат ще може не існувати

Для добової аналітики є ще одна умова.

Перший перегляд конкретного набору вимірів за сьогодні може не мати готового рядка.

Потрібні і вставка, і накопичення
First view today: aggregate row does not exist        |        vcreate row with views = 1  Next view: aggregate row already exists        |        vincrease views by 1

Тому потрібен контракт "створи рядок, якщо його немає, або атомарно збільш значення, якщо він уже існує".

Саме тут доречна операція типу upsert, але важлива не назва методу, а її атомарність.

Спрощено SQL може виглядати як накопичувальний upsert

Цей SQL показує принцип атомарного накопичення. Це не твердження про буквальний SQL поточного production-коду ICanUp.
SQL - Спрощений приклад накопичення
INSERT INTO analytics_daily_aggregates (    aggregate_date,    metric,    subject_type,    subject_id,    locale,    views)VALUES (    ?, ?, ?, ?, ?, 1)ON DUPLICATE KEY UPDATE    views = views + 1;

Перший запит створює рядок із 1.

Наступні запити не виконують цикл "прочитати -> змінити -> зберегти", а накопичують значення безпосередньо в базі.

Ключова умова - база повинна знати, що означає один агрегат

Атомарна вставка або оновлення працює правильно тільки тоді, коли є однозначне визначення рядка, який потрібно збільшити.

Для добової аналітики один агрегат визначається не тільки датою.

Ідентичність добового агрегату
aggregate identity date+ metric+ subject type+ optional subject identity+ locale+ selected dimensions

Два різні виміри не повинні потрапити в один рядок

Наприклад, перегляд української та англійської версії одного Post не повинен випадково накопичуватися в одному локалізованому агрегаті, якщо аналітика має показувати розподіл за мовою.

Один матеріал, два окремі мовні агрегати
2026-09-18Post 42locale = ukviews = 20  2026-09-18Post 42locale = enviews = 8

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

Виміри є частиною ідентичності, а не просто додатковим описом

Якщо два запити мають різні набори вимірів, вони повинні збільшувати різні агрегати.

Різні виміри створюють різні агрегати
Request A:date    = 2026-09-18locale  = ukdevice  = desktop  Request B:date    = 2026-09-18locale  = ukdevice  = mobile  Result:two different aggregate buckets

Унікальність у базі захищає контракт

Якщо ідентичність агрегату визначена тільки в PHP, два конкурентні запити все одно можуть одночасно вирішити, що рядка ще немає.

Тому структура бази повинна допомагати гарантувати, що один набір ключових значень не створить два окремі добові рядки.

Унікальність має підтримуватися базою
same aggregate identity Request A -> INSERTRequest B -> INSERT without database uniqueness:two rows may appear with database uniqueness:one identity remains one bucket

Атомарність потрібна і для deduplicated views

В аналітиці ICanUp окремо є звичайні перегляди та дедупліковані перегляди.

Дедуплікація визначає, чи потрібно збільшувати другий показник для конкретного запиту.

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

Різні правила, однакова вимога до атомарності
eligible view    |    +--> views + 1 deduplication says "count"    |    +--> deduplicated_views + 1
Deduplicated views у цій аналітиці не варто трактувати як точну кількість унікальних людей. Це окрема метрика з власними правилами дедуплікації.

Не потрібно блокувати весь процес аналітики

Мета атомарного запису не в тому, щоб обгорнути кожен перегляд великим ручним блокуванням.

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

Я хочу, щоб база даних виконувала мінімальну операцію над конкретним агрегатом.

Добовий агрегат потребує єдиної межі дня

Коли ключ містить дату, виникає ще одне важливе питання: коли саме починається новий день?

Не можна в одному місці брати часову зону PHP-процесу, в іншому часову зону MySQL, а в третьому випадково використовувати UTC.

Межа звітного дня
23:59:59 Europe/Kyiv        |        v2026-09-18  00:00:00 Europe/Kyiv        |        v2026-09-19

Для моєї аналітики дата агрегату повинна визначатися детерміновано за одним звітним часовим контрактом. У поточній архітектурі це прив’язано до Europe/Kyiv.

Server timezone не повинна випадково визначати аналітику

Сервер може працювати в UTC. Контейнер, черга або окремий процес теж може мати інші налаштування середовища.

Якщо просто викликати "сьогодні" без явного контракту, частина переглядів біля опівночі може потрапити не в той день.

PHP - Явно визначити звітну дату
$reportingDate = $occurredAt    ->setTimezone(        new DateTimeZone('Europe/Kyiv')    )    ->format('Y-m-d');
Важлива не конкретна форма цього прикладу, а однакове правило у всіх місцях, які записують або читають добову аналітику.

Перехід на літній і зимовий час не треба рахувати вручну

Я не хочу зберігати логіку на кшталт "Київ завжди UTC+3".

Часова зона повинна бути іменованою, щоб правила переходу застосовувала стандартна бібліотека часу.

Іменована часова зона замість постійного зміщення
Use:Europe/Kyiv Do not hardcode:UTC+2 foreverUTC+3 forever

Запис і читання повинні погоджуватися щодо дня

Недостатньо правильно визначити день лише під час запису.

Фільтр "сьогодні", добовий графік і порівняння періодів повинні використовувати той самий часовий контракт.

Одна межа дня для запису й читання
Tracking:timestamp -> Europe/Kyiv -> aggregate date Admin Analytics:selected period -> Europe/Kyiv day boundaries Both must agree

Чому я зберігаю агрегати, а не кожен сирий перегляд

Для поточної задачі мені не потрібне нескінченне сховище кожного HTTP-запиту.

Основні питання аналітики можна вирішити через накопичені показники за днем і обмеженими вимірами.

Сирі події

Добові агрегати

Один рядок на кожен перегляд

Один рядок на комбінацію ключових вимірів

Швидко росте обсяг даних

Обсяг контрольованіший

Дає детальну історію кожної події

Добре підходить для totals і графіків

Може зберігати надмірні дані

Легше тримати privacy-safe contract

Але агрегат збільшує важливість правильного ключа

Коли сирої події немає, неправильний запис не можна просто "перерахувати пізніше" з повної історії.

Тому особливо важливі три речі:

  • правильна ідентичність агрегату;
  • атомарне збільшення;
  • детермінована дата.

Додавання нового виміру змінює кількість агрегатів

Кожен новий вимір розбиває дані на дрібніші групи.

Це корисно, але має ціну.

Кожна комбінація може створювати окремий агрегат
date+ locale+ country+ device+ browser+ OS+ traffic source+ subject...

Тому в аналітику я додаю обмежені, нормалізовані виміри, а не довільні значення з кожного запиту.

Наприклад, для країни достатньо нормалізованого коду країни, а не сирої IP-адреси.

Порожнє значення теж повинно мати один контракт

Якщо певний вимір невідомий, важливо однаково представляти цей стан під час формування ключа.

Інакше логічно однакові запити можуть потрапляти в різні агрегати через різницю між null, порожнім рядком або умовним unknown.

Нормалізація невідомих значень
Bad: country = nullcountry = ""country = "unknown" Three representationsof the same semantic state

Повторна спроба запису потребує окремого рішення

Атомарне збільшення захищає від конкурентного перезапису, але не вирішує автоматично всі проблеми.

Якщо той самий логічний перегляд через помилку буде записаний двічі, база чесно збільшить лічильник двічі.

Тому конкурентна безпека та ідемпотентність повторної обробки є різними задачами.

Проблема

Що вирішує

Два одночасні реальні перегляди

Атомарне накопичення

Один і той самий перегляд обробили двічі

Потрібний окремий контракт дедуплікації або ідемпотентності

Кеш не є історичним джерелом аналітики

Ще одне рішення в моїй архітектурі: історичні показники зберігаються в базі даних.

Кеш може допомагати читанню, але очищення кешу не повинно стирати історію переглядів.

База даних зберігає історію
Public request    |    vAnalytics persistence    |    vDatabase history Cache:optional read optimizationnot historical storage

Тести повинні створювати саме проблему конкуренції

Звичайний тест "записали два перегляди послідовно й отримали 2" не доводить, що реалізація безпечна при конкуренції.

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

  • перший запис створює новий агрегат;
  • наступний запис збільшує той самий агрегат;
  • два різні набори вимірів створюють різні агрегати;
  • однаковий ключ не створює дубльовані добові рядки;
  • звичайні views і deduplicated views накопичуються незалежно;
  • перегляди по різні боки межі дня потрапляють у різні дати;
  • UK та EN не змішуються, якщо locale входить у виміри;
  • видалення кешу не видаляє історичні показники.

Особливо важливо тестувати опівніч

Граничний тест для звітного дня
2026-09-18 23:59:59 Europe/Kyiv-> aggregate date 2026-09-18 2026-09-19 00:00:00 Europe/Kyiv-> aggregate date 2026-09-19

Такий тест маленький, але захищає весь добовий графік від дуже неприємного зміщення біля опівночі.

Що я не роблю в лічильниках аналітики

  • Не читаю поточний views у PHP лише для того, щоб записати views + 1 назад.
  • Не покладаюся тільки на перевірку "рядок уже існує?" перед INSERT.
  • Не визначаю унікальність агрегату лише в коді застосунку.
  • Не використовую випадкову часову зону процесу для добової дати.
  • Не змішую різні набори вимірів в один агрегат.
  • Не зберігаю довільні необмежені значення як аналітичні виміри без потреби.
  • Не називаю deduplicated views точною кількістю унікальних людей.
  • Не використовую кеш як єдине історичне сховище.
  • Не вважаю атомарний increment автоматичним вирішенням повторної обробки тієї самої події.

Мій короткий checklist для накопичувальної аналітики

  • Спочатку визначити точну ідентичність одного агрегату.
  • Закріпити цю ідентичність обмеженням у базі даних.
  • Не використовувати read -> increment -> save для конкурентного лічильника.
  • Збільшувати значення атомарно в базі.
  • Підтримати створення нового рядка і збільшення існуючого одним безпечним контрактом.
  • Нормалізувати значення вимірів до побудови ключа.
  • Мати одну часову зону звітності.
  • Однаково визначати межі дня під час запису й читання.
  • Окремо розглядати concurrency, дедуплікацію та повторну обробку.
  • Тестувати межу дня та різні набори вимірів.
  • Зберігати історію в постійному сховищі, а кеш використовувати лише як оптимізацію.

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

Висновок

У добовій аналітиці ICanUp головною проблемою було не саме збільшення views, а гарантія, що одночасні запити не перезапишуть результат один одного.

Схема "прочитати -> додати 1 -> зберегти" працює послідовно, але може тихо втрачати оновлення при конкуренції.

Тому накопичення виконується атомарно на рівні бази даних, а один добовий агрегат визначається стабільним набором ключових значень: датою, метрикою, типом сутності, її ідентичністю та потрібними вимірами.

Не менш важлива дата. Якщо різні частини системи по-різному визначають початок дня, навіть правильні атомарні лічильники дадуть неправильний добовий графік. Тому запис і читання мають використовувати один часовий контракт.

Мій головний висновок: аналітичний лічильник має бути правильним не тільки для одного запиту. Він має залишатися правильним тоді, коли десятки запитів намагаються змінити його одночасно.