Тајните на перформансите на базите на податоци: Повеќе од индексирање за вистинска брзина
A database can have excellent indexes and still feel slow. That is the uncomfortable truth behind many performance investigations: indexing is important, but it is only one piece of a system that moves data through application code, network connections, transaction boundaries, and competing workloads.
The fastest database work is often the work you never ask the database to do. The next fastest is work that is shaped deliberately: small result sets, predictable queries, short transactions, and a schema that matches how the product actually behaves.
Thinking beyond indexes changes performance tuning from a collection of emergency fixes into an engineering practice.
Start with the shape of the work
Before adding an index, inspect what the application is requesting. A query can use an index and still be expensive because it returns too many rows, joins large intermediate sets, sorts a broad result, or forces the server to evaluate a function for every candidate row.
A common example is fetching an entire record when an endpoint needs only a name and status. Selecting fewer columns reduces database work, network transfer, PHP memory use, and serialization overhead.
SELECT id, name, status
FROM customers
WHERE account_id = ?
ORDER BY created_at DESC
LIMIT 50;
This is usually preferable to SELECT *, especially for tables containing large text fields, JSON documents, or binary data. It also makes dependencies explicit: future schema changes are less likely to quietly increase the cost of a frequently called endpoint.
Query shape includes frequency. A query that takes a few milliseconds may not deserve attention when it runs once per day. The same query becomes important when it runs once for every item in a list, every request in a busy API, or every worker iteration.
Find and eliminate repeated queries
The N+1 query pattern is one of the most reliable ways to turn a responsive application into a database bottleneck. It appears when code loads a list and then performs one extra query for each row.
Imagine an API that returns 100 orders and queries the customer for every order. Even if each customer lookup is indexed, the application has created 101 round trips instead of one or two well-designed queries.
In PHP, make data access visible enough to spot this pattern. Repository methods, ORM relation loading, and serializer callbacks can all issue queries at a distance from the controller that triggered them. Query logging in development and request-level query counts are often more revealing than a single slow-query report.
The repair is context-dependent:
- Fetch related records in a join when the result shape remains manageable.
- Load related entities in a bounded batch using
WHERE id IN (...). - Return a purpose-built projection for a read endpoint instead of assembling it from many generic object loads.
- Set explicit limits so a batch cannot grow without bound.
Do not treat eager loading as an automatic cure. Joining several one-to-many relationships can multiply rows dramatically. The goal is not “one query at all costs”; it is a small, predictable amount of database work.
Read execution plans, not intentions
Developers often reason from what a query means. Database engines must reason from how to execute it. Those are different questions.
An execution plan helps reveal whether the engine is scanning a table, using a useful access path, sorting an unexpectedly large set, or choosing a join order that makes a query expensive. Use the plan feature provided by the database in use, and compare its estimates with the number of rows actually returned when that information is available.
Plans are most useful when paired with a concrete question: why did this endpoint become slower as the table grew? Is the sort avoidable? Is a join predicate selective? Is a condition preventing the database from using an efficient access path?
For example, wrapping a column in a function can change the work required:
SELECT id, email
FROM users
WHERE DATE(created_at) = ?;
If the application can express the same requirement as a range, it is often easier for the database to narrow the candidate rows:
SELECT id, email
FROM users
WHERE created_at >= ?
AND created_at < ?;
The correct details depend on the database, data distribution, and existing schema. The principle is stable: write predicates that describe a narrow, direct path to the desired records.
Keep transactions short and purposeful
Transaction design is a performance decision as much as a correctness decision. A long transaction can hold locks, retain old row versions, increase contention, and make unrelated requests wait.
Keep the transactional section narrow. Validate input, call remote services, generate documents, and perform expensive calculations before or after the database transaction whenever correctness permits. Inside the transaction, do the minimum required reads and writes, then commit promptly.
This does not mean sacrificing consistency. It means being precise about what must be atomic. If creating an order requires reserving inventory and recording the order together, those changes may belong in one transaction. Sending a confirmation email does not need to keep that transaction open.
When retries are necessary for transient transaction failures, make operations safe to retry. Idempotency keys for externally initiated writes and unique constraints for business identifiers are practical safeguards. A retry loop without an idempotency strategy can create duplicate work.
Pagination is a database design choice
Offset pagination is simple:
SELECT id, created_at, title
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000;
But deep offsets can require the database to walk past many rows before returning the requested page. They also make shifting datasets harder to navigate consistently.
For large, ordered collections, keyset pagination is often a better fit. Instead of asking for page 501, ask for records after the last item seen:
SELECT id, created_at, title
FROM posts
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;
The ordering must be deterministic, which is why a tie-breaker such as id matters. This approach is particularly useful for feeds, audit logs, and operational dashboards.
Use caching as a contract, not a bandage
Caching can protect a database from repeated reads, but it introduces a new question: when is cached data valid? A cache without an invalidation or expiry strategy simply moves correctness risk to another layer.
Cache stable, expensive, frequently requested results. Use bounded expiration where slightly stale data is acceptable. Invalidate targeted keys after writes when the application can reliably identify what changed. Avoid caching highly personalized or rapidly changing data unless the ownership and lifecycle are clear.
Connection management matters too. Reusing connections through an appropriate application or infrastructure strategy can reduce setup overhead, but each connection consumes database resources. The right pool size is not “as many as possible”; it is enough concurrency for real demand without overwhelming the database.
Measure the whole request
A slow API response may spend little time in SQL. It may be waiting on a connection, transforming a large result in PHP, calling another service, or encoding an oversized JSON response. Measure request duration alongside query count, query duration, rows returned, error rate, and connection wait time.
Performance work becomes much easier when every optimization has a stated hypothesis and a before-and-after measurement. “This endpoint fetches too much data” is actionable. “The database is slow” is only a starting point.
Speed is restraint
Indexes are powerful because they reduce unnecessary searching. The broader lesson is the same: reduce unnecessary work everywhere. Ask for fewer rows, make fewer round trips, hold locks for less time, serialize less data, and cache only what you can explain.
A fast database-backed system is rarely the result of one clever index. It is the result of many ordinary decisions made with care, each one preserving room for the next stage of growth.