¿Estrategia para reservas grupales concurrentes?

8

Considere una base de datos de reserva de asientos. Hay una lista de n asientos, y cada uno tiene un atributo is_booked. 0 significa que no lo es, 1 significa que sí lo es. Cualquier número más alto y hay una sobreventa.

¿Cuál es la estrategia para tener múltiples transacciones (donde cada transacción reservará un grupo de asientos y simultáneamente) sin permitir más reservas?

Simplemente seleccionaría todos los asientos sin reservar, seleccionaría un grupo aleatorio de y de ellos, los reservaría todos y verificaría si esa reserva es correcta (es decir, el número de is_booked no es superior a uno, lo que significaría que otra transacción ha reservado el asiento y comprometido), luego comprometerse. de lo contrario, cancele e intente nuevamente.

Esto se ejecuta en el nivel de aislamiento Read Committed en Postgres.

Benjamin Scherer
fuente

Respuestas:

5

Debido a que no nos está diciendo mucho de lo que necesita, adivinaré todo, y haremos que sea moderadamente complejo simplificar algunas de las posibles preguntas.

Lo primero sobre MVCC es que en un sistema altamente concurrente desea evitar el bloqueo de la tabla. Como regla general, no puede decir qué no existe sin bloquear la tabla para la transacción. Eso te deja una opción: no confíes INSERT.

Dejo muy poco como ejercicio para una aplicación de reserva real aquí. No manejamos,

  • Overbooking (como característica)
  • O qué hacer si no quedan asientos x restantes.
  • Buildout a cliente y transacción.

La clave aquí está en UPDATE.Bloqueamos solo las filas UPDATEantes de que comience la transacción. Podemos hacer esto porque hemos insertado todos los billetes de los asientos a la venta en la tabla, event_venue_seats.

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

Datos de prueba

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

Y ahora para la transacción de reserva

Ahora tenemos el eventid codificado en uno, debe configurarlo para cualquier evento que desee, customeridy txnidesencialmente hacer el asiento reservado y decirle quién lo hizo. La FOR UPDATEes la clave. Esas filas están bloqueadas durante la actualización.

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;

Actualizaciones

Para reservas programadas

Usaría una reserva programada. Como cuando compra entradas para un concierto, tiene M minutos para confirmar la reserva, o alguien más tiene la oportunidad: Neil McGuigan hace 19 minutos

Lo que harías aquí es establecer el booking.event_venue_seats.txnidcomo

txnid int REFERENCES transactions ON DELETE SET NULL

En el segundo en que el usuario reserva el set, el UPDATEpone en el txnid. Su tabla de transacciones se parece a esto.

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

Entonces en cada minuto corres

DELETE FROM transactions
WHERE txn_expire < now()

Puede pedirle al usuario que extienda el temporizador cuando esté a punto de caducar. O bien, simplemente deje que elimine txnidy descienda en cascada liberando los asientos.

Evan Carroll
fuente
Este es un enfoque agradable e inteligente: su tabla de transacciones juega el papel de bloqueo de mi segunda tabla de reservas; y tener un uso extra.
joanolo
En la sección "transacción de reserva", en la subconsulta de selección interna de la declaración de actualización, ¿por qué une asientos, lugar y evento ya que no está utilizando ningún dato que no esté almacenado en event_venue_seats?
Ynv
1

Creo que esto se puede lograr mediante el uso de una pequeña mesa doble elegante y algunas restricciones.

Comencemos por alguna estructura (no 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 */
) ;

Las reservas de la mesa, en lugar de tener una is_bookedcolumna, tienen una bookercolumna. Si es nulo, el asiento no está reservado, de lo contrario, este es el nombre (id) del reservante.

Agregamos algunos datos de ejemplo ...

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

Creamos una segunda tabla para reservas, con una restricción:

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 tabla contendrá una COPIA de las tuplas (session_id, seat_number, booker), con una FOREIGN KEYrestricción; eso no permitirá que las reservas originales sean ACTUALIZADAS por otra tarea. [Suponiendo que nunca hay dos tareas relacionadas con el mismo booker ; si ese fuera el caso, se task_iddebería agregar una columna determinada .]

Siempre que necesitemos hacer una reserva, la secuencia de pasos seguidos dentro de la siguiente función muestra el camino:

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 hacer una reserva, su programa debe intentar ejecutar 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 ;

Esto se basa en dos hechos 1. La FOREIGN KEYrestricción no permitirá que se rompan los datos . 2. ACTUALIZAMOS la tabla de reservas, pero solo INSERTAMOS (y nunca ACTUALIZAMOS ) en la tabla bookings_with_bookers (la segunda tabla).

No necesita SERIALIZABLEnivel de aislamiento, lo que simplificaría enormemente la lógica. En la práctica, sin embargo, se esperan callejones sin salida , y el programa que interactúa con la base de datos debe estar diseñado para manejarlos.

joanolo
fuente
Es necesario SERIALIZABLEporque si se ejecutan dos book_sessions al mismo tiempo, count(*)el segundo txn podría leer la tabla antes de que la primera book_session se complete con su INSERT. Como regla general, no es seguro hacer una prueba de inexistencia wo / SERIALIZABLE.
Evan Carroll
@EvanCarroll: Creo que la combinación de 2 tablas y el uso de un CTE evita esta necesidad. Juegas con el hecho de que las restricciones te ofrecen una garantía de que, al final de tu transacción, todo es consistente o abortas. Se comporta de manera muy similar a serializable .
joanolo
1

Usaría una CHECKrestricción para evitar el exceso de reservas y evitar el bloqueo explícito de filas.

La tabla podría definirse así:

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 reserva de un lote de asientos se realiza por un solo 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.

Su código debe tener una lógica de reintento. Normalmente, simplemente intente ejecutar esto UPDATE. La transacción consistiría en esta UPDATE. Si no hubo problemas, puede estar seguro de que se ha reservado todo el lote. Si obtiene una infracción de restricción CHECK, debe volver a intentarlo.

Entonces, este es un enfoque optimista.

  • No bloquee nada explícitamente.
  • Intenta hacer el cambio.
  • Vuelva a intentar si se viola la restricción.
  • No necesita ninguna verificación explícita después de UPDATE, porque la restricción (es decir, el motor DB) lo hace por usted.
Vladimir Baranov
fuente
1

Enfoque 1s - ACTUALIZACIÓN única:

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

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

3er enfoque - Tabla de cola:

Las transacciones en sí mismas no actualizan la tabla de asientos. Todos INSERTAN sus solicitudes en una tabla de cola.
Un proceso separado toma todas las solicitudes de la tabla de colas y las maneja, asignando asientos a los solicitantes.

Ventajas:
- Al usar INSERT, se elimina el bloqueo / contención
- No se garantiza el exceso de reservas mediante el uso de un solo proceso para la asignación de asientos

Desventajas:
- La asignación de asientos no es inmediata

bentaly
fuente