Database Performance Tuning & Profiling
Production-verified memory allocation standards, execution plan analysis, index bloat remediation, slow query log profiling, and anti-pattern guardrails.
shared_buffers: How Much RAM Belongs to PostgreSQL?
PostgreSQL dedicated buffer pool sizing
The primary memory cache where PostgreSQL buffers table and index data pages. Misconfigurations cause double-buffering waste or severe disk I/O thrashing. Dedicated nodes should allocate 25% of total RAM; shared instances must scale down.
<8 GB RAM → 15–20% · 8–64 GB → 25% · >64 GB → 16 GB ceiling (rely on effective_cache_size for OS caching)
# postgresql.conf
# 32 GB RAM'li dedicated sunucu örneği
shared_buffers = 8GB
effective_cache_size = 24GB # OS cache dahil toplam beklentiwork_mem: Per-Operation Sort & Hash Budgeting
Sort, hash joins, and OOM risk control
Defines memory allocated to EACH individual sort, hash-join, and aggregate step. A complex query executes multiple operations simultaneously; multiplied across active client connections, aggressive values cause OOM crashes. Spilled sorts appear as "external merge Disk" in EXPLAIN ANALYZE.
work_mem ≈ (RAM × 0.25) / max_connections — keep global conservative, elevate dynamically per-session for heavy analytics.
-- Global ayar (ihtiyatlı)
-- postgresql.conf
work_mem = 64MB
-- Ağır rapor sorgusu için session bazlı yükseltme
SET work_mem = '256MB';
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, SUM(amount)
FROM orders
GROUP BY customer_id
ORDER BY SUM(amount) DESC;Index Bloat: The Invisible Performance Killer
B-Tree leaf fragmentation and concurrent index rebuilds
High-frequency UPDATE and DELETE workloads fragment B-Tree leaf pages. When index bloat exceeds 30%, index scans suffer unnecessary buffer page lookups. REINDEX CONCURRENTLY reclaims space without exclusive table locking.
Bloat > 30% → scheduled REINDEX · Bloat > 50% → urgent rebuild · Inspect bloat ratio following heavy table purges
-- Index bloat tahmini (pgstattuple extension gerekir)
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
schemaname || '.' || relname AS table,
indexrelname AS index,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
round(100 * (pgstatindex(indexrelid)).leaf_fragmentation, 1) AS bloat_pct
FROM pg_stat_user_indexes
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;
-- Kesintisiz rebuild
REINDEX INDEX CONCURRENTLY idx_orders_created_at;pg_stat_statements: Uncover Top 10 Resource Hogs
Cumulative CPU and I/O consumption query profiling
Stop guessing query bottlenecks in production. pg_stat_statements records normalized query execution metrics, call frequency, and buffer I/O timings. Prioritizing top cumulative time queries delivers the highest ROI.
Sort by total_exec_time first, not mean_exec_time. High frequency moderate-latency queries often generate far greater system load than rare slow ones.
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- En pahalı 10 sorgu (toplam süre)
SELECT
round(total_exec_time::numeric, 1) AS total_ms,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round((100 * total_exec_time / SUM(total_exec_time) OVER ())::numeric, 1) AS pct,
left(query, 80) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;innodb_buffer_pool_size: The Heart of MySQL Performance
InnoDB buffer pool sizing and cache hit ratio validation
InnoDB buffer pool caches table data, secondary indexes, and undo logs. On dedicated MySQL servers, allocate 70–80% of system RAM. Too small causes continuous disk IOPS bottlenecks; too large leaves no headroom for thread buffers, triggering OS OOM killers.
Dedicated Host → RAM × 0.75 · Shared Host → RAM × 0.50 · Buffer Pool Hit Ratio < 99% → increase size or optimize scans
# my.cnf — 64 GB RAM dedicated MySQL sunucu
[mysqld]
innodb_buffer_pool_size = 48G
innodb_buffer_pool_instances = 8 # her instance ~6 GB
innodb_buffer_pool_chunk_size = 128M-- Buffer pool hit ratio kontrolü
SELECT
ROUND(
(1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests)) * 100, 2
) AS hit_ratio_pct
FROM
(SELECT VARIABLE_VALUE AS Innodb_buffer_pool_reads
FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') AS a,
(SELECT VARIABLE_VALUE AS Innodb_buffer_pool_read_requests
FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests') AS b;
-- Hedef: >= 99.0Slow Query Log: Capture Sub-Second Outliers
Sub-second threshold logging and pt-query-digest analytics
MySQL slow query log captures queries exceeding the execution threshold or omitting indexes. Setting long_query_time = 0.5 (500ms) establishes a solid production baseline. Analyze logs with pt-query-digest to identify aggregate CPU drain.
Keep long_query_time below 1.0s. 1 second is unacceptable in OLTP workloads; 100–200ms queries should be treated as high priority warnings.
# my.cnf
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.5
log_queries_not_using_indexes = 1
log_slow_admin_statements = 1# En pahalı sorguları özetle (Percona Toolkit)
pt-query-digest /var/log/mysql/slow.log \
--limit 10 \
--order-by Query_time:sumEXPLAIN ANALYZE: Read Ground Reality, Not Estimates
Execution plan interpretation, buffer cache misses & filters
EXPLAIN provides optimizer estimates; EXPLAIN ANALYZE executes the query and reports ground truth timings. Adding BUFFERS uncovers exact cache hits vs disk reads. High Seq Scan on large tables signals missing/unusable indexes or poor filter selectivity.
actual time ≫ estimated cost → stale optimizer stats (run ANALYZE) · high "rows removed by filter" → index WHERE selectivity is poor · high shared read → memory pressure
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, c.name, SUM(oi.qty * oi.price) AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
WHERE o.created_at >= NOW() - INTERVAL '30 days'
GROUP BY o.id, c.name
ORDER BY total DESC
LIMIT 50;
-- Dikkat edilecek satırlar:
-- Seq Scan on orders → index eksik olabilir
-- Buffers: shared read=8421 → disk'ten okundu, cache miss
-- Rows Removed by Filter: 1.2M → WHERE selektivitesi düşükCovering Index: Zero Primary Table Lookups
Eliminating clustered index traversals via "Using index"
When an index satisfies all requested SELECT, WHERE, and ORDER BY columns, InnoDB avoids clustered index pointer lookups completely. EXPLAIN confirms this via "Using index". Highly effective for hot OLTP queries.
Combine WHERE + ORDER BY + SELECT columns into composite indexes. Do not create indexes blindly — focus exclusively on top 3-5 query patterns to avoid write amplification.
-- Yavaş: tabloya geri dönmek zorunda
-- EXPLAIN: type=ref, Extra=Using where
SELECT email, created_at
FROM users
WHERE status = 'active'
ORDER BY created_at DESC
LIMIT 20;
-- Covering index
CREATE INDEX idx_users_status_created_email
ON users (status, created_at DESC, email);
-- Artık: Extra=Using index → tabloya hiç dokunulmazAutovacuum Tuning: Preventing Dead Tuple Crisis
Aggressive bloat mitigation and transaction ID exhaustion guardrails
Autovacuum recycles dead tuples to prevent table bloat and transaction ID wraparound outages. Default postgresql.conf settings are too conservative for heavy write workloads. Lower scale factors trigger timely passes, while higher autovacuum_work_mem accelerates index scans.
High-write tables require autovacuum_vacuum_scale_factor = 0.05 (default 0.2). Alert when database TXID age reaches 1B (crisis point: 2B).
# postgresql.conf — global
autovacuum_max_workers = 4
autovacuum_vacuum_scale_factor = 0.05
autovacuum_analyze_scale_factor = 0.02
autovacuum_work_mem = 1GB-- Tablo bazlı override (en yoğun tablo)
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_threshold = 1000,
autovacuum_analyze_scale_factor = 0.01
);
-- Wraparound riski izleme
SELECT
datname,
age(datfrozenxid) AS xid_age,
round(age(datfrozenxid)::numeric / 2000000000 * 100, 1) AS wraparound_pct
FROM pg_database
ORDER BY xid_age DESC;
-- wraparound_pct > 70 → kritik alarmN+1 Query Anti-Pattern: Eliminating Loop Queries
Connection pool starvation & excessive roundtrip elimination
Fetching a list and issuing individual database queries inside application loops creates severe connection pool starvation and multiplies network roundtrips. 100 orders × 1 customer = 101 trips. Solve via JOIN or parameterized IN/ANY arrays.
Never issue database queries inside application loops. Replace 1 list + N detail queries with single JOINs or array queries.
-- ❌ N+1 (uygulama sözde-kodu)
-- orders = SELECT * FROM orders LIMIT 100
-- for o in orders:
-- customer = SELECT * FROM customers WHERE id = o.customer_id
-- ✅ Tek sorgu — JOIN
SELECT o.id, o.total, c.name, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.created_at DESC
LIMIT 100;
-- ✅ Toplu fetch — ANY / IN
SELECT id, name, email
FROM customers
WHERE id = ANY(ARRAY[1, 5, 12, 44, 89]); -- PostgreSQL
SELECT id, name, email
FROM customers
WHERE id IN (1, 5, 12, 44, 89); -- MySQLSeq Scan Hunt: Identifying Missing Indexes
Identifying missing indexes via sequential vs index scan statistics
High sequential scan counts with low index scan ratios in pg_stat_user_tables reveal missing index opportunities. For large tables (>10K rows), frequent Seq Scans almost always indicate indexing gaps, whereas small lookup tables naturally benefit from sequential scans.
seq_scan > 1000 AND n_live_tup > 10000 AND idx_scan / (seq_scan + idx_scan) < 0.5 → missing index candidate. Never blindly index small tables.
SELECT
schemaname || '.' || relname AS table,
seq_scan,
idx_scan,
n_live_tup AS rows,
round(
CASE WHEN (seq_scan + idx_scan) = 0 THEN 0
ELSE 100.0 * idx_scan / (seq_scan + idx_scan) END, 1
) AS idx_usage_pct,
pg_size_pretty(pg_relation_size(relid)) AS size
FROM pg_stat_user_tables
WHERE n_live_tup > 10000
AND seq_scan > 1000
ORDER BY seq_scan DESC
LIMIT 20;EXPLAIN JSON: Deep Cost and Scan Inspection
cost_info, rows_examined_per_scan and temporary table diagnostics
Standard EXPLAIN tabular format hides optimizer cost calculations. EXPLAIN FORMAT=JSON provides cost_info, rows_examined_per_scan, and used_key_parts, revealing why the optimizer chose a specific join order or index.
High query_cost + rows_examined_per_scan ≫ returned rows → poor index selectivity. using_temporary_table: true + using_filesort: true combined → severe memory/disk temp table overhead.
EXPLAIN FORMAT=JSON
SELECT o.id, SUM(oi.qty * oi.unit_price) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'shipped'
AND o.created_at >= '2026-01-01'
GROUP BY o.id
ORDER BY total DESC
LIMIT 20\G
-- Odaklanılacak alanlar:
-- cost_info.query_cost
-- rows_examined_per_scan
-- used_key_parts
-- using_filesort / using_temporary_tablePartial Index: Indexing Only Hot Working Sets
Targeted partial indexes for soft-deletes and active state queries
Targeting subsets of data with a WHERE clause creates compact partial indexes that reduce storage by up to 90% and lower write amplification. Ideal for soft-deletes, queue status flags, and active-only lookups.
If queries consistently filter by static predicates (status = 'open', deleted_at IS NULL) → use partial indexes. Avoid for dynamic filter conditions.
-- ❌ Tüm satırları indeksle (şişman)
CREATE INDEX idx_orders_status ON orders (status, created_at);
-- ✅ Sadece açık siparişler
CREATE INDEX idx_orders_open_created
ON orders (created_at DESC)
WHERE status = 'open';
-- Soft-delete pattern
CREATE INDEX idx_users_active_email
ON users (email)
WHERE deleted_at IS NULL;
-- Sorgu partial index'i kullanır:
SELECT * FROM orders
WHERE status = 'open'
AND created_at >= NOW() - INTERVAL '7 days';Unused & Redundant Indexes: The Silent Write Tax
Auditing unused and redundant indexes using MySQL sys schema
Every write operation incurs maintenance overhead across all secondary indexes. Redundant or unqueried indexes inflate the buffer pool and slow down DML. Use MySQL sys schema to identify candidates for safe deprecation.
Indexes unreferenced in sys.schema_unused_indexes over 30 days → deprecation candidates. Never blindly drop PKs, unique constraints, or foreign key indexes.
-- Kullanılmayan index'ler (MySQL 5.7+ / 8.0 sys schema)
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema NOT IN ('mysql', 'sys', 'performance_schema');
-- Redundant index'ler
SELECT * FROM sys.schema_redundant_indexes
WHERE table_schema = 'app_production';
-- Güvenli kaldırma (önce staging'de dene)
-- ALTER TABLE orders DROP INDEX idx_orders_old_status;PgBouncer: 500 Clients ≠ 500 Postgres Backends
Preventing memory spikes and context switching with transaction pooling
Every native PostgreSQL backend process consumes 5-10MB RAM. 500 direct client connections create massive CPU context-switching overhead and OOM risk. PgBouncer in transaction pooling mode multiplexes hundreds of apps into dozens of dedicated server connections.
OLTP → pool_mode = transaction · Session-heavy/prepared statements → session mode · Set default_pool_size between (CPU × 2) and (CPU × 4).
# pgbouncer.ini
[databases]
app_db = host=127.0.0.1 port=5432 dbname=app_db
[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 40
min_pool_size = 10
reserve_pool_size = 10
server_reset_query = DISCARD ALL
ignore_startup_parameters = extra_float_digits-- PostgreSQL tarafı: gerçek bağlantı tavanı
-- postgresql.conf
max_connections = 100 -- pool arkasında düşük tutDurability vs Throughput: The fsync Trade-off
Redo log flush frequency and ACID durability trade-offs
Fsyncing redo logs on every commit (1) provides strict ACID compliance at the cost of disk latency. Value 2 buffers commits in OS cache with once-per-second flushes, risking <=1s loss on hard power failure. Value 0 is highest risk.
Financial/orders/ledger workloads → strictly 1. High-throughput logs, telemetry, analytics → 2. Never deploy 0 in production.
# my.cnf
[mysqld]
# Full durability (varsayılan, önerilen)
innodb_flush_log_at_trx_commit = 1
sync_binlog = 1
# Yüksek yazma, 1 sn kayıp tolere edilebilir
# innodb_flush_log_at_trx_commit = 2
# sync_binlog = 1-- Runtime kontrol
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';BRIN: Lightweight Indexing for Billions of Rows
Block Range Indexing for massive append-only time-series datasets
Standard B-Trees consume gigabytes of storage on massive append-only time-series tables. BRIN (Block Range Index) stores summary min/max ranges per disk block group, occupying only megabytes with minimal write impact.
Table > 50-100GB AND column physically correlates with storage order (timestamp, serial ID) → use BRIN. Ineffective for high random distributions (UUIDs).
-- Zaman serisi olay tablosu
CREATE TABLE events (
id bigserial,
created_at timestamptz NOT NULL DEFAULT now(),
payload jsonb
) PARTITION BY RANGE (created_at);
-- BRIN: pages_per_range ayarı trade-off
CREATE INDEX idx_events_created_brin
ON events USING BRIN (created_at)
WITH (pages_per_range = 32);
-- Karşılaştırma
SELECT
indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'events';Single INSERT Bottlenecks: Unlocking Bulk Ingestion
Multi-row batched statements and fast LOAD DATA INFILE pipelines
Executing individual INSERT statements triggers repetitive transaction commits, redo log fsyncs, and B-Tree rebalances. Batch multi-row statements and LOAD DATA INFILE accelerate bulk ingestion by 10-50x.
Batch 100-1000 rows per statement instead of single rows. Use explicit batch transactions instead of per-statement autocommit.
-- ❌ Yavaş
INSERT INTO events (user_id, event_type) VALUES (1, 'click');
INSERT INTO events (user_id, event_type) VALUES (2, 'view');
-- ... 10.000 kez
-- ✅ Multi-value batch
INSERT INTO events (user_id, event_type, created_at) VALUES
(1, 'click', NOW()),
(2, 'view', NOW()),
(3, 'click', NOW());
-- 500–1000 satır/statement ideal
-- ✅ Dosyadan bulk
LOAD DATA LOCAL INFILE '/tmp/events.csv'
INTO TABLE events
FIELDS TERMINATED BY ',' ENCLOSED BY '"'
LINES TERMINATED BY '\n'
(user_id, event_type, @created)
SET created_at = STR_TO_DATE(@created, '%Y-%m-%d %H:%i:%s');# mysql client ile
mysql --local-infile=1 -e "LOAD DATA LOCAL INFILE ..."Who Blocks Whom? PostgreSQL Lock Tree Diagnostics
Diagnosing blocker-blocked query trees and idle-in-transaction locks
Apparent query slowdowns are often caused by lock contention rather than CPU/disk bottlenecks. Cross-referencing pg_locks with pg_stat_activity reveals root blocking transactions and idle-in-transaction connection leaks.
idle in transaction > 30s → application bug or connection leak. Attempt pg_cancel_backend first before resorting to forceful pg_terminate_backend.
-- Blocker → blocked ağacı
SELECT
blocked.pid AS blocked_pid,
blocked.usename AS blocked_user,
left(blocked.query, 60) AS blocked_query,
blocking.pid AS blocking_pid,
blocking.usename AS blocking_user,
left(blocking.query, 60) AS blocking_query,
blocked.wait_event_type,
blocked.wait_event,
now() - blocked.query_start AS blocked_duration
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid AND NOT bl.granted
JOIN pg_locks bk ON bk.locktype = bl.locktype
AND bk.database IS NOT DISTINCT FROM bl.database
AND bk.relation IS NOT DISTINCT FROM bl.relation
AND bk.page IS NOT DISTINCT FROM bl.page
AND bk.tuple IS NOT DISTINCT FROM bl.tuple
AND bk.virtualxid IS NOT DISTINCT FROM bl.virtualxid
AND bk.transactionid IS NOT DISTINCT FROM bl.transactionid
AND bk.classid IS NOT DISTINCT FROM bl.classid
AND bk.objid IS NOT DISTINCT FROM bl.objid
AND bk.objsubid IS NOT DISTINCT FROM bl.objsubid
AND bk.granted
JOIN pg_stat_activity blocking ON blocking.pid = bk.pid
WHERE blocked.pid <> blocking.pid;
-- Nazik iptal / sert sonlandırma
-- SELECT pg_cancel_backend(12345);
-- SELECT pg_terminate_backend(12345);Gap Lock Storms: Mitigating MySQL Deadlocks
Suppressing gap locks and deadlock storms via READ COMMITTED isolation
InnoDB default REPEATABLE READ takes gap locks that frequently collide under concurrent write traffic. In most OLTP architectures, switching to READ COMMITTED suppresses gap locking and eliminates the vast majority of deadlocks.
If deadlocks occur across index ranges, verify query ordering, then evaluate READ COMMITTED. Regularly inspect LATEST DETECTED DEADLOCK in SHOW ENGINE INNODB STATUS.
-- Mevcut isolation
SELECT @@transaction_isolation;
-- Session bazlı (önerilen ilk adım)
SET SESSION transaction_isolation = 'READ COMMITTED';
-- Global (my.cnf kalıcı)
-- [mysqld]
-- transaction_isolation = READ-COMMITTED
-- Son deadlock'u incele
SHOW ENGINE INNODB STATUS\G
-- "LATEST DETECTED DEADLOCK" bölümüne bak# my.cnf — ek gözlem
[mysqld]
innodb_print_all_deadlocks = 1
innodb_deadlock_detect = ONTuning PostgreSQL I/O Costs for SSDs & NVMe
random_page_cost and parallel bitmap scan sizing for modern NVMe drives
Default effective_io_concurrency = 1 and random_page_cost = 4.0 are calibrated for legacy spinning HDDs. On modern SSDs and NVMe drives, tuning random_page_cost closer to seq_page_cost encourages optimal index utilization.
HDD → keep defaults. SATA SSD → random_page_cost = 1.5, effective_io_concurrency = 200. NVMe → random_page_cost = 1.1, effective_io_concurrency = 300-500.
# postgresql.conf — NVMe production örneği
random_page_cost = 1.1
seq_page_cost = 1.0
effective_io_concurrency = 400
maintenance_io_concurrency = 400
# Tablespace bazlı override da mümkün:
# ALTER TABLESPACE fast_ssd SET (random_page_cost = 1.1);SELECT *: Eliminating Redundant Payload Overheads
Network payload bloat, TOAST overhead and lost covering index benefits
SELECT * nullifies covering index opportunities, inflates network payload, and forces unnecessary TOAST/LOB disk lookups. Explicit column lists reduce serialization overhead and memory footprint.
Never fetch columns absent from the API response payload. Always combine explicit projection lists with indexed pagination.
-- ❌
SELECT * FROM products WHERE category_id = 42;
-- ✅
SELECT id, name, price, slug
FROM products
WHERE category_id = 42
ORDER BY name
LIMIT 50 OFFSET 0;
-- PostgreSQL: geniş TOAST kolonunu bilinçli hariç tut
SELECT id, title, created_at
FROM articles
WHERE status = 'published';
-- body / html_content çekilmedi → TOAST I/O yokTempDB: Eliminating PAGELATCH_UP Contention
PFS/SGAM allocation page contention & multiple equal data files
High temp table creation and teardown rates cause severe PFS/GAM allocation bottlenecks when TempDB uses a single file. Splitting TempDB into multiple equal data files matches CPU core counts and prevents thread stalls.
<=8 cores → 1 equal TempDB file per logical core; >8 cores → start with 8 files and scale in increments of 4.
-- TempDB Dosyaları Eşit Boyutlandırma ve Otomatik Büyüme (MB bazlı)
ALTER DATABASE tempdb MODIFY FILE (NAME = tempdev, SIZE = 8GB, FILEGROWTH = 512MB);
ALTER DATABASE tempdb ADD FILE (NAME = tempdev2, FILENAME = 'D:\MSSQL\DATA\tempdev2.ndf', SIZE = 8GB, FILEGROWTH = 512MB);
ALTER DATABASE tempdb ADD FILE (NAME = tempdev3, FILENAME = 'D:\MSSQL\DATA\tempdev3.ndf', SIZE = 8GB, FILEGROWTH = 512MB);
ALTER DATABASE tempdb ADD FILE (NAME = tempdev4, FILENAME = 'D:\MSSQL\DATA\tempdev4.ndf', SIZE = 8GB, FILEGROWTH = 512MB);