Хорошие новости для тех, кто всё ещё использует row-level локи в PostgreSQL

Привет, меня зовут Дима, я работаю в Ozon в небольшой команде, которая предоставляет разработчикам PostgreSQL as s Service. Мы сопровождаем тысячи кластеров, поэтому разработчики, в основном, сами отвечают за перфоманс PostgreSQL. Иногда они приходят к нам за помощью с неочевидными проблемами. Одной из моих самых нелюбимых фичей (из-за реализации) являются row-level локи, и поэтому я и коллеги часто подозревают в непредсказуемом поведении сервера ДБ. 

В интернетах я встречал статьи — как использовать advisory и об их проблемах. Но мало где видел информацию, почему их нужно использовать. Например, здесь рассказано о проблемах for shared; или вот ещё в рассылках писали о неэффективном SLRU. 

Я всегда пытался объяснить в первую очередь себе, почему так не люблю row-level локи. В какой-то момент сделал очередную попытку разобраться — результаты собрал в эту заметку. 

Дисклеймер (он же tldr): в статье я не буду погружаться в подробности реализации, но постараюсь с помощью простых инструментов показать оверхед, создаваемый row-level локами.

А зачем нужны эти локи?

Стандартный уровень изоляции в PostgreSQL — read committed. Это означает, что каждый новый запущенный запрос в транзакции видит свой слепок данных. В том числе видит данные, которые были закомиченны конкурирующими запросами между тем, когда закончился предыдущий запрос и сервер начал обрабатывать новый запрос в рамках одной транзакции. В этом временном интервале может произойти классическая ситуация гонок, которая может повлечь за собой логическое коррупцию данных, которую программисты решают при помощи row-level локов.

Стенд

Ничего примечательного, возьмем небольшую рабочую лошадку: тестирование мы будем проводить на 4-ядерной машине (Xeon Gold 6240R) с 4 ГБ памяти. Возьмём PostgreSQL версии 12; из основных настроек — shared_buffers 1Gb, реплика не подключена. Данные заполняются при помощи pgbench, коэффициент масштабирования (--scale) 100.

For Share

Share-lock используется для того, чтобы прочитать данные, которые после взятия лока никто не может изменить. То есть если клиенты соблюдают правила игры и берут share/exclusive lock перед тем, как прочитать или изменить данные, то взятие share-лока гарантирует, что никто не сможет изменить данные.

Давайте проведём простой эксперимент: допустим, есть такой pgbench-скрипт, который берёт share-лок на одну тысячу записей:

\set r random(1, 500)
do $$
declare
   counter integer := 0;
begin
   while counter < 1000 loop
      perform  * from pgbench_accounts where aid = (:r+counter) for share;
      counter := counter + 1;
   end loop;
end$$;

Запустим этот скрипт в одного клиента:

$ pgbench -f script-row-level-share.sql -c 1 -j 1 -T 60
...
number of transactions actually processed: 2185
latency average = 27.463 ms
tps = 36.410041 (excluding connections establishing)

Давайте посмотрим, что происходит в wal:

$ pg_waldump --bkp-details pg_wal/0000000100000008000000DB
...
rmgr: Heap        len (rec/tot):     54/    54, tx:    2270545, lsn: 8/DBFFFF10, prev 8/DBFFFED8, desc: LOCK off 10: xid 2270545: flags 0x00 LOCK_ONLY EXCL_LOCK KEYSHR_LOCK
        blkref #0: rel 1663/13432/17068 fork main blk 158676
...

Видим, что информация о row-level локах хранится прямо в heap (в данных). В xmax записывается xid (номер транзакции, который взял лок), но с флагом HEAP_XMAX_LOCK_ONLY, который не помечает строку на удаление, а носит информативный характер. 

Данные в heap PostgreSQL пишет в связи с тем, что локов может быть много, а оперативной памяти, где можно было бы хранить эти локи — ограниченный объём. Как вы понимаете, запись на страницу не бесплатная и влечёт за собой пересчёт checksums и запись wal и всей грязной страницы на диск. Это создаёт дополнительную нагрузку на CPU  и IO.

Но самое интересное происходит когда клиентов, которые конкурируют за row-level, становится больше, чем один. Запустим тот же скрипт в несколько клиентов:

rmgr: Heap        len (rec/tot):     54/    54, tx:    4076612, lsn: 23/A4FFF2C8, prev 23/A4FFF290, desc: LOCK off 51: xid 135360939: flags 0x00 IS_MULTI LOCK_ONLY EXCL_LOCK KEYSHR_LOCK
        blkref #0: rel 1663/13432/41538 fork main blk 158678
rmgr: MultiXact   len (rec/tot):     70/    70, tx:    4076618, lsn: 23/A4FFF300, prev 23/A4FFF2C8, desc: CREATE_ID 135360941 offset 2274305627 nmembers 4: 4076614 (sh) 4076616 (sh) 4076617 (sh) 4076618 (sh)
rmgr: Heap        len (rec/tot):     54/    54, tx:    4076618, lsn: 23/A4FFF348, prev 23/A4FFF300, desc: LOCK off 1: xid 135360941: flags 0x00 IS_MULTI LOCK_ONLY EXCL_LOCK KEYSHR_LOCK
        blkref #0: rel 1663/13432/41538 fork main blk 158671

К flags добавилось IS_MULTI, который означает, что лок взяла не одна транзакция, а группа, объединённая в мультитранзакцию. Это не удивительно, места на странице мало, разместить все возможные счётчики транзакций в странице невозможно.

846f23f57dcf562470bfe8d86169b3ba.png

Поэтому кроме обычных xid PostgreSQL начнёт выписывать новые MultiXact транзакции. По сути мы получим ещё один счётчик, у которого есть wraparound, и он также должен обслуживаться с помощью vacuum. Соответствие между mXid и xid хранятся на диске в директории pg_multixact:

$ du -hs ./pg_multixact/*
5.1G    ./pg_multixact/members
221M    ./pg_multixact/offsets

Подробнее про MultiXact может рассказать документация в коде.

For Update

Это эксклюзивный lock, который мы используем для того, чтобы изменить данные. Чаще всего разработчику необходимо не for share, а более простой for update — эксклюзивный лок, который конфликтует со всем остальными row-level локами.

Казалось бы, взятие данного лока —  это просто запись в heap бита информации о том, что транзакция взяла multi-lock. Мы уже смирились с тем, что shared тоже пишет на диск; согласны с этим небольшим пенальти на нагрузку. Давайте посмотрим, что ещё интересного можно найти. Модифицируем pgbench-скрипт для for update, используя распределение zipfian.

\set aid random_zipfian(1, 100, 1.1)
begin;
select :aid from pgbench_accounts where aid = :aid for update;
update pgbench_accounts set abalance = abalance + 1 where aid = :aid;
update pgbench_accounts set abalance = abalance * 2 where aid = :aid;
update pgbench_accounts set abalance = abalance - 2 where aid = :aid;
end;

Запустим скрипт на 10 минут на 90 клиентов:

$ pgbench -f ~/script-for-update.sql -P 1 -T 600 -j 2 -c 90 --report-latencies
...
statement latencies in milliseconds:
         0.023  \set aid random_zipfian(1, 100, 1.1)
         0.287  begin;
        66.490  select :aid from pgbench_accounts where aid = :aid for update;
         0.515  update pgbench_accounts set abalance = abalance + 1 where aid = :aid;
         0.460  update pgbench_accounts set abalance = abalance * 2 where aid = :aid;
         0.439  update pgbench_accounts set abalance = abalance - 2 where aid = :aid;
         1.403  end;

Значение в 66ms на ожидание, взятие и освобождение лока — это много или мало? Давайте сравним это с advisory локом:

\set aid random_zipfian(1, 100, 1.1)
begin;
select pg_advisory_xact_lock(:aid);
update pgbench_accounts set abalance = abalance + 1 where aid = :aid;
update pgbench_accounts set abalance = abalance * 2 where aid = :aid;
update pgbench_accounts set abalance = abalance - 2 where aid = :aid;
end;

Запустим в тех же самых условиях:

$ pgbench -f ~/script-advisory.sql -P 1 -T 600 -j 2 -c 90 --report-latencies
...
statement latencies in milliseconds:
         0.013  \set aid random_zipfian(1, 100, 1.1)
         0.131  begin;
        35.536  select pg_advisory_xact_lock(:aid);
         0.257  update pgbench_accounts set abalance = abalance + 1 where aid = :aid;
         0.213  update pgbench_accounts set abalance = abalance * 2 where aid = :aid;
         0.200  update pgbench_accounts set abalance = abalance - 2 where aid = :aid;
         0.892  end;

Результат лучше почти в два раза! Почему for update оказался настолько медленнее, чем advisory в данном тесте? Несмотря на то, что for update так же как и share lock «пачкает» страницу с данными, указанный факт не мог дать такого эффекта. Посмотрите, как делается HOT-апдейт в соседних строчках — оверхед не более 0.2–0.3 ms.

Не стоит считать, что for update просто медленнее в два раза. Это лишь означает, что на конкуренцию между бакендами за лок уходило не более 30ms, а ещё 30ms — на явные внутренние локи.

d1cfb92be2173412b90ac65f1474e718.png

Давайте выясним это и заглянем в flamegraph:

$ perf record -F 99 -a -g -- sleep 60
$ perf script > out.perf
$ ./stackcollapse-perf.pl out.perf > out.folded
$ ./flamegraph.pl out.kern_folded > kernel.svg
на что тратится время в случае с for updateна что тратится время в случае с for update

Когда серверный процесс, обслуживающий запрос пользователя, работает с MultiXact — к сожалению, ему приходится обращаться в горячую, пока плохо оптимизированную систему дополнительных (multiXact) счётчиков транзакций. Такую, что даже снятие лока UnlockTable занимает ощутимое время (судя по perf в данном тесте, на это уходит примерно 10ms).

Advisory лок тоже пользуется примитивами LockAcquireExtended, но для чтения консистентных данных ему не приходиться брать такое количество локов. Как мы видим, это положительно сказывается на производительности.

Зависимость от количества клиентов

Давайте установим max_connection в 300 и посмотрим, как будет изменяться производительность в зависимости от количества клиентов.

Как меняется производительность в зависимости от количества клиентовКак меняется производительность в зависимости от количества клиентов

Как мы видим, насыщение на стенде происходит в районе десяти клиентов, но производительность for update падает значительно быстрее, чем advisory.

Как row-level влияет на производительность других запросов

Давайте измерим производительность скрипта:

\set aid random_zipfian(1, 100, 1.1)
begin;
update pgbench_accounts set abalance = abalance + 0 where aid = :aid;
end;

И одновременно будем запустим фоновый for update и advisory локи в десять клиентов.

Для advisory:

transaction type: script-hot-update.sql
...
number of clients: 40
number of threads: 2
duration: 600 s
...
tps = 3446.137796 (including connections establishing)
...

Для for update:

transaction type: script-hot-update.sql
...
number of clients: 40
number of threads: 2
duration: 600 s
...
tps = 2534.646948 (including connections establishing)
...

Таким образом, мы видим, что обычные запросы, которые никоим образом не работают с for update — получают пенальти по производительности (в полтора раза), только из-за факта присутствия IS_MULTI в данных.

Как использовать advisory вместо row-level

В качестве замены row-level локов предлагается использовать advisory локи, которые не связаны с данными, а по сути представляют из себя мьютекс в разделяемой памяти. Это является одновременным их плюсом и минусом. Минус заключается в том, что количество одновременных advisory-локов ограничено значением не менее, чем max_connection*max_lock_per_transaction. Данная область памяти выделяется при старте PostgreSQL вне зависимости от того, будете ли вы использовать ее или нет. В случае с max_connection = 100 и остальными параметрами по умолчанию, у вас будет не менее 6k локов одновременно, что более, чем хватает для общего использования.

Рекомендуется использовать функции advisory_xact. Они освобождаются автоматически после окончания транзакции, их можно брать несколько раз и они совместимы с pgbouncer в режиме transaction-pool.

Каждый advisory береться на всю базу, поэтому возможны конфликты. К счастью, advisory может принимать не только один идентификатор, а два. Чтобы не было пересечений между локами на разные таблицы с одинаковым id, можно использовать oid-таблицы в качестве первого параметра:

select pg_try_advisory_lock('table_1'::regclass::oid, id)

Блокировать можно только int-значения. Если у вас ключ текстовое значение, вы можете превратить его в int, используя конвертацию в md5:

create or replace function obtain_lock_id(id text) returns void as $$
    select pg_advisory_xact_lock( ('x'||substr(md5($1),1,16))::bit(64)::bigint )
-- или использовать незадокументированную функцию hashtext:
--  select pg_advisory_xact_lock( hashtext($1) );
$$ language 'sql';

Строит предупредить о некоторых особенностях планирования запросов с advisory_locks, в случае с row-level локами фактически лок происходит в вершине плана, после фильтрации:

explain select id from test where id % 3 = 0 for update ;
                         QUERY PLAN
------------------------------------------------------------
 LockRows  (cost=0.00..29.11 rows=6 width=10)
   ->  Seq Scan on test  (cost=0.00..29.05 rows=6 width=10)
         Filter: ((id % 3) = 0)
(3 rows)

В случае с advisory лок берётся при фильтрации. Записи будут заблокированы, но клиенту вернётся лишь часть результата:

explain select id from test where id % 3 = 0 and pg_try_advisory_xact_lock(id);
                               QUERY PLAN
------------------------------------------------------------------------
 Seq Scan on test  (cost=0.00..35.40 rows=2 width=4)
   Filter: (pg_try_advisory_xact_lock((id)::bigint) AND ((id % 3) = 0))
(2 rows)

Сравнительная таблица:

row-level

advisory

for update

pg_advisory_xact_lock (int, (int))

for update skip locked

pg_try_advisory_xact_lock (int, (int))

for update nowait

pg_try_advisory_xact_lock (int, (int))

for share

pg_advisory_xact_lock_shared (int, (int))

Row-level VS Advisory: выжимка

Row-level локи:

  • вызывают перезапись страницы, даже for share;

  • используют MultiXact, которые по факту представляют из себя ещё один счётчик, wraparound которого обслуживает vacuum наравне с обычным счетчиком транзакций;

  • добавляют ещё латенси из-за обслуживания связки «MultiXact» <-> «массив обычных xid» и этот вклад существенный  — может составлять до x4 и зависит от данных, железа и параметров использования;

  • как мы увидели, сам факт использования MultiXact вызывает дополнительный оверхед при манипуляции с данными, с которыми работают row-level локи;

Advisory локи:

  • не являются полной заменой row-level локов и потребуют изменения вашего кода;

  • переход на advisory локи увеличат производительность вашего приложения.

Что осталось за рамками статьи

В этот раз мы не успели поговорить о autovacuum, который приходит в горячую область данных, где активно используются row-level локи. Чтобы поговорить об этом, по-хорошему нужно бы воспроизвести ситуацию, с которой приходилось встречаться на практике, когда резко возрастает нагрузка на сервер –, но это довольно непростой кейс для воспроизведения. 

По опыту могу сказать, что даже переход с row-level на двойной лок «advisory + row-level» позволяет сгладить данный эффект. Графики и пруфы не смогу показать: всё это происходило в стародавние времена, когда вышел PostgreSQL 10.2. Предлагаю вам поверить мне на слово :)

Ещё в случае с advisory есть вопросы ко времени восстановления после сбоя. Возможно, это станет темой для одной из следующих статей.

В конце хочу пожелать всем удачи и высокого аптайма! Давайте исследовать и обращать внимание разработчиков на проблемы в тех инструментах, которыми пользуемся — так победим!

© Habrahabr.ru