Проблемная область, в которой я работаю, — это управление возвратами для электронной коммерции.
Я использую Postgres (11.9) и имею следующие таблицы (я удалил ряд полей из каждой таблицы, которые не имеют отношения к вопросу):
CREATE TABLE "order" (
id BIGSERIAL PRIMARY KEY,
platform text NOT NULL,
platform_order_id text NOT NULL,
CONSTRAINT platform_order_id_unique UNIQUE (platform, platform_order_id)
);
CREATE TABLE order_item (
id BIGSERIAL PRIMARY KEY,
order_id int8 NOT NULL,
platform_item_id text NOT NULL,
quantity integer,
CONSTRAINT FK_order_item_order_id FOREIGN KEY (order_id) REFERENCES "order",
CONSTRAINT platform_item_id_unique UNIQUE (order_id, platform_item_id)
);
CREATE TABLE return (
id BIGSERIAL PRIMARY KEY,
order_id int8 NOT NULL,
CONSTRAINT FK_return_order_id FOREIGN KEY (order_id) REFERENCES "order"
);
CREATE TABLE return_item (
return_id int8 NOT NULL,
order_item_id int8 NOT NULL,
quantity integer NOT NULL,
CONSTRAINT FK_return_item_return_id FOREIGN KEY (return_id) REFERENCES return,
CONSTRAINT FK_return_item_item_id FOREIGN KEY (order_item_id) REFERENCES order_item
);
Чтобы кратко объяснить предметную область, я извлекаю заказы с платформ электронной коммерции и сохраняю их в своей базе данных. Заказ состоит из одного или нескольких отдельных предметов, которые имеют quantity > 1
. Когда пользователь желает вернуть товар, он может вернуть количество, не превышающее количество за возврат.
Говоря более конкретно, если я куплю две черные маленькие футболки в одном заказе, вы найдете order
в базе данных с одним order_item
, количество которого равно 2
. У меня была бы возможность создать два отдельных возврата, в каждом из которых один return_item
ссылался бы на один и тот же order_item_id
, но с количеством 1.
order_item
и return_item
вставлены в разные транзакции, и я не запрещаю нескольким транзакциям обновлять любую из них одновременно.
Как я могу гарантировать, что суммарное значение каждого quantity
для всех return_item
с конкретным order_item_id
не превышает количество, хранящееся в соответствующем order_item
для с указанным id
?
Проще говоря, как предотвратить возврат третьего предмета, если количество этого предмета в исходном заказе было 2, как в примере, который я описал?
Достаточно просто написать проверку приложения, чтобы поймать это в большинстве случаев, а также несложно добавить пункт проверки бизнес-правил WHERE
в мои return_item
вставки, но ни одно из этих решений не дает мне таких гарантий согласованности, как ограничение уникальности. Как мне написать здесь триггер для ошибки при вставке? Или есть лучший подход, чем триггер?
Единственное решение, о котором я могу думать, это денормализация.
Добавьте столбец integer
total_returns
в order_item
, который изменяется с помощью триггера на return_item
всякий раз, когда строки добавляются или удаляются или изменяется quantity
.
Затем у вас может быть простое проверочное ограничение на order_item
, которое гарантирует, что ваш инвариант выполняется.
BEGIN;
/* for consistency */
ALTER TABLE order_item
ALTER quantity SET NOT NULL
ALTER quantity SET DEFAULT 0;
ALTER TABLE order_item
ADD total_returns bigint DEFAULT 0 NOT NULL;
ALTER TABLE order_item
ADD CONSTRAINT not_too_many_returns
CHECK (total_returns <= quantity);
/* trigger function */
CREATE FUNCTION requrn_order_trig() RETURNS trigger
LANGUAGE plpgsql AS
$$BEGIN
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
UPDATE order_item
SET total_returns = total_returns + NEW.quantity;
WHERE id = NEW.order_item_id;
END IF;
IF TG_OP = 'DELETE' OR TG_OP = 'UPDATE' THEN
UPDATE order_item
SET total_returns = total_returns - OLD.quantity;
WHERE id = OLD.order_item_id;
END IF;
RETURN NULL;
END;$$;
CREATE TRIGGER requrn_order_trig
AFTER INSERT OR UPDATE OR DELETE ON return_item
FOR EACH ROW EXECUTE PROCEDURE requrn_order_trig();
UPDATE order_item AS oi
SET total_returns = (SELECT sum(quantity)
FROM return_item AS r
WHERE r.order_item_id = oi.id);
COMMIT;
Это возможно, но чтобы избежать условий гонки, вам нужна SERIALIZABLE
или пессимистическая блокировка, что также не очень хорошо сказывается на производительности.
Я бы не рекомендовал хранить эту производную информацию, потому что ее утомительно поддерживать.
Вместо этого вы можете реализовать логику в запросе DML. Рассмотрим следующий запрос для регистрации нового возвращенного элемента:
insert into return_item (order_item_id, quantity)
select v.*
from (values ($1, $2)) as v(order_item_id, quantity)
inner join order_items oi on oi.id = v.order_item_id
where oi.quantity >= v.quantity + (
select coalesce(sum(ri.quantity), 0) from return_item ri where ri.order_item_id = v.order_item_id
)
Входные значения задаются как $1
и $2
. Запрос выводит соответствующую строку в order_items
и проверяет, превышает ли общее возвращенное количество этого товара заказанное количество.
Вся логика реализована в одном запросе, поэтому при наличии нескольких параллельных процессов нет риска состояния гонки.
Из вашего приложения вы можете проверить, затронул ли запрос какую-либо строку. Если это не так, то вы знаете, что возврат был отклонен.
Вы можете поместить запрос в хранимую процедуру, если собираетесь использовать ее на регулярной основе.
Вы просите конкретно триггерное решение. Кстати, вы можете добиться того же и с помощью простого SQL, если вы можете убедиться, что все клиенты используют необходимые операторы. Связанный пример:
Вы упомянули, что возможен одновременный доступ для записи. Это делает его более сложным. Например, две транзакции могут попытаться вернуть элемент из одного и того же order_item
одновременно. Оба проверяют и обнаруживают, что можно вернуть еще один элемент, и делают это, тем самым превышая количество order_item.quantity
на 1. Предостережение классического параллелизма.
Чтобы защититься от него, вы можете использовать SERIALIZABLE изоляцию транзакций. Но это значительно дороже, и все транзакции, которые могут выполнять запись в задействованные таблицы, должны придерживаться его.
В качестве альтернативы снимите стратегические блокировки строк на уровне изоляции по умолчанию READ COMMITTED. Вот базовая реализация:
Триггерная функция:
CREATE FUNCTION trg_return_item_insup_bef()
RETURNS trigger
LANGUAGE plpgsql AS
$func$
DECLARE
_ordered_items int;
_remaining_items int;
BEGIN
SELECT quantity
FROM order_item
WHERE id = NEW.order_item_id
FOR NO KEY UPDATE -- lock the parent row first ... (!!!)
INTO _ordered_items; -- ... while fetching quantity
SELECT _ordered_items - COALESCE(sum(quantity), 0)
FROM return_item
WHERE order_item_id = NEW.order_item_id
INTO _remaining_items;
IF NEW.quantity > _remaining_items THEN
RAISE EXCEPTION 'Tried to return % items, but only % of % are left.'
, NEW.quantity, _remaining_items, _ordered_items;
END IF;
RETURN NEW;
END
$func$;
Курок:
CREATE TRIGGER insup_bef
BEFORE INSERT OR UPDATE ON return_item
FOR EACH ROW
EXECUTE PROCEDURE trg_return_item_insup_bef();
db<>рабочий пример здесь
Любая попытка вернуть элементы сначала блокирует родительскую строку в order_item
. Конкурирующие транзакции должны дождаться, пока эта будет зафиксирована, и тогда они увидят новые зафиксированные строки. Это устраняет состояние гонки. FOR NO KEY UPDATE правильная сила блокировки. Ни в слабую, ни в слишком сильную.
Запись в order_item
также может повлиять на общее количество предметов. Но они также снимают блокировку записи (неявно) и вынуждены стоять в очереди таким же образом. Но если возможны более поздние обновления order_item.quantity
, вам придется добавить туда аналогичные проверки в триггере (на случай, если он будет понижен).
Я добавил основную информацию в сообщение об ошибке, возникающее при превышении количества. Вы можете разместить больше или меньше информации там.
Пример установки может быть оптимизирован. "заказ" - зарезервированное слово. Таблица return
в примере бесполезна, как и return_item.return_id
. ПК отсутствует в return_item
. order_item.quantity
должно быть NOT NULL CHECK (quantity > 0)
. COALESCE
в функции триггера является избыточным при правильной реализации. Но это второстепенные заметки.
Это отличный ответ и, безусловно, работает. Мне вот интересно, а можно ли это сделать без столбца денормализации/автообновления? Просто запустить запрос через триггер во время вставки для нового return_item и отклонить вставку, если общее количество превысит допустимое количество? Если это возможно, это было бы самым чистым ИМО. Я никогда раньше не писал триггеры, поэтому я не уверен, что это возможно.