Databases

Database Engineering Essentials: Relational Modeling, B-Tree Indexes, ACID, and Connection Pools

DD
Ankur Ishwar
12 min read Updated Sep 7, 2026
Dropout Developer • Editorial Databases

Database Engineering Essentials: Relational Modeling, B-Tree Indexes, ACID, and Connection Pools

Oct 16, 202312 min read

In Indian engineering colleges, DBMS professors force you to memorize Codd's twelve rules and draw ER diagrams on unruled paper. Then you graduate, join an IT company, and watch a production database crash during a Diwali sale because nobody configured a connection pool.

Most tutorials teach you how to write SELECT * FROM users;. They do not teach you how databases actually run under load. When traffic spikes, your slow queries pin the CPU at 100%, TCP sockets run out, and your API server starts dropping requests.

To build reliable backend systems, you need a physical mental model of the database engine: how tables map to disk blocks, how B-Tree indexes traverse pages, and how ACID transactions prevent double-spending.

Relational Normalization Without Academic Jargon

Junior developers often grab a document database like MongoDB because they want to skip thinking about schema migrations. Six months later, they write hundreds of lines of brittle JavaScript code to simulate manual joins across unindexed collections.

Relational databases exist to eliminate data anomalies. Normalization is simply the discipline of storing each piece of state in exactly one place.

  • First Normal Form (1NF): Every column contains atomic (indivisible) values. Never store comma-separated strings like "tag1,tag2,tag3" inside a single text field. Use a separate relationship table.
  • Second Normal Form (2NF): Every non-key column must depend on the entire primary key, not just part of a composite key.
  • Third Normal Form (3NF): No transitive dependencies. If a user table stores pincode and city_name, the city depends on the pincode, not the user. Separate it into a location lookup table so a typo in one record does not corrupt your analytics.

Enforce these constraints at the database level with foreign keys and check constraints. Application code has bugs. Postgres constraints do not.

The B-Tree Index: How Search Actually Works

When you run SELECT * FROM orders WHERE user_id = 4521; on an unindexed table with 2 million rows, Postgres performs a sequential scan. It reads every single 8KB data page from the disk into memory, checking every row one by one. On spinning disks or throttled cloud SSDs, this takes seconds.

A B-Tree (Balanced Tree) index solves this by maintaining a sorted tree structure of keys pointing to physical heap tuple identifiers (CTIDs). The depth of a B-Tree for millions of rows is usually just 3 or 4 levels.

-- Create an indexed table with proper constraints
CREATE TABLE accounts (
    id BIGSERIAL PRIMARY KEY,
    account_number VARCHAR(32) UNIQUE NOT NULL,
    user_id BIGINT NOT NULL,
    balance_paise BIGINT NOT NULL CHECK (balance_paise >= 0),
    status VARCHAR(16) DEFAULT 'active',
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Create a B-Tree index on user_id for lookup queries
CREATE INDEX idx_accounts_user_id ON accounts(user_id);

With this index, finding an account requires reading 3 index pages from the root node down to the leaf node, followed by one single heap fetch. Your query execution drops from 850 milliseconds to 0.4 milliseconds.

The Cost of Indexes

Indexes are not free. Every index on a table requires disk space and adds overhead to every INSERT, UPDATE, and DELETE. When you insert a new record into an indexed table, Postgres must write the row to the heap table and update every single index tree. If a leaf page is full, the database must perform an expensive page split.

Index your foreign keys and your filter columns. Never index every column blindly.

Reading Execution Plans: EXPLAIN ANALYZE

Never guess why a query is slow. Ask the query planner using EXPLAIN (ANALYZE, BUFFERS):

EXPLAIN (ANALYZE, BUFFERS) 
SELECT id, balance_paise 
FROM accounts 
WHERE user_id = 1042;

-- Output:
-- Index Scan using idx_accounts_user_id on accounts  (cost=0.43..8.45 rows=1 width=16) (actual time=0.042..0.044 rows=1 loops=1)
--   Index Cond: (user_id = 1042)
--   Buffers: shared hit=4
-- Planning Time: 0.120 ms
-- Execution Time: 0.068 ms

Key terms to scan for in the output:

  • Seq Scan on a large table: You missed an index or the planner decided scanning the whole table was faster than jumping through an index.
  • Index Scan: The planner read the index and fetched matching rows from the heap table.
  • Index Only Scan: All requested columns exist directly inside the index itself. The database never touched the heap table. This is the fastest read pattern in relational engines.
  • Buffers: shared hit=4: All 4 required pages were already in the Postgres buffer cache in RAM. Zero physical disk I/O was required.

ACID Guarantees: Protecting Financial Data

When you transfer ₹500 from Account A to Account B, two operations must happen: subtract ₹500 from A, and add ₹500 to B. If the server loses power between line 1 and line 2, money vanishes into thin air.

ACID guarantees prevent this catastrophe:

  • Atomicity: All statements in a transaction commit together, or none of them do. Postgres records changes to the Write-Ahead Log (WAL) before writing to table files.
  • Consistency: The database transitions from one valid state to another, enforcing all checks, foreign keys, and unique constraints.
  • Isolation: Concurrent transactions cannot see half-baked changes from each other.
  • Durability: Once a transaction commits, the WAL writes to non-volatile disk storage via an fsync call. Even if someone pulls the server plug, the data survives reboot.

Preventing Race Conditions with SELECT FOR UPDATE

In high-concurrency systems, standard SELECT queries do not lock rows. If two API requests arrive simultaneously to deduct money from an account with ₹1,000, both requests read ₹1,000, both calculate the new balance as ₹500, and both write ₹500. The customer spends ₹1,000 but only ₹500 gets deducted.

Use explicit row locking inside a transaction:

BEGIN;

-- Lock the specific row for write access
SELECT balance_paise 
FROM accounts 
WHERE id = 9812 
FOR UPDATE;

-- Perform balance checks in application code, then update
UPDATE accounts 
SET balance_paise = balance_paise - 50000 
WHERE id = 9812;

COMMIT;

The FOR UPDATE clause locks that exact row until the transaction commits. The second concurrent request queues behind the lock, reads the updated balance of ₹500, and handles the logic correctly.

Connection Pools: Why Direct DB Connections Kill You

Each client connection to PostgreSQL is not a lightweight thread. Postgres uses a process-based concurrency model. Every connection forks a dedicated backend process consuming 5MB to 10MB of RAM.

If you run a Node.js or Go web service with 50 container instances, and each instance opens a pool of 20 direct database connections, you have 1,000 concurrent backend processes hammering the database. The operating system spends more CPU cycles switching process contexts than executing queries.

[App Container 1] --\
[App Container 2] ----> [PgBouncer: 1000 Client Conns] ===(25 Active Conns)===> [Postgres Server]
[App Container 3] --/

Place a connection pooler like PgBouncer in front of your database. PgBouncer holds thousands of idle client sockets open while multiplexing queries through a small pool of 20 to 30 active Postgres backend connections. This cuts RAM consumption by 80% and keeps database throughput steady during traffic spikes.

Production Database Checklist

  1. Never use SELECT * in production backend code. Query only the columns you need to allow index-only scans and reduce network serialization costs.
  2. Always wrap multi-step writes inside explicit BEGIN ... COMMIT transaction blocks.
  3. Set strict statement timeouts (for example, statement_timeout = '3000ms') to kill rogue runaway queries before they starve other users.
  4. Run automated daily backups with pg_dump or WAL archiving (pgBackRest) and test restoring the dump once a month on a scratch staging machine.

Treat the database like the core engine of your software, because it is. Code is cheap. Your customer data is irreplaceable.

Found this useful?
View all articles

Keep Reading

Related Articles

Learn with Dropout Developer

Build real software with AI

Step-by-step learning paths, vibe coding tutorials, and certified developer programs designed for the modern engineer.