触发以确保总和小于不同 table 中的值

Trigger to ensure aggregate sum less than a value in a different table

我工作的问题领域是return电子商务管理。

我正在使用 Postgres (11.9) 并具有以下 table(我从每个 table 中删除了一些与问题无关的字段):

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 的不同项目组成。当用户希望 return 一件商品时,他们可以 return 达到每个 return.

的数量

更具体地说,如果我在一个订单中购买两件黑色小 T 恤,您会在数据库中找到一件 order,其中一件 order_item 的数量为 2。我将能够创建两个单独的 return,每个 return 有一个 return_item 引用相同的 order_item_id 但数量为 1.

order_itemreturn_item 被插入到不同的事务中,我不会阻止多个事务同时更新其中任何一个。

如何确保具有特定 order_item_id 的所有 return_item 的每个 quantity 的总值不超过存储在相应 order_item 中的数量用说 id?

用更简单的英语来说,当原始订单中该商品的数量为 2 时,我如何防止 return编辑该商品,如我所描述的示例?

在大多数情况下,编写应用程序检查来捕获此问题很容易,并且向我的 return_item 插入添加业务规则检查 WHERE 子句也不难,但这些解决方案都不是给我唯一性约束所做的一致性保证。我将如何编写触发器以在此处插入时出错?或者有比触发器更好的方法吗?

我能想到的唯一解决方案是反规范化。

integertotal_returns 添加到 order_item,每当添加或删除行或 quantity 更改时,都会由 return_item 上的触发器修改.

然后您可以对 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;

我不建议存储这些派生信息,因为维护起来很乏味。

相反,您可以在 DML 查询中实现逻辑。考虑以下查询来注册一个新的 returned 项目:

insert into return_item (order_item_id, quantity)
select v.*
from (values (, )) 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
)

输入值以 </code> 和 <code> 的形式给出。查询在 order_items 中引入相应的行,并检查该项目的总 returned 数量是否大于订购数量。

整个逻辑在单个查询中实现,因此如果您有多个并发进程,则不存在竞争条件的风险。

在您的应用程序中,您可以检查查询是否影响了任何行。如果没有,那么您就知道 return 被拒绝了。

如果要定期使用,可以将查询放在存储过程中。

你专门要求触发方案。作为记录,您也可以使用普通 SQL 实现相同的效果,只要您可以确保所有客户端都使用必要的语句即可。相关范例:

  • Delete parent if it's not referenced by any other child

触发解决方案

您提到并发写访问是可能的。这使它变得更加复杂。例如,两个事务可能会同时尝试 return 来自同一个 order_item 的项目。两者都检查并发现可以 returned 并这样做,从而超过 order_item.quantity 的数量 1。经典并发警告。

为了防御它,您可以使用 SERIALIZABLE 事务隔离。但这相当昂贵,并且所有可能写入涉及的 tables 的事务都必须坚持它。

或者,取消战略 row locks in default 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<>fiddle here

任何对 return 项的尝试都会首先锁定 order_item 中的父行。竞争事务必须等到这个事务被提交——然后才会看到新提交的行。这消除了竞争条件。 FOR NO KEY UPDATE 是正确的锁强度。既不弱也不强。

写入 order_item 也会影响项目总数。但是那些也(隐含地)取出写锁并被迫以相同的方式排队。但是,如果以后可以对 order_item.quantity 进行更新,则您必须在那里的触发器中添加类似的检查(以防它被降低)。

我在超出数量时出现的错误消息中添加了基本信息。您可能会在此处添加或多或少的信息。

可以优化示例设置。 “order”是一个保留字。 table return 在示例中没有用,return_item.return_id 也是如此。 return_item 中缺少 PK。 order_item.quantity 应该是 NOT NULL CHECK (quantity > 0)COALESCE 在触发函数中的正确实现是多余的。但这些都是次要笔记。