Блокировки в PostgreSQL: 2. Блокировки строк

    В прошлый раз мы говорили о блокировках на уровне объектов, в частности — о блокировках отношений. Сегодня посмотрим, как в PostgreSQL устроены блокировки строк и как они используются вместе с блокировками объектов, поговорим про очереди ожидания и про тех, кто лезет без очереди.



    Блокировки строк


    Устройство


    Напомню несколько важных выводов из прошлой статьи.

    • Блокировка должна существовать где-то в разделяемой памяти сервера.
    • Чем выше гранулярность блокировок, тем меньше конкуренция (contention) среди одновременно работающих процессов.
    • С другой стороны, чем выше гранулярность, тем больше места в памяти занимают блокировки.

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

    Есть разные пути решения этой проблемы. В некоторых СУБД происходит повышение уровня блокировки: если блокировок уровня строк становится слишком много, они заменяются одной более общей блокировкой (например, уровня страницы или всей таблицы).

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

    В PostgreSQL информация о том, что строка заблокирована, хранится только и исключительно в версии строки внутри страницы данных (а не в оперативной памяти). То есть это вовсе и не блокировка в обычном понимании, а просто некий признак. Этим признаком на самом деле является номер транзакции xmax в сочетании с дополнительными информационными битами; чуть позже мы детально посмотрим, как это устроено.

    Плюс в том, что мы можем блокировать сколько угодно строк, не потребляя никаких ресурсов.

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

    Ну мониторинг ладно, а с очередью что-то надо делать. Для этого все-таки приходится использовать «обычные» блокировки. Если нам надо дождаться освобождения строки, фактически мы должны дождаться окончания блокирующей транзакции — все блокировки снимаются при фиксации или откате. А для этого можно запросить блокировку номера блокирующей транзакции (которая, напомню, удерживается самой транзакцией в исключительном режиме). Таким образом число используемых блокировок пропорционально числу одновременно работающих процессов, а не количеству изменяемых строк.

    Исключительные режимы


    Всего существует 4 режима, в которых можно заблокировать строку. Из них два режима представляют исключительные (exclusive) блокировки, которые одновременно может удерживать только одна транзакция.

    • Режим FOR UPDATE предполагает полное изменение (или удаление) строки.
    • Режим FOR NO KEY UPDATE — изменение только тех полей, которые не входят в уникальные индексы (иными словами, при таком изменении все внешние ключи остаются без изменений).

    Команда UPDATE сама выбирает минимальный подходящий режим блокировки; обычно строки блокируются в режиме FOR NO KEY UPDATE.

    Как вы помните, при удалении или изменении строки в поле xmax текущей актуальной версии записывается номер текущей транзакции. Он показывает, что версия строки удалена данной транзакцией. Так вот, тот же самый номер xmax используется и как признак блокировки. В самом деле, если xmax в версии строки соответствует активной (еще не завершенной) транзакции и мы хотим обновить именно эту строку, то мы должны дождаться завершения транзакции, так что дополнительный признак не нужен.

    Давайте посмотрим. Создадим таблицу счетов, такую же, как в прошлой статье.

    => CREATE TABLE accounts(
      acc_no integer PRIMARY KEY,
      amount numeric
    );
    => INSERT INTO accounts
      VALUES (1, 100.00), (2, 200.00), (3, 300.00);
    

    Чтобы заглядывать в страницы, нам, конечно, потребуется уже хорошо знакомое расширение pageinspect.

    => CREATE EXTENSION pageinspect;
    

    Для удобства создадим представление, показывающее только интересующую нас информацию: xmax и некоторые информационные биты.

    => CREATE VIEW accounts_v AS
    SELECT '(0,'||lp||')' AS ctid,
           t_xmax as xmax,
           CASE WHEN (t_infomask & 128) > 0   THEN 't' END AS lock_only,
           CASE WHEN (t_infomask & 4096) > 0  THEN 't' END AS is_multi,
           CASE WHEN (t_infomask2 & 8192) > 0 THEN 't' END AS keys_upd,
           CASE WHEN (t_infomask & 16) > 0 THEN 't' END AS keyshr_lock,
           CASE WHEN (t_infomask & 16+64) = 16+64 THEN 't' END AS shr_lock
    FROM heap_page_items(get_raw_page('accounts',0))
    ORDER BY lp;
    

    Итак, начинаем транзакцию и обновляем сумму первого счета (ключ не меняется) и номер второго счета (ключ меняется):

    => BEGIN;
    => UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 1;
    => UPDATE accounts SET acc_no = 20 WHERE acc_no = 2;
    

    Заглядываем в представление:

    => SELECT * FROM accounts_v LIMIT 2;
    
     ctid  |  xmax  | lock_only | is_multi | keys_upd | keyshr_lock | shr_lock 
    -------+--------+-----------+----------+----------+-------------+----------
     (0,1) | 530492 |           |          |          |             | 
     (0,2) | 530492 |           |          | t        |             | 
    (2 rows)
    

    Режим блокировки определяется информационным битом keys_updated.

    То же самое поле xmax задействовано и при блокировании строки командой SELECT FOR UPDATE, но в этом случае проставляется дополнительный информационный бит (xmax_lock_only), который говорит о том, что версия строки только заблокирована, но не удалена и по-прежнему актуальна.

    => ROLLBACK;
    => BEGIN;
    => SELECT * FROM accounts WHERE acc_no = 1 FOR NO KEY UPDATE;
    => SELECT * FROM accounts WHERE acc_no = 2 FOR UPDATE;
    

    => SELECT * FROM accounts_v LIMIT 2;
    
     ctid  |  xmax  | lock_only | is_multi | keys_upd | keyshr_lock | shr_lock 
    -------+--------+-----------+----------+----------+-------------+----------
     (0,1) | 530493 | t         |          |          |             | 
     (0,2) | 530493 | t         |          | t        |             | 
    (2 rows)
    

    => ROLLBACK;
    


    Разделяемые режимы


    Еще два режима представляют разделяемые (shared) блокировки, которые могут удерживаться несколькими транзакциями.

    • Режим FOR SHARE применяется, когда нужно прочитать строку, но при этом нельзя допустить, чтобы она как-либо изменилась другой транзакцией.
    • Режим FOR KEY SHARE допускает изменение строки, но только неключевых полей. Этот режим, в частности, автоматически используется PostgreSQL при проверке внешних ключей.

    Посмотрим.

    => BEGIN;
    => SELECT * FROM accounts WHERE acc_no = 1 FOR KEY SHARE;
    => SELECT * FROM accounts WHERE acc_no = 2 FOR SHARE;
    

    В версиях строк видим:

    => SELECT * FROM accounts_v LIMIT 2;
    
     ctid  |  xmax  | lock_only | is_multi | keys_upd | keyshr_lock | shr_lock 
    -------+--------+-----------+----------+----------+-------------+----------
     (0,1) | 530494 | t         |          |          | t           | 
     (0,2) | 530494 | t         |          |          | t           | t
    (2 rows)
    

    В обоих случаях установлен бит keyshr_lock, а режим SHARE можно распознать, посмотрев еще один информационный бит.

    Вот как выглядит общая матрица совместимости режимов.

    режим FOR KEY SHARE FOR SHARE FOR NO KEY UPDATE FOR UPDATE
    FOR KEY SHARE Х
    FOR SHARE Х Х
    FOR NO KEY UPDATE Х Х Х
    FOR UPDATE Х Х Х Х

    Из нее видно, что:

    • исключительные режимы конфликтуют между собой;
    • разделяемые режимы совместимы между собой;
    • разделяемый режим FOR KEY SHARE совместим с исключительным режимом FOR NO KEY UPDATE (то есть можно одновременно обновлять неключевые поля и быть уверенным в том, что ключ не изменится).

    Мультитранзакции


    До сих пор мы считали, что блокировка представляется номером блокирующей транзакции в поле xmax. Но разделяемые блокировки могут удерживаться несколькими транзакциями, а в одно поле xmax нельзя записать несколько номеров. Как быть?

    Для разделяемых блокировок применяются так называемые мультитранзакции (MultiXact). Это группа транзакций, которой присвоен отдельный номер. Этот номер имеет ту же размерность, что и обычный номер транзакции, но номера выделяются независимо (то есть в системе могут быть одинаковые номера транзакций и мультитранзакций). Чтобы отличить одно от другого, используется еще один информационный бит (xmax_is_multi), а детальная информация об участниках такой группы и режимах блокировки находятся в файлах в каталоге $PGDATA/pg_multixact/. Естественно, последние использованные данные хранятся в буферах в общей памяти сервера для ускорения доступа.

    Добавим к имеющимся блокировкам еще одну исключительную, выполненную другой транзакцией (мы можем это сделать, поскольку режимы FOR KEY SHARE и FOR NO KEY UPDATE совместимы между собой):

    |  => BEGIN;
    |  => UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 1;
    

    => SELECT * FROM accounts_v LIMIT 2;
    
     ctid  |  xmax  | lock_only | is_multi | keys_upd | keyshr_lock | shr_lock 
    -------+--------+-----------+----------+----------+-------------+----------
     (0,1) |     61 |           | t        |          |             | 
     (0,2) | 530494 | t         |          |          | t           | t
    (2 rows)
    

    В первой строке видим, что обычный номер заменен на номер мультитранзакции — об этом говорит бит xmax_is_multi.

    Чтобы не вникать во внутренности реализации мультитранзакций, можно воспользоваться еще одним расширением, которое позволяет увидеть всю информацию о всех типах блокировок строк в удобном виде.

    => CREATE EXTENSION pgrowlocks;
    => SELECT * FROM pgrowlocks('accounts') \gx
    
    -[ RECORD 1 ]-----------------------------
    locked_row | (0,1)
    locker     | 61
    multi      | t
    xids       | {530494,530495}
    modes      | {"Key Share","No Key Update"}
    pids       | {5892,5928}
    -[ RECORD 2 ]-----------------------------
    locked_row | (0,2)
    locker     | 530494
    multi      | f
    xids       | {530494}
    modes      | {"For Share"}
    pids       | {5892}
    

    => COMMIT;
    

    |  => ROLLBACK;
    

    Настройка заморозки


    Поскольку для мультитранзакций выделяются отдельные номера, которые записываются в поле xmax версий строк, то из-за ограничения разрядности счетчика с ними возникают такая же проблеме переполнения (xid wraparound), что и с обычным номером.

    Поэтому для номеров мультитранзакций тоже необходимо выполнять аналог заморозки — заменять старые номера на новые (или на обычный номер транзакции, если в момент заморозки блокировка удерживается уже только одной транзакцией).

    Заметим, что заморозка обычных номеров транзакций выполняется только для поля xmin (так как если у версии строки непустое поле xmax, то либо это уже неактуальная версия и она будет очищена, либо транзакция xmax отменена и ее номер нас не интересует). А вот для мультитранзакций речь идет о поле xmax актуальной версии строки, которая может оставаться актуальной, но при этом постоянно блокируется разными транзакциями в разделяемом режиме.

    За заморозку мультитранзакций отвечают параметры, аналогичные параметрам обычной заморозки: vacuum_multixact_freeze_min_age, vacuum_multixact_freeze_table_age, autovacuum_multixact_freeze_max_age.

    Кто крайний?


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

    Начнем с того, что построим представление над pg_locks. Во-первых, сделаем вывод чуть более компактным, во-вторых, ограничимся только интересными блокировками (фактически, отбрасываем блокировки виртуальных номеров транзакций, индекса на таблице accounts, pg_locks и самого представления — в общем, всего того, что не имеет отношения к делу и только отвлекает).

    => CREATE VIEW locks_v AS
    SELECT pid,
           locktype,
           CASE locktype
             WHEN 'relation' THEN relation::regclass::text
             WHEN 'transactionid' THEN transactionid::text
             WHEN 'tuple' THEN relation::regclass::text||':'||tuple::text
           END AS lockid,
           mode,
           granted
    FROM pg_locks
    WHERE locktype in ('relation','transactionid','tuple')
    AND (locktype != 'relation' OR relation = 'accounts'::regclass);
    

    Теперь начнем первую транзакцию и обновим строку.

    => BEGIN;
    => SELECT txid_current(), pg_backend_pid();
    
     txid_current | pg_backend_pid 
    --------------+----------------
           530497 |           5892
    (1 row)
    
    => UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 1;
    
    UPDATE 1
    

    Что с блокировками?

    => SELECT * FROM locks_v WHERE pid = 5892;
    
     pid  |   locktype    |  lockid  |       mode       | granted 
    ------+---------------+----------+------------------+---------
     5892 | relation      | accounts | RowExclusiveLock | t
     5892 | transactionid | 530497   | ExclusiveLock    | t
    (2 rows)
    

    Транзакция удерживает блокировку таблицы и собственного номера. Пока все ожидаемо.

    Начинаем вторую транзакцию и пытаемся обновить ту же строку.

    |  => BEGIN;
    |  => SELECT txid_current(), pg_backend_pid();
    
    |   txid_current | pg_backend_pid 
    |  --------------+----------------
    |         530498 |           5928
    |  (1 row)
    
    |  => UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 1;
    

    Что с блокировками второй транзакции?

    => SELECT * FROM locks_v WHERE pid = 5928;
    
     pid  |   locktype    |   lockid   |       mode       | granted 
    ------+---------------+------------+------------------+---------
     5928 | relation      | accounts   | RowExclusiveLock | t
     5928 | transactionid | 530498     | ExclusiveLock    | t
     5928 | transactionid | 530497     | ShareLock        | f
     5928 | tuple         | accounts:1 | ExclusiveLock    | t
    (4 rows)
    

    А вот тут интереснее. Помимо блокировки таблицы и собственного номера, мы видим еще две блокировки. Вторая транзакция обнаружила, что строка заблокирована первой и «повисла» на ожидании ее номера (granted = f). Но откуда и зачем взялась блокировка версии строки (locktype = tuple)?

    Не путайте блокировку версии строки (tuple lock) и блокировку строки (row lock). Первая — обычная блокировка типа tuple, которую видно в pg_locks. Вторая — пометка в странице данных: xmax и информационные биты.

    Когда транзакция собирается изменить строку, она выполняет следующую последовательность действий:

    1. Захватывает исключительную блокировку изменяемой версии строки (tuple).
    2. Если xmax и информационные биты говорят о том, что строка заблокирована, то запрашивает блокировку номера транзакции xmax.
    3. Прописывает свой xmax и необходимые информационные биты.
    4. Освобождает блокировку версии строки.

    Когда строку обновляла первая транзакция, она тоже захватывала блокировку версии строки (п. 1), но тут же ее отпустила (п. 4).

    Когда пришла вторая транзакция, она захватила блокировку версии строки (п. 1), но была вынуждена запросить блокировку номера первой транзакции (п. 2) и на этом повисла.

    Что произойдет, если появится третья аналогичная транзакция? Она попытается захватить блокировку версии строки (п. 1) и повиснет уже на этом шаге. Проверим.

    ||     => BEGIN;
    ||     => SELECT txid_current(), pg_backend_pid();
    
    ||      txid_current | pg_backend_pid 
    ||     --------------+----------------
    ||            530499 |           5964
    ||     (1 row)
    
    ||     => UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 1;
    

    => SELECT * FROM locks_v WHERE pid = 5964;
    
     pid  |   locktype    |   lockid   |       mode       | granted 
    ------+---------------+------------+------------------+---------
     5964 | relation      | accounts   | RowExclusiveLock | t
     5964 | tuple         | accounts:1 | ExclusiveLock    | f
     5964 | transactionid | 530499     | ExclusiveLock    | t
    (3 rows)
    

    Четвертая, пятая и т. д. транзакции, желающие обновить ту же самую строку, ничем не будут отличаться от транзакции 3 — все они будут «висеть» на одной и той же блокировке версии строки.

    Добавим до кучи еще одну транзакцию.

    |||        => BEGIN;
    |||        => SELECT txid_current(), pg_backend_pid();
    
    |||         txid_current | pg_backend_pid 
    |||        --------------+----------------
    |||               530500 |           6000
    |||        (1 row)
    
    |||        => UPDATE accounts SET amount = amount - 100.00 WHERE acc_no = 1;
    

    => SELECT * FROM locks_v WHERE pid = 6000;
    
     pid  |   locktype    |   lockid   |       mode       | granted 
    ------+---------------+------------+------------------+---------
     6000 | relation      | accounts   | RowExclusiveLock | t
     6000 | transactionid | 530500     | ExclusiveLock    | t
     6000 | tuple         | accounts:1 | ExclusiveLock    | f
    (3 rows)
    

    Общую картину текущих ожиданий можно увидеть в представлении pg_stat_activity, добавив информацию о блокирующих процессах:

    => SELECT pid, wait_event_type, wait_event, pg_blocking_pids(pid) 
    FROM pg_stat_activity 
    WHERE backend_type = 'client backend';
    
     pid  | wait_event_type |  wait_event   | pg_blocking_pids 
    ------+-----------------+---------------+------------------
     5892 |                 |               | {}
     5928 | Lock            | transactionid | {5892}
     5964 | Lock            | tuple         | {5928}
     6000 | Lock            | tuple         | {5928,5964}
    (4 rows)
    

    Получается своеобразная «очередь», в которой есть первый (тот, кто удерживает блокировку версии строки) и все остальные, выстроившиеся за первым.

    Зачем нужна такая навороченная конструкция? Допустим, у нас не было бы блокировки версии строки. Тогда и вторая, и третья (и т. д.) транзакции ждали бы блокировки номера первой транзакции. В момент завершения первой транзакции заблокированный ресурс исчезает (а что это вы тут делаете, а? транзакция-то закончилась) и теперь все зависит от того, какой из ожидающих процессов будет первым разбужен операционной системой и, соответственно, успеет заблокировать строку. Все остальные процессы тоже будут разбужены, но им придется снова вставать в очередь — теперь уже за другим процессом.

    Это чревато тем, что какая-то из транзакций может неопределенно долго ждать своей очереди, если по неудачному стечению обстоятельств ее все время будет «объезжать» другие транзакции. По-английски эта ситуация называется lock starvation.

    В нашем случае получается примерно то же самое, но все-таки чуть лучше: транзакции, которая пришла второй, гарантируется, что именно она получит доступ к ресурсу следующей. Но что происходит со следующими (третьей и дальше)?

    Если первая транзакция завершится откатом, все будет хорошо: пришедшие транзакции пойдут в том порядке, как они выстроились в очередь.

    Но — вот незадача — если первая транзакция завершится фиксацией, то исчезает не только номер транзакции, но и версия строки! То есть версия, конечно, остается, но перестает быть актуальной, и обновлять надо будет совсем другую, последнюю версию (той же строки). Ресурс, за которым стояла очередь, исчезает, и все устраивают гонку (race) за обладание новым ресурсом.

    Пусть первая транзакция завершится фиксацией.

    => COMMIT;
    

    Вторая транзакция будет разбужена и выполнит пп. 3 и 4.

    |  UPDATE 1
    

    => SELECT * FROM locks_v WHERE pid = 5928;
    
     pid  |   locktype    |  lockid  |       mode       | granted 
    ------+---------------+----------+------------------+---------
     5928 | relation      | accounts | RowExclusiveLock | t
     5928 | transactionid | 530498   | ExclusiveLock    | t
    (2 rows)
    

    А что с третьей транзакцией? Она проскакивает п. 1 (потому что ресурс исчез) и застревает на п. 2:

    => SELECT * FROM locks_v WHERE pid = 5964;
    
     pid  |   locktype    |  lockid  |       mode       | granted 
    ------+---------------+----------+------------------+---------
     5964 | relation      | accounts | RowExclusiveLock | t
     5964 | transactionid | 530498   | ShareLock        | f
     5964 | transactionid | 530499   | ExclusiveLock    | t
    (3 rows)
    

    И то же самое происходит с четвертой транзакцией:

    => SELECT * FROM locks_v WHERE pid = 6000;
    
     pid  |   locktype    |  lockid  |       mode       | granted 
    ------+---------------+----------+------------------+---------
     6000 | relation      | accounts | RowExclusiveLock | t
     6000 | transactionid | 530498   | ShareLock        | f
     6000 | transactionid | 530500   | ExclusiveLock    | t
    (3 rows)
    

    То есть и третья, и четвертая транзакция ожидают завершения второй. Очередь превратилась в тыкву толпу.

    Завершаем все начатые транзакции.

    |  => COMMIT;
    

    ||     UPDATE 1
    
    ||     => COMMIT;
    

    |||        UPDATE 1
    
    |||        => COMMIT;
    

    Больше подробностей о блокировании строк можно почерпнуть из README.tuplock.

    Вас тут не стояло


    Итак, идея двухуровневой схемы блокирования состоит в том, чтобы уменьшить вероятность вечного ожидания «невезучей» транзакции. Тем не менее, как мы уже видели, такая ситуация вполне возможна. А если приложение использует разделяемые блокировки, все может стать еще печальней.

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

    => BEGIN;
    => SELECT txid_current(), pg_backend_pid();
    
     txid_current | pg_backend_pid 
    --------------+----------------
           530501 |           5892
    (1 row)
    
    => SELECT * FROM accounts WHERE acc_no = 1 FOR SHARE;
    
     acc_no | amount 
    --------+--------
          1 | 100.00
    (1 row)
    

    Вторая транзакция пытается обновить ту же строку, но не может — режимы SHARE и NO KEY UPDATE несовместимы.

    |  => BEGIN;
    |  => SELECT txid_current(), pg_backend_pid();
    
    |   txid_current | pg_backend_pid 
    |  --------------+----------------
    |         530502 |           5928
    |  (1 row)
    
    |  => UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 1;
    

    Вторая транзакция ждет завершения первой и удерживает блокировку версии строки — пока все, как в прошлый раз.

    => SELECT * FROM locks_v WHERE pid = 5928;
    
     pid  |   locktype    |   lockid    |       mode       | granted 
    ------+---------------+-------------+------------------+---------
     5928 | relation      | accounts    | RowExclusiveLock | t
     5928 | tuple         | accounts:10 | ExclusiveLock    | t
     5928 | transactionid | 530501      | ShareLock        | f
     5928 | transactionid | 530502      | ExclusiveLock    | t
    (4 rows)
    

    И тут появляется третья транзакция, которая хочет разделяемую блокировку. Беда в том, что она не пытается захватывать блокировку версии строки (поскольку не собирается изменять строку), а просто пролезает без очереди — ведь она совместима с первой транзакцией.

    ||     BEGIN
    ||     => SELECT txid_current(), pg_backend_pid();
    
    ||      txid_current | pg_backend_pid 
    ||     --------------+----------------
    ||            530503 |           5964
    ||     (1 row)
    
    ||     => SELECT * FROM accounts WHERE acc_no = 1 FOR SHARE;
    
    ||      acc_no | amount 
    ||     --------+--------
    ||           1 | 100.00
    ||     (1 row)
    

    И вот уже две транзакции блокируют строку:

    => SELECT * FROM pgrowlocks('accounts') \gx
    
    -[ RECORD 1 ]---------------
    locked_row | (0,10)
    locker     | 62
    multi      | t
    xids       | {530501,530503}
    modes      | {Share,Share}
    pids       | {5892,5964}
    

    Что теперь произойдет, когда первая транзакция завершится? Вторая транзакция будет разбужена, но увидит, что блокировка строки никуда не исчезла, и снова встанет в «очередь» — на этот раз за третьей транзакцией:

    => COMMIT;
    => SELECT * FROM locks_v WHERE pid = 5928;
    
     pid  |   locktype    |   lockid    |       mode       | granted 
    ------+---------------+-------------+------------------+---------
     5928 | relation      | accounts    | RowExclusiveLock | t
     5928 | tuple         | accounts:10 | ExclusiveLock    | t
     5928 | transactionid | 530503      | ShareLock        | f
     5928 | transactionid | 530502      | ExclusiveLock    | t
    (4 rows)
    

    И только когда третья транзакция завершится (и если за это время не появятся другие разделяемые блокировки), вторая сможет выполнить обновление.

    ||     => COMMIT;
    

    |  UPDATE 1
    
    |  => ROLLBACK;
    

    Пожалуй, пора сделать некоторые практические выводы.

    • Одновременно обновлять одну и ту же строку таблицы во многих параллельных процессах — не самая удачная идея.
    • Если и использовать разделяемые блокировки типа SHARE в приложении, то осмотрительно.
    • Проверка внешних ключей не должна мешать, поскольку ключевые поля обычно не меняются, а режимы KEY SHARE и NO KEY UPDATE совместимы.


    Просили не занимать


    Обычно команды SQL ожидают освобождения необходимых им ресурсов. Но иногда хочется отказаться от выполнения команды, если блокировку не удалось получить сразу же. Для этого такие команды, как SELECT, LOCK, ALTER, позволяют использовать фразу NOWAIT.

    Например:

    => BEGIN;
    => UPDATE accounts SET amount = amount + 100.00 WHERE acc_no = 1;
    

    |  => SELECT * FROM accounts FOR UPDATE NOWAIT;
    
    |  ERROR:  could not obtain lock on row in relation "accounts"
    

    Команда немедленно завершается с ошибкой, если ресурс оказался занят. В прикладном коде такую ошибку можно перехватить и обработать.

    У команд UPDATE и DELETE фразу NOWAIT указать нельзя, но можно сначала выполнить SELECT FOR UPDATE NOWAIT, а затем — если получилось — обновить или удалить строку.

    Есть еще один вариант не ждать — использовать команду SELECT FOR с фразой SKIP LOCKED. Такая команда будет пропускать заблокированные строки, но обрабатывать свободные.

    |  => BEGIN;
    |  => DECLARE c CURSOR FOR
    |       SELECT * FROM accounts ORDER BY acc_no FOR UPDATE SKIP LOCKED;
    |  => FETCH c;
    
    |   acc_no | amount 
    |  --------+--------
    |        2 | 200.00
    |  (1 row)
    

    В этом примере первая — заблокированная — строка была пропущена и мы сразу получили (и заблокировали) вторую.

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

    => ROLLBACK;
    
    |  => ROLLBACK;
    

    Продолжение.
    • +24
    • 6.4k
    • 7
    Postgres Professional
    131.39
    Разработчик СУБД Postgres Pro
    Share post

    Similar posts

    Comments 7

      +1
      Спасибо за статью, сохранил *thumbs up*
        +1

        Рад, что понравилось.

        +1

        Спасибо за статьи, довольно доступная подача не самого простого материала. Хорошо помогает упорядочить свои отрывочные знания.


        Уточните, пожалуйста, следующий момент. Вот цитата: "Команда UPDATE сама выбирает минимальный подходящий режим блокировки; обычно строки блокируются в режиме FOR NO KEY UPDATE".
        Плюс к этому известно, что любой апдейт в постгресе все равно действует как delete+insert, даже если обновляется одно "простое" поле. Во многих ORM удобно использовать save метод, который генерирует update на все поля, и изменившиеся, и нет. Постгрес достаточно умный, чтобы понять, что KEY поля не менялись, или нет? И вообще практика при апдейте указывать только нужные поля реально несёт какую-то пользу (ну кроме сокращения трафика).

          +1
          Постгрес достатчно умный, чтобы понять, что KEY поля не менялись, или нет?

          Достаточно (:


          И вообще практика при апдейте указывать только нужные поля реально несёт какую-то пользу

          Безусловно несет.
          Если вы обновляете поля, которых нет ни в одном индексе, у вас работает HOT. Но если вы обновляете все поля (и у вас на таблице есть хотя бы один индекс) — увы.
          Ну и журнальные записи будет меньше.

            +2

            А HOT будет применен, если обновится поле с индексом, но значение останется тем же? По ссылке про него прочитал, но не совсем понятно, "обновление поля" — это любой апдейт или именно изменение значения.

              +1

              Хм, век живи…
              Я был уверен, что нет, но решил проверить. И оказалось, что если при обновлении значение фактически не меняется, то HOT работает. Более того, и в журнал ничего лишнего не пишется.
              Так что обманул я вас, нет (почти) никакой разницы.

                0

                Спасибо. Явно указывать, что сохранять, все равно, конечно, надо, но в legacy коде хотя бы можно не рыться в надежде что-то таким образом оптимизировать :)

        Only users with full accounts can post comments. Log in, please.