Копія даних під одну задачу
Реляційна база тримає звʼязки, обмеження й транзакції, і саме тому кожен запис у ній дорожчий, ніж міг би бути. Коли задача вузька й гаряча, її виносять у сховище, зроблене під неї. тримає ключі в памʼяті й відповідає за мікросекунди: кеш, черги, блокування, лічильники. будує інвертований індекс і шукає з помилками, синонімами й фасетами. на кшталт ClickHouse агрегує мільярди подій, читаючи лише потрібні колонки.
Документні бази на кшталт MongoDB зберігають обʼєкти цілком, без схеми таблиць. Для PHP-сайту з компаніями, вакансіями й відгуками, де все повʼязане, це рідко виграш: звʼязки доведеться тримати в коді.
Головне правило: одне, і це реляційна база. Кеш можна скинути, пошуковий індекс перебудувати з таблиць, звіт перерахувати. Якщо втрата Redis чи індексу губить дані, а не швидкість, архітектура зламана.
Виберіть два-три сховища
| PostgreSQL | Redis | Meilisearch | Elasticsearch | ClickHouse | MongoDB | |
|---|---|---|---|---|---|---|
| Модель даних | таблиці, звʼязки, JSONB | ключ → рядок, хеш, список, множина | документи в індексі | JSON-документи, інвертований індекс | колонкові таблиці | BSON-документи |
| Довговічність | ✓ WAL | памʼять, на диск RDB або AOF | ✓ на диску | ✓ репліки шардів | ✓ на диску | ✓ журнал |
| Транзакції | ✓ ACID | ✗ MULTI без відкату, атомарні команди | ✗ | ✗ | ✗ звичних транзакцій немає | ✓ багатодокументні |
| Запити | SQL, JOIN, вікна | за ключем | пошук з помилками, фільтри, фасети | Query DSL, агрегації | SQL з агрегаціями | фільтри по документу, агрегаційний конвеєр |
| Сильна сторона | цілісність і будь-які запити | мікросекунди і атомарні лічильники | пошук «як треба» без налаштувань | великі обсяги, логи, складна релевантність | GROUP BY по мільярдах подій | гнучка схема одного документа |
| У Laravel | Eloquent і Query Builder | драйвери cache, queue, session, lock | Scout, драйвер meilisearch | сторонні пакети, у Scout драйвера немає | HTTP-клієнт або сторонній пакет | пакет mongodb/laravel-mongodb |
| Коли брати | Джерело правди. Багато задач з цього списку вона закриває сама, поки навантаження помірне. | Кеш, черги, блокування, ліміти частоти. Даних, які шкода втратити, у ньому не тримають. | Пошук по каталогу на десятки тисяч і мільйони документів. Typesense з тим самим призначенням теж має драйвер у Scout. | Коли пошук і аналітика по тексту стають окремим продуктом. OpenSearch це відгалуження Elasticsearch 7.10 зі схожим API. | Аналітика подій: перегляди, кліки, логи. Не для рядків, які часто оновлюють. | Коли дані справді документи без звʼязків. Для сайту з компаніями, вакансіями й відгуками звʼязки важливіші. |
Тут описано поведінку за документацією, ці сховища в цій темі не запускались. Драйвери Laravel перевірено в коді laravel/framework і laravel/scout.
Задача і сховище під неї
Кожне нове сховище це ще один сервіс, який треба оновлювати, моніторити й синхронізувати. Виносять туди, де реляційна база справді впирається, а не наперед.
кеш обчислень і сторінок
Redis
Читання за ключем за мікросекунди, TTL на кожному ключі. Драйвер database теж працює, поки таблиця cache не стала гарячою.
черга фонових задач
Redis або SQL
Черга database бере задачу через FOR UPDATE SKIP LOCKED, і на старті цього досить. Redis і Horizon, коли задач багато й потрібен моніторинг.
блокування й ліміти частоти
Redis
Атомарні команди Redis дають блокування й лічильник без гонок. Cache::lock() і RateLimiter працюють і на database, але кожна перевірка це запит до бази.
пошук з помилками, морфологією й фасетами
пошуковий рушій
Meilisearch чи Typesense знаходять «розробника» за словом «розробник» і «clickhouse» за «clikhouse». PostgreSQL з pg_trgm закриває частину цього без нового сервісу.
звіти по мільярду подій
ClickHouse
Колонкове зберігання й стиснення. На мільйонах рядків PostgreSQL із правильними індексами ще справляється.
гроші, відгуки, акаунти
лише SQL
Тут потрібні транзакції, зовнішні ключі й обмеження. Ці дані живуть тільки в реляційній базі, а в інші сховища потрапляють копією.
Два воркери, одна таблиця задач
Черга на базі даних працює, доки два воркери не беруть ту саму задачу. FOR UPDATE SKIP LOCKED вирішує це: кожен воркер блокує свій рядок, а заблоковані іншими просто пропускає, не чекаючи. DatabaseQueue у Laravel дописує саме його на MySQL 8.0.1+, MariaDB 10.6+ і PostgreSQL 9.5+.
worker A takes 1 worker B takes 2
B не чекає, поки A закомітить, і не отримує задачу 1 вдруге. Без SKIP LOCKED B чекала б на рядок 1, а потім перечитала його.
worker A takes 1 worker B takes 2
Так само з MySQL 8.0.1. На старших версіях Laravel бере звичайний FOR UPDATE, і воркери стають у чергу один за одним.
Error: in prepare, near "FOR": syntax error
У SQLite немає блокувань рядків, і Laravel FOR UPDATE для неї не дописує. Паралельні воркери на SQLite впираються в блокування всієї бази на запис.
Пошук за підрядком з індексом
Перш ніж ставити Meilisearch, варто подивитись, що вміє сама база. LIKE '%clickhouse%' звичайним B-tree не прискорити, а розширення pg_trgm у PostgreSQL ділить текст на трійки символів і будує по них GIN-індекс. Він прискорює LIKE і ILIKE з відсотком на початку і вміє пошук за схожістю: оператор <% знаходить «ClickHouse» за запитом з помилкою «clikhouse».
Морфології pg_trgm не дає: «розробник» і «розробниця» для нього просто схожі рядки. Релевантність, фасети й синоніми лишаються за пошуковим рушієм.
SELECT id FROM vacancies WHERE lower(title) LIKE '%clickhouse%';Gather (cost=1000.00..4431.71 rows=20 width=8) (actual time=58.074..61.779 rows=1 loops=1)
Workers Planned: 1
-> Parallel Seq Scan on vacancies (cost=0.00..3429.71 rows=12 width=8) (actual time=55.625..55.627 rows=0 loops=2)
Filter: (lower(title) ~~ '%clickhouse%'::text)
Rows Removed by Filter: 100000
Execution Time: 61.804 ms SELECT id FROM vacancies WHERE lower(title) LIKE '%clickhouse%';Bitmap Heap Scan on vacancies (cost=90.19..163.91 rows=20 width=8) (actual time=0.034..0.035 rows=1 loops=1)
Recheck Cond: (lower(title) ~~ '%clickhouse%'::text)
Heap Blocks: exact=1
-> Bitmap Index Scan on vacancies_title_trgm (cost=0.00..90.19 rows=20 width=0) (actual time=0.022..0.022 rows=1 loops=1)
Index Cond: (lower(title) ~~ '%clickhouse%'::text)
Execution Time: 0.076 ms SELECT title FROM vacancies WHERE 'clikhouse' <% lower(title);Bitmap Heap Scan on vacancies (cost=122.08..1902.85 rows=2000 width=29) (actual time=0.046..0.047 rows=1 loops=1)
Filter: ('clikhouse'::text <% lower(title))
Heap Blocks: exact=1
-> Bitmap Index Scan on vacancies_title_trgm (cost=0.00..121.58 rows=2000 width=0) (actual time=0.024..0.024 rows=1 loops=1)
Index Cond: (lower(title) %> 'clikhouse'::text)
Execution Time: 0.085 ms
-- рядок: Розробник ClickHouse-аналітики, word_similarity = 0.62 PostgreSQL 16.15, 200 001 вакансія, розширення CREATE EXTENSION pg_trgm. Вивід знято зі справжньої бази, рядки Planning Time прибрано.
- gin_trgm_ops
- Клас операторів pg_trgm для GIN: CREATE INDEX … USING gin (lower(title) gin_trgm_ops). Індекс за виразом lower(title), бо так пише умову запит.
- ~~
- Так PostgreSQL показує LIKE у плані. ILIKE виглядає як ~~*.
- <%
- Слово з запиту схоже на якесь слово в тексті не менше, ніж на pg_trgm.word_similarity_threshold (0,6 за замовчуванням). Схожість рахує word_similarity().
Redis і Scout у коді
Більшість переходів на Redis у Laravel це зміна драйвера в .env: код Cache, Queue, RateLimiter і Cache::lock однаковий для database і redis. Пошук через Scout потребує трохи коду в моделі й окремого сервісу.
Без Redis і без пошукового рушія
Цей сайт усе тримає в PostgreSQL. Кеш, сесії й черга налаштовані на драйвер database, образ застосунку не має розширення redis, а в docker-compose.yml немає ні Redis, ні пошукового сервісу. Черга при цьому стоїть без діла: класів з ShouldQueue у коді немає, а контейнера з queue:work у compose немає. Фонову роботу роблять команди планувальника, а контент-задачі для локального воркера живуть в окремій таблиці content_tasks, яку leaseBatch() роздає через lockForUpdate(). Пошук захований за інтерфейсом SearchEngine, і підключити Meilisearch означало б написати другу реалізацію й змінити один рядок привʼязки в AppServiceProvider.
app/Console/Commands/Concerns/WritesAlone.phpCache::lock('content-writer:'.$writer, 4 * 3600) не дає двом запускам одного писаря працювати разом. Сховище database, тож блокування це рядок у cache_locks.app/Http/Controllers/AuthController.phpRateLimiter::tooManyAttempts і hit обмежують спроби входу, маршрути мають throttle. Лічильники лежать у таблиці cache.src/Search/Infrastructure/EloquentSearchEngine.phpПошук по вакансіях, компаніях, проєктах, статтях, документації й питаннях через LOWER(колонка) LIKE для кожного слова. Інтерфейс SearchEngine лежить у src/Search/Application, привʼязка в app/Providers/AppServiceProvider.php.src/Analytics/Infrastructure/Persistence/EloquentTrafficReport.phpАналітика відвідувань рахується по таблиці visits у PostgreSQL, без окремої аналітичної бази.Dockerfileinstall-php-extensions pdo_pgsql pgsql intl zip bcmath opcache pcntl gd: розширення redis серед них немає.Що йде не так з другим сховищем
Redis як основна база
Кошики, замовлення чи баланси лежать лише в Redis. Після рестарту без AOF губляться останні хвилини, а при нестачі памʼяті політика витіснення мовчки видаляє ключі.
Натомість: У Redis тільки те, що можна відновити: кеш, задачі черги, лічильники з періодичним скиданням у базу. Усе інше в реляційній базі.
Пошуковий індекс розходиться з таблицею
Вакансію закрили в транзакції, яка потім відкотилась, або змінили масовим update(), що не викликає подій моделі. Індекс показує те, чого в базі вже немає.
Натомість: Увімкніть after_commit у config/scout.php, для масових змін викликайте searchable() чи unsearchable() вручну, а scout:import тримайте як шлях повного перебудування.
Новий сервіс замість індексу
Пошук повільний, і команда ставить Elasticsearch, хоча на таблиці немає жодного індексу під цей запит.
Натомість: Спершу EXPLAIN і індекс: B-tree для префікса, pg_trgm для підрядка, tsvector для слів. Окремий рушій тоді, коли потрібні морфологія, релевантність і фасети.
Кеш без інвалідації
Cache::rememberForever для даних, які змінюються, і жодного Cache::forget при зміні. Користувачі бачать стару ціну чи статус годинами.
Натомість: TTL на кожен ключ і явне скидання в місці, де дані змінюються. Ключ з версією (roadmap:tracks:v2) спрощує скидання при деплої.
ClickHouse для даних, які оновлюють
Статус відгуку чи профіль користувача в колонковій базі. UPDATE там це важка асинхронна мутація, а не зміна одного рядка.
Натомість: У ClickHouse пишуть події, які не змінюються: перегляди, кліки, логи. Поточний стан лишається в реляційній базі.
- Redis
- Сервер ключів і значень у памʼяті: рядки, хеші, списки, множини, відсортовані множини. Команди атомарні, тож на ньому легко зробити лічильник чи блокування. На диск пише знімками RDB або журналом AOF, як налаштуєте.
- Пошуковий рушій
- Meilisearch, Typesense, Elasticsearch чи OpenSearch. Тримає копію документів в інвертованому індексі: слово → список документів. Вміє релевантність, помилки в словах, префікси, фасети з кількістю.
- Колонкова база
- Зберігає кожну колонку окремо й стиснуто. Запит SUM(views) GROUP BY day читає дві колонки з сотні, тож агрегації по мільярдах рядків ідуть секунди. Оновлення й видалення окремих рядків дорогі.
- Джерело правди
- Сховище, з якого відновлюють усе інше. Реляційна база з транзакціями й обмеженнями підходить для цього найкраще, а кеш і індекси похідні від неї.
Питання зі співбесід
Якщо в Redis лише кеш, сайт працюватиме повільніше, поки кеш не наповниться знову: кожне промахування рахує дані з бази. Нічого не загубиться. Якщо ж у Redis лежали сесії чи черга, користувачів розлогінить, а незавершені задачі зникнуть, тож для них потрібне збереження на диск або інше сховище.
Кожен воркер вибирає першу вільну задачу й блокує її рядок. Без SKIP LOCKED другий воркер чекає на той самий рядок, а після коміту першого перечитує його і йде далі, тож воркери працюють по черзі. SKIP LOCKED пропускає заблоковані рядки, і кожен одразу бере свою задачу. Laravel вмикає його сам на MySQL 8.0.1+, MariaDB 10.6+ і PostgreSQL 9.5+.
EXPLAIN, щоб побачити перебір таблиці. Для пошуку за підрядком у PostgreSQL GIN-індекс з pg_trgm прискорює LIKE '%…%' з десятків мілісекунд до часток мілісекунди і дає пошук за схожістю. Для пошуку слів tsvector з GIN. Окремий рушій варто ставити, коли потрібні морфологія, релевантність, синоніми й фасети, а не сама швидкість.
База лишається джерелом правди, індекс похідний. Оновлення в індекс відправляються після коміту (after_commit у Scout або подія після транзакції, в ідеалі через outbox-таблицю), через чергу з повторами. Масові зміни, що оминають події моделі, оновлюють індекс явно. Періодична звірка або повний scout:import лагодить те, що загубилось, а пошук завжди повертає id, за якими сторінка дочитує актуальні дані з бази.