Особенности работы внутренних механизмов PostgreSQL позволяют ему быть очень быстрым в одних ситуация и «не очень» в других. Сегодня остановимся на классическом примере конфликта между тем, как работает СУБД и тем, что делает с ней разработчик — UPDATE vs принципы MVCC.

Кратко сюжет из отличной статьи:
Когда строка изменяется командой UPDATE, фактически выполняются две операции: DELETE и INSERT. В текущей версии строки устанавливается xmax, равный номеру транзакции, выполнившей UPDATE. Затем создается новая версия той же строки; значение xmin у нее совпадает с значением xmax предыдущей версии.
Через какое-то время после завершения этой транзакции старая или новая версии, в зависимости от COMMIT/ROOLBACK, будут признаны «мертвыми» (dead tuples) при проходе VACUUM по таблице и зачищены.



Но это произойдет далеко не сразу, а вот проблемы с «мертвецами» можно нажить очень быстро — при многократном или массовом обновлении записей в большой таблице, а чуть позже столкнуться с ситуацией, что и VACUUM не сможет помочь.

#1: I Like To Move It


Допустим, ваш метод на бизнес-логике работает себе, и вдруг понимает, что надо бы обновить поле X в какой-то записи:

UPDATE tbl SET X = <newX> WHERE pk = $1;

Потом, по ходу выполнения, выясняет, что поле Y надо бы обновить тоже:

UPDATE tbl SET Y = <newY> WHERE pk = $1;

… а потом еще и Z — чего уж мелочиться-то?

UPDATE tbl SET Z = <newZ> WHERE pk = $1;

Сколько версий этой записи теперь имеем в базе? Ага, 4 штуки! Из них одна актуальная, а 3 должен будет прибрать за вами [auto]VACUUM.

Не надо так! Используйте обновление всех полей за один запрос — почти всегда логику работы метода можно так изменить:

UPDATE tbl SET X = <newX>, Y = <newY>, Z = <newZ> WHERE pk = $1;

#2: Use IS DISTINCT FROM, Luke!


Итак, вам все-таки захотелось обновить много-много записей в таблице (в ходе применения скрипта или конвертера, например). И в скрипт летит что-то такое:

UPDATE tbl SET X = <newX> WHERE pk BETWEEN $1 AND $2;

Примерно в таком виде запрос встречается достаточно часто и почти всегда не для заполнения пустого нового поля, а для коррекции каких-то ошибок в данных. При этом сама корректность уже существующих данных вообще не учитывается — а зря! То есть запись переписывается, даже если там лежало ровно то, что и хотелось — а зачем? Поправим:

UPDATE tbl SET X = <newX> WHERE pk BETWEEN $1 AND $2 AND X IS DISTINCT FROM <newX>;

Многие не в курсе про существование такого замечательного оператора, поэтому вот шпаргалка по IS DISTINCT FROM и другим логическим операторам в помощь:

… и немного про операции над сложными ROW()-выражениями:

#3: А я милого узнаю по… блокировке


Запускаются два одинаковых параллельных процесса, каждый из которых пытается пометить на записи, что она находится «в работе»:

UPDATE tbl SET processing = TRUE WHERE pk = $1;

Даже если эти процессы предметно делают независимые друг от друга вещи, но в рамках одного ID, на этом запросе второй клиент «залочится», пока не закончится первая транзакция.

Решение №1: задача сведена к предыдущей

Просто снова добавим IS DISTINCT FROM:

UPDATE tbl SET processing = TRUE WHERE pk = $1 AND processing IS DISTINCT FROM TRUE;

В таком виде второй запрос просто ничего не будет менять в базе, там и так уже «все как надо» — поэтому и блокировка не возникнет. Дальше факт «ненахождения» записи уже обрабатываем в прикладном алгоритме.

Решение №2: advisory locks

Большая тема для отдельной статьи, в которой можно почитать про способы применений и «грабли» рекомендательных блокировок.

Решение №3: без[д]умные вызовы

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