0% прочитано

Прогрес, який не повинен рухатися назад: чому "останній запис переміг" тут неправильний

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

7 жовтня 2026 р. 8 хв читанняЛаравель

На перший погляд прогрес читання дуже простий.

Користувач прокрутив сторінку до 62%, клієнт відправив 62, сервер зберіг 62.

Потім користувач дійшов до 70%, сервер отримав 70 і оновив значення.

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

При швидкому прокручуванні запит із 70 може завершитися раніше, а старіший запит із 62 прийти трохи пізніше.

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

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

Запити можуть завершитися не в тому порядку, у якому були створені

Старіший запит перемагає лише тому, що прийшов пізніше
Користувач швидко прокручує сторінку  Клієнт 62%  --------------------->  запит A70%  ----------->           запит B  Сервер запит Bprogress = 70        |        vзбережено 70

Жоден із запитів сам по собі не є неправильним.

Обидва значення справді відповідали стану інтерфейсу в момент створення.

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

У розподіленій системі це припущення ненадійне.

Простий update реалізує правило "останній запис переміг"

PHP - Останній запит повністю замінює попереднє значення
$progress = $request->integer(    'progress_percent',); $reading->update([    'progress_percent' =>        $progress,]);

Для багатьох полів така поведінка абсолютно нормальна.

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

Але прогрес має іншу семантику.

Якщо користувач уже досяг 70%, звичайне оновлення до 62% не описує новий стан. Воно повертає стан назад через порядок доставки запитів.

Прогрес у цьому сценарії є монотонним станом

Значення може лише зростати
Допустимо: 12 -> 24 -> 48 -> 73 -> 100  Не повинно траплятися: 12 -> 48 -> 73 -> 55  Правило: new_progress >= stored_progress

Монотонність тут є частиною змісту поля.

Нове повідомлення від клієнта не означає "замінити прогрес цим значенням".

Воно означає: "користувач досяг щонайменше цього місця".

Тому правильне правило збереження вже інше:

зберегти більше з двох значень - поточного і отриманого.

Перше правильне правило - не зменшувати вже досягнуте значення

PHP - Зберігати максимальне значення
$incomingProgress =    $request->integer(        'progress_percent',    ); $nextProgress = max(    $reading->progress_percent,    $incomingProgress,); $reading->update([    'progress_percent' =>        $nextProgress,]);

Цей код уже краще відображає бізнесове правило.

Запит із меншим значенням не може зменшити прогрес.

Але якщо кілька запитів реально виконуються одночасно, одного max() у PHP може бути недостатньо.

max() не захищає від конкурентного read-write сценарію сам по собі

Обидва запити обчислили max на застарілому значенні
У базі: progress = 50  Запит A читає 50incoming = 80max = 80  Запит B читає 50incoming = 70max = 70  Запит A записує 80

Обидва запити правильно використали max().

Але обидва прочитали старе значення 50 до того, як інший записав нове.

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

Один варіант - повторно прочитати значення під блокуванням

PHP - Актуальне значення читається перед записом
DB::transaction(    function () use (        $readingId,        $incomingProgress,    ): void {        $reading = ReadingProgress::query()            ->whereKey(                $readingId,            )            ->lockForUpdate()            ->firstOrFail();         $reading->progress_percent = max(            $reading->progress_percent,            $incomingProgress,        );

Тепер конкурентний запит не може одночасно прочитати той самий рядок для критичного оновлення.

Він дочекається завершення першої транзакції, потім побачить уже нове значення і виконає max() від актуального стану.

Наприклад, після збережених 80 запит із 70 просто залишить 80.

Для простого числового правила можна виконати атомарний update в базі

PHP - Порівняння і запис виконуються однією SQL-операцією
DB::update(    <<<'SQL'        UPDATE reading_progress        SET progress_percent = GREATEST(            progress_percent,            ?        )        WHERE id = ?    SQL,    [        $incomingProgress,        $readingId,    ],);

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

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

Цей варіант особливо зручний для дуже простого монотонного поля.

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

Не кожне поле в тому самому записі має правило max()

Семантика визначається для кожного поля окремо
progress_percent-> зберігати максимум  completed_at-> після завершення   не втрачати значення  last_read_at-> зберігати актуальніший час  current_position-> залежить від змісту поля 

Важливо не перетворити max() на універсальне правило для всього запису.

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

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

Завершений стан теж не повинен випадково скасовуватися

Старий запит не скасовує вже досягнуте завершення
Було: progress = 100completed = yes  Пізніше прийшов старий запит: progress = 87  Неправильно: progress = 87completed = no 

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

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

Тут уже варто перевіряти весь інваріант, а не лише одне числове поле.

Якщо прогрес іноді справді треба скинути, це має бути окрема операція

PHP - Звичайне оновлення і reset мають різну семантику
$progressService->record(    readingId: $readingId,    progress: 82,); $progressService->reset(    readingId: $readingId,);

Монотонність не означає, що значення ніколи за жодних умов не може стати меншим.

Наприклад, продукт може мати явну дію "почати читання заново".

Але це вже інший намір користувача.

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

Тому зменшення стану, якщо воно взагалі дозволене, краще оформити окремою явною командою.

Клієнт може зменшити кількість запитів, але сервер все одно захищає правило

На клієнті можна використовувати затримку, обмеження частоти або не відправляти кожен відсоток прокручування окремим запитом.

Це корисно для навантаження.

Але така оптимізація не є гарантією правильності.

Мережа все одно може затримати запит, користувач може відкрити кілька вкладок, а два пристрої можуть оновлювати той самий стан одночасно.

Тому правило "прогрес не зменшується" я все одно захищаю на сервері.

Цей принцип стосується не тільки прогресу читання

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

Наприклад:

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

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

Що я перевіряю тестами

Основні сценарії
1. Було 40, прийшло 60   -> збережено 60  2. Було 60, прийшло 40   -> залишається 60  3. Було 60, прийшло 60   -> залишається 60  4. Одночасно приходять 80 і 70   -> фінально залишається 80  

Що я перевіряю, коли стан не повинен рухатися назад

Мій короткий список:

  • Чи може це значення за змістом зменшуватися?
  • Чи є менше значення новим наміром або лише старішим повідомленням?
  • Чи можуть запити приходити не по порядку?
  • Чи достатньо простого max(), або є конкурентний read-write сценарій?
  • Чи можна виконати порівняння і запис атомарно в базі?
  • Чи потрібно повторно читати стан під lockForUpdate()?
  • Чи мають інші поля цього запису окремі правила об’єднання?
  • Чи не може старий запит скасувати вже завершений стан?
  • Якщо потрібен reset, чи оформлений він окремою операцією?

Останній запит не завжди має право перемогти

У цьому багу сервер не втрачав запити і не зберігав неправильні числа.

Проблема була тоншою: правильні значення застосовувалися за неправильним правилом.

Для монотонного прогресу пізніший HTTP-запит не обов’язково представляє новіший стан.

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

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

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

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