Хабрахабр

Блокировки в 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 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 |
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), что и с обычным номером.

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

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

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

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

Во-первых, сделаем вывод чуть более компактным, во-вторых, ограничимся только интересными блокировками (фактически, отбрасываем блокировки виртуальных номеров транзакций, индекса на таблице accounts, pg_locks и самого представления — в общем, всего того, что не имеет отношения к делу и только отвлекает). Начнем с того, что построим представление над 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, которую видно в pg_locks. Не путайте блокировку версии строки (tuple lock) и блокировку строки (row lock). Вторая — пометка в странице данных: 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;

Продолжение следует.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть