Strategia per prenotazioni di gruppo simultanee?


8

Prendi in considerazione un database di prenotazione dei posti. C'è un elenco di n posti e ognuno ha un attributo is_booked. 0 significa che non lo è, 1 significa che lo è. Qualsiasi numero più alto e c'è un overbooking.

Qual è la strategia per avere più transazioni (in cui ogni transazione prenoterà un gruppo di y posti contemporaneamente) senza consentire prenotazioni eccessive?

Vorrei semplicemente selezionare tutti i posti non prenotati, selezionare un gruppo selezionato in modo casuale di y di essi, prenotarli tutti e verificare se la prenotazione è corretta (ovvero il numero di is_booked non è superiore a uno, il che significherebbe un'altra transazione che ha prenotato il posto e commit), quindi commit. in caso contrario interrompere e riprovare.

Questo viene eseguito a livello di isolamento Read Committed in Postgres.

Risposte:


5

Poiché non ci stai dicendo molto di ciò di cui hai bisogno, indovinerò tutto e renderemo moderatamente complesso semplificare alcune delle possibili domande.

La prima cosa su MVCC è che in un sistema altamente concorrente si desidera evitare il blocco delle tabelle. Come regola generale, non è possibile sapere cosa non esiste senza bloccare la tabella per la transazione. Questo ti lascia un'opzione: non fare affidamento INSERT.

Lascio molto poco come esercizio per una vera app di prenotazione qui. Non gestiamo,

  • Overbooking (come funzionalità)
  • O cosa fare se non ci sono posti rimanenti x.
  • Buildout al cliente e transazione.

La chiave qui è nel UPDATE.Blocco solo delle righe UPDATEprima dell'inizio della transazione. Possiamo farlo perché abbiamo inserito tutte le cinture di biglietti in vendita nella tabella, event_venue_seats.

Crea uno schema di base

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

Dati di test

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 ora per la transazione di prenotazione

Ora abbiamo il codice hardid eventid su uno, dovresti impostarlo su qualsiasi evento tu voglia, customeride txnidessenzialmente rendere riservato il posto e dirti chi lo ha fatto. La FOR UPDATEchiave è. Quelle righe sono bloccate durante l'aggiornamento.

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;

aggiornamenti

Per prenotazioni a tempo

Utilizzeresti una prenotazione a tempo. Come quando acquisti i biglietti per un concerto, hai M minuti per confermare la prenotazione o qualcun altro ne ha la possibilità - Neil McGuigan 19 minuti fa

Quello che faresti qui è impostare booking.event_venue_seats.txnidcome

txnid int REFERENCES transactions ON DELETE SET NULL

Nel momento in cui l'utente riserva il seet, UPDATEinserisce il txnid. La tabella delle transazioni è simile a questa.

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

Quindi in ogni minuto corri

DELETE FROM transactions
WHERE txn_expire < now()

È possibile richiedere all'utente di estendere il timer quando si avvicina la scadenza. Oppure, basta lasciarlo eliminare txnide scorrere in cascata liberando i posti.


Questo è un approccio simpatico e intelligente: la tua tabella delle transazioni gioca il ruolo di blocco della mia seconda tabella delle prenotazioni; e avere un uso extra.
joanolo

Nella sezione "transazione di prenotazione", nella sottoquery secondaria selezionata all'interno dell'istruzione di aggiornamento, perché ti unisci a posti, sede ed evento poiché non stai utilizzando dati che non sono già memorizzati in event_venue_seats?
Ynv,

1

Penso che ciò possa essere realizzato usando un piccolo tavolo doppio di fantasia e alcuni vincoli.

Partiamo da una struttura (non completamente normalizzata):

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

Le prenotazioni dei tavoli, invece di avere una is_bookedcolonna, hanno una bookercolonna. Se è nullo, il posto non è prenotato, altrimenti questo è il nome (id) del booker.

Aggiungiamo alcuni dati di esempio ...

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

Creiamo una seconda tabella per le prenotazioni, con una limitazione:

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;

Questa seconda tabella conterrà una COPIA delle tuple (session_id, seat_number, booker), con un FOREIGN KEYvincolo; ciò non consentirà AGGIORNAMENTO delle prenotazioni originali da parte di un'altra attività. [Supponendo che non ci siano mai due compiti relativi allo stesso booker ; in tal caso, task_idaggiungere una determinata colonna.]

Ogni volta che è necessario effettuare una prenotazione, la sequenza di passaggi seguiti nella seguente funzione mostra la via:

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 ;

Per effettuare davvero una prenotazione, il tuo programma dovrebbe provare a eseguire qualcosa del tipo:

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

Ciò si basa su due fatti 1. Il FOREIGN KEYvincolo non consente la rottura dei dati . 2. AGGIORNIAMO la tabella delle prenotazioni, ma solo INSERISCI (e mai AGGIORNAMENTI ) su bookings_with_bookers uno (la seconda tabella).

Non ha bisogno del SERIALIZABLElivello di isolamento, il che semplificherebbe notevolmente la logica. In pratica, tuttavia, sono previsti deadlock e il programma che interagisce con il database deve essere progettato per gestirli.


È necessario SERIALIZABLEperché se due book_session vengono eseguite contemporaneamente, count(*)dal secondo txn è possibile leggere la tabella prima che la prima book_session venga completata INSERT. Come regola generale, non è sicuro testare la non esistenza wo / SERIALIZABLE.
Evan Carroll,

@EvanCarroll: penso che la combinazione di 2 tabelle e l'utilizzo di un CTE eviti questa necessità. Giochi col fatto che i vincoli ti offrono la garanzia che, alla fine della tua transazione, tutto sia coerente o che interrompi. Si comporta in modo molto simile alla serializzabile .
joanolo,

1

Vorrei utilizzare un CHECKvincolo per impedire l'overbooking ed evitare il blocco esplicito delle righe.

La tabella potrebbe essere definita in questo modo:

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

La prenotazione di un lotto di posti viene effettuata da un singolo 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.

Il tuo codice dovrebbe avere una logica di nuovo tentativo. Normalmente, prova semplicemente a eseguire questo UPDATE. La transazione consisterebbe in questo UPDATE. Se non si sono verificati problemi, puoi essere sicuro che l'intero lotto sia stato prenotato. Se si riscontra una violazione del vincolo CHECK, è necessario riprovare.

Quindi, questo è un approccio ottimista.

  • Non bloccare nulla in modo esplicito.
  • Prova a fare il cambiamento.
  • Riprova se il vincolo viene violato.
  • Non è necessario alcun controllo esplicito dopo il UPDATE, poiché il vincolo (ovvero il motore DB) lo fa per te.

1

Approccio 1s - AGGIORNAMENTO singolo:

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

2o approccio - 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;

3o approccio - Tabella delle code:

Le transazioni stesse non aggiornano la tabella dei posti. Tutti INSERIRE le loro richieste in una tabella di coda.
Un processo separato prende tutte le richieste dalla tabella delle code e le gestisce, assegnando posti ai richiedenti.

Vantaggi:
- Utilizzando INSERT, il blocco / contesa viene eliminato
- Non viene garantito l'overbooking utilizzando un unico processo per l'assegnazione dei posti

Svantaggi:
- L'assegnazione dei posti non è immediata

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.