Strategie für gleichzeitige Gruppenbuchungen?


8

Betrachten Sie eine Sitzplatzbuchungsdatenbank. Es gibt eine Liste von n Sitzen, und jeder hat ein Attribut is_booked. 0 bedeutet, dass es nicht so ist, 1 bedeutet, dass es nicht so ist. Jede höhere Nummer und es gibt eine Überbuchung.

Was ist die Strategie für mehrere Transaktionen (bei denen bei jeder Transaktion eine Gruppe von y Plätzen gleichzeitig gebucht wird), ohne dass Überbuchungen zulässig sind?

Ich würde einfach alle nicht gebuchten Sitzplätze auswählen, eine zufällig ausgewählte Gruppe von y auswählen, sie alle buchen und prüfen, ob diese Buchung korrekt ist (auch bekannt als die Anzahl von is_booked nicht über eins liegt, was bedeuten würde, dass eine andere Transaktion den Sitzplatz gebucht hat und festgeschrieben), dann festschreiben. Andernfalls abbrechen und erneut versuchen.

Dies wird auf der Isolationsstufe Read Committed in Postgres ausgeführt.

Antworten:


5

Da Sie uns nicht viel von dem erzählen, was Sie brauchen, werde ich für alles raten, und wir werden es mäßig komplex machen, einige der möglichen Fragen zu vereinfachen.

Das erste an MVCC ist, dass Sie in einem System mit hoher Gleichzeitigkeit das Sperren von Tabellen vermeiden möchten. In der Regel können Sie nicht feststellen, was nicht vorhanden ist, ohne die Tabelle für die Transaktion zu sperren. Damit haben Sie eine Möglichkeit: Verlassen Sie sich nicht darauf INSERT.

Ich lasse hier sehr wenig als Übung für eine echte Buchungs-App. Wir kümmern uns nicht,

  • Überbuchung (als Feature)
  • Oder was tun, wenn keine X-Plätze mehr vorhanden sind?
  • Buildout zum Kunden und zur Transaktion.

Der Schlüssel hier ist in UPDATE.Wir sperren nur die Zeilen für, UPDATEbevor die Transaktion beginnt. Wir können dies tun, weil wir alle zum Verkauf stehenden Sitzplatzkarten in die Tabelle eingefügt haben event_venue_seats.

Erstellen Sie ein Basisschema

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

Testdaten

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

Und jetzt zur Buchungstransaktion

Jetzt haben wir die Ereignis-ID fest auf eins codiert. Sie sollten dies auf ein beliebiges Ereignis einstellen customeridund im txnidWesentlichen den Sitzplatz reservieren und Ihnen mitteilen, wer es getan hat. Das FOR UPDATEist der Schlüssel. Diese Zeilen werden während des Updates gesperrt.

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;

Aktualisierung

Für zeitgesteuerte Reservierungen

Sie würden eine zeitgesteuerte Reservierung verwenden. Wenn Sie beispielsweise Tickets für ein Konzert kaufen, haben Sie M Minuten Zeit, um die Buchung zu bestätigen, oder jemand anderes hat die Chance - Neil McGuigan vor 19 Minuten

Was Sie hier tun würden, ist das Einstellen booking.event_venue_seats.txnidvon

txnid int REFERENCES transactions ON DELETE SET NULL

In der Sekunde, in der der Benutzer das Seet reserviert, UPDATEsetzt er das txnid ein. Ihre Transaktionstabelle sieht ungefähr so ​​aus.

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

Dann rennst du in jeder Minute

DELETE FROM transactions
WHERE txn_expire < now()

Sie können den Benutzer auffordern, den Timer zu verlängern, wenn er sich dem Ablauf nähert. Oder lassen Sie es einfach löschen txnidund kaskadieren Sie die Sitze frei.


Dies ist ein netter und intelligenter Ansatz: Ihre Transaktionstabelle spielt die Sperrrolle meiner zweiten Buchungstabelle. und haben eine zusätzliche Verwendung.
Joanolo

Warum verbinden Sie im Abschnitt "Buchungstransaktion" in der inneren Unterabfrage "Wählen" der Aktualisierungsanweisung Sitzplätze, Veranstaltungsort und Veranstaltung, da Sie keine Daten verwenden, die nicht bereits in event_venue_seats gespeichert sind?
Ynv

1

Ich denke, dies kann durch die Verwendung eines kleinen ausgefallenen Doppeltisches und einiger Einschränkungen erreicht werden.

Beginnen wir mit einer (nicht vollständig normalisierten) Struktur:

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

Die Tabellenbuchungen haben anstelle einer is_bookedSpalte eine bookerSpalte. Wenn es null ist, ist der Sitzplatz nicht gebucht, andernfalls ist dies der Name (ID) des Buchers.

Wir fügen einige Beispieldaten hinzu ...

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

Wir erstellen eine zweite Tabelle für Buchungen mit einer Einschränkung:

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;

Diese zweite Tabelle enthält eine KOPIE der Tupel (session_id, seat_number, booker) mit einer FOREIGN KEYEinschränkung. das wird nicht die Original - Buchungen ermöglicht durch eine andere Aufgabe zu aktualisierenden. [Angenommen, es gibt nie zwei Aufgaben, die sich mit demselben Bucher befassen . In diesem Fall sollte eine bestimmte task_idSpalte hinzugefügt werden.]

Wann immer wir eine Buchung vornehmen müssen, zeigt die Abfolge der Schritte, die in der folgenden Funktion ausgeführt werden, den Weg:

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 ;

Um wirklich eine Buchung vorzunehmen, sollte Ihr Programm versuchen, Folgendes auszuführen:

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

Dies beruht auf zwei Tatsachen: 1. Die FOREIGN KEYEinschränkung lässt nicht zu, dass die Daten beschädigt werden . 2. Wir AKTUALISIEREN die Buchungstabelle, aber nur INSERT (und niemals UPDATE ) für die bookings_with_bookers one (die zweite Tabelle).

Es wird keine SERIALIZABLEIsolationsstufe benötigt , was die Logik erheblich vereinfachen würde. In der Praxis sind jedoch Deadlocks zu erwarten, und das mit der Datenbank interagierende Programm sollte so ausgelegt sein, dass sie diese verarbeiten.


Es muss, SERIALIZABLEweil, wenn zwei book_sessions gleichzeitig ausgeführt werden, der count(*)vom zweiten txn die Tabelle lesen könnte, bevor die erste book_session mit seinem fertig ist INSERT. In der Regel ist es nicht sicher, auf Nichtexistenz zu testen SERIALIZABLE.
Evan Carroll

@EvanCarroll: Ich denke, dass die Kombination von 2 Tabellen und die Verwendung eines CTE diese Notwendigkeit vermeidet. Sie spielen mit der Tatsache, dass Einschränkungen Ihnen eine Garantie bieten, dass am Ende Ihrer Transaktion alles konsistent ist oder Sie abbrechen. Es verhält sich sehr ähnlich wie serialisierbar .
Joanolo

1

Ich würde eine CHECKEinschränkung verwenden, um eine Überbuchung und ein explizites Sperren von Zeilen zu verhindern.

Die Tabelle könnte folgendermaßen definiert werden:

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

Die Buchung einer Reihe von Sitzplätzen erfolgt durch eine Einzelperson 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.

Ihr Code sollte eine Wiederholungslogik haben. Versuchen Sie normalerweise einfach, dies auszuführen UPDATE. Die Transaktion würde aus dieser bestehen UPDATE. Wenn es keine Probleme gab, können Sie sicher sein, dass die gesamte Charge gebucht wurde. Wenn Sie eine CHECK-Einschränkungsverletzung erhalten, sollten Sie es erneut versuchen.

Dies ist also ein optimistischer Ansatz.

  • Sperren Sie nichts explizit.
  • Versuchen Sie, die Änderung vorzunehmen.
  • Wiederholen Sie diesen Vorgang, wenn die Einschränkung verletzt wird.
  • Sie benötigen nach dem keine expliziten Überprüfungen UPDATE, da die Einschränkung (dh die DB-Engine) dies für Sie erledigt.

1

1s Ansatz - Single UPDATE:

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

2. Ansatz - 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. Ansatz - Warteschlangentabelle:

Die Transaktionen selbst aktualisieren den Sitztisch nicht. Sie alle fügen ihre Anforderungen in eine Warteschlangentabelle ein.
Ein separater Prozess nimmt alle Anforderungen aus der Warteschlangentabelle und verarbeitet sie, indem den Anforderern Sitzplätze zugewiesen werden.

Vorteile:
- Durch die Verwendung von INSERT werden Sperren / Konflikte beseitigt.
- Durch die Verwendung eines einzigen Prozesses für die Sitzplatzzuweisung wird keine Überbuchung sichergestellt

Nachteile:
- Die Sitzplatzzuweisung erfolgt nicht sofort

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.