Estratégia para reservas simultâneas de grupos?

8

Considere um banco de dados de reservas de assentos. Há uma lista de n assentos, e cada um tem um atributo is_booked. 0 significa que não é, 1 significa que é. Qualquer número mais alto e há uma reserva em excesso.

Qual é a estratégia para ter várias transações (onde cada transação reservará um grupo de y lugares simultaneamente) sem permitir reservas em excesso?

Eu simplesmente selecionaria todos os assentos não reservados, selecionaria um grupo de y deles selecionado aleatoriamente, os reservaria todos e verificaria se a reserva está correta (ou seja, o número de is_booked não está acima de um, o que significaria outra transação que reservou o assento e confirmado) e, em seguida, confirmado. caso contrário, aborte e tente novamente.

Isso é executado no nível de isolamento Read Committed no Postgres.

Benjamin Scherer
fonte

Respostas:

5

Como você não está nos dizendo muito do que precisa, vou adivinhar tudo e tornaremos moderadamente complexo simplificar algumas das perguntas possíveis.

A primeira coisa sobre o MVCC é que, em um sistema altamente concorrente, você deseja evitar o bloqueio de tabelas. Como regra geral, você não pode dizer o que não existe sem bloquear a tabela para a transação. Isso deixa uma opção: não confie INSERT.

Deixo muito pouco como exercício para um aplicativo de reservas real aqui. Nós não lidamos,

  • Overbooking (como um recurso)
  • Ou o que fazer se não houver x lugares restantes.
  • Desenvolvimento para cliente e transação.

A chave aqui está em UPDATE.Nós bloqueamos apenas as linhas UPDATEantes do início da transação. Podemos fazer isso porque inserimos todos os ingressos para venda na tabela event_venue_seats,.

Crie um esquema básico

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)
);

Dados de teste

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);

E agora para a transação de reserva

Agora temos a eventid codificado para um, você deve definir isso para qualquer evento que você quer, customeride txnidessencialmente fazer o assento reservado e dizer-lhe quem fez isso. A FOR UPDATEchave é. Essas linhas são bloqueadas durante a atualização.

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;

Atualizações

Para reservas programadas

Você usaria uma reserva programada. Como quando você compra ingressos para um show, você tem M minutos para confirmar a reserva ou outra pessoa tem a chance - Neil McGuigan 19 mins atrás

O que você faria aqui é definir booking.event_venue_seats.txnidcomo

txnid int REFERENCES transactions ON DELETE SET NULL

O segundo que o usuário reserva o seet, o UPDATEcoloca no txnid. Sua tabela de transações se parece com isso.

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

Então, a cada minuto você corre

DELETE FROM transactions
WHERE txn_expire < now()

Você pode solicitar ao usuário que estenda o cronômetro quando estiver próximo do vencimento. Ou, deixe-o excluir txnide desça em cascata, liberando os assentos.

Evan Carroll
fonte
Essa é uma abordagem agradável e inteligente: sua tabela de transações desempenha o papel de bloqueio da minha segunda tabela de reservas; e tenha um uso extra.
joanolo
Na seção "transação de reserva", na subconsulta de seleção interna da instrução de atualização, por que você ingressa em assentos, local e evento porque não está usando nenhum dado que ainda não esteja armazenado em event_venue_seats?
Ynv 30/11/2018
1

Acho que isso pode ser conseguido com o uso de uma pequena mesa dupla sofisticada e algumas restrições.

Vamos começar por alguma estrutura (não totalmente normalizada):

/* 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 */
) ;

As reservas da tabela, em vez de ter uma is_bookedcoluna, possuem uma bookercoluna. Se for nulo, o assento não será reservado, caso contrário, esse é o nome (id) do contratante.

Nós adicionamos alguns dados de exemplo ...

-- 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) ;

Criamos uma segunda tabela para reservas, com uma restrição:

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;

Esta segunda tabela conterá uma CÓPIA das tuplas (session_id, seat_number, booker), com uma FOREIGN KEYrestrição; isso não permitirá que as reservas originais sejam ATUALIZADAS por outra tarefa. [Supondo que nunca haja duas tarefas lidando com o mesmo agendador ; se for esse o caso, uma determinada task_idcoluna deve ser adicionada.]

Sempre que precisamos fazer uma reserva, a sequência de etapas seguidas na seguinte função mostra o caminho:

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 ;

Para realmente fazer uma reserva, seu programa deve tentar executar algo como:

-- 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 ;

Isso depende de dois fatos: 1. A FOREIGN KEYrestrição não permitirá que os dados sejam quebrados . 2. ATUALIZAMOS a tabela de reservas, mas apenas INSERT (e nunca UPDATE ) no bookings_with_bookers um (a segunda tabela).

Não precisa de SERIALIZABLEnível de isolamento, o que simplificaria bastante a lógica. Na prática, no entanto, são esperados impasses , e o programa que interage com o banco de dados deve ser projetado para lidar com eles.

joanolo
fonte
É necessário SERIALIZABLEporque, se duas sessões de livro forem executadas ao mesmo tempo, a count(*)partir do segundo txn poderá ler a tabela antes que a primeira sessão de livro seja concluída INSERT. Como regra geral, não é seguro testar a inexistência de wo / SERIALIZABLE.
Evan Carroll
@EvanCarroll: Eu acho que a combinação de 2 tabelas e o uso de um CTE evita essa necessidade. Você brinca com o fato de que as restrições oferecem uma garantia de que, no final de sua transação, tudo é consistente ou você aborta. Ele se comporta de maneira muito semelhante à serializável .
joanolo
1

Eu usaria uma CHECKrestrição para evitar reservas em excesso e evitar o bloqueio explícito de linhas.

A tabela pode ser definida assim:

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)
);

A reserva de um lote de assentos é feita por um único 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.

Seu código deve ter uma lógica de nova tentativa. Normalmente, simplesmente tente executar isso UPDATE. A transação consistiria nessa UPDATE. Se não houver problemas, você pode ter certeza de que todo o lote foi contratado. Se você receber uma violação da restrição CHECK, tente novamente.

Portanto, essa é uma abordagem otimista.

  • Não bloqueie nada explicitamente.
  • Tente fazer a mudança.
  • Tente novamente se a restrição for violada.
  • Você não precisa de verificações explícitas após o UPDATE, porque a restrição (ou seja, o mecanismo do banco de dados) faz isso por você.
Vladimir Baranov
fonte
1

Abordagem 1s - atualização única:

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

2a abordagem - 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ª abordagem - tabela de filas:

As transações em si não atualizam a tabela de assentos. Todos eles inserem seus pedidos em uma tabela de filas.
Um processo separado pega todas as solicitações da tabela de filas e as manipula, alocando assentos aos solicitantes.

Vantagens:
- Ao usar INSERT, o bloqueio / contenção é eliminado
- Nenhuma reserva em excesso é garantida usando um único processo para alocação de assentos

Desvantagens:
- A alocação de assentos não é imediata

bentaly
fonte