Три випуски Laravel за тиждень, з 13.36 13 жовтня до 13.38 20 жовтня, мають спільну тему: роботу з репліками бази даних. Хто розносить читання й запис по різних серверах, давно бореться із застарілими даними після запису й тримав для цього власні макроси. Тепер ці інструменти є у фреймворку. Головні з них написав Джек Бейліс (Jack Bayliss), який, за його словами, саме впроваджує розділення читання й запису у себе.
Звідки береться проблема
Розділене з'єднання в config/database.php виглядає так:
'mysql' => [
'driver' => 'mysql',
'read' => [
'host' => ['10.0.0.11', '10.0.0.12'],
],
'write' => [
'host' => ['10.0.0.10'],
],
'sticky' => true,
// ...
],
Опція sticky гарантує, що після запису наступні читання йдуть на write-сервер. Але лише до кінця поточного запиту. Форма відправляється PUT-запитом і робить редирект на GET - це вже два запити, і другий знову читає з репліки, яка може ще не мати змін. Автор PR пише, що в них затримка реплікації зазвичай до секунди, але буває і 15. Результат - користувач оновлює сторінку, бачить старий заголовок і йде писати баг-репорт.
PreventStaleDatabaseReads (13.38)
Новий middleware (#62078) розв'язує саме цей сценарій. Підключіть його до групи web у bootstrap/app.php:
use Illuminate\Foundation\Http\Middleware\PreventStaleDatabaseReads;
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
PreventStaleDatabaseReads::class,
]);
})
Або лише до потрібних маршрутів, з власним вікном у секундах:
Route::middleware(PreventStaleDatabaseReads::for(15))->group(function () {
Route::get('/posts/{post}', [PostController::class, 'show']);
Route::put('/posts/{post}', [PostController::class, 'update']);
});
Механіка проста. Якщо запит щось записав, middleware зберігає в сесії мітку часу _database_modified_at. На наступних запитах, поки мітка в межах вікна (2 секунди за замовчуванням), він вмикає useWriteConnectionWhenReading(), і всі читання цього користувача йдуть на write-сервер. Вікно варто ставити не менше за найдовшу затримку, яку показують ваші метрики реплікації: коротше вікно пропустить старі дані.
Обмеження варто знати заздалегідь:
- потрібна сесія, тож для stateless API middleware не підходить;
- відстежується лише з'єднання за замовчуванням;
- захищено лише того, хто писав: інші користувачі й далі можуть читати старі дані з репліки.
DB::usingWritePdo() (13.38)
Для коду, якому свіжі дані потрібні незалежно від користувача, є usingWritePdo() (#62099). Він закриває пастку, на яку легко наступити з onWriteConnection(): той діє лише на запит, який сам починає, а зв'язок, завантажений з результату, - це вже новий запит, і він іде на репліку.
// posts() усе одно читає з репліки
$posts = User::onWriteConnection()
->where('email', $email)
->first()
?->posts()
->whereNull('published_at')
->get();
// тут усі читання в колбеку йдуть на write-сервер
$posts = DB::usingWritePdo(fn () => User::query()
->where('email', $email)
->first()
?->posts()
->whereNull('published_at')
->get());
Метод повертає результат колбеку і відновлює попередній стан з'єднання, навіть якщо колбек кинув виняток. Усередині DB::transaction() він не потрібен: запити в транзакції й так ідуть на write-сервер.
Як це протестувати
Кожен запис у query log має ключ readWriteType зі значенням read або write. За ним можна перевірити, куди пішов запит після оновлення, - приклад тесту є в Laravel News:
DB::forgetRecordModificationState();
DB::enableQueryLog();
$this->actingAs($user)->get("/posts/{$post->id}")->assertOk();
$this->assertSame('write', DB::getQueryLog()[0]['readWriteType']);
Головна пастка: RefreshDatabase для такого тесту не годиться. Він загортає кожен тест у транзакцію, а в транзакції всі запити й так ідуть на write-сервер, тож тест пройде навіть без middleware. Використовуйте DatabaseMigrations або DatabaseTruncation.
Sticky-з'єднання у воркерах черг (13.36)
Воркер черги - це один довгоживучий процес, який тримає те саме з'єднання між джобами. Якщо sticky увімкнено, одна джоба, що щось записала, переводить на write-сервер читання всіх наступних джоб цього воркера - аж до його перезапуску. Тобто репліки для черг фактично простоюють.
Laravel 13.36 додав прапорець (#61915), з яким воркер скидає цей стан перед кожною джобою:
// AppServiceProvider::boot()
use Illuminate\Queue\Worker;
Worker::$flushStickyConnections = true;
За замовчуванням прапорець вимкнено, бо якась джоба може покладатися на те, що прочитає дані, записані попередньою джобою в тому самому воркері. Правильно таке оформлювати ланцюжком чи батчем, але перед увімкненням перевірте такі місця.
Нюанс із драйвером черги database: щоб зарезервувати джобу, він оновлює таблицю jobs. Якщо вона на тому самому з'єднанні, що й дані застосунку, цей UPDATE знову вмикає sticky-стан одразу після скидання, і прапорець нічого не дає. Як пояснює Laravel News, рішення - винести чергу на окреме з'єднання через DB_QUEUE_CONNECTION=queue. Драйверів Redis і SQS це не стосується.
Інше корисне
DB::olap() для PlanetScale і Vitess (#61932, 13.36). Перемикає з'єднання в OLAP-режим на час колбеку, щоб обійти обмеження OLTP-режиму (на PlanetScale - не більше 100 000 рядків на запит), і ставить режим і на read-, і на write-з'єднання. На звичайному MySQL просто виконує колбек. Поруч з'явився isVitess().
Rule::-білдери для всіх правил валідації (#61833, 13.36): Rule::requiredWithout('email'), Rule::url()->protocols(['https']), Rule::uuid()->version(4), Rule::distinct()->ignoreCase(). Автодоповнення в IDE замість рядків, а значення з комою більше не розбивається на два.
#[CountCrashesAsExceptions] тепер працює на слухачах у черзі, mailables і сповіщеннях, а не лише на джобах (13.38). Раніше слухач, що падав від нестачі пам'яті, з $tries = 0 повторювався без кінця.
#[DeleteWhenMissingModels] і #[FailOnTimeout] більше не ігноруються на mailables у черзі (13.36): лист для видаленого замовлення не потрапить у failed_jobs, а лист, що впав за таймаутом, не прийде двічі.
lazy() і lazyById() звільняють попередній чанк перед завантаженням наступного - пікове споживання пам'яті приблизно вдвічі менше (13.38).
- Екранування в Markdown-листах (13.36): значення користувача в тексті Markdown-посилання могло через зворотну скісну риску змінити адресу посилання. Якщо ви підставляєте введення користувачів у Markdown-листи, це привід оновитися.
13.37 вийшов здебільшого випуском виправлень, але кілька речей помітні: підтримка JPEG XL в Image API, опція strict у Rule::numeric(), виправлення N+1 при вкладених include у JSON:API-ресурсах і звільнення unique-локу, коли постановка джоби в чергу не вдалася.
Чи варто оновлюватися
Так, якщо у вас розділені read/write-з'єднання: оновіться до 13.38 і почніть з PreventStaleDatabaseReads на маршрутах, де користувачі редагують дані, підібравши вікно за метриками реплікації. Worker::$flushStickyConnections вмикайте свідомо, після перевірки джоб, що читають дані одна одної, а з драйвером database - лише разом з окремим DB_QUEUE_CONNECTION. Усі нові можливості опційні, тож саме оновлення нічого не змінить, доки ви їх не ввімкнете.