Razvoj

Demystify Database Performance: Beyond Profiling, Towards Real Solutions

Razotkrivanje performansi baza podataka: izvan profiliranja, prema stvarnim rješenjima

A slow database is rarely just a database problem. It is usually a system problem wearing a database-shaped mask: an API endpoint fetches too much data, an ORM quietly issues dozens of queries, an index no longer matches the access pattern, or a queue worker competes with web traffic for the same resources.

Profiling is essential because it tells you where time is going. But a profile is a starting point, not a solution. The real work is translating a slow query or overloaded connection pool into a change that improves the behavior of the whole system without making it harder to maintain.

Start with the request, not the query

When an endpoint is slow, it is tempting to open the database console and optimize the most expensive SQL statement. First ask a broader question: what does this request actually need to accomplish?

A product listing may render only a name, price, and thumbnail, yet the application could be loading full product records, related inventory, every image, and several computed relationships. Even a well-indexed query is wasteful when it retrieves data that will be discarded.

Define the response shape before tuning SQL. Then make the data access match it. This often produces the safest performance improvement because it reduces work rather than merely making unnecessary work faster.

$products = $repository->findVisibleProducts(
    categoryId: $categoryId,
    fields: ['id', 'name', 'price', 'thumbnail_url'],
    limit: 24
);

The important part is not the method name. It is the discipline: retrieve the smallest useful result set, paginate deliberately, and avoid treating database rows as an unlimited in-memory collection.

Read query plans as explanations

Query timing tells you that something is slow. A query plan helps explain why. Use your database’s plan inspection tool, such as EXPLAIN, against representative queries and realistic parameter values. Look for operations that signal disproportionate work: full scans on large tables, expensive sorts, large intermediate result sets, or joins that multiply rows unexpectedly.

An index is useful only when it supports the actual predicate, join, and ordering pattern. Adding indexes reactively can make writes slower and obscure the real access pattern. Instead, begin with a concrete query:

SELECT id, created_at, total
FROM orders
WHERE customer_id = ?
  AND status = ?
ORDER BY created_at DESC
LIMIT 20;

A composite index beginning with customer_id and status, followed by created_at, may align with this query. But it should be validated against the database’s plan and the workload’s write rate. Index design is not a checklist; it is a trade-off between faster reads, storage, and write cost.

Watch for queries that defeat good indexes

Some expressions make a column harder to use efficiently in a predicate. For example, filtering by a function applied to an indexed timestamp can force more work than a bounded range.

SELECT id
FROM events
WHERE created_at >= ?
  AND created_at < ?;

This form is generally easier to reason about than transforming created_at inside the condition. The principle is simple: preserve a direct comparison between the stored value and a supplied boundary whenever the business rule allows it.

Eliminate query multiplication

The classic N+1 query problem remains common because it hides behind clean-looking application code. A page loads twenty orders, then each order loads its customer or line items separately. The individual queries may be fast, but network round trips, connection usage, and repeated planning add up.

Fix it by loading related data in bounded sets, joining when the resulting row shape is appropriate, or querying children with an IN condition and grouping them in application code. Which option is best depends on cardinality. Joining orders to a small, one-to-one customer relation is different from joining orders to thousands of line items.

  • Use eager loading when the related data is definitely needed.
  • Use a separate batched query when a join would produce excessive duplicate parent rows.
  • Use explicit projections so eager loading does not become eager over-fetching.
  • Measure query count per request as well as total duration.

ORMs are valuable, but they cannot infer the performance characteristics of a business screen. Treat generated SQL as production code: inspect it when behavior changes, especially around relationships, filters, and pagination.

Separate transactional work from everything else

Database transactions should protect a small, coherent business change. They should not include HTTP calls, slow file processing, email delivery, or lengthy calculations. Holding a transaction open while waiting for external work increases lock duration and makes contention more likely.

A practical boundary is often: validate input, perform the necessary database changes, commit, and then dispatch follow-up work to a queue. The queued job should be designed to tolerate retries. That means it must avoid creating duplicate side effects when it runs more than once.

For example, a job that sends an invoice should record or check a durable business state before treating the invoice as newly sent. A retry mechanism is not a guarantee of exactly-once execution; it is a reason to make each operation safely repeatable where possible.

Connection pools, containers, and capacity are part of the design

Docker makes it easy to run many application containers. It also makes it easy to create more database connections than the database can comfortably handle. If each PHP worker can open connections and the deployment scales horizontally, the total possible connections can grow faster than expected.

Set application worker counts, connection limits, and database capacity as one system. Leave headroom for migrations, administrative access, background workers, and temporary traffic spikes. When connections are exhausted, adding another application container usually amplifies the failure rather than resolving it.

Also distinguish latency from saturation. A single poorly indexed query may cause occasional slow requests. A saturated database may cause widespread timeouts, queue backlog, and retry storms. The remedies differ, so observability should include query duration, query count, active connections, error rates, and queue depth.

Cache the result of a decision, not confusion

Caching can be powerful when a value is expensive to compute, requested repeatedly, and acceptable to serve slightly stale. It is much less useful when added before the data model and query pattern are understood.

Choose cache keys that reflect the actual inputs, define expiration and invalidation behavior, and decide what happens on a miss or cache outage. A cache that cannot fail gracefully becomes another source of availability problems. For frequently changed data, consider whether a simpler query or precomputed read model is easier to operate than elaborate invalidation rules.

Make performance a property of the architecture

The strongest database optimizations are often unglamorous: a narrower API response, an index justified by a real plan, one batched query instead of many, a shorter transaction, or a deployment limit that prevents connection exhaustion.

Profiling points to the hot path. Engineering judgment determines whether the lasting fix belongs in SQL, PHP, an API contract, a queue, a cache, or capacity planning. When teams consistently ask what work the system is doing and why, performance stops being an emergency exercise and becomes a deliberate part of maintainable design.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.