На перший погляд прогрес читання дуже простий.
Користувач прокрутив сторінку до 62%, клієнт відправив 62, сервер зберіг 62.
Потім користувач дійшов до 70%, сервер отримав 70 і оновив значення.
Проблема з’являється, коли запити приходять не в тому порядку, у якому були створені.
При швидкому прокручуванні запит із 70 може завершитися раніше, а старіший запит із 62 прийти трохи пізніше.
Якщо сервер просто зберігає останнє отримане значення, прогрес рухається назад.
Запити можуть завершитися не в тому порядку, у якому були створені
Користувач швидко прокручує сторінку Клієнт 62% ---------------------> запит A70% -----------> запит B Сервер запит Bprogress = 70 | vзбережено 70Жоден із запитів сам по собі не є неправильним.
Обидва значення справді відповідали стану інтерфейсу в момент створення.
Помилка виникає в правилі збереження: сервер поводиться так, ніби порядок приходу запитів дорівнює порядку бізнесових змін.
У розподіленій системі це припущення ненадійне.
Простий update реалізує правило "останній запис переміг"
$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Монотонність тут є частиною змісту поля.
Нове повідомлення від клієнта не означає "замінити прогрес цим значенням".
Воно означає: "користувач досяг щонайменше цього місця".
Тому правильне правило збереження вже інше:
зберегти більше з двох значень - поточного і отриманого.
Перше правильне правило - не зменшувати вже досягнуте значення
$incomingProgress = $request->integer( 'progress_percent', ); $nextProgress = max( $reading->progress_percent, $incomingProgress,); $reading->update([ 'progress_percent' => $nextProgress,]);Цей код уже краще відображає бізнесове правило.
Запит із меншим значенням не може зменшити прогрес.
Але якщо кілька запитів реально виконуються одночасно, одного max() у PHP може бути недостатньо.
max() не захищає від конкурентного read-write сценарію сам по собі
У базі: progress = 50 Запит A читає 50incoming = 80max = 80 Запит B читає 50incoming = 70max = 70 Запит A записує 80Обидва запити правильно використали max().
Але обидва прочитали старе значення 50 до того, як інший записав нове.
Тому проблема вже не в самій формулі, а в тому, що читання і запис не є однією захищеною операцією.
Один варіант - повторно прочитати значення під блокуванням
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 в базі
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 Якщо завершення випливає з досягнутого прогресу, воно теж має відповідати монотонній природі цього стану.
Після завершення старий мережевий запит не повинен повернути матеріал у незавершений стан.
Тут уже варто перевіряти весь інваріант, а не лише одне числове поле.
Якщо прогрес іноді справді треба скинути, це має бути окрема операція
$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-запит не обов’язково представляє новіший стан.
Тому я не дозволяю порядку мережевої доставки вирішувати, наскільки далеко користувач уже просунувся.
Я зберігаю найбільше досягнуте значення і захищаю це правило від конкурентних оновлень.
"Останній запис переміг" є стратегією збереження. Вона правильна лише тоді, коли відповідає змісту самого стану.
Якщо стан може рухатися лише вперед, старіший запит не повинен отримувати право повернути його назад лише тому, що прийшов останнім.



