Stratégie pour les réservations de groupe simultanées?

8

Considérez une base de données de réservation de sièges. Il y a une liste de n sièges, et chacun a un attribut is_booked. 0 signifie que ce n'est pas le cas, 1 signifie que c'est le cas. Tout nombre supérieur et il y a une surréservation.

Quelle est la stratégie pour avoir plusieurs transactions (où chaque transaction réservera un groupe de y sièges simultanément) sans autoriser les réservations excédentaires?

Je sélectionnerais simplement tous les sièges non réservés, sélectionnerais un groupe sélectionné au hasard de y d'entre eux, les réserverais tous et vérifierais que cette réservation est correcte (alias le nombre d'is_booked n'est pas supérieur à un, ce qui signifierait une autre transaction ayant réservé le siège et commis), puis validez. sinon abandonnez et réessayez.

Ceci est exécuté au niveau d'isolement Read Committed in Postgres.

Benjamin Scherer
la source

Réponses:

5

Parce que vous ne nous dites pas grand-chose de ce dont vous avez besoin, je devine pour tout, et nous allons le rendre modérément complexe pour simplifier certaines des questions possibles.

La première chose à propos de MVCC est que dans un système hautement simultané, vous voulez éviter le verrouillage de table. En règle générale, vous ne pouvez pas dire ce qui n'existe pas sans verrouiller la table de la transaction. Cela vous laisse une option: ne comptez pas sur INSERT.

Je laisse très peu comme exercice pour une vraie application de réservation ici. Nous ne gérons pas,

  • Overbooking (en tant que fonctionnalité)
  • Ou que faire s'il n'y a pas de sièges x restants.
  • Buildout au client et transaction.

La clé ici se trouve dans la zone UPDATE.Nous verrouillons uniquement les lignes UPDATEavant le début de la transaction. Nous pouvons le faire parce que nous avons tous insérés sièges-billets à vendre dans le tableau, event_venue_seats.

Créer un schéma de 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)
);

Données de 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);

Et maintenant pour la transaction de réservation

Maintenant, nous avons le code événementiel en dur, vous devez le régler sur l'événement que vous voulez, customeridet txnidessentiellement réserver le siège et vous dire qui l'a fait. C'est la FOR UPDATEclé. Ces lignes sont verrouillées lors de la mise à jour.

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;

Mises à jour

Pour les réservations chronométrées

Vous utiliseriez une réservation chronométrée. Comme lorsque vous achetez des billets pour un concert, vous avez M minutes pour confirmer la réservation, ou quelqu'un d'autre a la chance - Neil McGuigan Il y a 19 minutes

Qu'est - ce que vous feriez ici est fixé le booking.event_venue_seats.txnidcomme

txnid int REFERENCES transactions ON DELETE SET NULL

La seconde où l'utilisateur réserve le seet, le UPDATEmet dans le txnid. Votre table de transactions ressemble à ceci.

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

Puis à chaque minute où tu cours

DELETE FROM transactions
WHERE txn_expire < now()

Vous pouvez inviter l'utilisateur à prolonger la minuterie à l'approche de l'expiration. Ou, laissez-le simplement supprimer le txnidet descendez en cascade pour libérer les sièges.

Evan Carroll
la source
C'est une approche agréable et intelligente: votre table de transactions joue le rôle de verrouillage de ma deuxième table de réservations; et avoir une utilisation supplémentaire.
joanolo
Dans la section "transaction de réservation", dans la sous-requête de sélection interne de l'instruction de mise à jour, pourquoi joignez-vous des sièges, un lieu et un événement car vous n'utilisez aucune donnée qui n'est pas déjà stockée dans event_venue_seats?
Ynv
1

Je pense que cela peut être accompli en utilisant une petite table double fantaisie et quelques contraintes.

Commençons par une structure (pas complètement normalisée):

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

Les réservations de table, au lieu d'avoir une is_bookedcolonne, ont une bookercolonne. S'il est nul, le siège n'est pas réservé, sinon c'est le nom (id) du booker.

Nous ajoutons quelques exemples de données ...

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

Nous créons une deuxième table pour les réservations, avec une restriction:

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;

Cette deuxième table contiendra une COPIE des tuples (session_id, seat_number, booker), avec une FOREIGN KEYcontrainte; cela ne permettra pas de mettre à jour les réservations d'origine par une autre tâche. [En supposant qu'il n'y a jamais deux tâches concernant le même booker ; si tel était le cas, une certaine task_idcolonne devrait être ajoutée.]

Chaque fois que nous devons faire une réservation, la séquence d'étapes suivie dans la fonction suivante montre le chemin:

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 ;

Pour vraiment faire une réservation, votre programme doit essayer d'exécuter quelque chose comme:

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

Cela repose sur deux faits 1. La FOREIGN KEYcontrainte ne permettra pas de casser les données . 2. Nous METTONS À JOUR la table des réservations, mais INSÉRONS seulement (et jamais METTRE À JOUR ) sur la table bookings_with_bookers (la deuxième table).

Il n'a pas besoin de SERIALIZABLEniveau d'isolement, ce qui simplifierait considérablement la logique. En pratique, cependant, des blocages sont à prévoir et le programme interagissant avec la base de données doit être conçu pour les gérer.

joanolo
la source
Il en a besoin SERIALIZABLEcar si deux book_sessions sont exécutées en même temps, count(*)le deuxième txn pourrait lire la table avant que la première book_session n'en ait fini avec son INSERT. En règle générale, il n'est pas sûr de tester la non-existence de wo / SERIALIZABLE.
Evan Carroll
@EvanCarroll: Je pense que la combinaison de 2 tables et l'utilisation d'un CTE évite cette nécessité. Vous jouez avec le fait que les contraintes vous offrent une garantie qu'à la fin de votre transaction, tout est cohérent ou que vous avortez. Il se comporte de manière très similaire à sérialisable .
joanolo
1

J'utiliserais une CHECKcontrainte pour empêcher la surréservation et éviter le verrouillage explicite des lignes.

La table pourrait être définie comme ceci:

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 réservation d'un lot de places se fait par un seul 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.

Votre code doit avoir une logique de nouvelle tentative. Normalement, essayez simplement d'exécuter ceci UPDATE. La transaction comprendrait celle-ci UPDATE. S'il n'y a eu aucun problème, vous pouvez être sûr que le lot entier a été réservé. Si vous obtenez une violation de contrainte CHECK, vous devez réessayer.

Il s'agit donc d'une approche optimiste.

  • Ne verrouillez rien de manière explicite.
  • Essayez de faire le changement.
  • Réessayez si la contrainte n'est pas respectée.
  • Vous n'avez pas besoin de vérifications explicites après le UPDATE, car la contrainte (c'est-à-dire le moteur de base de données) le fait pour vous.
Vladimir Baranov
la source
1

Approche 1s - MISE À JOUR unique:

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

2ème approche - 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ème approche - Table de file d'attente:

Les transactions elles - mêmes ne mettent pas à jour la table des sièges. Ils INSÉRENT tous leurs demandes dans une table de file d'attente.
Un processus distinct prend toutes les demandes de la table de file d'attente et les gère, en allouant des sièges aux demandeurs.

Avantages:
- En utilisant INSERT, le verrouillage / conflit est éliminé
- Aucune surréservation n'est assurée en utilisant un processus unique pour l'attribution des sièges

Inconvénients:
- L'attribution des sièges n'est pas immédiate

bentaly
la source