Стратегия одновременного группового бронирования?

8

Рассмотрим базу данных бронирования мест. Есть список из n мест, и у каждого есть атрибут is_booked. 0 означает, что это не так, 1 означает, что это так. Любое большее число и есть перебронирование.

Какова стратегия для нескольких транзакций (когда каждая транзакция будет резервировать группу из y мест одновременно), не допуская перебронирования?

Я просто выбрал бы все незабронированные места, выбрал случайным образом выбранную группу из них, зарезервировал их все и проверил, правильное ли это бронирование (иначе число is_booked не превышает одного, что означало бы другую транзакцию, забронировавшую место и совершено), затем совершить. в противном случае прервите и попробуйте снова.

Это выполняется на уровне изоляции Read Committed в Postgres.

Бенджамин Шерер
источник

Ответы:

5

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

Первое, что касается MVCC, это то, что в системе с высокой степенью параллелизма вы хотите избежать блокировки таблиц. Как правило, вы не можете сказать, что не существует, не блокируя таблицу для транзакции. Это оставляет вам один вариант: не полагайтесь на INSERT.

Я оставляю очень мало в качестве упражнения для реального приложения бронирования здесь. Мы не справляемся,

  • Перебронирование (как функция)
  • Или что делать, если не осталось х-мест.
  • Сборка для клиента и транзакции.

Ключ здесь в том, что 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);

А теперь для транзакции бронирования

Теперь у нас есть четкая и жестко запрограммированная единица, вы должны установить это на любое событие, которое вы хотите, 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 минут для подтверждения бронирования, или у кого-то еще есть шанс - Нил МакГиган 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и каскадом освободить места.

Эван Кэрролл
источник
Это хороший и интеллектуальный подход: ваша таблица транзакций играет роль блокировки моей второй таблицы бронирований; и иметь дополнительное использование.
Жоаноло
В разделе «транзакция бронирования» во внутреннем подзапросе select оператора 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столбец. Если значение равно нулю, место не забронировано, в противном случае это имя (идентификатор) участника.

Мы добавим несколько примеров данных ...

-- 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) с одним 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. Мы ОБНОВЛЯЕМ таблицу бронирований, но только INSERT (и никогда не ОБНОВЛЯЕМ ) на bookings_with_bookers one (вторая таблица).

Ему не нужен SERIALIZABLEуровень изоляции, что значительно упростит логику. На практике, однако, следует ожидать взаимоблокировок , и программа, взаимодействующая с базой данных, должна быть рассчитана на их обработку.

joanolo
источник
Это действительно необходимо, SERIALIZABLEпотому что, если две book_sessions выполняются одновременно, count(*)вторая txn может прочитать таблицу до того, как первая book_session завершит работу с ней INSERT. Как правило, проверять несуществование wo / небезопасно SERIALIZABLE.
Эван Кэрролл
@EvanCarroll: я думаю, что комбинация 2 таблиц и использование 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.

Ваш код должен иметь логику повторных попыток. Обычно просто попробуйте запустить это UPDATE. Сделка будет состоять из этого UPDATE. Если проблем не было, можете быть уверены, что вся партия была забронирована. Если вы получили нарушение ограничения CHECK, повторите попытку.

Итак, это оптимистичный подход.

  • Не блокируйте ничего явно.
  • Попробуйте внести изменения.
  • Повторите попытку, если ограничение нарушено.
  • Вам не нужны никакие явные проверки после UPDATE, потому что ограничение (то есть механизм БД) делает это за вас.
Владимир Баранов
источник
1

1 подход - одиночное ОБНОВЛЕНИЕ:

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

2-й подход - 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;

3-й подход - таблица очередей:

Сами транзакции не обновляют таблицу мест. Все они ВСТАВЛЯЮТ свои запросы в таблицу очередей. Отдельный процесс принимает все запросы из таблицы очереди и обрабатывает их, путем выделения места на реквестеры.

Преимущества:
- с помощью INSERT устраняется блокировка / конфликт
- нет перебронирования за счет использования единого процесса для распределения мест

Недостатки:
- Распределение мест не сразу

bentaly
источник