并发团体预订的策略?


8

考虑一个座位预订数据库。有一个n个席位的列表,每个席位都有一个属性is_booked。0表示不是,1表示是。任何更高的数量,都有一个超额预订。

在不允许超额预定的情况下进行多笔交易(每笔交易将同时预订一组y个席位)的策略是什么?

我只需选择所有未预订的座位,从中随机选择一组y,然后全部预订,然后检查预订是否正确(也就是is_booked的数量未超过一个,这表示已预订了该座位的另一笔交易,提交),然后提交。否则中止并重试。

这在Postgres中的隔离级别Read Committed上运行。

Answers:


5

因为您没有告诉我们很多您需要的东西,所以我会为您做所有的猜测,并且为了简化一些可能的问题,我们将使其变得相当复杂。

MVCC的第一件事是在高度并发的系统中,您希望避免表锁定。通常,如果不锁定事务表,就无法确定不存在什么。这给您一个选择:不要依赖INSERT

在这里,我只剩下做一个真正的预订应用程序的练习。我们不处理

  • 超量预订(作为一项功能)
  • 或者,如果没有剩余的X座位怎么办。
  • 建立客户和交易。

关键是在交易开始之前,UPDATE.我们仅锁定行UPDATE。之所以可以这样做,是因为我们将所有待售的机票插入了表格中event_venue_seats

创建一个基本模式

CREATE SCHEMA booking;
CREATE TABLE booking.venue (
  venueid    serial PRIMARY KEY,
  venue_name text   NOT NULL
  -- stuff
);
CREATE TABLE booking.seats (
  seatid        serial PRIMARY KEY,
  venueid       int    REFERENCES booking.venue,
  seatnum       int,
  special_notes text,
  UNIQUE (venueid, seatnum)
  --stuff
);
CREATE TABLE booking.event (
  eventid         serial     PRIMARY KEY,
  event_name      text,
  event_timestamp timestamp  NOT NULL
  --stuff
);
CREATE TABLE booking.event_venue_seats (
  eventid    int     REFERENCES booking.event,
  seatid     int     REFERENCES booking.seats,
  txnid      int,
  customerid int,
  PRIMARY KEY (eventid, seatid)
);

测试数据

INSERT INTO booking.venue (venue_name)
VALUES ('Madison Square Garden');

INSERT INTO booking.seats (venueid, seatnum)
SELECT venueid, s
FROM booking.venue
  CROSS JOIN generate_series(1,42) AS s;

INSERT INTO booking.event (event_name, event_timestamp)
VALUES ('Evan Birthday Bash', now());

-- INSERT all the possible seat permutations for the first event
INSERT INTO booking.event_venue_seats (eventid,seatid)
SELECT eventid, seatid
FROM booking.seats
INNER JOIN booking.venue
  USING (venueid)
INNER JOIN booking.event
  ON (eventid = 1);

现在进行预订交易

现在我们将eventid硬编码为1,您应该将其设置为所需的任何事件,customerid并从txnid本质上保留座位并告诉您是谁做的。将FOR UPDATE是关键。这些行在更新期间被锁定。

UPDATE booking.event_venue_seats
SET customerid = 1,
  txnid = 1
FROM (
  SELECT eventid, seatid
  FROM booking.event_venue_seats
  JOIN booking.seats
    USING (seatid)
  INNER JOIN booking.venue
    USING (venueid)
  INNER JOIN booking.event
    USING (eventid)
  WHERE txnid IS NULL
    AND customerid IS NULL
    -- for which event
    AND eventid = 1
  OFFSET 0 ROWS
  -- how many seats do you want? (they're all locked)
  FETCH NEXT 7 ROWS ONLY
  FOR UPDATE
) AS t
WHERE
  event_venue_seats.seatid = t.seatid
  AND event_venue_seats.eventid = t.eventid;

更新

对于定时预订

您将使用定时预订。就像购买音乐会门票一样,您有M分钟的时间来确认预订,否则其他人将有机会–尼尔·麦奎根(Neil McGuigan)19分钟前

您将在此处设置booking.event_venue_seats.txnid

txnid int REFERENCES transactions ON DELETE SET NULL

用户保留UPDATE秒数的秒数,即txnid 的投入数。您的交易表如下所示。

CREATE TABLE transactions (
  txnid       serial PRIMARY KEY,
  txn_start   timestamp DEFAULT now(),
  txn_expire  timestamp DEFAULT now() + '5 minutes'
);

然后每分钟

DELETE FROM transactions
WHERE txn_expire < now()

您可以在即将到期时提示用户延长计时器。或者,只是让它删除,txnid然后级联下来释放座位。


这是一种很好的智能方法:您的交易表充当我的第二个预订表的锁定角色;并有额外的用途。
joanolo

在“预订交易”部分中,在update语句的内部选择子查询中,为什么不加入尚未存储在event_venue_seats中的任何数据,为什么要加入席位,场地和事件?
Ynv

1

我认为这可以通过使用一些花哨的双表和一些约束来实现。

让我们从一些(未完全标准化)的结构开始:

/* Everything goes to one schema... */
CREATE SCHEMA bookings ;
SET search_path = bookings ;

/* A table for theatre sessions (or events, or ...) */
CREATE TABLE sessions
(
    session_id integer /* serial */ PRIMARY KEY,
    session_theater TEXT NOT NULL,   /* Should be normalized */
    session_timestamp TIMESTAMP WITH TIME ZONE NOT NULL,
    performance_name TEXT,           /* Should be normalized */
    UNIQUE (session_theater, session_timestamp) /* Alternate natural key */
) ;

/* And one for bookings */
CREATE TABLE bookings
(
    session_id INTEGER NOT NULL REFERENCES sessions (session_id),
    seat_number INTEGER NOT NULL /* REFERENCES ... */,
    booker TEXT NULL,
    PRIMARY KEY (session_id, seat_number),
    UNIQUE (session_id, seat_number, booker) /* Needed redundance */
) ;

表格预订没有一is_booked栏,而是一booker栏。如果为空,则不预订座位,否则为预订者的姓名(id)。

我们添加一些示例数据...

-- Sample data
INSERT INTO sessions 
    (session_id, session_theater, session_timestamp, performance_name)
VALUES 
    (1, 'Her Majesty''s Theatre', 
        '2017-01-06 19:30 Europe/London', 'The Phantom of the Opera'),
    (2, 'Her Majesty''s Theatre', 
        '2017-01-07 14:30 Europe/London', 'The Phantom of the Opera'),
    (3, 'Her Majesty''s Theatre', 
        '2017-01-07 19:30 Europe/London', 'The Phantom of the Opera') ;

-- ALl sessions have 100 free seats 
INSERT INTO bookings (session_id, seat_number)
SELECT
    session_id, seat_number
FROM
    generate_series(1, 3)   AS x(session_id),
    generate_series(1, 100) AS y(seat_number) ;

我们创建第二张预订表,但有一个限制:

CREATE TABLE bookings_with_bookers
(
    session_id INTEGER NOT NULL,
    seat_number INTEGER NOT NULL,
    booker TEXT NOT NULL,
    PRIMARY KEY (session_id, seat_number)
) ;

-- Restraint bookings_with_bookers: they must match bookings
ALTER TABLE bookings_with_bookers
  ADD FOREIGN KEY (session_id, seat_number, booker) 
  REFERENCES bookings.bookings (session_id, seat_number, booker) MATCH FULL
   ON UPDATE RESTRICT ON DELETE RESTRICT
   DEFERRABLE INITIALLY DEFERRED;

第二个表将包含(session_id,seat_number,booker)元组的COPY,并具有一个FOREIGN KEY约束;这会不会让原本预定由另一个任务进行更新。[假设同一个预订者永远不会有两个任务;如果是这种情况,task_id则应添加特定的列。]

每当我们需要进行预订时,以下功能中遵循的步骤顺序就会显示出以下方式:

CREATE or REPLACE FUNCTION book_session 
    (IN _booker text, IN _session_id integer, IN _number_of_seats integer) 
RETURNS integer  /* number of seats really booked */ AS
$BODY$

DECLARE
    number_really_booked INTEGER ;
BEGIN
    -- Choose a random sample of seats, assign them to the booker.

    -- Take a list of free seats
    WITH free_seats AS
    (
    SELECT
        b.seat_number
    FROM
        bookings.bookings b
    WHERE
        b.session_id = _session_id
        AND b.booker IS NULL
    ORDER BY
        random()     /* In practice, you'd never do it */
    LIMIT
        _number_of_seats
    FOR UPDATE       /* We want to update those rows, and book them */
    )

    -- Update the 'bookings' table to have our _booker set in.
    , update_bookings AS 
    (
    UPDATE
        bookings.bookings b
    SET
        booker = _booker
    FROM
        free_seats
    WHERE
        b.session_id  = _session_id AND 
        b.seat_number = free_seats.seat_number
    RETURNING
        b.session_id, b.seat_number, b.booker
    )

    -- Insert all this information in our second table, 
    -- that acts as a 'lock'
    , insert_into_bookings_with_bookers AS
    (
    INSERT INTO
        bookings.bookings_with_bookers (session_id, seat_number, booker)
    SELECT
        update_bookings.session_id, 
        update_bookings.seat_number, 
        update_bookings.booker
    FROM
        update_bookings
    RETURNING
        bookings.bookings_with_bookers.seat_number
    )

    -- Count real number of seats booked, and return it
    SELECT 
        count(seat_number) 
    INTO
        number_really_booked
    FROM
        insert_into_bookings_with_bookers ;

    RETURN number_really_booked ;
END ;
$BODY$
LANGUAGE plpgsql VOLATILE NOT LEAKPROOF STRICT
COST 10000 ;

要真正进行预订,您的程序应尝试执行以下操作:

-- Whenever we wich to book 37 seats for session 2...
BEGIN TRANSACTION  ;
SELECT
    book_session('Andrew the Theater-goer', 2, 37) ;

/* Three things can happen:
    - The select returns the wished number of seats  
         => COMMIT 
           This can cause an EXCEPTION, and a need for (implicit)
           ROLLBACK which should be handled and the process 
           retried a number of times
           if no exception => the process is finished, you have your booking
    - The select returns less than the wished number of seats
         => ROLLBACK and RETRY
           we don't have enough seats, or some rows changed during function
           execution
    - (There can be a deadlock condition... that should be handled)
*/
COMMIT /* or ROLLBACK */ TRANSACTION ;

这取决于两个事实:1. FOREIGN KEY约束不允许破坏数据。2.我们更新了bookings表,但是对bookings_with_bookers的一个表(第二个表)仅进行了INSERT(并且从未进行UPDATE)。

它不需要SERIALIZABLE隔离级别,这将大大简化逻辑。但是,实际上,死锁是可以预料的,与数据库交互的程序应设计为处理死锁


它确实需要,SERIALIZABLE因为如果同时执行两个book_sessions,count(*)则从第二个txn开始可以在第一个book_session完成其操作之前读取该表INSERT。通常,测试wo /的不存在是不安全的SERIALIZABLE
埃文·卡罗尔

@EvanCarroll:我认为将两个表结合使用CTE可以避免这种必要。您会遇到这样的事实,即约束为您提供了保证,即在交易结束时,一切都是一致的或您中止了。它的行为与可序列化的行为非常相似。
joanolo

1

我将使用CHECK约束来防止超量预订并避免显式锁定行。

该表可以这样定义:

CREATE TABLE seats
(
    id serial PRIMARY KEY,
    is_booked int NOT NULL,
    extra_info text NOT NULL,
    CONSTRAINT check_overbooking CHECK (is_booked >= 0 AND is_booked <= 1)
);

一批座位的预订由一个人完成UPDATE

UPDATE seats
SET is_booked = is_booked + 1
WHERE 
    id IN
    (
        SELECT s2.id
        FROM seats AS s2
        WHERE
            s2.is_booked = 0
        ORDER BY random() -- or id, or some other order to choose seats
        LIMIT <number of seats to book>
    )
;
-- in practice use RETURNING to get back a list of booked seats,
-- or prepare the list of seat ids which you'll try to book
-- in a separate step before this UPDATE, not on the fly like here.

您的代码应具有重试逻辑。通常,只需尝试运行this即可UPDATE。交易将包括这一部分UPDATE。如果没有问题,则可以确保整个批次都已预订。如果收到CHECK约束违规,则应重试。

因此,这是一种乐观的方法。

  • 不要显式锁定任何内容。
  • 尝试进行更改。
  • 如果违反约束,请重试。
  • 您无需在后面进行任何显式检查UPDATE,因为约束(即数据库引擎)可以为您完成检查。

1

1s方法-单次更新:

UPDATE seats
SET is_booked = is_booked + 1
WHERE seat_id IN
(SELECT seat_id FROM seats WHERE is_booked = 0 LIMIT y);

第二种方法-LOOP(plpgsql):

v_counter:= 0;
WHILE v_counter < y LOOP
  SELECT seat_id INTO STRICT v_seat_id FROM seats WHERE is_booked = 0 LIMIT 1;
  UPDATE seats SET is_booked = 1 WHERE seat_id = v_seat_id AND is_booked = 0;
  GET DIAGNOSTICS v_rowcount = ROW_COUNT;
  IF v_rowcount > 0 THEN v_counter:= v_counter + 1; END IF;
END LOOP;

第三种方法-队列表:

交易本身不会更新席位表。他们都INSERT他们的请求到队列中的表。
一个单独的进程需要从队列表中的所有请求,并处理它们,通过分配席位请求者。

优点:
-通过使用INSERT,消除了锁定/争用
-通过使用单个过程进行席位分配,不会确保超额预订

缺点:
-座位分配不是立即的

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.