PostgreSQL Antipatterns: анализируем блокировки — SELF JOIN vs WINDOW

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

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

  • ожидание блокировки
    LOG: process 38162 still waiting for ExclusiveLock on advisory lock [225382138,225386226,141586103,2] after 100.047 ms
  • получение блокировки
    LOG: process 38162 acquired ExclusiveLock on advisory lock [225382138,225386226,141586103,2] after 150.741 ms
  • взаимоблокировка
    ERROR: deadlock detected


deadlock’и исключим из анализа — это просто ошибки, и попробуем выяснить, сколько всего времени мы потеряли из-за блокировок за конкретный день на определенном хосте.
Для начала нам понадобится таблица, куда мы будем собирать все такие записи:

CREATE TABLE lock(
  dt       -- ключ хронологического секционирования
    date
, host     -- сервер, на котором возникла блокировка
    uuid
, pid      -- PID процесса из строки лога
    integer
, ts       -- момент события
    timestamp
, event    -- { lock-wait | lock-acquire | deadlock-detect }
    lockevent
, type     -- { relation | extend | ... }
    locktype
, mode     -- { AccessShare | RowShare | ... }
    lockmode
, lock     -- объект блокировки
    uuid
, exectime -- продолжительность
    numeric(32,2)
);


Более подробно про организацию секционирования в нашей системе мониторинга можно прочитать в статье «Пишем в PostgreSQL на субсветовой: 1 host, 1 day, 1TB», а про различные типы и режимы блокировок — в «DBA: кто скрывается за блокировкой».


Как слышится, так и пишется


Попробуем ответить на вопрос, вынесенный в начало статьи, простейшим способом.

5rzhbyybkg8ukfu1unxl_6drt3y.png

Что такое время ожидания блокировки? Ну, очевидно же, — это время ее получения для каждого случая ее ожидания:

  • берем каждый случай ожидания (lock-wait)
  • для него находим ближайшую «снизу» по времени запись получения (lock-acquire) «этой же» (lock, pid, mode) блокировки — то есть на тот же объект, в том же процессе, с тем же режимом


Тип блокировки (type) в нашем случае можно опустить, поскольку он однозначно определяется самим объектом (lock).

Дальше останется только просуммировать полученные результаты.

SELECT
  ts
, pid
, event
, type
, mode
, lock
, exectime
, T.*
FROM
  lock lc
, LATERAL (
    SELECT
      exectime waittime
    FROM
      lock
    WHERE
      (
        dt
      , host
      , lock
      , pid
      , mode
      , event
      ) = (
        '2020-06-19'::date
      , lc.host
      , lc.lock
      , lc.pid
      , lc.mode
      , 'lock-acquire'
      ) AND
      ts >= lc.ts
    ORDER BY
      ts
    LIMIT 1
  ) T
WHERE
  (
    lc.dt
  , lc.host
  , lc.event
  ) = (
    '2020-06-19'::date
  , 'e828a54d-7e8a-43dd-b213-30c3201a6d8e'::uuid
  , 'lock-wait'::lockevent
  );


Все просто и ясно! А что нам покажет EXPLAIN?…

pmyqtexj_cshi8p-dpmujljzqaw.png

Оу… пришлось прочитать почти 900MB данных, причем почти все — из-за поиска связанной записи для каждой блокировки, не очень красиво.

Но является ли этот запрос вообще корректным для нашей задачи? Нет! Посмотрим внимательно в собранные данные:

tm4o9qycfzpkqyxd2zsao9tbiwm.png

Ой… Оказывается, сервер «жалуется» в логи на одну и ту же неполученную блокировку иногда много-много раз. А это означает, что мы учли время ее ожидания кратно количеству таких записей в логе, что совсем не соответствует желаемому.

Помни о цели!


Собственно, а зачем мы вообще для каждой записи ожидания ищем связанную? Мы же хотим узнать, сколько заняло ожидание, а оно прямо записано в lock-acquire. Так давайте сразу отбирать только их, тогда будет всего лишь один Index Scan — правильно?

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

smk8l78qf7ezfg_lgfu-2d9dxeu.png

Так неужели нет способа за одно чтение сразу получить все нужные нам данные?

Window Functions: семерых одним ударом


На помощь нам придут оконные функции.
2guf6isju2vemqjuqzmmhhpflck.png

А конкретнее — модель выделения «цепочек» в готовой выборке из статьи «SQL HowTo: собираем «цепочки» с помощью window functions».

Сначала поймем, что условием окончания «цепочки» — то есть сегмента подряд идущих по ключу (host, lock, pid, mode) записей блокировки — для нас является или явное возникновение event = 'lock-acquire' или (что очень редко, но бывает) начало нового сегмента блокировки того же объекта, чья длительность (exectime) начала считаться заново.

vbodokvqrfqur_tpqf8kajtuspa.png

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

xnnsl7fuzroyfyzbc-onstvhlwu.png

-- формируем условие окончания блокировки
WITH lc AS (
  SELECT
    *
  , CASE
      WHEN event = 'lock-wait' THEN
        exectime > coalesce(lead(exectime) OVER(PARTITION BY lock, pid, mode ORDER BY ts, exectime), 0) -- "перелом" времени ожидания
      ELSE TRUE -- 'lock-acquire' - блокировка получена
    END cond -- условие окончания "цепочки"
  FROM
    lock lc
  WHERE
    event <> 'deadlock-detect' AND -- исключаем все deadlock
    (
      lc.dt
    , lc.host
    ) = (
      '2020-06-19'::date
    , 'e828a54d-7e8a-43dd-b213-30c3201a6d8e'::uuid
    )
)
-- оставляем только "последние" записи - их exectime и есть время ожидания "всей" блокировки
SELECT
  ts
, pid
, event
, type
, mode
, lock
, exectime
FROM
  lc
WHERE
  cond;


usng6blt5kk8behu6e6saaaxkbw.png

Теперь мы прочитали всего 8MB данных (в 100 раз меньше!), чуть-чуть уменьшив итоговое время выполнения.

Его можно уменьшить еще, если создать индекс, идеально подходящий под OVER (то есть включающий lock, pid, mode, ts, exectime), избавившись от Sort-узла. Но обычно поле в индексе «за timestamp» делать не стоит.

© Habrahabr.ru