DATABASE ENGINEERING#03 • Performance & Scalability

MySQL & PostgreSQL Query Optimization Under Heavy Load: A Practical Guide.

When database traffic increases, small query inefficiencies can cause major slowdowns or downtime. Here is a practical guide to finding slow queries, reading EXPLAIN plans, creating composite indexes, fixing pagination bottlenecks, and tuning connection pools under high concurrency.

By Shyam KumarMar 05, 20268 min read
#MySQL#PostgreSQL#Performance
01 / HIGH CONCURRENCY

What Happens When Databases Experience Heavy Load

In local development or with a small number of users, almost every SQL query runs in under 10 milliseconds. Even an unindexed table scan across 50,000 rows feels instant. But when your application handles thousands of concurrent requests per second, system behavior changes significantly.

A single unindexed query taking 250ms might not seem problematic on its own. But if 200 requests run it simultaneously, the database suddenly faces 50 seconds of CPU work. Connection pools become exhausted, threads wait on locks, memory usage spikes, and response times degrade significantly.

Addressing this usually does not require upgrading hardware immediately. In most cases, identifying and fixing a few critical bottlenecks stabilizes the system.

02 / OBSERVABILITY FIRST

Step 1: Identify Slow Queries with Profiling Tools

Before changing any code, you need real data on which queries consume the most CPU cycles and disk I/O. Both MySQL and PostgreSQL provide built-in tools to track this.

In PostgreSQL: Use pg_stat_statements

pg_stat_statements records total execution time, call counts, and buffer reads across every query shape.

find-slow-postgres.sql(SQL)
-- Find the top 5 queries taking the most total CPU time:
SELECT 
    round((total_exec_time / 1000)::numeric, 2) AS total_time_seconds,
    calls,
    round((mean_exec_time)::numeric, 2) AS avg_time_ms,
    rows,
    substr(query, 1, 80) AS query_preview
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;

In MySQL: Turn on the Slow Query Log

Log queries that take longer than 500 milliseconds or do not use indexes:

my.cnf(INI)
# Enable slow query logging in MySQL configuration:
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.5            # Catch anything taking over 500ms
log_queries_not_using_indexes = 1
03 / QUERY EXECUTION PLANS

Step 2: Understand EXPLAIN & EXPLAIN ANALYZE

When you run a query, the database optimizer chooses an execution plan to retrieve data. Running EXPLAIN ANALYZE executes the query and shows the actual execution time and steps taken.

Table Scan (Seq Scan)

The database scans every row on disk or in the buffer cache from start to finish. On large tables under heavy load, this causes high disk I/O and CPU usage.

Index Scan / Index Only Scan

The database uses an index to jump directly to matching rows. The number of buffer reads drops significantly compared to a table scan.

explain-example.sql(SQL)
-- Run EXPLAIN ANALYZE on your suspect query:
EXPLAIN ANALYZE
SELECT id, email, created_at 
FROM users 
WHERE status = 'ACTIVE' AND country_code = 'IN';

-- BAD OUTPUT (Sequential Table Scan):
-- Seq Scan on users  (cost=0.00..18420.00 rows=48210 width=36) (actual time=0.045..248.120 rows=45120 loops=1)
-- Filter: ((status = 'ACTIVE') AND (country_code = 'IN'))
-- Rows Removed by Filter: 954880
04 / INDEXING STRATEGY

Step 3: Understanding Composite Indexes (The Leftmost Prefix Rule)

Creating single-column indexes on every column (one on status, one on country_code, and one on created_at) is often inefficient. Under heavy load, the database may not be able to combine multiple indexes efficiently for the same query.

Rule of Thumb for Composite Index Column Order:
Place exact equality filters first (WHERE status = ...), then range filters (WHERE created_at > ...), and finally sorting fields (ORDER BY ...).
create-composite-index.sql(SQL)OPTIMIZED
-- Create a composite index in the exact order of filtering and sorting:
CREATE INDEX idx_users_country_status_created 
ON users (country_code, status, created_at DESC);

-- Now this query executes in < 2ms using an Index Scan:
SELECT id, email, created_at 
FROM users 
WHERE country_code = 'IN' 
  AND status = 'ACTIVE' 
ORDER BY created_at DESC 
LIMIT 20;

PostgreSQL Partial Indexes

If 90% of rows have status = 'PROCESSED' and queries only check for pending jobs, you do not need to index the processed rows. Index only the rows you search for:

partial-index.sql(SQL)OPTIMIZED
-- Index only rows that actually need active processing:
CREATE INDEX idx_orders_pending 
ON orders (created_at) 
WHERE status = 'PENDING';
-- Keeps the index significantly smaller in memory and reduces INSERT/UPDATE overhead:
05 / COMMON BOTTLENECKS

The 4 Common Anti-Patterns That Hurt Database Throughput

Anti-Pattern 1: Wrapping Indexed Columns in Functions

When you wrap an indexed column in a function like DATE(), YEAR(), or LOWER(), the database engine may not be able to use the B-Tree index efficiently and may fall back to a full table scan.

function-index-fix.sql(SQL)
-- ❌ BAD: Database must evaluate YEAR() on every single row:
SELECT * FROM orders WHERE YEAR(created_at) = 2026;

-- ✅ GOOD: Range query uses the B-Tree index directly:
SELECT id, amount, status FROM orders 
WHERE created_at >= '2026-01-01 00:00:00' 
  AND created_at <  '2027-01-01 00:00:00';

Anti-Pattern 2: Deep OFFSET Pagination (The 50,000 Row Trap)

When using OFFSET 50000 LIMIT 20, the database must scan through 50,020 rows, discard the first 50,000, and return only 20. Under heavy concurrency, this wastes CPU and memory bandwidth.

keyset-pagination.sql(SQL)
-- ❌ BAD: Discards 100,000 rows on every page request:
SELECT * FROM audit_logs ORDER BY id DESC LIMIT 20 OFFSET 100000;

-- ✅ GOOD: Keyset Pagination (cursor-based) avoids scanning and discarding previous rows:
SELECT id, message, created_at 
FROM audit_logs 
WHERE id < 948210  -- last seen ID from previous page
ORDER BY id DESC 
LIMIT 20;

Anti-Pattern 3: Indiscriminate SELECT * in High-Traffic Endpoints

Fetching large TEXT, BLOB, or JSON columns you do not need increases memory pressure on the database buffer pool and consumes unnecessary network bandwidth. Select only the columns needed by the application.

Anti-Pattern 4: The N+1 Query Problem from ORMs (Hibernate / Prisma)

If you load 100 orders and iterate through each order to fetch its associated customer, the application executes 1 + 100 = 101 database queries. Use explicit JOIN FETCH, joins, or batch fetching instead.

join-fetch-example.sql(SQL)
-- ❌ BAD: 1 query for orders + N queries for each customer:
SELECT * FROM orders WHERE status = 'PENDING';
-- (followed by 100 individual: SELECT * FROM customers WHERE id = ?)

-- ✅ GOOD: 1 single query returns all required relations:
SELECT o.id, o.amount, o.status, c.id, c.name, c.email 
FROM orders o 
JOIN customers c ON o.customer_id = c.id 
WHERE o.status = 'PENDING';
06 / INFRASTRUCTURE TUNING

Connection Pool Tuning (Why More Connections Is Often Worse)

A common mistake is increasing the connection pool size to 200 or 500 when queries slow down. On a database server with 8 CPU cores, running 200 concurrent active queries means 200 threads competing for 8 cores. The CPU spends significant time context switching instead of executing queries.

PostgreSQL / HikariCP Pool Sizing Formula:
connections = ((CPU_cores * 2) + effective_spindle_count)
On an 8-core server with modern SSDs, a connection pool of 16 to 25 connections will often handle more throughput and have lower latency than a pool of 200.
application.properties(PROPERTIES)
# Recommended HikariCP configuration for high-concurrency Spring Boot:
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.connection-timeout=20000   # Fail fast if pool exhausted (20s)
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=1200000
SUMMARY & CHECKLIST

Your 6-Point Query Optimization Checklist

1Inspect EXPLAIN ANALYZE: Check execution plans and look for unexpected Seq Scan operations on larger tables.
2Build Composite Indexes: Order columns by equality filters first, range filters second, and sort order third.
3Eliminate Functions on Indexed Columns: Rewrite queries to filter on raw column values with range boundaries.
4Adopt Keyset Pagination: Replace deep OFFSET 50000 with WHERE id < last_seen_id LIMIT 20.
5Tune Connection Pools: Keep connection pool sizes moderate (typically 15 to 30) to minimize CPU context switching.
6Buffer Pool Allocation: Ensure the database buffer cache (such as InnoDB buffer pool) is sized appropriately for available RAM.
Back to All Articles in The Tech Corner
Need Database Architecture Advice? Contact Me