Design
Performance Metrics
Key performance indicators and methods to measure database design effectiveness
Performance Metrics Analysis
Measuring performance metrics helps identify design issues that impact query efficiency and overall database health. This guide covers essential metrics and how to measure them.
Key Performance Indicators
Performance Metrics Overview:
┌─────────────────────────────────────────────────────────────┐
│ DATABASE PERFORMANCE │
├─────────────┬─────────────┬─────────────┬──────────────────┤
│ Query │ Index │ Table │ Connection │
│ Metrics │ Metrics │ Metrics │ Metrics │
├─────────────┼─────────────┼─────────────┼──────────────────┤
│ • Exec Time │ • Usage % │ • Size │ • Pool Usage │
│ • Rows Read │ • Hit Ratio │ • Bloat │ • Wait Time │
│ • Scan Type │ • Unused │ • Dead Rows │ • Active Count │
│ • Plan Cost │ • Duplicate │ • Growth │ • Idle Count │
└─────────────┴─────────────┴─────────────┴──────────────────┘Query Performance Metrics
1. Query Execution Time Analysis
PostgreSQL - Enable Query Logging:
-- Enable slow query logging
ALTER SYSTEM SET log_min_duration_statement = 1000; -- Log queries > 1 second
SELECT pg_reload_conf();
-- Find slowest queries using pg_stat_statements
SELECT
ROUND((total_exec_time / 1000)::numeric, 2) as total_seconds,
calls,
ROUND((mean_exec_time)::numeric, 2) as avg_ms,
ROUND((max_exec_time)::numeric, 2) as max_ms,
rows,
SUBSTRING(query, 1, 100) as query_preview
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;MySQL - Slow Query Analysis:
-- Enable slow query log
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
-- Query performance schema
SELECT
DIGEST_TEXT,
COUNT_STAR as exec_count,
ROUND(SUM_TIMER_WAIT/1000000000000, 3) as total_sec,
ROUND(AVG_TIMER_WAIT/1000000000, 3) as avg_ms,
SUM_ROWS_EXAMINED as rows_examined
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 20;2. Query Execution Plan Analysis
Understanding Execution Plans:
-- PostgreSQL EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.created_at > '2024-01-01';
-- Key metrics to watch:
-- 1. Actual Time vs Planning Time
-- 2. Rows (estimated vs actual)
-- 3. Scan type (Seq Scan vs Index Scan)
-- 4. Buffers (shared hit vs read)Execution Plan Warning Signs:
| Warning Sign | Impact | Solution |
|---|---|---|
Seq Scan on large table | Full table read | Add appropriate index |
Nested Loop with high rows | O(n*m) complexity | Consider hash/merge join |
Large rows removed by filter | Inefficient filtering | Move conditions to index |
High shared read vs hit | Poor cache utilization | Increase shared_buffers |
Sort without index | Memory/disk sorting | Add sorted index |
3. Table Scan Frequency
PostgreSQL - Detect Full Table Scans:
-- Tables with high sequential scan ratio
SELECT
schemaname,
relname as table_name,
seq_scan,
idx_scan,
CASE
WHEN (seq_scan + idx_scan) = 0 THEN 0
ELSE ROUND(100.0 * seq_scan / (seq_scan + idx_scan), 2)
END as seq_scan_percentage,
n_live_tup as row_count
FROM pg_stat_user_tables
WHERE n_live_tup > 1000 -- Tables with meaningful data
ORDER BY seq_scan_percentage DESC, seq_scan DESC
LIMIT 20;Good vs Bad Scan Ratios:
| Seq Scan % | Rating | Recommendation |
|---|---|---|
| 0-10% | Excellent | Well-indexed |
| 10-30% | Good | Review occasional full scans |
| 30-50% | Fair | Add indexes for common queries |
| 50%+ | Poor | Urgent index optimization needed |
Index Performance Metrics
1. Index Usage Analysis
PostgreSQL - Index Usage Statistics:
-- Index usage and effectiveness
SELECT
schemaname,
tablename,
indexname,
idx_scan as times_used,
idx_tup_read as tuples_read,
idx_tup_fetch as tuples_fetched,
pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_stat_user_indexes
ORDER BY idx_scan DESC;
-- Unused indexes (candidates for removal)
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) as size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE '%_pkey'
AND schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;2. Index Hit Ratio
PostgreSQL - Cache Hit Ratio:
-- Overall cache hit ratio (should be > 99%)
SELECT
'index hit ratio' as metric,
ROUND(
100.0 * sum(idx_blks_hit) /
NULLIF(sum(idx_blks_hit + idx_blks_read), 0),
2
) as percentage
FROM pg_statio_user_indexes
UNION ALL
SELECT
'table hit ratio',
ROUND(
100.0 * sum(heap_blks_hit) /
NULLIF(sum(heap_blks_hit + heap_blks_read), 0),
2
)
FROM pg_statio_user_tables;Hit Ratio Interpretation:
| Hit Ratio | Interpretation | Action |
|---|---|---|
| > 99% | Excellent | Data fits in memory |
| 95-99% | Good | Monitor for degradation |
| 90-95% | Fair | Consider increasing memory |
| < 90% | Poor | Significant I/O overhead |
3. Duplicate Index Detection
-- Find potentially duplicate indexes
SELECT
pg_size_pretty(sum(pg_relation_size(idx))::bigint) as size,
(array_agg(idx))[1] as idx1,
(array_agg(idx))[2] as idx2,
(array_agg(idx))[3] as idx3
FROM (
SELECT
indexrelid::regclass as idx,
indrelid,
indkey,
coalesce(indexprs::text, ''),
coalesce(indpred::text, '')
FROM pg_index
) sub
GROUP BY indrelid, indkey, coalesce, coalesce
HAVING count(*) > 1
ORDER BY sum(pg_relation_size(idx)) DESC;Table Health Metrics
1. Table Bloat Analysis
PostgreSQL - Estimate Table Bloat:
-- Simplified bloat estimation
SELECT
current_database(),
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) as total_size,
n_dead_tup as dead_rows,
n_live_tup as live_rows,
CASE
WHEN n_live_tup > 0
THEN ROUND(100.0 * n_dead_tup / n_live_tup, 2)
ELSE 0
END as dead_ratio_percent,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_live_tup > 1000
ORDER BY n_dead_tup DESC
LIMIT 20;Bloat Recommendations:
| Dead Ratio | Action |
|---|---|
| < 10% | Normal, autovacuum handling |
| 10-20% | Monitor, may need manual vacuum |
| 20-50% | Run VACUUM, investigate update patterns |
| > 50% | VACUUM FULL or pg_repack recommended |
2. Table Size Growth Tracking
-- Create a size tracking table
CREATE TABLE IF NOT EXISTS db_size_history (
recorded_at TIMESTAMP DEFAULT NOW(),
table_name TEXT,
total_size BIGINT,
row_count BIGINT
);
-- Record current sizes (run periodically)
INSERT INTO db_size_history (table_name, total_size, row_count)
SELECT
schemaname || '.' || relname,
pg_total_relation_size(schemaname || '.' || relname),
n_live_tup
FROM pg_stat_user_tables;
-- Analyze growth trends
SELECT
table_name,
MIN(recorded_at) as first_record,
MAX(recorded_at) as last_record,
MIN(total_size) as initial_size,
MAX(total_size) as current_size,
pg_size_pretty((MAX(total_size) - MIN(total_size))::bigint) as growth
FROM db_size_history
GROUP BY table_name
HAVING MAX(total_size) > MIN(total_size)
ORDER BY (MAX(total_size) - MIN(total_size)) DESC;Connection Metrics
PostgreSQL Connection Analysis
-- Current connection status
SELECT
state,
COUNT(*) as connection_count,
COUNT(*) FILTER (WHERE state = 'active') as active,
COUNT(*) FILTER (WHERE state = 'idle') as idle,
COUNT(*) FILTER (WHERE state = 'idle in transaction') as idle_in_txn
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
GROUP BY state;
-- Long-running queries
SELECT
pid,
now() - pg_stat_activity.query_start as duration,
state,
SUBSTRING(query, 1, 100) as query_preview
FROM pg_stat_activity
WHERE (now() - pg_stat_activity.query_start) > interval '5 minutes'
AND state = 'active';Performance Scoring Dashboard
Comprehensive Performance Assessment Query:
-- Performance Health Score Calculator
WITH metrics AS (
-- Index scan ratio
SELECT
'index_scan_ratio' as metric,
COALESCE(
ROUND(100.0 * SUM(idx_scan) / NULLIF(SUM(idx_scan + seq_scan), 0), 2),
100
) as value
FROM pg_stat_user_tables
UNION ALL
-- Cache hit ratio
SELECT
'cache_hit_ratio',
COALESCE(
ROUND(100.0 * sum(heap_blks_hit) /
NULLIF(sum(heap_blks_hit + heap_blks_read), 0), 2),
100
)
FROM pg_statio_user_tables
UNION ALL
-- Tables with primary keys percentage
SELECT
'tables_with_pk_ratio',
ROUND(100.0 * COUNT(*) FILTER (
WHERE EXISTS (
SELECT 1 FROM information_schema.table_constraints tc
WHERE tc.table_name = t.table_name
AND tc.constraint_type = 'PRIMARY KEY'
)
) / COUNT(*), 2)
FROM information_schema.tables t
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
)
SELECT
metric,
value,
CASE
WHEN value >= 90 THEN 'Excellent'
WHEN value >= 70 THEN 'Good'
WHEN value >= 50 THEN 'Fair'
ELSE 'Poor'
END as rating
FROM metrics;Performance Scoring Summary
| Metric | Weight | Excellent | Good | Fair | Poor |
|---|---|---|---|---|---|
| Index Scan Ratio | 30% | > 90% | 70-90% | 50-70% | < 50% |
| Cache Hit Ratio | 25% | > 99% | 95-99% | 90-95% | < 90% |
| Avg Query Time | 25% | < 100ms | 100-500ms | 500ms-2s | > 2s |
| Table Bloat | 10% | < 10% | 10-20% | 20-50% | > 50% |
| Unused Indexes | 10% | 0 | 1-3 | 4-10 | > 10 |
Monitoring Best Practices
Monitoring Frequency:
┌─────────────────────────────────────────────────────────────┐
│ │
│ Real-time: │
│ └── Active connections, running queries, locks │
│ │
│ Hourly: │
│ └── Slow query log analysis, cache hit ratios │
│ │
│ Daily: │
│ └── Index usage stats, table sizes, bloat levels │
│ │
│ Weekly: │
│ └── Unused indexes, growth trends, capacity planning │
│ │
│ Monthly: │
│ └── Full performance audit, index optimization review │
│ │
└─────────────────────────────────────────────────────────────┘