Dizajn baze podataka: projektiranje predvidljivosti pod opterećenjem
Most database incidents do not begin with a dramatic failure. They begin with a harmless-looking query, a convenient column type, or an endpoint that works perfectly with a small dataset. Then traffic grows, rows accumulate, concurrent requests arrive, and the database becomes the place where small design shortcuts turn into unpredictable latency.
Good database design is not about chasing an ideal schema diagram. It is about engineering predictable behavior under real load: known query paths, clear ownership of data, bounded work per request, and failure modes that are understandable when the system is busy.
Design Around Access Patterns, Not Just Entities
Entity relationships matter, but production performance is shaped by how the application reads and writes data. A schema can look beautifully normalized while forcing every important request into expensive joins, broad scans, or repeated round trips.
Start by naming the queries your system must perform. For an order API, that may include:
- Fetch one order by its public identifier.
- List a customer’s recent orders with pagination.
- Find orders awaiting payment processing.
- Record an order and its line items atomically.
Each query implies constraints on keys, indexes, ordering, and pagination. If a request commonly filters by customer_id and sorts by created_at, an index aligned with that path is usually more useful than separate indexes chosen only because each column appears in a filter.
SELECT id, status, total_amount, created_at
FROM orders
WHERE customer_id = :customer_id
AND created_at < :cursor
ORDER BY created_at DESC
LIMIT 50;
The supporting index should reflect the filter and order used by the query. Exact index syntax and optimizer behavior vary by database engine, so verify the plan against the engine you operate rather than treating any rule as universal.
Choose Identifiers Deliberately
Primary keys are infrastructure. They affect joins, index size, write locality, URLs, migrations, and integration boundaries. A numeric internal key is compact and efficient for many relational workloads. A generated public identifier can prevent easy enumeration in external APIs. These needs do not have to compete: it is often reasonable to keep an internal primary key and a separately indexed public ID.
What matters most is consistency. If one service treats an ID as an integer while another stores it as a string, bugs migrate quietly into validation, serialization, and joins. Decide where identifiers are created, what representation crosses API boundaries, and whether callers may rely on their ordering.
Normalization Is a Tool, Not a Ritual
Normalization protects correctness by giving each fact one authoritative home. A customer email should not be copied into every order merely because it is convenient to display. If it changes, duplicated data becomes a repair problem.
But some duplication is intentional. An order may preserve a shipping address snapshot because historical accuracy matters more than reflecting the customer’s current profile. A reporting table may store precomputed values because recalculating them during every request would be too costly.
The useful question is not “is this normalized?” It is “what invariant does this representation preserve, and who maintains it?” Denormalization without an update strategy is deferred corruption. Denormalization with a clear source of truth, transactional update path, and reconciliation plan can be a pragmatic performance decision.
Make Writes Explicitly Atomic
Many backend bugs are partial-write bugs. An application inserts an order, updates inventory, and creates a payment record; the third step fails, leaving the first two committed. The happy path may look correct for months while the recovery path remains undefined.
Use database transactions for changes that must succeed or fail together. Keep them short: validate inputs before opening the transaction, avoid network calls inside it, and commit as soon as the invariant is established.
$pdo->beginTransaction();
try {
$orderId = createOrder($pdo, $customerId, $items);
reserveInventory($pdo, $items);
recordOrderEvent($pdo, $orderId, 'created');
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
This does not make every distributed operation atomic. For example, sending an email or calling a payment provider cannot safely be folded into a local database transaction. Instead, persist the state change and an event for later delivery, then make the consumer idempotent. Retrying becomes safer when processing the same event twice does not create two charges or two shipments.
Indexes Need a Budget
An index speeds some reads by adding storage, memory pressure, and write work. Every insert, update, or delete may need to maintain each relevant index. The answer to a slow query is therefore not “index every column.” It is to measure the query, inspect its execution plan, and add the smallest index that supports a meaningful access pattern.
Watch for common traps:
- Filtering on a function applied to a column, which can prevent efficient index use.
- Using broad
SELECT *queries where the endpoint needs only a few fields. - Offset pagination for deep result sets, where the database must increasingly skip work.
- Indexes that are never used but still slow writes.
Cursor-based pagination is often a better fit for large, ordered lists. It makes the next page a continuation from a known row rather than a request to discard an ever-larger prefix. The cursor must use a stable ordering, commonly a timestamp plus a unique tie-breaker.
Protect the Database From the Application
A database is shared capacity. One endpoint with an unbounded query can harm unrelated requests. Put limits at the application boundary: cap page sizes, validate sort fields against an allowlist, set appropriate timeouts, and avoid accepting arbitrary filter expressions from clients.
Connection handling matters as well. Opening too many concurrent connections can overwhelm the database before CPU or storage becomes the visible bottleneck. Configure application workers and connection pools with the database’s limits in mind. In containerized deployments, remember that scaling application replicas multiplies potential connections unless pooling is designed at the same time.
Also distinguish a slow database from a slow request. Record useful telemetry around query duration, error class, transaction retries, and connection wait time. Avoid logging sensitive parameters by default. When a regression appears, an execution plan and a representative query are usually more valuable than speculation.
Migrations Are Production Code
A schema migration is not just a file that runs successfully on a laptop. On a busy system, adding an index, changing a type, or backfilling a new column can lock tables, consume I/O, or run long enough to disrupt normal work. Plan migrations as deployable operations with rollback and observability in mind.
Prefer compatible, staged changes. Add a nullable column before requiring it. Deploy application code that can handle both old and new shapes. Backfill in bounded batches. Add constraints only after existing data satisfies them. Remove obsolete columns only after all deployed consumers no longer depend on them.
Predictability Is the Real Feature
The strongest database designs rarely feel clever. They make ordinary operations boring: a request has a known query shape, a write has a clear transaction boundary, an index has a reason to exist, and a migration has a safe path through production.
That discipline pays off when load increases or requirements change. The database stops being a mysterious bottleneck and becomes a dependable part of the system: one whose costs, constraints, and behavior the team can explain before the next incident has to explain them for them.