Laravel read replicas — як перевірити, що репліка наздогнала primary

Матеріал розбирає підхід Tobias Petry, доданий у Laravel 13.40, для точнішої роботи з read replicas без фіксованих таймаутів.

Проблема: читання зі старої репліки після запису

У типовій конфігурації Laravel з read/write підключенням записи йдуть на primary, а читання — на одну з реплік. Репліки отримують зміни асинхронно і можуть відставати. Через це користувач після сабміту форми може потрапити на сторінку, яка читає зі старої репліки, і бачити попередні дані.

До версії 13.40 основним рішенням був часовий буфер: деякий час після запису читання для цього користувача примусово йшли на primary. У Laravel це спрощує middleware PreventStaleDatabaseReads, який за замовчуванням тримає вікно в 2 секунди.

У такого підходу є два очевидні недоліки:

  • якщо лаг реплікації більший за вікно — користувач усе одно читає старі дані;
  • якщо лаг менший — частина читань дарма навантажує primary, хоча репліка вже наздогнала.

Підхід з commit ID вирішує це за рахунок прямої перевірки стану репліки, а не грубого припущення по часу.

Нові методи DB: getLastCommitId, hasCommitId, mergeCommitIds

У Laravel 13.40 з'явилися три методи підключення до БД, які працюють з commit ID:

  • DB::getLastCommitId() — отримати позицію запису на primary;
  • DB::hasCommitId($id, wait: $seconds = 0) — перевірити, чи репліка вже наздогнала цю позицію;
  • DB::mergeCommitIds([...$ids]) — об'єднати кілька позицій в одну, яка «накриває» усі ці записи.
use Illuminate\Support\Facades\DB;

// After a write, on the primary
$order = Order::create([...]);
$commitId = DB::getLastCommitId();

// Later, on the read connection
DB::hasCommitId($commitId);          // checks once and returns at once
DB::hasCommitId($commitId, wait: 2); // waits up to 2 seconds for the replica
// Combine IDs from several writes into one
$combined = DB::mergeCommitIds([$firstId, $secondId]);

Ключові деталі по кожному методу:

  • getLastCommitId()
    • завжди звертається до write-підключення;
    • повертає рядок з поточною позицією реплікації на primary;
    • формат залежить від СУБД (GTID/WAL), але для додатку це просто непрозорий ідентифікатор.
  • hasCommitId($id, wait: 0)
    • повертає true, коли сервер застосував усі записи до вказаного commit ID включно;
    • параметр wait — ціле число секунд, скільки СУБД може блокувати запит, очікуючи на реплікацію;
    • при wait: 0 перевірка виконується один раз без очікування;
    • якщо за відведений час репліка не наздогнала — повертає false.
  • mergeCommitIds([$firstId, $secondId, ...])
    • повертає один commit ID, що логічно включає усі передані;
    • на PostgreSQL це просто максимальна WAL позиція;
    • на MySQL/MariaDB — об'єднаний GTID set, що може охоплювати записи з декількох primary;
    • якщо масив порожній — кидає RuntimeException.

Формат commit ID в різних СУБД

База даних Тип commit ID Приклад
MySQL GTID set з @@global.gtid_executed 3e11fa47-71ca-11e1-9e33-c80aa9429562:1-77
MariaDB GTID position з @@gtid_binlog_pos 0-1-1052
PostgreSQL WAL position з pg_current_wal_lsn() 0/16B3748

Вимоги до конфігурації бази

Нові методи спираються на механізми реплікації конкретних СУБД. Для них потрібні певні налаштування сервера, і не всі варіанти підтримуються.

База даних Необхідні налаштування Параметр wait у hasCommitId()
MySQL log_bin, gtid_mode = ON, enforce_gtid_consistency = ON Підтримується
MariaDB log_bin Підтримується
PostgreSQL Додаткові налаштування не потрібні Підтримується з PostgreSQL 19
SQLite, SQL Server Не підтримується Не підтримується

Нюанси по поведінці в різних СУБД:

  • MySQL 8 за замовчуванням вмикає binary logging (log_bin), але gtid_mode і enforce_gtid_consistency залишаються вимкненими — їх потрібно налаштовувати окремо;
  • у MariaDB binary logging log_bin за замовчуванням вимкнено;
  • якщо потрібні налаштування відсутні, getLastCommitId() кидає RuntimeException і прямо вказує, яких саме параметрів не вистачає;
  • для SQLite і SQL Server будь-який виклик commit ID методів закінчується RuntimeException — реплікація через них не покривається;
  • на PostgreSQL 18 і нижче виклик hasCommitId() з параметром wait призводить до помилки навіть на primary.

Якщо ви використовуєте керовану БД (managed service), змінити ці параметри може бути неможливо — тоді потрібно або домовлятися з провайдером, або залишатися на старій схемі з часовим вікном.

Який саме сервер перевіряє hasCommitId

DB::hasCommitId() виконує select, тож звертається до того самого сервера, з якого наразі йдуть читання у межах запиту.

У таких випадках перевірка фактично йде на primary:

  • ви знаходитесь всередині транзакції БД;
  • для з'єднання увімкнений sticky, і в поточному запиті вже були записи;
  • ви явно примусили читання з primary через useWriteConnectionWhenReading() або usingWritePdo().

Primary завжди «має» власні коміти, тож у цих випадках hasCommitId() поверне true. Це коректно з точки зору послідовності: подальші читання цього ж HTTP-запиту теж підуть на primary.

Для реплік важливо пам'ятати ще одну деталь: для read-конекшена Laravel обирає одну репліку на запит. Якщо у секції read в конфігурації кілька хостів, то:

  • при першому читанні в запиті Laravel обирає один хост і створює PDO;
  • hasCommitId() перевіряє саме цей хост;
  • усі подальші select у межах запиту йдуть на нього ж;
  • у наступному HTTP-запиті може бути обрана інша репліка, тому не варто кешувати результат hasCommitId() між запитами.

Middleware для «прочитати власні записи» через commit ID

Автори матеріалу пропонують переписати логіку PreventStaleDatabaseReads з timestamp на commit ID. Замість фіксованого вікна в сесії зберігається commit ID останнього запису користувача, а на кожен запит репліка перевіряється безпосередньо.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;

class ReadYourOwnWrites
{
    public function handle(Request $request, Closure $next): Response
    {
        $pending = $request->session()->get('db_commit_id');

        if ($pending !== null) {
            if (DB::hasCommitId($pending)) {
                $request->session()->forget('db_commit_id');
                $pending = null;
            } else {
                DB::useWriteConnectionWhenReading();
            }
        }

        $response = $next($request);

        if (DB::hasModifiedRecords()) {
            $commitId = DB::getLastCommitId();

            $request->session()->put('db_commit_id', $pending === null
                ? $commitId
                : DB::mergeCommitIds([$pending, $commitId]));
        }
        return $response;
    }
}

Послідовність роботи ReadYourOwnWrites така:

  1. Зчитати з сесії ключ db_commit_id — це commit ID, який користувач «чекає».
  2. Якщо ключ є:
    • виконати DB::hasCommitId($pending) на поточному read-конекшені;
    • якщо репліка вже має цей commit ID — видалити ключ із сесії й продовжити читати з реплік;
    • якщо ще ні — викликати DB::useWriteConnectionWhenReading(), змусивши читання піти на primary.
  3. Перед відправкою відповіді перевірити, чи були зміни в БД в цьому запиті через DB::hasModifiedRecords().
  4. Якщо були:
    • отримати новий commit ID з primary через DB::getLastCommitId();
    • якщо попереднього commit ID в сесії не було — просто записати новий;
    • якщо був — об'єднати попередній і новий через DB::mergeCommitIds() і зберегти результат.

Такий підхід дає декілька практичних ефектів:

  • якщо лаг реплікації невеликий (наприклад, приблизно 50 мс), то при редиректі з PUT на GET репліка зазвичай уже наздогнала, і сторінка читає з репліки без зайвих навантажень на primary;
  • якщо лаг значний (скажімо, близько 10 секунд), усі запити користувача з незакритим commit ID продовжать читати з primary, поки хоча б одна репліка не наздожене;
  • якщо користувач робить декілька записів поспіль, mergeCommitIds() дозволяє тримати один агрегований commit ID в сесії, який охоплює всі ці записи;
  • при повному збої реплікації commit ID в сесії так і не «закриється», але користувач при цьому буде завжди читати актуальні дані з primary.

У middleware свідомо не використовується wait: блокувати веб-запит у очікуванні реплікації неефективно — у той час ті ж дані може швидко віддати primary.

Підключення middleware до web-групи

Щоб middleware мав доступ до сесії, його додають у web групу в bootstrap/app.php після стандартного session middleware:

use App\Http\Middleware\ReadYourOwnWrites;
->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        ReadYourOwnWrites::class,
    ]);
})

Це дозволяє застосувати «прочитати власні записи» для всіх типових веб-флоу — редиректи після форм, редагування постів, зміна налаштувань профілю тощо.

Очікування репліки в черзі (queue jobs)

У задачах черги сесії немає, але проблема та сама: job, який запускається одразу після запису, може стартувати до того, як репліка отримає потрібний рядок. Автори пропонують передавати commit ID у сам job і робити обмежене очікування в handle().

Передати commit ID при диспатчі job

У прикладі з відправкою листа підтвердження замовлення commit ID береться після завершення транзакції з усіма записами:

use App\Jobs\SendOrderConfirmation;
use Illuminate\Support\Facades\DB;

$order = DB::transaction(function () use ($request) {
    $order = Order::create([...]);
    $order->lines()->createMany([...]);

    return $order;
});
SendOrderConfirmation::dispatch($order->id, DB::getLastCommitId());

Суттєвий момент: DB::getLastCommitId() потрібно викликати вже після DB::transaction(). Усередині транзакції primary ще не зафіксував зміни, тому commit ID не включатиме це замовлення.

Очікування в handle () з таймаутом

У job commit ID приймається як звичайний аргумент конструктора:

Приклад коду в оригіналі

Послідовність роботи job така:

  1. У handle() перевірити DB::hasCommitId($this->commitId, wait: 2).
    • Якщо протягом 2 секунд репліка наздогнала потрібний commit ID — викликати $this->send(), читаючи з репліки.
    • Якщо ні — виконати $this->send() усередині DB::usingWritePdo(), тимчасово перевівши читання на primary.
  2. У методі send() завантажити замовлення з пов'язаними даними (lines, customer) і відправити лист.

У цьому прикладі є два важливі обмеження:

  • job приймає orderId, а не саму модель Order. Якщо б використовувався trait SerializesModels з передачею моделі, Laravel завантажив би її з БД ще до виконання handle(), тобто до перевірки hasCommitId(). На відстаючій репліці це могло б закінчитися ModelNotFoundException;
  • fallback робиться через usingWritePdo(), а не useWriteConnectionWhenReading(). Причина в тому, що queue worker перевикористовує одне підключення між багатьма job, і usingWritePdo() відновлює попередні налаштування після завершення callback. Це важливо, щоб інші job знову читали з реплік, а не «зависли» на primary.

Така схема дає можливість:

  • у більшості випадків дочекатися репліки й читати з неї;
  • при великих лагів не чекати занадто довго і безпечно перейти на primary;
  • уникати помилок завантаження моделі з порожньої (ще не наздогнаної) репліки.

Коли commit ID кращий за фіксоване вікно, а коли — ні

На практиці підхід з commit ID найбільш корисний, коли:

  • реплікація може давати рідкі, але великі лаги (десятки секунд і більше);
  • primary помітно дорожчий за репліки і його хочеться навантажувати тільки тоді, коли це справді потрібно;
  • частина читань критична до актуальності одразу після запису (профіль, замовлення, налаштування доступу).

У простіших системах з мінімальним лагом (кілька мілісекунд) і невеликою кількістю реплік часове вікно через PreventStaleDatabaseReads може бути достатнім — воно простіше в налаштуванні і не вимагає специфічної конфігурації БД.

Підхід з commit ID вимагає:

  • коректної конфігурації GTID/WAL і реплікації на рівні СУБД;
  • готовності обробляти RuntimeException там, де методи commit ID технічно недоступні (SQLite, SQL Server або обмежений managed сервіс);
  • акуратного поводження з підключеннями в довгоживучих процесах (queue workers), щоб не «залипнути» на primary.

Якщо ці умови прийнятні, commit ID дають значно більш точний і керований спосіб забезпечити «читаю свої власні записи» як у веб-запитах, так і в фонових job.


Джерело: Paul Redmond — Laravel News

Пошук

Закрити