Postgres запрос SELECT не видит данных из закомиченной транзакции при изоляции Read Commited

Рейтинг: 2Ответов: 1Опубликовано: 12.02.2025

Столкнулся со странным поведением Postgres при параллельном выполнении запроса в разных транзакциях. Postgres 15.3, уровень изоляции транзакций на БД - Read Commited. Запрос выглядит так:

with inserting as (
            insert into device(device_guid, platform, created_at)
            values (
                '5f0ca10f-a257-44ac-940e-a808c63a9914'::uuid,
                'WEB',
                NOW()
            )
            ON CONFLICT (device_guid) DO NOTHING
            returning *
        )

        select id, device_guid, platform, created_at, 'INSERT' as db_operation from inserting
                UNION ALL
        select id, device_guid, platform, created_at, 'GET' as db_operation from device where device_guid = '5f0ca10f-a257-44ac-940e-a808c63a9914'::uuid

(на device_guid стоит unique index)

Ожидалось следующее поведение:

  1. На вход приходят device_guid и некоторые данные (в примере '5f0ca10f-a257-44ac-940e-a808c63a9914', 'WEB', NOW())
  2. В CTE происходит попытка вставки переданного device_guid. Если такой device_guid уже есть, то просто выходим из CTE к следующему шагу (select). Если такого device_guid нет в таблице, то вставляем его в таблицу и так же вставляем его в CTE inserting
  3. Далее делаем селект из физической таблицы device и временной CTE таблицы inserting. Здесь db_operation подскажет, откуда взяли строку, из CTE (INSERT) или строка уже была в таблице device (GET).
  4. Если выполнить дважды запрос, то все ожидаемо отрабатывает: первый раз появляется одна строка с db_operation = 'INSERT', а второй раз одна строка с db_operation = 'GET'.

Но все ломается, когда две транзакции одновременно начинают выполнять этот запрос c одинаковым device_guid.. Я рассчитывал, что правильным будет следующее поведение:

  1. Транзакция А стартует чуть раньше транзакции Б
  2. Тр-я А пытается вставить строку в таблицу, в которой есть уникальный индекс. А это значит, что тр-я А возмет эксклюзивную блокировку на строку и share блокировку на индекс до тех пор, пока транзакция не закоммитится.
  3. Тр-я Б видит, что тр-я А взяла блокировку и просто ждет, пока блокировка отпустится.
  4. Тр-я А делает успешную вставку и переходит к двум select. Но т.к. транзакция еще не закомичена, то блокировка не снимается.
  5. Тр-я А завершает свои селекты и весь скрипт запроса и коммитит транзакцию с отпуском блокировки.
  6. Тр-я Б видит, что блокировка снята, пытается вставить строку в device с тем же device_guid, получает конфликт, который спокойно пропускает и переходит к блоку с select
  7. На этом этапе уже должна была быть закомичена тр-я А (а точнее уже в п.5), а значит select из таблицы device уже должен увидеть строку, которую вставила тр-я А. Это гарантирует нам уровень изоляции Read commited.
  8. Здесь блок select-ов с union all должен был вернуть одну строку с db_operation = 'GET', т.е. строку уже лежащую в физической таблице device, которую туда положила тр-я А

Но по-факту, 8 пункт возвращает 0 строк..

Ошибка обнаружилась изначально в spring data jdbc и jooq, НО затем я многократно воспроизвел ее в стандартном psql. Сделать это можно обернув sql-скрипт в begin; ... commit; и выполнив его в двух отдельных консолях psql. но только в одной прописать begin; ... (без commit;), а затем во второй консоли весь скрипт с begin; ... commit; Если глянуть локи, то видно, что вторая транзакция будет заблокирована первой и виден лок на юник индексе поля device_guid. Но как только ввести commit; в первой консоли, то вторая завершит свою транз-ю и вернет 0 строк..

Такое ощущение, что происходит это потому что, когда готовится коммит транз-ии А, то сначала отпускается лок на индексе и строке, а только затем происходит коммит всей транзакции А. И за это время между отпуском лока и комитом, транзакция Б успевает выполнить свой код, так и не увидев закомиченную строку от транзакции А.. Третий день пытаюсь понять, это я ожидаю не того поведения, либо это постгрес ведет себя неожиданно. Пересматривал доки по блокировкам, CTE, уровням изоляции. Вроде бы все описано так, как я ожидаю. Буду благодарен любой информации!

Ответы

▲ 1Принят

Вариант, чтобы отрабатывало корректно - использовать запрос с проверкой поля xmax при INSERT INTO (https://postgrespro.ru/docs/postgrespro/9.5/ddl-system-columns)

with inserting as (
    insert into device(device_guid, platform, created_at)
    values (
        '5f0ca10f-a257-44ac-940e-a808c63a9914'::uuid,
        'WEB',
        NOW()
    )
    ON CONFLICT (device_guid) DO UPDATE
    SET platform = EXCLUDED.platform
    returning *, CASE WHEN xmax = 0 THEN 'INSERT' ELSE 'GET' END AS 
    db_operation
)
SELECT * FROM inserting;

Если xmax = 0, то строку вставили, но еще не изменяли. Иначе туда запишется ID изменяющей транзакции.

Explain analyze (INSERT):

CTE Scan on inserting  (cost=0.01..0.04 rows=1 width=200) (actual time=0.167..0.172 rows=1 loops=1)
CTE inserting
->  Insert on device  (cost=0.00..0.01 rows=1 width=168) (actual time=0.162..0.165 rows=1 loops=1)
      Conflict Resolution: UPDATE
      Conflict Arbiter Indexes: device_device_guid_uq_idx
      Tuples Inserted: 1
      Conflicting Tuples: 0
      ->  Result  (cost=0.00..0.01 rows=1 width=168) (actual  time=0.030..0.031 rows=1 loops=1)
Planning Time: 0.135 ms
Execution Time: 0.313 ms

Explain analyze (GET)

CTE Scan on inserting  (cost=0.01..0.04 rows=1 width=200) (actual time=0.093..0.094 rows=1 loops=1)
CTE inserting
->  Insert on device  (cost=0.00..0.01 rows=1 width=168) (actual time=0.090..0.091 rows=1 loops=1)
      Conflict Resolution: UPDATE
      Conflict Arbiter Indexes: device_device_guid_uq_idx
      Tuples Inserted: 0
      Conflicting Tuples: 1
      ->  Result  (cost=0.00..0.01 rows=1 width=168) (actual time=0.009..0.009 rows=1 loops=1)
Planning Time: 0.059 ms
Execution Time: 0.153 ms