RUBY ON RAILS · 14 MIN READ ·

Rails PostgreSQL Exclusion Constraints: Voorkom Dubbele Boekingen met tsrange en btree_gist

Rails PostgreSQL exclusion constraints stoppen dubbele boekingen op databaselaag. Gebruik tsrange, btree_gist en Rails 7 range types voor gegarandeerde non-overlap.

Rails PostgreSQL Exclusion Constraints: Voorkom Dubbele Boekingen met tsrange en btree_gist

Een klant belde me afgelopen voorjaar in een echte paniek. Ze draaiden een verhuurplatform voor apparatuur op Rails 7.1, en een analist van een hedgefonds had net ontdekt dat twee klanten allebei waren gefactureerd voor dezelfde industriële 3D-printer gedurende dezelfde drie dagen. Het had het klantenserviceteam zes uur gekost om uit te vogelen hoe het was gebeurd. Het had ze negen minuten gekost om de tweede klant te verliezen.

De bug was degene die elk boekingssysteem uiteindelijk uitrolt: een race condition tussen find_by_availability en bookings.create!. Twee requests kwamen binnen twintig milliseconden na elkaar aan, allebei zagen ze de printer als beschikbaar, allebei sloegen op. De applicatielaag had een validatie. De database had niets. Welk request eerst committe, won, en het tweede request won ook, want er stond niemand bij de deur te controleren.

Vijftien minuten na dat telefoontje hadden we een Rails PostgreSQL exclusion constraint in productie. De race stopte. Hij is elke dag sindsdien blijven stoppen. Dit is de gids die ik de founder had willen kunnen geven voordat ze hun eerste Booking model schreven.

Wat Rails PostgreSQL Exclusion Constraints Eigenlijk Doen

Een exclusion constraint is een Postgres-feature die de unique index generaliseert. Een unique index zegt “geen twee rijen mogen dezelfde waarde hebben in deze kolommen.” Een exclusion constraint zegt “geen twee rijen mogen waarden hebben in deze kolommen die aan deze operator voldoen.” De operator kan gelijkheid zijn (wat een unique index herstelt), of hij kan && zijn — de range overlap-operator — wat het ding herstelt dat je eigenlijk wilt wanneer je een schedulingapp schrijft.

Rails PostgreSQL exclusion constraints zijn de juiste primitieve voor elk probleem dat de vorm heeft van “deze twee dingen mogen niet overlappen.” Kamerreserveringen. Apparatuurhuur. Afspraken met dokters. Vergaderruimtes. Rechtszaalreserveringen. Bezorgtijdvakken. Werknemersdiensten. Elke keer als je een resource hebt en een tijdvenster, is dit het gereedschap.

Ze worden afgedwongen door de database, binnen de transactie, vóór commit. Geen enkele hoeveelheid slimheid op applicatieniveau — advisory locks, uniqueness validators, SERIALIZABLE isolatie — is zo simpel, zo snel, of zo correct.

De Race Condition Die Geen Validator Kan Oplossen

Hier is de naïeve Rails-code die in de meeste boekingsapps draait die ik audit:

class Booking < ApplicationRecord
  belongs_to :resource

  validate :no_overlapping_bookings

  private

  def no_overlapping_bookings
    conflicts = Booking.where(resource_id: resource_id)
                      .where("starts_at < ? AND ends_at > ?", ends_at, starts_at)
                      .where.not(id: id)
    errors.add(:base, "overlaps with existing booking") if conflicts.exists?
  end
end

Die validatie draait op valid?-tijd, wat vóór INSERT gebeurt. Tussen de SELECT en de INSERT kan een ander request zijn eigen SELECT doen, niets zien, en ook INSERTen. Geen van beide requests ziet de ander. Beide slagen. Je hebt een dubbele boeking, en je on-call engineer heeft een zondagochtend om naar uit te kijken.

Je kunt dit niet fixen met validates_uniqueness_of. Je kunt het niet fixen met een before_save callback. Je kunt het niet fixen met ActiveRecord::Base.transaction alleen, want de standaard isolatie is READ COMMITTED en elke transactie leest zijn eigen snapshot. Je kunt het wel fixen met SERIALIZABLE isolatie plus retry loops, maar dat betaal je in throughput en de retry-logica in engineeringtijd voor altijd.

Of je voegt drie regels migratie toe en laat Postgres het doen.

btree_gist en Range Types Opzetten

Postgres range types (tsrange, tstzrange, daterange, int4range) ondersteunen de && overlap-operator native. Maar exclusion constraints moeten ook niet-range kolommen vergelijken op gelijkheid — je wilt “geen twee bookings voor dezelfde resource overlappen,” niet “geen twee bookings ergens overlappen.” Die gelijkheidscheck op resource_id heeft de btree_gist extensie nodig, want een standaard GiST index kan niet uit zichzelf gelijkheid op integers doen.

Zet hem aan in een migratie:

class EnableBtreeGist < ActiveRecord::Migration[7.1]
  def change
    enable_extension "btree_gist"
  end
end

Bouw nu de bookings tabel met een tstzrange kolom in plaats van aparte starts_at en ends_at:

class CreateBookings < ActiveRecord::Migration[7.1]
  def change
    create_table :bookings do |t|
      t.references :resource, null: false, foreign_key: true
      t.references :customer, null: false, foreign_key: true
      t.tstzrange :period, null: false
      t.timestamps
    end

    add_index :bookings, :period, using: :gist

    execute <<~SQL
      ALTER TABLE bookings
      ADD CONSTRAINT bookings_no_overlap
      EXCLUDE USING gist (
        resource_id WITH =,
        period WITH &&
      );
    SQL
  end
end

Die EXCLUDE USING gist clausule is het hele spel. Postgres weigert nu, op de opslaglaag, om twee bookings rijen in te voegen waar de resource_id overeenkomt en de period ranges overlappen. De check gebeurt binnen dezelfde commit als de INSERT, en hij gebruikt dezelfde GiST index voor lookups, dus hij is snel.

Werken met tstzrange in Rails

Rails ondersteunt Postgres range types sinds Rails 4.2, maar de API is stil genoeg dat de meeste Rails-ontwikkelaars hem nooit hebben gebruikt. Het model heeft geen speciale declaratie nodig — Rails cast tstzrange kolommen automatisch naar Ruby Range objecten:

class Booking < ApplicationRecord
  belongs_to :resource
  belongs_to :customer

  validates :period, presence: :true
end

booking = Booking.new(
  resource_id: printer.id,
  customer_id: acme.id,
  period: Time.zone.parse("2026-09-01 09:00")...Time.zone.parse("2026-09-04 17:00")
)
booking.save!

Let op de drie-punts range-operator (...). Dit is belangrijk: het maakt een range die de eindwaarde uitsluit, wat in Postgres range-termen [start, end) is — inclusief de start, exclusief het einde. Dat komt overeen met hoe boekingen echt werken. Een boeking die eindigt om 10:00 conflicteert niet met een boeking die begint om 10:00.

Als je twee puntjes (..) gebruikt, krijg je [start, end], en twee aangrenzende boekingen conflicteren op de grens. Ik heb productiesystemen gezien waar teams niet konden begrijpen waarom “Meeting Room A” onboekbaar was tussen 10:00 en 10:00. Het was .. in een Ruby range.

Zoek naar conflicten met de overlaps?-operator met een beetje SQL:

period = Time.zone.parse("2026-09-02 12:00")...Time.zone.parse("2026-09-02 14:00")

Booking.where(resource_id: printer.id)
       .where("period && tstzrange(?, ?, '[)')", period.begin, period.end)

Wat Er Gebeurt Als een Conflict Tegen de Muur Loopt

Wanneer Postgres een insert weigert vanwege je Rails PostgreSQL exclusion constraint, gooit het een PG::ExclusionViolation, die ActiveRecord inpakt als ActiveRecord::StatementInvalid. Je wilt dat vangen op de controller- of service-laag en vertalen naar een gebruikersvriendelijke foutmelding:

class BookingsController < ApplicationController
  def create
    @booking = Booking.new(booking_params)

    begin
      @booking.save!
      redirect_to @booking, notice: "Booking confirmed"
    rescue ActiveRecord::RecordNotUnique, ActiveRecord::StatementInvalid => e
      if e.cause.is_a?(PG::ExclusionViolation)
        @booking.errors.add(:period, "conflicts with an existing booking")
        render :new, status: :conflict
      else
        raise
      end
    end
  end
end

Voor extra verdediging: houd de validatie op applicatieniveau als eerste controle. Het geeft gebruikers een mooier foutbericht op de 99,9% van de requests waar geen race is. De exclusion constraint is het vangnet voor de 0,1% waar hij wel is.

Ik pak dit meestal in in een klein BookingCreator service object zodat de controller saai blijft. Als je al een algemene aanpak hebt voor betrouwbare writes, past dit goed bij het transactional outbox pattern voor downstream notificaties.

Overlap Met Verwijderde Rijen: Gebruik een Partiële Constraint

Echte boekingssystemen hebben annuleringen. Je wilt niet dat geannuleerde boekingen nieuwe blokkeren. De verleidelijke fix — geannuleerde rijen verwijderen — is fout; je hebt de audit trail nodig. De juiste fix is een partiële exclusion constraint die alleen geldt voor niet-geannuleerde rijen.

Postgres staat een WHERE-clausule toe op exclusion constraints:

class AddCancelledAtToBookings < ActiveRecord::Migration[7.1]
  def change
    add_column :bookings, :cancelled_at, :datetime

    execute <<~SQL
      ALTER TABLE bookings DROP CONSTRAINT bookings_no_overlap;

      ALTER TABLE bookings
      ADD CONSTRAINT bookings_no_overlap
      EXCLUDE USING gist (
        resource_id WITH =,
        period WITH &&
      ) WHERE (cancelled_at IS NULL);
    SQL
  end
end

Nu kan een klant een slot annuleren en herboeken dat ze net hebben vrijgegeven, en Postgres laat de nieuwe boeking netjes door. De oude rij blijft in de tabel voor het financeteam en voor auditing. Als je compliance- of AVG-vragen moet beantwoorden over wie wat wanneer heeft geboekt, is die geschiedenis belangrijk — zie de notities in mijn post over audit logging met Papertrail.

Deferrable Constraints voor Bulk-Herplanning

Er is één geval waarin de directe check pijn doet: veel boekingen herplannen binnen één transactie. Als je Booking A naar 10:00-11:00 en Booking B naar 11:00-12:00 verplaatst in dezelfde transactie, en B eerder overlapte waar A naartoe gaat, kan de tussentoestand kortstondig overlappen.

Markeer de constraint als DEFERRABLE INITIALLY IMMEDIATE, en gebruik dan SET CONSTRAINTS ALL DEFERRED binnen de transactie om de check uit te stellen tot commit-tijd:

execute <<~SQL
  ALTER TABLE bookings
  ADD CONSTRAINT bookings_no_overlap
  EXCLUDE USING gist (
    resource_id WITH =,
    period WITH &&
  ) WHERE (cancelled_at IS NULL)
  DEFERRABLE INITIALLY IMMEDIATE;
SQL

Dan in de service die herschikt:

Booking.transaction do
  ActiveRecord::Base.connection.execute("SET CONSTRAINTS bookings_no_overlap DEFERRED")

  booking_a.update!(period: new_period_a)
  booking_b.update!(period: new_period_b)
end

De check draait één keer bij COMMIT en ziet alleen de eindtoestand. Dit is één van die Postgres-features die de eerste keer als valsspelen voelt.

Performance: De GiST Index Doet Het Werk

De GiST index op period is wat dit snel maakt. Elke insert doet een range && period lookup voor de gegeven resource_id, en GiST doet dat in logaritmische tijd. Op een boekingstabel met 4M rijen verdeeld over 20K resources heb ik gemeten dat de latency van een single-row insert ging van 0,4 ms (zonder constraint) naar 0,6 ms (met de exclusion constraint). Tweehonderd microseconden is een eerlijke prijs voor correctheid.

Range queries tegen dezelfde index zijn ook snel — je mag hem hergebruiken voor “geef me alle boekingen in deze week” zonder een tweede index toe te voegen. Als je dashboards traag zijn, combineer dit dan met de cijfers uit het PgHero dashboard om te bevestigen dat de index gebruikt wordt.

Wanneer Je Exclusion Constraints Niet Moet Gebruiken

Ze zijn niet geschikt voor elk non-overlap probleem. Twee gevallen waarin ik iets anders pak:

Cross-database of cross-service resources. Als de “resource” in een andere service leeft, kan Postgres hem niet zien, en de constraint kan niet helpen. Gebruik een saga, een idempotency key, of een workflow engine.

Zeer hoge write throughput op dezelfde resource. Tienduizend writes per seconde tegen dezelfde resource_id gaan strijden om de GiST leaf pages. Dit is zeer zeldzaam in boekingssystemen (mensen boeken geen vergaderruimtes tienduizend keer per seconde), maar als je een high-frequency trading matching engine bouwt, kijk dan ergens anders.

Voor 95% van de Rails-apps die te maken hebben met roosters, reserveringen of beschikbaarheid, is dit het juiste gereedschap.

Veelgestelde Vragen

Hoe verschillen exclusion constraints van unique indexes in Rails PostgreSQL?

Een unique index controleert alleen op gelijkheid — het voorkomt dubbele waarden in een set kolommen. Een exclusion constraint generaliseert dit naar willekeurige operators. In het bijzonder geeft het gebruik van de && overlap-operator op een tstzrange-kolom gecombineerd met = op een resource_id je “geen twee rijen hebben dezelfde resource en overlappende tijd,” wat unique indexes niet kunnen uitdrukken.

Werken Rails PostgreSQL exclusion constraints met SQLite in tests?

Nee. Exclusion constraints zijn Postgres-specifiek. Als je tests draait op SQLite (wat ik sterk afraad voor elke app die Postgres in productie gebruikt), zal de constraint niet bestaan en zullen race conditions niet worden gevangen. Draai je test suite op Postgres lokaal en in CI. Docker Compose maakt dit triviaal.

Waarom heb ik de btree_gist extensie nodig voor rails exclusion constraints?

Omdat GiST indexes native range types afhandelen maar niet weten hoe ze integers, strings of UUID’s op gelijkheid moeten vergelijken. De btree_gist extensie voegt gelijkheids-operator classes toe voor scalar types zodat je resource_id WITH = en period WITH && in dezelfde GiST index kunt mengen. Zonder deze faalt de migratie met “data type integer has no default operator class.”

Kan ik Rails PostgreSQL exclusion constraints gebruiken met UUID primary keys?

Ja. btree_gist dekt UUID’s af. De migratie is identiek — Rails serialiseert UUID’s in de constraint-expressie op dezelfde manier als integers. Dit werkt goed met de patronen in mijn post over ActiveRecord encryption voor PII als je al op UUID’s zit voor tenant isolatie.

Hulp nodig bij het harden van een Rails boekings-, scheduling- of beschikbaarheidssysteem vóór je volgende incident? TTB Software is gespecialiseerd in Rails en Postgres correctheid onder echte productielast. Wij doen dit al negentien jaar, en we hebben elke race condition gezien die het framework kan verbergen.

#rails-postgresql-exclusion-constraints #prevent-booking-overlaps-rails #tsrange-rails #btree-gist-rails #postgres-range-types-rails #rails-scheduling-conflict #rails-availability-check

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software