Две оплаты прошли, а срок вырос один раз. Потерянное обновление в PostgreSQL

Допустим, подписчик оплатил два продления по 30 суток. Оба обработчика прочитали один и тот же срок, прибавили к нему по 30 суток и сохранили результат. Обе операции завершились успешно, а срок увеличился только один раз.

В предыдущей статье два заказа претендовали на один слот консультации. При продлении подписки каждая из двух подтверждённых оплат должна добавить свой период к общему сроку. Статья про бронирование слота

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

Шаг

Обработчик A

Обработчик B

Прочитали срок

1 января, 12:00

1 января, 12:00

Прибавили 30 суток

31 января, 12:00

31 января, 12:00

Сохранили новую дату

31 января, 12:00

31 января, 12:00

Все даты ниже — 2026 год, время UTC. Период фиксированный, 720 часов. Для тарифа с календарными месяцами понадобится другое правило прибавления.

Создайте временную таблицу в тестовом подключении PostgreSQL. Все команды примера выполняются в нём же.

CREATE TEMP TABLE renewal_demo ( id integer PRIMARY KEY, access_until timestamptz NOT NULL);INSERT INTO renewal_demo VALUES (1, '2026-01-01 12:00:00+00');

Пусть оба обработчика уже вычислили 31 января. Тогда они запишут одну и ту же дату.

UPDATE renewal_demoSET access_until = timestamptz '2026-01-31 12:00:00+00'WHERE id = 1;UPDATE renewal_demoSET access_until = timestamptz '2026-01-31 12:00:00+00'WHERE id = 1;SELECT access_until AT TIME ZONE 'UTC' AS access_until_utcFROM renewal_demo;

Результат — 31 января, 12:00. Этот последовательный пример воспроизводит запись двух заранее вычисленных дат. Блокировка строки упорядочит такие обновления, но оба по-прежнему записывают одно и то же значение.

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

UPDATE renewal_demoSET access_until = timestamptz '2026-01-01 12:00:00+00'WHERE id = 1;UPDATE renewal_demoSET access_until = access_until + interval '720 hours'WHERE id = 1;UPDATE renewal_demoSET access_until = access_until + interval '720 hours'WHERE id = 1;SELECT access_until AT TIME ZONE 'UTC' AS access_until_utcFROM renewal_demo;

Теперь получится 2 марта, 12:00. К исходному сроку добавились 60 суток. Приложение передало операцию прибавления, поэтому второй запрос использовал результат первого.

Для одновременных запросов на уровне Read Committed PostgreSQL документирует такое поведение. Если строка уже обновляется, второй UPDATE дождётся завершения первой транзакции. После её коммита он повторно проверит условие WHERE и применит прибавление к обновлённой версии строки. Здесь условие остаётся верным, поскольку идентификатор подписки сохраняется. Документация PostgreSQL по Read Committed

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

Если приложение читает срок, вычисляет новую дату и записывает её обратно, два обработчика могут затереть результат друг друга. Для показанного случая прибавление внутри UPDATE решает эту проблему: каждое продление применяется к актуальному значению строки.

В проверке обработчика полезно разделить чтение и запись так, чтобы два вызова успели получить один исходный срок. Если каждый добавляет 30 суток, итог должен увеличиться на 60. Проверка только успешного завершения обоих вызовов потерянное продление пропустит.

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев