Databases · lesson 3 of 6
Intermediate
2Advanced
4Also part of Databases
12- + Schema Design from SQL
- + Indexing Strategy: When an Index Actually Helps from PostgreSQL
- + Composite Indexes: The Filesort a Single-Column Index Can't Avoid from MySQL
- + Clustered vs Nonclustered: When an Index Makes a Query Slower from SQL Server
- + A Real Deadlock, and Why Lock Order Fixed It from PostgreSQL
- + Isolation Levels: The Same Transaction Reading Two Different Values from MySQL
- + A Real Dirty Read: Seeing a Value That Never Actually Existed from SQL Server
- + Replication: A Second Server, and the read_only Gap Nobody Mentions from MySQL
- + Killing the Primary and Measuring the Real Failover Time from MongoDB
- + Point-in-Time Recovery: Undoing a Bad DELETE That Already Committed from PostgreSQL
- + Backup and Recovery: What a Restore Actually Gives Back from MySQL
- + Quorum Consistency: When a Read Is Guaranteed to See the Latest Write from System Design
Indexing Across Engines: One B-Tree Idea, Measured Twice
The same customer_id lookup, on the same shape of 100k+ row table, measured before and after an index in both PostgreSQL and MySQL — two engines, two real query planners, the same order-of-magnitude win.
By SkillByExample · Updated
The same idea — index the column you filter on — measured on two different engines against comparably-sized real data, not asserted once and assumed to generalize.
PostgreSQL: sequential scan vs. indexed lookup
psql -U postgres -c "EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 12345;"
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------
Gather (cost=1000.00..8033.20 rows=2757 width=12) (actual time=6.711..38.444 rows=9 loops=1)
Workers Planned: 1
Workers Launched: 1
-> Parallel Seq Scan on orders (cost=0.00..6757.50 rows=1622 width=12) (actual time=4.747..26.573 rows=4 loops=2)
Filter: (customer_id = 12345)
Rows Removed by Filter: 249996
Planning Time: 0.474 ms
Execution Time: 38.508 ms
(8 rows)
Parallel Seq Scan — every one of the 500,000 rows gets checked against the filter, split
across a parallel worker. Execution Time: 38.508 ms for a query that only needed 9 rows.
psql -U postgres -c "CREATE INDEX idx_orders_customer ON orders(customer_id);"
psql -U postgres -c "EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 12345;"
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on orders (cost=4.51..46.54 rows=11 width=12) (actual time=0.126..0.176 rows=9 loops=1)
Recheck Cond: (customer_id = 12345)
Heap Blocks: exact=9
-> Bitmap Index Scan on idx_orders_customer (cost=0.00..4.50 rows=11 width=0) (actual time=0.087..0.087 rows=9 loops=1)
Index Cond: (customer_id = 12345)
Planning Time: 0.825 ms
Execution Time: 0.298 ms
(7 rows)
38.508 ms → 0.298 ms — a measured ~129x improvement on the same 500,000-row table, same
query, same 9-row result. The index turned “check every row” into “look up exactly which
rows qualify, then fetch just those.”
MySQL: the same shape of query, the same shape of result
mysql -uroot -p dbcompare -e "EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 12345;"
-> Filter: (orders.customer_id = 12345) (cost=10082 rows=10026) (actual time=4.34..28.8 rows=2 loops=1)
-> Table scan on orders (cost=10082 rows=100256) (actual time=1.28..22.4 rows=100000 loops=1)
Table scan — MySQL’s name for the same idea as Postgres’s Seq Scan — checking all 100,000
rows, taking 28.8 ms.
mysql -uroot -p dbcompare -e "CREATE INDEX idx_orders_customer ON orders(customer_id);"
mysql -uroot -p dbcompare -e "EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 12345;"
-> Index lookup on orders using idx_orders_customer (customer_id=12345) (cost=0.7 rows=2) (actual time=0.0174..0.0186 rows=2 loops=1)
28.8 ms → 0.0186 ms — a different engine, a different query planner, a different plan
vocabulary (Index lookup instead of Bitmap Index Scan), and the same order-of-magnitude
story: an unindexed equality filter on a 100k+-row table costs milliseconds; an indexed one
costs microseconds.
What transfers, and what doesn’t
The B-tree idea itself transfers directly: index a column you filter on by equality, and the
engine can jump straight to the matching rows instead of checking every one. What doesn’t
transfer is the plan’s vocabulary and the exact mechanics — Postgres’s Bitmap Heap Scan
step (collecting matching row locations, then fetching them) has no exact MySQL equivalent in
this plan. For the real depth on either engine’s specific optimizer — composite indexes,
covering indexes, when the optimizer chooses not to use an available index — see this
site’s own postgresql/indexing-strategy.md and mysql/indexing-strategy.md, each with real
measured examples of those deeper cases on one engine.
Practice
1. Run the unindexed query again after the index exists, on a column that has no index, to confirm the planner still scans.
psql -U postgres -c "EXPLAIN ANALYZE SELECT * FROM orders WHERE amount = 500;"
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
Gather (cost=1000.00..7429.27 rows=498 width=12) (actual time=0.341..24.415 rows=480 loops=1)
Workers Planned: 1
Workers Launched: 1
-> Parallel Seq Scan on orders (cost=0.00..6379.47 rows=293 width=12) (actual time=0.059..19.050 rows=240 loops=2)
Filter: (amount = 500)
Rows Removed by Filter: 249760
Planning Time: 0.656 ms
Execution Time: 24.483 ms
(8 rows)
Adding an index on customer_id did nothing for a query filtering on amount — the index
only speeds up queries on the column it actually covers, still scanning here at a similar cost
to the original unindexed customer_id query.
2. Confirm the indexed customer_id query stays fast when it needs to return more rows, not just the same 9.
psql -U postgres -c "EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id BETWEEN 12000 AND 12100;"
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on orders (cost=18.41..1967.18 rows=974 width=12) (actual time=1.178..7.922 rows=959 loops=1)
Recheck Cond: ((customer_id >= 12000) AND (customer_id <= 12100))
Heap Blocks: exact=814
-> Bitmap Index Scan on idx_orders_customer (cost=0.00..18.16 rows=974 width=0) (actual time=0.687..0.688 rows=959 loops=1)
Index Cond: ((customer_id >= 12000) AND (customer_id <= 12100))
Planning Time: 9.558 ms
Execution Time: 8.541 ms
(7 rows)
Still a Bitmap Index Scan, and still far faster than the original 38.508ms unindexed scan
even though this query returns ~960 rows instead of 9 — the index serves a range condition on
the same column just as well as a single equality match, and the cost scales with the number
of matching rows fetched from the heap, not with the size of the whole table.
3. Check how many rows the index actually covers with a count, to confirm it isn't silently partial.
psql -U postgres -c "SELECT COUNT(*) FROM orders;"
count
--------
500000
(1 row)
Matches the original insert count — the index was built against the full table, not a subset, so the earlier speedup measurement wasn’t an artifact of the index only covering some rows.
Next: ACID vs. BASE, measured — a real lost update Redis won’t stop and Postgres will.