Представим, что у вас есть некоторая табличка статистики, куда вы периодически скидываете таймстамп последнего "текущего" состояния в паре координат - например, (ID организации, ID сотрудника)
.
Как больно наступить на грабли в совсем простом, казалось бы, запросе?
Вначале он обычно выглядит примерно как-то вот так:
INSERT INTO activity(
fk1
, fk2
, ts
)
VALUES
('8d335b64-8597-464c-affb-b4734bc67acc', '198d636e-1b35-4a20-a42a-38ec9abc47ee', now())
, ('8d335b64-8597-464c-affb-b4734bc67acc', '4fa31f97-c5ea-40a3-8a4b-1df598db39ca', now())
, ('8d335b64-8597-464c-affb-b4734bc67acc', '6b46ec7d-70c4-4b6d-b6bc-4a0a6cf45051', now())
, ('8d335b64-8597-464c-affb-b4734bc67acc', 'fc41beb4-f67d-45f4-b37a-c71cfe530f43', now())
, ('8d335b64-8597-464c-affb-b4734bc67acc', 'c868affb-3a8c-4692-92de-2a952134d52f', now())
, ('8d335b64-8597-464c-affb-b4734bc67acc', '75abe193-ab1f-41b3-8fc8-cd8f34b269f2', now())
, ('a227a9ee-bb05-48d6-a885-94f7cea06cd8', '388179c4-594e-4088-bb56-c99ff4df7efe', now())
, ('8d335b64-8597-464c-affb-b4734bc67acc', 'd8b0af23-53a5-45cb-8b08-7f27b04faa6d', now())
, ('8d335b64-8597-464c-affb-b4734bc67acc', '75aa761d-28da-4b9d-b5d5-a74a60725c36', now())
-- ... +100500 строк
ON CONFLICT
(fk1, fk2)
DO UPDATE SET
ts = greatest(activity.ts, EXCLUDED.ts);
Давайте разберем те проблемы, которые создал таким запросом разработчик как для себя, так и для своей базы.
Проблема #1: deadlock при конкурентной вставке
LOG: process 19607 still waiting for ShareLock on transaction 3456521168 after 1000.105 ms
DETAIL: Process holding the lock: 18492. Wait queue: 19607.
CONTEXT: while inserting index tuple (16124,49) in relation "activity"
Казалось бы, ничто не предвещало - у нас ведь "обычный INSERT
"?
На самом деле, не совсем обычный - обратите внимание на часть DO UPDATE SET
- то есть при наличии в таблице записи с той же ключевой парой (fk1, fk2)
, она должна быть обновлена.
И тут мы возвращаемся к проблеме плохо упорядоченных обновлений, о которой я рассказывал в статье "Борем deadlock при пакетных UPDATE".
Там же описано и решение - упорядочить обновляемые записи заранее. В нашем случае мы не знаем достоверно, какие из записей захотят обновляться, а какие будут вставлены впервые, поэтому правильным решением будет отсортировать заранее все.
Проблема #2: выполнение функции на каждой записи
В каждой записи у нас указана функция now()
, которая в рамках одного запроса всегда возвращает одно и то же значение.
Если бы разработчик хотел действительно разные "реальные" значения, то использовал бы
clock_timestamp()
. Подробнее про разные функции "сейчас" можно почитать в "SQL HowTo: наперегонки со временем".
Значит, у нас налицо просто техническая ошибка с повторным вычислением одного и того же, которая нам чего-то да стоила. Давайте попробуем оценить ее величину, вычислив now()
10M раз:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT i, now() FROM generate_series(1, 1e7) i;
Function Scan on generate_series i (actual time=2464.211..3729.080 rows=10000000 loops=1)
Planning Time: 0.068 ms
Execution Time: 4176.732 ms
... или не вычислив:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT i, NULL::timestamptz FROM generate_series(1, 1e7) i;
Function Scan on generate_series i (actual time=2191.738..3374.842 rows=10000000 loops=1)
Planning Time: 0.035 ms
Execution Time: 3785.943 ms
Не сказать, что разница грандиозна, но ~400мс на 10M вызовов тут потрачены - это нам очень повезло, что now()
достаточно "легкая", но все-таки лучше эти вызовы не клонировать.
Проблема #3: повторяющиеся значения в теле запроса
Очевидно, что организаций у нас всегда будет много меньше, чем сотрудников в них, поэтому значения ID организации
будут повторяться достаточно часто.
Этой темы я уже подробно касался в статье "PostgreSQL Antipatterns: «слишком много золота»", и приведенное там решение достаточно тривиально - собрать все значения в один JSON-параметр, клонируя как можно меньше данных.
В нашем случае его структура будет примерно такой:
{
fk1_1 : [fk2_1, fk2_2]
, fk1_2 : [fk2_3, fk2_4, fk2_5]
}
Избавляемся от проблем
Так как же, в итоге, должен выглядеть идеальный запрос для подобной задачи?
Примерно как-то вот так:
INSERT INTO activity(
fk1
, fk2
, ts
)
SELECT
fk1
, json_array_elements_text(fk2s) fk2 -- разворачиваем json-массив
, ts
FROM
json_each($1::json) T(fk1, fk2s) -- разворачиваем ключи json-объекта
, now() ts -- STABLE-функция вызовется однократно
ORDER BY
1, 2 -- сортируем по (fk1, fk2), чтобы гарантировать совпадение порядка
ON CONFLICT
(fk1, fk2)
DO UPDATE SET
ts = greatest(activity.ts, EXCLUDED.ts);
/*
$1 = {
"8d335b64-8597-464c-affb-b4734bc67acc" : [
"198d636e-1b35-4a20-a42a-38ec9abc47ee"
, "4fa31f97-c5ea-40a3-8a4b-1df598db39ca"
, "6b46ec7d-70c4-4b6d-b6bc-4a0a6cf45051"
]
, "a227a9ee-bb05-48d6-a885-94f7cea06cd8" : [
"388179c4-594e-4088-bb56-c99ff4df7efe"
]
}
*/