<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
БАЗИ ДАНИХ · РУШІЇ Й СУБД

Коли потрібна не реляційна базаBeyond relational

Реляційна база вміє майже все, але не все однаково добре. Кеш на мікросекунди, пошук з помилками й морфологією, звіт по мільярду подій роблять окремі сховища. Правило одне: вони тримають копію, а джерело правди лишається в SQL.

13 хв читання Junior → Senior
01 · КОЛИ SQL НЕ ВИСТАЧАЄ

Копія даних під одну задачу

Реляційна база тримає звʼязки, обмеження й транзакції, і саме тому кожен запис у ній дорожчий, ніж міг би бути. Коли задача вузька й гаряча, її виносять у сховище, зроблене під неї. тримає ключі в памʼяті й відповідає за мікросекунди: кеш, черги, блокування, лічильники. будує інвертований індекс і шукає з помилками, синонімами й фасетами. на кшталт ClickHouse агрегує мільярди подій, читаючи лише потрібні колонки.

СЛОВНИКRedis. Сервер ключів і значень у памʼяті: рядки, хеші, списки, множини, відсортовані множини. Команди атомарні, тож на ньому легко зробити лічильник чи блокування. На диск пише знімками RDB або журналом AOF, як налаштуєте.
СЛОВНИКПошуковий рушій. Meilisearch, Typesense, Elasticsearch чи OpenSearch. Тримає копію документів в інвертованому індексі: слово → список документів. Вміє релевантність, помилки в словах, префікси, фасети з кількістю.
СЛОВНИККолонкова база. Зберігає кожну колонку окремо й стиснуто. Запит SUM(views) GROUP BY day читає дві колонки з сотні, тож агрегації по мільярдах рядків ідуть секунди. Оновлення й видалення окремих рядків дорогі.

Документні бази на кшталт MongoDB зберігають обʼєкти цілком, без схеми таблиць. Для PHP-сайту з компаніями, вакансіями й відгуками, де все повʼязане, це рідко виграш: звʼязки доведеться тримати в коді.

Головне правило: одне, і це реляційна база. Кеш можна скинути, пошуковий індекс перебудувати з таблиць, звіт перерахувати. Якщо втрата Redis чи індексу губить дані, а не швидкість, архітектура зламана.

СЛОВНИКДжерело правди. Сховище, з якого відновлюють усе інше. Реляційна база з транзакціями й обмеженнями підходить для цього найкраще, а кеш і індекси похідні від неї.
02 · ПОРІВНЯННЯ

Виберіть два-три сховища

Порівняння PostgreSQL, Redis, пошукових рушіїв, ClickHouse і MongoDB
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.

03 · КУДИ ЩО ВИНОСИТИ

Задача і сховище під неї

Кожне нове сховище це ще один сервіс, який треба оновлювати, моніторити й синхронізувати. Виносять туди, де реляційна база справді впирається, а не наперед.

Задача
кеш обчислень і сторінок 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 Тут потрібні транзакції, зовнішні ключі й обмеження. Ці дані живуть тільки в реляційній базі, а в інші сховища потрапляють копією.
04 · ЧЕРГА БЕЗ REDIS

Два воркери, одна таблиця задач

Черга на базі даних працює, доки два воркери не беруть ту саму задачу. FOR UPDATE SKIP LOCKED вирішує це: кожен воркер блокує свій рядок, а заблоковані іншими просто пропускає, не чекаючи. DatabaseQueue у Laravel дописує саме його на MySQL 8.0.1+, MariaDB 10.6+ і PostgreSQL 9.5+.

PostgreSQL 16 MySQL 8.0 SQLite 3.45
1-- воркер A і воркер B, кожен у своїй транзакції
2BEGIN;
3SELECT id FROM jobs
4WHERE queue = 'default' AND reserved_at IS NULL
5ORDER BY id
6LIMIT 1
7FOR UPDATE SKIP LOCKED;
worker A takes 1
worker B takes 2

B не чекає, поки A закомітить, і не отримує задачу 1 вдруге. Без SKIP LOCKED B чекала б на рядок 1, а потім перечитала його.

1BEGIN;
2SELECT id FROM jobs
3WHERE queue = 'default' AND reserved_at IS NULL
4ORDER BY id
5LIMIT 1
6FOR UPDATE SKIP LOCKED;
worker A takes 1
worker B takes 2

Так само з MySQL 8.0.1. На старших версіях Laravel бере звичайний FOR UPDATE, і воркери стають у чергу один за одним.

1SELECT * FROM jobs LIMIT 1 FOR UPDATE SKIP LOCKED;
Error: in prepare, near "FOR": syntax error

У SQLite немає блокувань рядків, і Laravel FOR UPDATE для неї не дописує. Паралельні воркери на SQLite впираються в блокування всієї бази на запис.

05 · ПОШУК ДО ПОШУКОВОГО РУШІЯ

Пошук за підрядком з індексом

Перш ніж ставити 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
gin_trgm_opsSELECT 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().
06 · У LARAVEL

Redis і Scout у коді

Більшість переходів на Redis у Laravel це зміна драйвера в .env: код Cache, Queue, RateLimiter і Cache::lock однаковий для database і redis. Пошук через Scout потребує трохи коду в моделі й окремого сервісу.

php Vacancy.php Кеш
1# .env: CACHE_STORE=redis, REDIS_CLIENT=phpredis
2
3$tracks = Cache::remember('roadmap:tracks', 3600, fn () => $this->tracks());
4
5Cache::store('redis')->increment('views:vacancy:42');
ЩО ПЕРЕВІРЯЄ
remember() читає ключ, а якщо його немає, обчислює й кладе з TTL. increment() атомарний у Redis, тож лічильник не губить переглядів при паралельних запитах.
КОЛИ БРАТИ
Дорогі обчислення, які можна перерахувати: зведення, курси валют, відповіді зовнішніх API.
php Vacancy.php Черга
1# .env: QUEUE_CONNECTION=redis
2
3ProcessImport::dispatch($sourceId);
4
5// php artisan queue:work redis --tries=3
6// або Horizon: php artisan horizon
ЩО ПЕРЕВІРЯЄ
Задача серіалізується в Redis-список, воркер забирає її атомарною командою. Horizon додає панель, метрики й автомасштабування воркерів, але працює лише з Redis.
КОЛИ БРАТИ
Коли database-черга стала вузьким місцем або потрібен моніторинг черг.
php Vacancy.php Блокування й ліміт
1$lock = Cache::lock('content-writer:'.$writer, 4 * 3600);
2
3if ($lock->get()) {
4 try { /* … */ } finally { $lock->release(); }
5}
6
7RateLimiter::attempt('apply:'.$ip, 5, fn () => $this->apply(), 60);
ЩО ПЕРЕВІРЯЄ
Блокування й ліміт беруть сховище кешу: на database це таблиці cache_locks і cache, на redis атомарні SET NX і INCR. Для middleware throttle є ще throttleWithRedis() у bootstrap/app.php.
КОЛИ БРАТИ
Одна команда за раз на кілька серверів, захист форм від перебору.
php Vacancy.php Пошук через Scout
1use Laravel\Scout\Searchable;
2
3class Vacancy extends Model
4{
5 use Searchable;
6
7 public function toSearchableArray(): array
8 {
9 return ['id' => $this->id, 'title' => $this->title, 'city' => $this->city];
10 }
11}
12
13Vacancy::search('laravel')->where('city', 'kyiv')->paginate(20);
ЩО ПЕРЕВІРЯЄ
Searchable надсилає документ в індекс після save() і прибирає після delete(). Індекс це копія: php artisan scout:import перебудовує його з таблиці. Драйвери в Scout: algolia, meilisearch, typesense, database і collection.
КОЛИ БРАТИ
Коли пошук має розуміти помилки, релевантність і фасети. Драйвер database дає LIKE по таблиці й підходить, щоб почати без сервісу.
07 · ЯК У PHP UKRAINE

Без Redis і без пошукового рушія

Цей сайт усе тримає в PostgreSQL. Кеш, сесії й черга налаштовані на драйвер database, образ застосунку не має розширення redis, а в docker-compose.yml немає ні Redis, ні пошукового сервісу. Черга при цьому стоїть без діла: класів з ShouldQueue у коді немає, а контейнера з queue:work у compose немає. Фонову роботу роблять команди планувальника, а контент-задачі для локального воркера живуть в окремій таблиці content_tasks, яку leaseBatch() роздає через lockForUpdate(). Пошук захований за інтерфейсом SearchEngine, і підключити Meilisearch означало б написати другу реалізацію й змінити один рядок привʼязки в AppServiceProvider.

php .env.docker.example фрагмент
1DB_CONNECTION=pgsql
2DB_HOST=db
3DB_PORT=5432
4DB_DATABASE=phpukraine
5
6SESSION_DRIVER=database← таблиця sessions
7SESSION_SECURE_COOKIE=true
8CACHE_STORE=database← таблиці cache і cache_locks
9QUEUE_CONNECTION=database← таблиця jobs, задач у ній немає
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 серед них немає.
08 · ТИПОВІ ПОМИЛКИ

Що йде не так з другим сховищем

✕ 1

Redis як основна база

Кошики, замовлення чи баланси лежать лише в Redis. Після рестарту без AOF губляться останні хвилини, а при нестачі памʼяті політика витіснення мовчки видаляє ключі.

Натомість: У Redis тільки те, що можна відновити: кеш, задачі черги, лічильники з періодичним скиданням у базу. Усе інше в реляційній базі.

✕ 2

Пошуковий індекс розходиться з таблицею

Вакансію закрили в транзакції, яка потім відкотилась, або змінили масовим update(), що не викликає подій моделі. Індекс показує те, чого в базі вже немає.

Натомість: Увімкніть after_commit у config/scout.php, для масових змін викликайте searchable() чи unsearchable() вручну, а scout:import тримайте як шлях повного перебудування.

✕ 3

Новий сервіс замість індексу

Пошук повільний, і команда ставить Elasticsearch, хоча на таблиці немає жодного індексу під цей запит.

Натомість: Спершу EXPLAIN і індекс: B-tree для префікса, pg_trgm для підрядка, tsvector для слів. Окремий рушій тоді, коли потрібні морфологія, релевантність і фасети.

✕ 4

Кеш без інвалідації

Cache::rememberForever для даних, які змінюються, і жодного Cache::forget при зміні. Користувачі бачать стару ціну чи статус годинами.

Натомість: TTL на кожен ключ і явне скидання в місці, де дані змінюються. Ключ з версією (roadmap:tracks:v2) спрощує скидання при деплої.

✕ 5

ClickHouse для даних, які оновлюють

Статус відгуку чи профіль користувача в колонковій базі. UPDATE там це важка асинхронна мутація, а не зміна одного рядка.

Натомість: У ClickHouse пишуть події, які не змінюються: перегляди, кліки, логи. Поточний стан лишається в реляційній базі.

09 · СЛОВНИК
Redis
Сервер ключів і значень у памʼяті: рядки, хеші, списки, множини, відсортовані множини. Команди атомарні, тож на ньому легко зробити лічильник чи блокування. На диск пише знімками RDB або журналом AOF, як налаштуєте.
Пошуковий рушій
Meilisearch, Typesense, Elasticsearch чи OpenSearch. Тримає копію документів в інвертованому індексі: слово → список документів. Вміє релевантність, помилки в словах, префікси, фасети з кількістю.
Колонкова база
Зберігає кожну колонку окремо й стиснуто. Запит SUM(views) GROUP BY day читає дві колонки з сотні, тож агрегації по мільярдах рядків ідуть секунди. Оновлення й видалення окремих рядків дорогі.
Джерело правди
Сховище, з якого відновлюють усе інше. Реляційна база з транзакціями й обмеженнями підходить для цього найкраще, а кеш і індекси похідні від неї.
10 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з SQL →

Якщо в 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, за якими сторінка дочитує актуальні дані з бази.