Привет! Меня зовут Геннадий, я Oracle и PostgreSQL DBA в компании Uzum. По пути еще иногда занимаюсь NoSQL, люблю автоматизировать информацию из Ansible и визуализировать нужные мне метрики в Grafana. Хочу поделиться дашбордом для Grafana, который отображает историю активных сессий PG с их текущим SQL, отсортированных по группам ожиданий. Он помогает мне искать проблемы производительности PG-баз, и может пригодиться тем, кто администрирует Postgres.

Дашборд выглядит так:

DBA, которые работают с Oracle, наверняка вспомнили Oracle Enterprise Manager и его визуализацию Active Sessions. Выглядит это так (с небольшими изменениями от версии к версии самого Oracle EM):

Что-то такое мне захотелось сделать и для Postgres. Конечно, подобные решения есть, в основном, в платных системах мониторинга, а вот в бесплатных достаточно удобного и информативного я не нашел. Какие-то варианты описывались на Хабре: раз, два. Спасибо авторам, решения не плохи, просто немного устарели: в одном нет графического интерфейса, а в другом он старый однопользовательский толстый клиент. А мне хотелось получить современный интерфейс именно в Grafana. Ок, берем лучшее из уже имеющегося, заодно прикрутим partitioning к таблице истории и автоматическое удаление старых данных (чтобы наша таблица истории не превратилась в терабайтного монстра, с которым сложно справиться). По версиям — это Postgres 15.3 и Grafana v11.2. Поехали!  

Делаем минимальным набором инструментов самого Postgres — из дополнительных расширений нам понадобится только pg_cron, но его необязательно собирать из исходников, есть и готовые пакеты под популярные дистрибутивы.

Итак, общая идея следующая: мы будем раз в 10 секунд сбрасывать данные по активным (и еще idle in transaction) сессиям из pg_stat_activity в нашу таблицу истории — pg_stat_activity_history, которая по сути та же pg_stat_activity, но с дополнительным полем sample_time (время создания снимка). Таким образом мы получим историю сессий. А потом будем запрашивать из нее в Grafana, группируя по типам ожиданий для построения временных графиков, и, в другом случае, по запросам для отображения статистики по ним снизу. 

Табличка истории у нас будет партиционированной, это позволит эффективно читать и, главное, удалять уже ненужную историю с drop partition без лишней WAL-генерации и загрузки autovacuum. Поверьте, если у вас достаточно нагруженная БД, то истории может быть десяток гигабайтов в день, и если диски у вас не резиновые, это вам очень пригодится.

Начнем с  создания партиционированной таблицы для хранения истории и автоматического создания и удаления партиций в ней (для этого можно использовать pg_partman, но так как у нас pg_cron и так везде используется, и де-факто стандарт — обходимся им). Итак, пишем код создания и удаления старых партиций для заданий, которые потом запланируем в pg_cron:

Код
CREATE SCHEMA adm AUTHORIZATION postgres;

CREATE OR REPLACE FUNCTION adm.create_day_partition(t_name character varying, s_name character varying)
 RETURNS void AS 
$$
DECLARE
    sql_query TEXT;
    P_NAME VARCHAR(255);
    N INTEGER := 0;
    t_owner  VARCHAR(255);
begin

    select t.tableowner into t_owner from pg_tables t where t.tablename  = t_name and t.schemaname  = s_name ;

    while N <= 10
    loop
        P_NAME :=  T_NAME|| replace(cast(CURRENT_DATE + N as varchar ),'-','_');

      IF NOT EXISTS (select 1 as f1 from pg_tables t
        where t.tablename  =  P_NAME and t.schemaname = S_NAME ) then

       sql_query := 'CREATE TABLE  ' || S_NAME || '.' || P_NAME || ' PARTITION OF ' || S_NAME || '.' || T_NAME || ' FOR VALUES FROM ('''
        || CURRENT_DATE + N || ' 00:00:00'') TO (''' || CURRENT_DATE + N + 1 || ' 00:00:00'')' ;
        EXECUTE sql_query ;

        sql_query := 'ALTER TABLE ' || S_NAME || '.' || P_NAME || ' OWNER TO ' || t_owner;
        EXECUTE sql_query ;

       END IF;
    N := N+1 ;
   end loop;
--   commit;
END $$ LANGUAGE plpgsql;

CREATE OR REPLACE FUNCTION adm.drop_old_day_partition ( T_NAME VARCHAR(240) -- table name
    ,S_NAME VARCHAR(100) -- schema name
    ,days_ago integer  -- delete partitions older than N days
    )
  RETURNS void AS
$$
DECLARE
    v_parent_rec RECORD;
begin
    FOR v_parent_rec IN (
            select q.* , 'DROP TABLE ' || q.part_name as sql_query
                from (
                  SELECT cast( inhrelid::regclass as varchar) AS part_name
                    ,to_date (  replace(  cast( inhrelid::regclass as varchar)
                             ,case when ( S_NAME = 'public' or S_NAME = '' ) then ''
                                   else S_NAME || '.'
                                   end ||
                                   T_NAME,'') ,'yyyy_mm_dd') as dt                                   
                  FROM   pg_catalog.pg_inherits i
                  WHERE  inhparent = ( S_NAME || '.' || T_NAME )::regclass ) q
                where q.dt < current_date - days_ago  )
    LOOP
        EXECUTE v_parent_rec.sql_query ;
    END LOOP;
END $$ LANGUAGE plpgsql;

Функция создает по 10 партиций за раз, на следующие 10 дней. Если какие-то уже созданы, то функция просто создаст недостающие. Можно создать хоть на год вперед, просто укажите дни в строке while N <= 10.

Теперь создадим таблицу хранения истории и ее партиции: 

Код
CREATE TABLE adm.pg_stat_activity_history (
        sample_time timestamptz ,
        datid oid NULL,
        pid int4 NULL,
        leader_pid int4 NULL,
        usesysid oid NULL,
        application_name text NULL,
        client_addr inet NULL,
        client_hostname text NULL,
        client_port int4 NULL,
        backend_start timestamptz NULL,
        xact_start timestamptz NULL,
        state_change timestamptz NULL,
        wait_event_type text NULL,
        wait_event text NULL,
        state text NULL,
        backend_xid xid NULL,
        backend_xmin xid NULL,
        query_id int8 NULL,
        query text null,
        query_start timestamptz NULL,
        duration numeric NULL
) PARTITION BY RANGE (sample_time);

CREATE INDEX pg_stat_act_h_idx_stime ON adm.pg_stat_activity_history USING btree (sample_time);


INSERT INTO cron.job (schedule,command,nodename,nodeport,"database",username,active,jobname) VALUES
	 ('20 0 * * *'
	  ,'select adm.create_day_partition(''pg_stat_activity_history'', ''adm'');  commit ;'
	 ,'',5432
	 ,'postgres','postgres',true,'part_awr_hist');
	
INSERT INTO cron.job (schedule,command,nodename,nodeport,"database",username,active,jobname) VALUES
         ('30 0 * * *'
          ,'select adm.drop_old_day_partition(''pg_stat_activity_history'', ''adm'', 14);  commit ;'
         ,'',5432
         ,'postgres','postgres',true,'del_part_awr_hist');
		 
select adm.create_day_partition('pg_stat_activity_history', 'adm');

CREATE OR REPLACE FUNCTION adm.pg_stat_activity_snapshot()
 RETURNS void AS 
$$
DECLARE
start_ts timestamp := (select clock_timestamp());
ldiff numeric := 0;
BEGIN
        WHILE ldiff < 60
        LOOP
insert
        into adm.pg_stat_activity_history
select
        clock_timestamp() as sample_time,
        datid ,
        pid ,
        leader_pid ,
        usesysid ,
        application_name ,
        client_addr inet ,
        client_hostname ,
        client_port ,
        backend_start ,
        xact_start ,
        state_change ,
        wait_event_type ,
        wait_event ,
        state ,
        backend_xid ,
        backend_xmin ,
        query_id ,
        query ,
        query_start ,
        1000 * extract(EPOCH from (clock_timestamp()-query_start)) as duration -- milliseconds (1/1000sec)
 from pg_stat_activity
 where state <> 'idle' and usename <> 'replicator'
        and pid != pg_backend_pid();

        perform pg_stat_clear_snapshot();
        perform pg_sleep(10);
        ldiff := EXTRACT (EPOCH FROM (clock_timestamp() - start_ts));

        END LOOP;
END $$ LANGUAGE plpgsql;


INSERT INTO cron.job (schedule, command, nodename, nodeport, database, username) values
('* * * * *', 'SELECT adm.pg_stat_activity_snapshot();', '', 5432, 'postgres', 'postgres');
		 
COMMIT;	

Не забывайте, что pg_cron запускает процесс локально и ему нужно авторизоваться, то есть требуется разрешение на trust connect в pg_hba. Итак, история собирается, партиции автоматически добавляются и удаляются. В принципе, это уже немало: табличная история наиболее полезна, когда вы будете раскапывать и анализировать конкретный проблемный момент. Но чтобы легко находить такие моменты, нам и нужна Grafana.

Весь экспериментальный путь настройки дашборда выкладывать смысла нет, мой финальный вариант выглядит так: 

Каждый график отображает количество сессий с соответствующим типом ожидания, при этом они нарастающие. Точка отсчета каждого графика не 0, а значение предыдущего, то есть в пике мы получаем суммарное количество активных сессий. 

Колонка avg_activity в нижней таблице — это среднее количество активных сессий с конкретным запросом в выбранный промежуток времени. Чтобы объяснить, как рассчитывается значение, приведу пример: если у вас была одна активная сессия продолжительностью полчаса, то при выбранном промежутке в час avg_activity будет 0,5; или если у вас было 10 активных сессий в течение 6 минут в диапазоне одного часа, то avg_activity будет 1. 

Просадки и всплески сразу хорошо видны. Правда, из графика мы видим только сами запросы, их время и их группы ожиданий. А если нам нужны подробности кто и откуда, то идем уже непосредственно в БД, в pg_stat_activity_history, благо нужные данные у нас уже есть.

Оптимизируем запросы, исправим долгие транзакции, и снова у нас «штиль». Ну, почти ?: 

В верхнем выпадающем списке можно фильтровать активные сессии по отдельным wait types, внизу данные по SQL-запросам по этим ожиданиям фильтруются сразу по выбранным типам ожиданий:

Примечание: когда вы фильтруете, выбирая конкретные цвета линий — «типов ожиданий» — в списке справа от графиков, то данные по SQL-запросам снизу не фильтруются. Это Grafana так работает, не перезапрашивает список SQL.

Ну и главное: файл для импорта дашборда можно взять здесь (там и все приведенные выше скрипты). 

Не забудьте добавить в Grafana в качестве datasource свою Postgres-БД с таблицей pg_stat_activity_history и указать его при импорте дашборда.

Вот и всё. Теперь для анализа проблем не сложно окинуть взглядом «сверху» активности в вашей БД в Grafana и раскопать подробности в конкретный момент времени в таблице истории сессий pg_stat_activity_history

Успешного вам troubleshooting!

Комментарии (0)