Tag

#performance

PostgreSQL: diagnostic queries

Same thing as for MS SQL, on the Postgres side. Written against PG 17 — I flag what breaks on older versions as it comes up.

First: pg_stat_statements

Without it, section 2 won’t run. The extension isn’t enabled by default.

In postgresql.conf:

shared_preload_libraries = 'pg_stat_statements'

Needs a restart — pg_reload_conf() won’t do it. Then, in the database:

CREATE EXTENSION pg_stat_statements;

It keeps 5000 distinct queries (pg_stat_statements.max). Resetting the counters:

SELECT pg_stat_statements_reset();

1. Active queries

SELECT
    a.pid,
    a.datname,
    a.usename,
    a.application_name,
    a.client_addr,
    a.state,
    a.wait_event_type,
    a.wait_event,
    now() - a.xact_start AS xact_age,
    now() - a.query_start AS query_age,
    now() - a.state_change AS state_age,
    pg_blocking_pids(a.pid) AS blocked_by,
    a.query
FROM pg_stat_activity a
WHERE a.pid <> pg_backend_pid()
  AND a.backend_type = 'client backend'
  AND a.state IS DISTINCT FROM 'idle'
ORDER BY COALESCE(a.xact_start, a.query_start, a.backend_start) ASC;

pg_blocking_pids tells you who is blocking whom — an empty array means that backend isn’t waiting on anything.

state IS DISTINCT FROM 'idle' drops idle connections but keeps idle in transaction. Those are usually the actual problem.

Killing:

-- Cancels the query, keeps the connection
SELECT pg_cancel_backend(12345);

-- Drops the connection
SELECT pg_terminate_backend(12345);

Try cancel first.

2. Slow queries

SELECT
    s.queryid,
    d.datname,
    s.calls,
    round((s.total_exec_time / 1000.0)::numeric, 1) AS total_sec,
    round(s.mean_exec_time::numeric, 2) AS mean_ms,
    round(s.max_exec_time::numeric, 2) AS max_ms,
    round(s.stddev_exec_time::numeric, 2) AS stddev_ms,
    round((100.0 * s.total_exec_time / NULLIF(sum(s.total_exec_time) OVER (), 0))::numeric, 1) AS pct_of_total_time,
    s.rows,
    round(s.rows::numeric / NULLIF(s.calls, 0), 1) AS rows_per_call,
    round((100.0 * s.shared_blks_hit / NULLIF(s.shared_blks_hit + s.shared_blks_read, 0))::numeric, 1) AS cache_hit_percent,
    pg_size_pretty(s.shared_blks_read * current_setting('block_size')::bigint) AS disk_read,
    pg_size_pretty(s.temp_blks_read * current_setting('block_size')::bigint) AS temp_read,
    pg_size_pretty(s.temp_blks_written * current_setting('block_size')::bigint) AS temp_written,
    pg_size_pretty(s.wal_bytes::bigint) AS wal_generated,
    round(s.shared_blk_read_time::numeric, 1) AS io_read_ms,
    round(s.shared_blk_write_time::numeric, 1) AS io_write_ms,
    s.query
FROM pg_stat_statements s
LEFT JOIN pg_database d ON d.oid = s.dbid
WHERE d.datname = current_database()
ORDER BY s.total_exec_time DESC
LIMIT 50;

Look at pct_of_total_time, not mean_ms.

temp_written above zero = it’s sorting on disk, raise work_mem. Low cache_hit_percent = reading from disk instead of cache. High stddev_ms = the plan falls apart for certain parameters.

3. Indexes

SELECT
    n.nspname AS schema_name,
    t.relname AS table_name,
    i.relname AS index_name,
    am.amname AS index_type,
    ix.indisunique AS is_unique,
    ix.indisprimary AS is_primary_key,
    (ix.indisvalid AND ix.indisready AND ix.indislive) AS is_valid,
    ix.indnkeyatts AS key_columns,
    ix.indnatts - ix.indnkeyatts AS included_columns,
    ix.indpred IS NOT NULL AS is_partial,
    NULLIF(t.reltuples, -1)::bigint AS row_count_estimate,
    round(pg_relation_size(i.oid) / 1024.0 / 1024, 2) AS index_size_mb,
    COALESCE(sui.idx_scan, 0) AS num_of_scans,
    sui.last_idx_scan,
    COALESCE(sui.idx_tup_read, 0) AS index_entries_read,
    COALESCE(sui.idx_tup_fetch, 0) AS heap_rows_fetched,
    COALESCE(sut.n_tup_ins, 0) AS table_inserts,
    COALESCE(sut.n_tup_upd, 0) AS table_updates,
    COALESCE(sut.n_tup_hot_upd, 0) AS hot_updates,
    GREATEST(COALESCE(sut.n_tup_upd, 0) - COALESCE(sut.n_tup_hot_upd, 0), 0) AS non_hot_updates,
    COALESCE(sut.n_tup_del, 0) AS table_deletes,
    sd.stats_reset AS counters_since,
    pg_get_expr(ix.indpred, ix.indrelid) AS predicate,
    pg_get_indexdef(i.oid) AS index_definition
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_class t ON t.oid = ix.indrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
JOIN pg_am am ON am.oid = i.relam
LEFT JOIN pg_stat_user_indexes sui ON sui.indexrelid = i.oid
LEFT JOIN pg_stat_user_tables sut ON sut.relid = t.oid
CROSS JOIN (
    SELECT stats_reset
    FROM pg_stat_database
    WHERE datname = current_database()
) sd
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
  AND t.relkind IN ('r', 'm', 'p')
ORDER BY pg_relation_size(i.oid) DESC;

num_of_scans = 0 is a candidate for dropping — but check counters_since first. That column is there precisely so you don’t drop an index based on a counter that was reset yesterday.

is_valid = false is a failed CREATE INDEX CONCURRENTLY. That index is never used yet still costs you on every write — drop it and rebuild.

non_hot_updates are the updates that had to touch the indexes. A lot of them = high maintenance cost.

last_idx_scan needs PG 16.

4. Dead tuples and stale statistics

SELECT
    s.schemaname,
    s.relname,
    s.n_live_tup,
    s.n_dead_tup,
    round(100.0 * s.n_dead_tup / NULLIF(s.n_live_tup + s.n_dead_tup, 0), 1) AS dead_percent,
    s.n_mod_since_analyze,
    s.n_ins_since_vacuum,
    age(c.relfrozenxid) AS xid_age,
    s.last_vacuum,
    s.last_autovacuum,
    s.last_analyze,
    s.last_autoanalyze,
    pg_size_pretty(pg_table_size(s.relid)) AS table_size,
    c.reloptions
FROM pg_stat_user_tables s
JOIN pg_class c ON c.oid = s.relid
WHERE s.n_dead_tup > 1000
   OR s.n_mod_since_analyze > 1000
ORDER BY dead_percent DESC NULLS LAST;

High dead_percent = autovacuum isn’t keeping up and the table is bloating.

High n_mod_since_analyze = the planner is working off stale statistics and picking bad plans. That usually hurts more than the bloat itself.

xid_age closing in on 200M (autovacuum_freeze_max_age) = wraparound is coming.

5. Sizes

SELECT
    n.nspname AS schema_name,
    c.relname AS table_name,
    NULLIF(c.reltuples, -1)::bigint AS row_count_estimate,
    pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
    pg_size_pretty(pg_table_size(c.oid)) AS table_size,
    pg_size_pretty(pg_relation_size(c.oid)) AS main_fork_size,
    pg_size_pretty(pg_indexes_size(c.oid)) AS indexes_size,
    pg_size_pretty(
        CASE
            WHEN c.reltoastrelid = 0 THEN 0
            ELSE pg_total_relation_size(c.reltoastrelid)
        END
    ) AS toast_size,
    round(100.0 * pg_total_relation_size(c.oid) / NULLIF(pg_database_size(current_database()), 0), 2) AS pct_of_database
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'm')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
ORDER BY pg_total_relation_size(c.oid) DESC;

pg_total_relation_size = table + indexes + TOAST. pg_table_size is the same without indexes, pg_relation_size is just the main fork.

row_count_estimate is an estimate from the last ANALYZE, not a COUNT(*). -1 means “never analyzed”.

Whole databases on the server:

SELECT
    datname,
    pg_size_pretty(pg_database_size(datname)) AS database_size
FROM pg_database
WHERE NOT datistemplate
ORDER BY pg_database_size(datname) DESC;

MS SQL - diagnostic

1. Running queries

SELECT 
	sqltext.TEXT,
	session_id,
	STATUS,
	command,
	cpu_time,
	total_elapsed_time,
	wait_type
FROM sys.dm_exec_requests req
CROSS APPLY sys.dm_exec_sql_text(sql_handle) AS sqltext
WHERE req.session_id <> @@SPID AND req.session_id > 50

Big total_elapsed_time with small cpu_time = the query is waiting, not working. Check wait_type then. Times are in milliseconds.

2. Fragmentation

SELECT
    S.name AS [Schema],
    T.name AS [Table],
    I.name AS [Index],
    DDIPS.index_type_desc AS [IndexType],
    DDIPS.avg_fragmentation_in_percent AS [FragmentationPercent],
    DDIPS.page_count AS [PageCount],
    DDIPS.page_count * 8.0 / 1024 AS [SizeMB]
FROM sys.dm_db_index_physical_stats(
    DB_ID(),
    NULL,
    NULL,
    NULL,
    'LIMITED'
) AS DDIPS 
INNER JOIN sys.tables T ON T.object_id = DDIPS.object_id
INNER JOIN sys.schemas S ON T.schema_id = S.schema_id
INNER JOIN sys.indexes I ON I.object_id = DDIPS.object_id AND I.index_id = DDIPS.index_id
WHERE
    DDIPS.database_id = DB_ID()
    AND T.is_ms_shipped = 0
    AND I.index_id > 0
    AND I.name IS NOT NULL
    AND DDIPS.page_count >= 100
    AND DDIPS.avg_fragmentation_in_percent > 10
ORDER BY
    DDIPS.avg_fragmentation_in_percent DESC;
Fragmentation What to do
below 5% ignore it
5–30% REORGANIZE
above 30% REBUILD

page_count >= 100, because smaller indexes sit in memory anyway — rebuilding them wastes time and log.

Leave it on 'LIMITED'. 'DETAILED' reads every page of the index and can flatten prod.

3. Index sizes

SELECT
    SCHEMA_NAME(t.schema_id) AS [SchemaName],
    t.name AS [TableName],
    i.name AS [IndexName],
    i.type_desc AS [IndexType],
    SUM(ps.used_page_count) * 8.0 / 1024 AS [IndexSizeMB],
    SUM(ps.row_count) AS [RowCount]
FROM sys.dm_db_partition_stats ps
JOIN sys.indexes i ON ps.object_id = i.object_id AND ps.index_id = i.index_id
JOIN sys.tables t ON ps.object_id = t.object_id
WHERE i.index_id > 0
GROUP BY
    t.schema_id, t.name, i.object_id, i.index_id, i.name, i.type_desc
HAVING SUM(ps.used_page_count) * 8.0 / 1024 > 1
ORDER BY [IndexSizeMB] DESC;

* 8.0 / 1024 — a page is 8 KB

index_id > 0 drops heaps. index_id = 1 is the clustered index, i.e. the table data itself — always at the top of the list, nothing to worry about.

4. Indexes and usage

S Tier for debugging

Magnum opus of the ms sql optimizer, I put it last, because only those who took the time to read the previous queries are worthy of it

SELECT
    SCHEMA_NAME(T.schema_id) AS Schema_Name,
    T.name AS Table_Name,
    IX.name AS Index_Name,
    IX.type_desc AS Index_Type,
    IX.is_unique AS IsUnique,
    IX.is_primary_key AS IsPrimaryKey,
    IX.is_unique_constraint AS IsUniqueConstraint,
    SUM(PS.row_count) AS 'RowCount',
    SUM(PS.used_page_count) * 8 AS IndexSizeKB,
    SUM(PS.used_page_count) * 8.0 / 1024 AS IndexSizeMB,
    ISNULL(IXUS.user_seeks, 0) AS NumOfSeeks,
    ISNULL(IXUS.user_scans, 0) AS NumOfScans,
    ISNULL(IXUS.user_lookups, 0) AS NumOfLookups,
    ISNULL(IXUS.user_updates, 0) AS NumOfUpdates,
    IXUS.last_user_seek AS LastSeek,
    IXUS.last_user_scan AS LastScan,
    IXUS.last_user_lookup AS LastLookup,
    IXUS.last_user_update AS LastUpdate
FROM sys.indexes IX
JOIN sys.tables T ON T.object_id = IX.object_id
JOIN sys.dm_db_partition_stats PS ON PS.object_id = IX.object_id AND PS.index_id = IX.index_id
LEFT JOIN sys.dm_db_index_usage_stats IXUS ON IXUS.database_id = DB_ID() AND IXUS.index_id = IX.index_id AND IXUS.object_id = IX.object_id
WHERE T.is_ms_shipped = 0 AND IX.index_id > 0
GROUP BY
    T.schema_id, T.name, IX.name, IX.type_desc, IX.is_unique, IX.is_primary_key, IX.is_unique_constraint, IXUS.user_seeks, IXUS.user_scans,
	IXUS.user_lookups, IXUS.user_updates, IXUS.last_user_seek, IXUS.last_user_scan, IXUS.last_user_lookup, IXUS.last_user_update
ORDER BY IndexSizeMB DESC;

TL;DR:

lots of seeks     → the index is usually pulling its weight
lots of scans     → find out why it is scanning
lots of lookups   → check whether the indexes are covering
lots of updates   → the cost of keeping the index around

In most cases:

Operation Typically What it means
Index Seek 🟢 good SQL Server hits a specific range of the index
Index Scan 🟡 depends reads a large chunk of the index, or all of it
Table Scan / Heap Scan 🟡/🔴 depends reads the table with no useful index
Key/Bookmark Lookup 🟡/🔴 after finding rows in the index it has to fetch the remaining columns from the table/clustered index separately (try INCLUDE)

The big one: sys.dm_db_index_usage_stats resets when the service restarts. Zero reads doesn’t mean “unused” — it may mean “the server came up yesterday”.

Check how long it’s even been counting:

SELECT sqlserver_start_time FROM sys.dm_os_sys_info;
Meme: a relieved man with closed eyes, hands on his face
When you optimized a proc from 28 seconds to 3 seconds by fixing one existing index and adding one new one

Redis: what's eating memory and time

Three commands for finding the biggest keys, the real memory usage, and which command is slowing the server down.

Biggest keys

redis-cli --bigkeys

Walks the whole database with SCAN and shows the biggest key of each type.

Mind the units: for strings it counts bytes, for collections the number of elements. A list with a million short entries will look “bigger” here than a single 10 MB string.

What it actually weighs

redis-cli --memkeys

Same walk, but measured with MEMORY USAGE — real bytes. Slower, but it doesn’t lie. Redis 6 and up.

A single key:

redis-cli MEMORY USAGE mykey

Both walks cover the entire keyspace. On a large database add -i 0.1 so they pause between batches instead of hammering the server.

Command stats

INFO commandstats

How many times each command ran and how long it took on average (usec_per_call). Usually this is where it turns out Redis isn’t slow — somebody is just hammering KEYS in a loop.

Reset the counters before measuring:

CONFIG RESETSTAT

← All tags