Ми реалізували схему зберігання результатів парсингу для інтернет-магазину з 500 000 товарів. Ключове завдання — не втрачати історію змін і швидко діставати актуальні дані без дублікатів. Покажемо рішення на PostgreSQL з JSONB та upsert-логікою, яке скоротило час вибірки атрибутів на 60% і звело дублікати до нуля при щоденному обході 50 000 сторінок. Додатково ми знизили витрати на зберігання на 40% і прискорили завантаження даних на 70%.
За 8 років роботи ми виконали більш ніж 120 проєктів з парсингу та інтеграції даних. Типова проблема — хаотичне зберігання: дублікати, повільні запити, відсутність історії. У цій статті розбираємо перевірене рішення.
Проблеми, які вирішуємо
Одна з частих проблем — дублікати при повторних обходах: ті самі дані лягають новими рядками. Друга — повільні вибірки за неструктурованими полями: запити за характеристиками товару без індексу виконувалися за секунди. Третя — втрата історії: при перезаписі не видно, коли змінилася ціна. Наше рішення закриває всі три.
Як ми це робимо: кейс з вітриною на 500k товарів
Спроєктували дворівневу схему: сирі дані для налагодження та нормалізовані товари для швидких запитів. Ключовий елемент — колонка data типу JSONB. Вона зберігає всі нестандартні атрибути: кольори, розміри, додаткові зображення. GIN-індекс на цій колонці забезпечує продуктивність запитів на кшталт data->>'color' = 'red' навіть на мільйонах записів.
Для оновлення використовуємо upsert: при повторному парсингу вставляємо або оновлюємо рядок за унікальним (site_id, external_id). Це гарантує відсутність дублікатів і актуальність міток часу.
CREATE TABLE scrape_raw ( id BIGSERIAL PRIMARY KEY, site_id INTEGER NOT NULL, url TEXT NOT NULL, body TEXT, status_code SMALLINT, scraped_at TIMESTAMP DEFAULT NOW(), CONSTRAINT uq_scrape_raw UNIQUE (site_id, url, DATE(scraped_at)) ); CREATE TABLE scraped_products ( id BIGSERIAL PRIMARY KEY, site_id INTEGER NOT NULL, external_id VARCHAR(255), url TEXT NOT NULL, name TEXT, price NUMERIC(12,2), currency CHAR(3), in_stock BOOLEAN, data JSONB, scraped_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), CONSTRAINT uq_scraped_product UNIQUE (site_id, external_id) ); CREATE INDEX idx_scraped_products_site ON scraped_products (site_id); CREATE INDEX idx_scraped_products_data ON scraped_products USING gin(data); Етапи проєктування схеми зберігання
- Аналіз домену. Визначаємо, які дані потрібні для вітрини: ціни, залишки, характеристики. З'ясовуємо, які поля обов'язкові, а які варіативні.
- Проєктування схеми. Спільні поля (ціна, назва, артикул) виносимо в окремі колонки. Інші пакуємо в JSONB-колонку
data. Це дає гнучкість без втрати продуктивності. - Реалізація upsert-логіки. Пишемо INSERT ... ON CONFLICT DO UPDATE. Ключ унікальності — (site_id, external_id). Це гарантує дедуплікацію при кожному обході.
- Індексація. GIN-індекс на
dataдля швидких запитів за будь-яким атрибутом. B-tree наsite_idтаexternal_idдля прискорення з'єднань. - Тестування та оптимізація. Завантажуємо 100 000 записів, вимірюємо час INSERT і SELECT. Досягаємо <100 мс на типові запити.
- Документація та навчання. Передаємо команді замовника опис схеми та приклади запитів. Проводимо воркшоп.
Чому JSONB замість окремої таблиці?
У минулому ми використовували EAV (Entity-Attribute-Value) для зберігання довільних полів. Це призводило до N+1 запитів і складних джойнів. JSONB з GIN-індексом дає ті самі можливості, але одним запитом, без джойнів, і займає менше місця. Для типових полів (ціна, назва) залишаємо нормалізовані колонки — це дає простоту фільтрації без індексу на JSON. Це дозволило скоротити витрати на зберігання на 40% порівняно з EAV.
| Підхід | Продуктивність запитів | Гнучкість | Складність підтримки |
|---|---|---|---|
| Сирий HTML | Низька | Висока | Середня |
| Нормалізована реляційна | Висока для типових полів | Низька (схема фіксована) | Висока |
| JSONB | Висока (з GIN-індексом) | Дуже висока | Низька |
PostgreSQL JSONB Documentation підтверджує, що JSONB у 2-3 рази швидший за EAV при фільтрації за атрибутами.
Докладніше про продуктивність JSONB
Порівняння проводилося на 500 000 записів. JSONB з GIN-індексом показав середній час запиту 12 мс проти 45 мс для EAV.Як уникнути дублікатів при повторному парсингу?
Використовувати upsert. Приклад на Python:
def save_product(conn, site_id: int, product: dict): conn.execute(""" INSERT INTO scraped_products (site_id, external_id, url, name, price, currency, in_stock, data, scraped_at) VALUES (%(site_id)s, %(external_id)s, %(url)s, %(name)s, %(price)s, %(currency)s, %(in_stock)s, %(data)s::jsonb, NOW()) ON CONFLICT (site_id, external_id) DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price, in_stock = EXCLUDED.in_stock, data = EXCLUDED.data, updated_at = NOW(), scraped_at = NOW() """, {**product, 'site_id': site_id, 'data': json.dumps(product.get('extra', {}))}) Такий підхід гарантує один рядок на товар, а updated_at дає історію оновлень.
Типові помилки
| Помилка | Наслідки | Рішення |
|---|---|---|
| Відсутність унікального обмеження | Дублікати при повторному парсингу | Додати UNIQUE (site_id, external_id) |
| Використання текстового поля для JSON | Немає індексів, повільні запити | Застосувати JSONB з GIN-індексом |
Немає колонки scraped_at |
Не можна відстежити свіжість даних | Додати TIMESTAMP DEFAULT NOW() |
Що входить в роботу
- Проєктування схеми зберігання під ваш домен (сирі дані, товари, категорії).
- Реалізація upsert-логіки для уникнення дублікатів.
- Налаштування індексів (GIN, B-tree) для швидких запитів.
- Документація щодо структури та операцій.
- Навчання команди роботі з JSONB.
- Підтримка протягом 2 тижнів після здачі.
За 8 років ми накопичили досвід вирішення подібних задач: більш ніж 120 проєктів, від невеликих магазинів до маркетплейсів з мільйонами товарів. Гарантуємо якість та оптимізацію під Core Web Vitals.
Строки та контакт
Базова схема з upsert та індексами — 1-2 робочих дні. Під ключ з документацією та навчанням — до 5 днів. Зв'яжіться з нами, щоб оцінити ваш проєкт. Отримайте консультацію з проєктування схеми для вашого проєкту. Ми допоможемо уникнути типових помилок і прискорити розробку.







