Database Monitoring & Observability
Detect issues before users do: metrics, logs, and alerts
You Can't Fix What You Can't Measure
Production databases fail in unpredictable ways: queries slow down, connections max out, disk fills up. Monitoring transforms invisible problems into visible metrics, enabling proactive fixes before outages occur. This lesson covers the essential metrics, tools, and alerting strategies for production database operations.
- GitHub (2018): Automated failover promoted a behind replica during a network partition; better lag monitoring in the failover logic could have prevented a 24hr outage
- AWS RDS: Provides dozens of CloudWatch metrics plus Performance Insights and Enhanced Monitoring; Amazon knows monitoring prevents outages
- GitLab: Lost 6 hours of production data, lacked monitoring to detect replication failure
Essential Database Metrics
The four golden signals of database monitoring: latency, traffic, errors, and saturation. Monitor these metrics to detect problems before they impact users.
1. Query Performance Metrics
| Metric | Description | Healthy Range | Alert Threshold |
|---|---|---|---|
| Average Query Time | Mean execution time across all queries | <10ms | >50ms (degraded), >100ms (critical) |
| P95 Query Time | 95th percentile (slow query threshold) | <50ms | >200ms (degraded), >500ms (critical) |
| P99 Query Time | 99th percentile (worst case latency) | <100ms | >500ms (degraded), >1000ms (critical) |
| Queries Per Second | Total query throughput | Application-specific | Sudden drop >30% or spike >200% |
| Slow Query Count | Queries exceeding slow query threshold | <1% of total | >5% (degraded), >10% (critical) |
2. Connection Metrics
| Metric | Description | Healthy Range | Alert Threshold |
|---|---|---|---|
| Active Connections | Currently executing queries | <50% of max | >70% (degraded), >90% (critical) |
| Idle Connections | Connected but not executing | <20% of max | >50% (connection leak suspected) |
| Connection Wait Time | Time waiting for available connection | 0ms (no wait) | >10ms (pool exhausted) |
| Failed Connections | Connection attempts that failed | 0 per minute | >5/min (degraded), >20/min (critical) |
3. Cache & Buffer Metrics
| Metric | Description | Healthy Range | Alert Threshold |
|---|---|---|---|
| Cache Hit Ratio | % of reads served from cache | >99% | <95% (degraded), <90% (critical) |
| Buffer Pool Hit Rate | % of pages found in shared buffers | >99% | <95% (increase shared_buffers) |
| Index Hit Rate | % of index lookups satisfied by cache | >99% | <95% (missing indexes or cold cache) |
4. Lock & Contention Metrics
| Metric | Description | Healthy Range | Alert Threshold |
|---|---|---|---|
| Deadlocks | Transactions aborted due to deadlock | 0 per hour | >1/hour (degraded), >10/hour (critical) |
| Lock Wait Time | Time transactions spend waiting for locks | <10ms | >100ms (contention), >1000ms (critical) |
| Long-Running Queries | Queries running >60 seconds | 0 | >5 queries (likely holding locks) |
5. Resource Utilization
| Metric | Description | Healthy Range | Alert Threshold |
|---|---|---|---|
| CPU Utilization | Processor usage by database | <70% | >80% (degraded), >95% (critical) |
| Memory Usage | RAM consumed by database | <80% | >90% (degraded), >95% (critical) |
| Disk I/O Wait | % time CPU waiting for disk | <10% | >30% (slow disk), >50% (critical) |
| Disk Space | Available storage | >30% free | <20% (warning), <10% (critical) |
| Replication Lag | Seconds replica is behind master | <1 second | >5s (degraded), >30s (critical) |
Query Performance Monitoring
Identify slow queries in real-time using PostgreSQL's statistics collector and pg_stat_statements extension.
Enable pg_stat_statements Extension
-- 1. Add to postgresql.conf shared_preload_libraries = 'pg_stat_statements, auto_explain' -- 2. Restart PostgreSQL sudo systemctl restart postgresql -- 3. Create extension in your database CREATE EXTENSION pg_stat_statements; -- Result: PostgreSQL now tracks query statistics (execution time, call count, etc.)
pg_stat_statements tracks aggregate statistics for all queries. Minimal overhead (<5%), essential for production monitoring.Finding Slowest Queries
-- Top 10 slowest queries by total execution time
SELECT
query,
calls,
total_exec_time,
mean_exec_time,
max_exec_time,
stddev_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
-- Example output:
-- query | calls | total_exec_time | mean_exec_time | max_exec_time
-- -----------------------------------------|-------|-----------------|----------------|---------------
-- SELECT * FROM orders WHERE user_id = $1 | 50000 | 125000ms | 2.5ms | 500ms
-- UPDATE products SET stock = stock - $1 | 25000 | 75000ms | 3.0ms | 1200ms
-- SELECT COUNT(*) FROM users | 1000 | 50000ms | 50ms | 150msPython Script: Monitor Query Performance
import psycopg2
import time
def monitor_slow_queries(threshold_ms=100):
"""Continuously monitor and alert on slow queries."""
conn = psycopg2.connect("dbname=myapp user=postgres")
cur = conn.cursor()
while True:
cur.execute("""
SELECT
pid,
now() - query_start AS duration,
state,
query
FROM pg_stat_activity
WHERE state != 'idle'
AND (now() - query_start) > interval '%s milliseconds'
ORDER BY duration DESC
""" % threshold_ms)
slow_queries = cur.fetchall()
if slow_queries:
print(f"⚠️ Found {len(slow_queries)} slow queries:")
for pid, duration, state, query in slow_queries:
print(f" PID {pid}: {duration} - {query[:100]}...")
else:
print("✓ All queries under threshold")
time.sleep(5) # Check every 5 secondsSlow Query Logs & Analysis
Slow query logs capture queries exceeding a time threshold. Essential for identifying performance regressions after deployments.
Configure PostgreSQL Slow Query Logging
-- Edit postgresql.conf # Log queries slower than 100ms log_min_duration_statement = 100 # Include query execution plan for slow queries auto_explain.log_min_duration = 100 auto_explain.log_analyze = true # Log destination log_destination = 'csvlog' logging_collector = on log_directory = 'log' log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log' # Restart PostgreSQL sudo systemctl restart postgresql # Result: Slow queries written to log files in CSV format
Analyzing Slow Query Logs with Python
import csv
import re
from collections import defaultdict
def analyze_slow_query_log(log_file):
"""Parse and analyze PostgreSQL slow query log."""
query_stats = defaultdict(lambda: {
'count': 0,
'total_time': 0,
'max_time': 0
})
with open(log_file, 'r') as f:
reader = csv.DictReader(f)
for row in reader:
if row.get('message', '').startswith('duration:'):
# Extract duration
duration_match = re.search(r'duration: ([d.]+) ms', row['message'])
if not duration_match:
continue
duration = float(duration_match.group(1))
# Extract query (normalize parameters)
query_match = re.search(r'statement: (.+)', row['message'])
if not query_match:
continue query = query_match.group(1)
# Normalize: replace numbers/strings with placeholders
normalized = re.sub(r"'[^']*'", "'?'", query)
normalized = re.sub(r'd+', '?', normalized)
# Update statistics
stats = query_stats[normalized]
stats['count'] += 1
stats['total_time'] += duration
stats['max_time'] = max(stats['max_time'], duration)
# Sort by total time
sorted_queries = sorted(
query_stats.items(),
key=lambda x: x[1]['total_time'],
reverse=True
)
# Print report
print("Top 10 Slow Query Patterns:")
for query, stats in sorted_queries[:10]:
avg_time = stats['total_time'] / stats['count']
print(f"
Query: {query[:100]}...")
print(f" Count: {stats['count']}")
print(f" Total: {stats['total_time']:.1f}ms")
print(f" Avg: {avg_time:.1f}ms")
print(f" Max: {stats['max_time']:.1f}ms")Database Monitoring Tools
Specialized tools provide dashboards, alerting, and historical analysis beyond what raw SQL queries offer.
pgAdmin
Web-based GUI for PostgreSQL administration and monitoring.
- Dashboard with server metrics
- Active query monitoring
- Lock viewer (see blocking queries)
- Visual EXPLAIN plans
- Database object statistics
DataGrip
JetBrains IDE for database development with built-in monitoring.
- Query console with execution plans
- Live table data viewer
- Schema comparison
- SQL autocomplete and refactoring
- Local query history
Prometheus + Grafana
Time-series metrics collection and visualization for production monitoring.
- Time-series metrics storage
- Beautiful dashboards (Grafana)
- Alerting rules and notifications
- Historical trend analysis
- Multi-database fleet monitoring
Managed Service Tools
Cloud providers offer built-in monitoring for managed databases.
- AWS RDS Performance Insights
- Google Cloud SQL Monitoring
- Azure Database Insights
- Heroku Postgres Metrics
Setting Up Prometheus + Grafana
# 1. Install postgres_exporter (exposes PostgreSQL metrics to Prometheus)
docker run -d \
--name postgres-exporter \
-p 9187:9187 \
-e DATA_SOURCE_NAME="postgresql://postgres:password@localhost:5432/postgres?sslmode=disable" \
prometheuscommunity/postgres-exporter
# 2. Configure Prometheus to scrape metrics (prometheus.yml)
scrape_configs:
- job_name: 'postgresql'
static_configs:
- targets: ['localhost:9187']
# 3. Start Prometheus
docker run -d \
--name prometheus \
-p 9090:9090 \
-v /path/to/prometheus.yml:/etc/prometheus/prometheus.yml \
prom/prometheus# 4. Start Grafana docker run -d \ --name grafana \ -p 3000:3000 \ grafana/grafana # 5. Open Grafana: http://localhost:3000 (admin/admin) # 6. Add Prometheus data source: http://prometheus:9090 # 7. Import PostgreSQL dashboard (ID: 9628) # Result: Full-featured monitoring dashboard with: # - QPS, connection count, cache hit rate # - Query duration percentiles (P50, P95, P99) # - Replication lag, deadlocks, locks # - CPU, memory, disk I/O # - 30-day historical data retention
Alerting Strategies
Alerts wake engineers when thresholds are breached. Good alerts detect real problems; bad alerts cause alert fatigue and ignored warnings.
Alert Severity Levels
Info
FYI notifications, no immediate action needed.
Examples:
- Cache hit rate 95-97%
- Replication lag 5-10s
- Disk space 20-30% free
Action: Monitor, schedule
optimization
Notification: Email, SlackWarning
Degraded performance, investigate within hours.
Examples:
- Cache hit rate <95%
- Replication lag >30s
- Connections >70%
- Disk space <20%
Action: Investigate today,
plan fix
Notification: Slack, PagerDutyCritical
Service impacting, wake someone immediately.
Examples: - Database unreachable - Connections >95% (maxed) - Disk space <10% - Replication stopped Action: Fix NOW (wake on-call) Notification: PagerDuty, phone
Prometheus Alert Rules
# alert_rules.yml - Define alerting thresholds
groups:
- name: postgresql_alerts
interval: 30s
rules:
# High connection usage
- alert: PostgreSQLConnectionsHigh
expr: pg_stat_database_numbackends / pg_settings_max_connections > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "PostgreSQL connections high ({{ $value | humanizePercentage }})"
description: "Database {{ $labels.datname }} using >80% of max connections"
# Low cache hit ratio
- alert: PostgreSQLCacheHitRateLow
expr: rate(pg_stat_database_blks_hit[5m]) /
(rate(pg_stat_database_blks_hit[5m]) + rate(pg_stat_database_blks_read[5m])) < 0.95
for: 10m
labels:
severity: warning
annotations:
summary: "Cache hit ratio low ({{ $value | humanizePercentage }})"
description: "Database {{ $labels.datname }} cache hit rate <95%" # Replication lag
- alert: PostgreSQLReplicationLag
expr: pg_replication_lag > 30
for: 2m
labels:
severity: critical
annotations:
summary: "Replication lag high ({{ $value }}s)"
description: "Replica {{ $labels.instance }} is {{ $value }}s behind master"
# Deadlocks detected
- alert: PostgreSQLDeadlocks
expr: rate(pg_stat_database_deadlocks[5m]) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "Deadlocks detected ({{ $value }}/sec)"
description: "Database {{ $labels.datname }} experiencing deadlocks"
# Load rules in Prometheus
prometheus --config.file=prometheus.yml --rules.file=alert_rules.ymlfor clause), alert fires. Sent to Alertmanager for routing to Slack, PagerDuty, email, etc.Python Script: Custom Alerting
import psycopg2
import requests
import time
SLACK_WEBHOOK = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
def check_database_health():
"""Monitor database and send alerts."""
conn = psycopg2.connect("dbname=myapp user=postgres")
cur = conn.cursor()
# Check 1: Connection usage
cur.execute("""
SELECT count(*) AS active,
current_setting('max_connections')::int AS max_conn
FROM pg_stat_activity
WHERE state != 'idle'
""")
active, max_conn = cur.fetchone()
usage_pct = (active / max_conn) * 100
if usage_pct > 80:
send_alert(
f"⚠️ High connection usage: {active}/{max_conn} ({usage_pct:.1f}%)",
severity="warning"
) # Check 2: Cache hit ratio
cur.execute("""
SELECT
sum(blks_hit) / (sum(blks_hit) + sum(blks_read)) AS cache_hit_ratio
FROM pg_stat_database
WHERE datname = current_database()
""")
cache_hit_ratio = cur.fetchone()[0]
if cache_hit_ratio < 0.95:
send_alert(
f"⚠️ Low cache hit ratio: {cache_hit_ratio:.2%}",
severity="warning"
)
# Check 3: Long-running queries
cur.execute("""
SELECT count(*)
FROM pg_stat_activity
WHERE state = 'active'
AND (now() - query_start) > interval '60 seconds'
""")
long_queries = cur.fetchone()[0]
if long_queries > 5:
send_alert(
f"🚨 {long_queries} queries running >60 seconds",
severity="critical"
)
def send_alert(message, severity="warning"):
"""Send alert to Slack."""
color = {"info": "#36a64f", "warning": "#ff9900", "critical": "#ff0000"}
requests.post(SLACK_WEBHOOK, json={
"attachments": [{
"color": color[severity],
"text": message
}]
})
# Run every 60 seconds
while True:
check_database_health()
time.sleep(60)Monitoring Best Practices
Do This
- Monitor the four golden signals: latency, traffic, errors, saturation
- Set up alerts before production: don't wait for the first outage
- Use percentiles (P95, P99) not averages for latency
- Enable pg_stat_statements in production (minimal overhead)
- Log slow queries (>100ms) with execution plans
- Monitor replication lag continuously (alert at >5s)
- Set up dashboards for on-call engineers (Grafana)
- Review slow query logs weekly to catch regressions
Avoid This
- Don't alert on everything (alert fatigue causes ignored warnings)
- Don't use average latency (hides outliers, use P95/P99)
- Don't ignore disk space (full disk causes database crash)
- Don't set thresholds too tight (alert at 80%+ connections; alerting at 50% causes unnecessary noise)
- Don't monitor only production (staging needs monitoring too)
- Don't forget to test alerts (simulate failures regularly)
- Don't rely on database-only metrics (monitor application latency too)
- Don't skip historical data (retention <7 days insufficient for analysis)
Production Database Monitoring Checklist
Before Going to Production
✓ Query Performance
- Enable
pg_stat_statementsextension - Configure slow query logging (threshold: 100ms)
- Set up dashboard showing P50/P95/P99 query latency
- Alert on P99 latency >500ms
✓ Connections
- Set
max_connectionsappropriately (start with 100) - Configure connection pooling (PgBouncer)
- Monitor active vs idle connections
- Alert on >80% connection usage
✓ Caching
- Monitor cache hit ratio (target >99%)
- Alert if cache hit ratio <95%
- Tune
shared_buffers(25% of RAM) - Monitor buffer pool evictions
✓ Replication
- Monitor replication lag continuously
- Alert on lag >5 seconds
- Critical alert on replication stopped
- Test failover procedure
✓ Resources
- Monitor CPU, memory, disk I/O
- Alert on disk space <20% free
- Track disk I/O wait times
- Set up log rotation (prevent disk fill)
✓ Locks & Deadlocks
- Monitor deadlock count (target: 0)
- Track lock wait times
- Alert on long-running queries (>60s)
- Dashboard showing blocking queries