Тесни грла во перформансите на PHP: Најдете и отстранете забавувања во базата на податоци
A PHP request can feel slow for reasons that have nothing to do with PHP. The controller may be tidy, the framework cache may be warm, and the application server may have plenty of CPU available. Yet one poorly shaped database query can hold the request open long enough to make the entire product feel unreliable.
The useful mental model is simple: PHP is often waiting. When a request spends most of its time waiting for MySQL, PostgreSQL, or another database, micro-optimizing loops or replacing a string function will not move the needle. Find where time is actually spent, then improve the query, schema, access pattern, or workload behind it.
Start with evidence, not suspicion
Before changing code, establish whether the database is the bottleneck. Measure request duration and, where possible, record query count and individual query timings. Application profiling, framework query logs in a controlled environment, database slow-query logs, and database monitoring can all help answer the same questions:
- Which endpoints are slow?
- How many queries does each request execute?
- Which queries consume the most total time?
- Are slow queries consistently slow, or only under load?
- Is the database waiting on locks, disk I/O, CPU, network, or connections?
A query that takes 80 milliseconds once may be acceptable. The same query executed 100 times in one request is a serious problem. Likewise, a query that is quick in a development database with 500 rows may become expensive when it must sort or scan millions of rows in production.
Keep measurement close to real request behavior. A manually copied query can be useful for investigation, but it may not include the same parameters, transaction state, concurrent workload, or connection conditions as the application.
The N+1 query problem is usually a design smell
N+1 happens when code loads a collection and then performs another query for every item in that collection. It is common in ORM-backed applications, but it can happen with any database layer.
$posts = $postRepository->findRecent();
foreach ($posts as $post) {
$author = $userRepository->findById($post->authorId());
// Render the post and author name.
}
If there are 50 posts, this can produce 51 queries. Even if each query is individually fast, the accumulated database work and round trips add latency. Under concurrent traffic, the pattern also raises pressure on the connection pool and database server.
Prefer loading the required data in a bounded number of queries. Depending on the data model, that may mean a join, an ORM eager-loading feature, or fetching authors with one WHERE id IN (...) query and mapping them in PHP. The goal is not always “one query”; it is a predictable and proportionate query count.
SELECT p.id, p.title, p.published_at, u.name AS author_name
FROM posts AS p
JOIN users AS u ON u.id = p.author_id
WHERE p.published_at IS NOT NULL
ORDER BY p.published_at DESC
LIMIT 50;
Do not overcorrect by eager-loading every relationship. Fetching unnecessary columns or large child collections can create a different performance problem. Load the data the response genuinely needs.
Use indexes to support actual query patterns
An index is not a general “make database faster” switch. It is a data structure that helps the database locate or order rows for particular access patterns. The best index depends on the query’s filters, joins, ordering, and the distribution of values in the table.
Consider an endpoint that lists published posts for a tenant, newest first:
SELECT id, title, published_at
FROM posts
WHERE tenant_id = ?
AND status = 'published'
ORDER BY published_at DESC
LIMIT 25;
A composite index beginning with the equality filters and including the ordering column may be appropriate, for example (tenant_id, status, published_at). Whether that exact index is right depends on the database engine, existing indexes, query variations, and cardinality. Use the database’s execution-plan tools, such as EXPLAIN, to validate that an index is useful rather than assuming it is.
Indexes have costs. They consume storage, increase write work, and can complicate maintenance. Adding several speculative indexes can slow inserts and updates while solving nothing. Treat each index as part of the application’s design: document the query it supports and re-evaluate it when that query changes.
Watch for index-hostile expressions
A query can accidentally prevent effective index use by applying functions to indexed columns or by using broad patterns. For example, filtering with a date-extraction function may force the database to evaluate many rows before deciding which ones match.
-- Often harder to optimize efficiently
WHERE DATE(created_at) = '2026-10-01'
-- A range is usually friendlier to an index on created_at
WHERE created_at >= '2026-10-01 00:00:00'
AND created_at < '2026-10-02 00:00:00'
The range form also makes the boundary explicit. Ensure the timestamps and day boundaries use the application’s intended time-zone rules.
Retrieve less data and paginate deliberately
SELECT * is convenient during early development, but it makes responses larger, increases memory use, and hides the true data contract. Select the columns needed by the endpoint. Avoid loading large text fields, JSON documents, or binary data merely because a list view needs an ID and title.
Pagination deserves similar care. Offset pagination is straightforward:
SELECT id, title, published_at
FROM posts
WHERE tenant_id = ?
ORDER BY published_at DESC
LIMIT 25 OFFSET 5000;
For increasingly deep pages, the database may still need to walk past many rows. For feeds and sequential browsing, keyset pagination is often a better fit. It uses the final item from the previous page as a cursor, with a stable ordering and a tie-breaker such as the primary key.
SELECT id, title, published_at
FROM posts
WHERE tenant_id = ?
AND (published_at < ? OR (published_at = ? AND id < ?))
ORDER BY published_at DESC, id DESC
LIMIT 25;
This approach requires careful cursor encoding and matching indexes, but it avoids making later pages progressively more expensive.
Do not ignore locks and connection pressure
Not every database slowdown is a slow query plan. A fast update can wait a long time for another transaction to release a lock. Transactions that perform external HTTP calls, user interaction, lengthy processing, or large batches while open are especially risky. Keep transactions short and limit them to the database changes that must succeed or fail together.
Connection management matters too. Creating too many simultaneous connections can overwhelm the database even when each query is reasonable. Configure PHP-FPM workers, queue consumers, and container replicas with the database’s connection capacity in mind. Persistent connections and pooling can be useful in the right environment, but they do not replace sensible concurrency limits or safe transaction cleanup.
Build a repeatable performance practice
The strongest fixes are usually boring: remove an N+1 pattern, add a justified index, stop selecting unused columns, shorten a transaction, or move expensive reporting work away from a synchronous request. Make those improvements repeatable by reviewing query counts on important endpoints, testing with realistic data volumes, and treating execution plans as part of diagnosing database changes.
PHP performance work becomes much less mysterious when you stop asking how to make code run faster and start asking what the request is waiting for. Once the database is visible in that conversation, bottlenecks become concrete engineering problems: measurable, explainable, and usually fixable.