Матеріал розбирає підхід 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 така:
- Зчитати з сесії ключ
db_commit_id— це commit ID, який користувач «чекає». - Якщо ключ є:
- виконати
DB::hasCommitId($pending)на поточному read-конекшені; - якщо репліка вже має цей commit ID — видалити ключ із сесії й продовжити читати з реплік;
- якщо ще ні — викликати
DB::useWriteConnectionWhenReading(), змусивши читання піти на primary.
- виконати
- Перед відправкою відповіді перевірити, чи були зміни в БД в цьому запиті через
DB::hasModifiedRecords(). - Якщо були:
- отримати новий commit ID з primary через
DB::getLastCommitId(); - якщо попереднього commit ID в сесії не було — просто записати новий;
- якщо був — об'єднати попередній і новий через
DB::mergeCommitIds()і зберегти результат.
- отримати новий commit ID з primary через
Такий підхід дає декілька практичних ефектів:
- якщо лаг реплікації невеликий (наприклад, приблизно 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 така:
- У
handle()перевіритиDB::hasCommitId($this->commitId, wait: 2).- Якщо протягом 2 секунд репліка наздогнала потрібний commit ID — викликати
$this->send(), читаючи з репліки. - Якщо ні — виконати
$this->send()усерединіDB::usingWritePdo(), тимчасово перевівши читання на primary.
- Якщо протягом 2 секунд репліка наздогнала потрібний commit ID — викликати
- У методі
send()завантажити замовлення з пов'язаними даними (lines,customer) і відправити лист.
У цьому прикладі є два важливі обмеження:
- job приймає
orderId, а не саму модельOrder. Якщо б використовувався traitSerializesModelsз передачею моделі, 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