Optimizare bază de date pentru magazin online

TTFB (Time To First Byte) este jumătate din viteza unui site. Un server care răspunde în 800ms face frontend-ul irelevant — oricâte imagini WebP ai avea. Optimizarea bazei de date taie din acest timp server, cu indexi corecți, query-uri eficiente, cache și tuning pe MySQL/PostgreSQL.

TTFB peste 600ms pe magazinul tău? Vezi serviciul de optimizare sau cere audit gratuit.

De ce contează TTFB

TTFB = timpul de la cerere HTTP până la primul byte primit. Se descompune în: DNS + conexiune TLS + request processing + query DB + randare server. Într-un magazin online, 60-80% din TTFB este timpul petrecut în bază de date.

Ținte: sub 200ms (excelent), 200-600ms (acceptabil), peste 600ms (problemă), peste 1s (critic). Un TTFB de 800ms înseamnă că LCP-ul nu poate coborî sub 800ms, orice ai face pe frontend.

Indexi compuși — fundația

90% din query-urile lente se rezolvă cu indexi corecți. Greșeala clasică: index pe o singură coloană când filtrarea se face pe mai multe.

Exemplu: listare produse pe categorie, active, sortate:

SELECT * FROM products
WHERE category_id = 42 AND status = 'active'
ORDER BY sort_order DESC LIMIT 20;

Index greșit (câte unul per coloană) = DB alege un index, apoi filtrează restul în memorie:

CREATE INDEX idx_category ON products(category_id);
CREATE INDEX idx_status ON products(status);

Index corect — compus, în ordinea selectivității:

CREATE INDEX idx_cat_status_sort
ON products(category_id, status, sort_order DESC);

Rezultat: query-ul devine index-only scan, nu mai atinge tabelul decât pentru cele 20 de rânduri returnate. Pe 100k produse: de la 350ms la 2ms.

Cum verifici indexii — EXPLAIN

Pune EXPLAIN în fața oricărui query lent:

EXPLAIN SELECT ... ;

Citește coloanele cheie:

  • type / access type: ALL = full table scan (rău). index = full index scan (mai bine). range, ref, eq_ref = bine. const = ideal.
  • rows: estimarea de rânduri citite. Dacă citește 100k ca să returneze 20 = index lipsă.
  • Extra: Using filesort și Using temporary = sortare pe disk (rău). Using index = index-only (ideal).
  • key: indexul ales efectiv. Dacă e NULL, niciun index nu a fost folosit.

Query N+1 — cel mai frecquent asasin de performanță

Pattern clasic cu ORM-uri (Eloquent, Django ORM, Prisma, Hibernate): încarcă o listă de produse, apoi pentru fiecare produs încarcă categoria sa, într-o buclă.

// RĂU — 101 query-uri
products = Product.objects.all()[:100]
for p in products:
    print(p.category.name)  # 1 query per produs
// BINE — 1 query cu JOIN (eager loading)
products = Product.objects.select_related('category').all()[:100]
for p in products:
    print(p.category.name)  # 0 query-uri suplimentare

În Eloquent (Laravel): Product::with('category')->take(100)->get(). În Prisma: prisma.product.findMany({ include: { category: true } }). Detectează N+1 cu query logging — dacă vezi 100+ query-uri pe o singură pagină, îl ai.

Pool de conexiuni — overhead ascuns

Deschiderea unei conexiuni DB durează 20-50ms (handshake TCP + auth). Dacă aplicația deschide conexiune per request, 1000 req/s = 20-50s doar în handshakes.

  • PostgreSQL: PgBouncer în modul transaction pooling. Reutilizează conexiuni între request-uri.
  • MySQL: ProxySQL sau pool nativ în framework (HikariCP pe Java, mysql2 pool pe Node).
  • Setări: pool_size egal cu (CPU cores × 2) + disk spindles. Prea mare = OOM, prea mic = queue.

Cache Redis — 90%+ hit rate

Catalogul de produse se schimbă rar, dar se citește la fiecare request. Perfect pentru cache. Ordine de prioritizare:

  • Object cache (WordPress/WooCommerce): plugin Redis Object Cache. Elimină 80% din query-uri — un WooCommerce lent devine rapid instant.
  • Query cache aplicație: rezultatul query-urilor „top produse", „categorii", „recomandări" cache-uite cu TTL 5-60 min.
  • Full page cache: pagini statice (home, categorie, produs) servite direct din Redis/Nginx. Invalidare la editare produs.
  • Sesiuni: mută sesiunile din DB/filesystem în Redis. Mai rapid, distribuit.

Evită capcana: cache fără strategie de invalidare = date vechi (preț greșit, stoc expirat). Folosește key versioning sau event-driven invalidation.

Partiționare — pentru tabele mari

Tabelele peste 1M rânduri beneficiază de partiționare. Clasic pentru magazin: orders partiționat pe lună:

-- PostgreSQL partiționare declarativă
CREATE TABLE orders (
    id BIGSERIAL,
    created_at TIMESTAMPTZ NOT NULL,
    customer_id BIGINT,
    total DECIMAL(10,2)
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_2026_07 PARTITION OF orders
    FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

Query pe luna curentă citește doar partiția lunii curente (30 zile), nu tot istoricul de 5 ani. Indexii sunt per-partiție — mai mici, mai rapizi. Maintenance (VACUUM, ANALYZE) poate rula per-partiție fără să blocheze tot tabelul.

Tuning MySQL / PostgreSQL — setări cu impact mare

MySQL (my.cnf)

  • innodb_buffer_pool_size = 50-70% din RAM total. Cea mai importantă setare.
  • innodb_buffer_pool_instances = 1 per GB de buffer pool.
  • innodb_log_file_size = 256MB-1GB pentru magazine cu multe scrieri.
  • query_cache_type = OFF — în MySQL 8+ a fost eliminat, pe 5.7 cauzează lock contention.
  • max_connections = pool_size × număr instanțe aplicație + margină.

PostgreSQL (postgresql.conf)

  • shared_buffers = 25% din RAM.
  • effective_cache_size = 50-75% din RAM (estimare, nu alocă).
  • work_mem = 4-16MB (sort/hash per query; mare = mai multă memorie per conexiune).
  • maintenance_work_mem = 256MB-1GB (VACUUM, CREATE INDEX).
  • random_page_cost = 1.1 pe SSD (default 4.0 e pentru disk mecanic).
  • autovacuum = on, autovacuum_naptime agresiv pe tabele cu multe scrieri.

Slow query log — instrumentul de diagnostic

Pornește slow query log și analizează-l săptămânal. MySQL:

[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.1   # 100ms
log_queries_not_using_indexes = 1

PostgreSQL: log_min_duration_statement = 100. Analizează cu pt-query-digest (MySQL) sau pg_stat_statements (PostgreSQL). Top 10 query-uri lente = top 10 oportunități de optimizare.

Caz concret — WooCommerce lent la checkout

Magazin cu 8.000 produse, checkout dura 4-8 secunde. Cauze găsite prin slow log:

  • Query N+1: la calcul shipping, câte un query per produs din coș (5-15 query-uri).
  • Lipsă index pe wp_woocommerce_order_items(order_id).
  • Object cache inactiv (WordPress folosea filesystem).

Intervenții: eager loading pe shipping, index adăugat, Redis Object Cache activat. Rezultat: checkout 4-8s → 0,8-1,2s. Conversie +23% în 30 zile.

Continuă lectură