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.
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.
Related Articles
Ledenboek bouwen: verenigingsbestuur vastleggen in Rails
Hoe we een ledenadministratieplatform voor Nederlandse verenigingen bouwden, en waarom het moeilijke deel niet de CRU...
Rails PgHero: Postgres Health Dashboard voor Trage Queries, Ontbrekende Indexen en Ruimtebewaking
Rails PgHero installatiegids: mount het Postgres health dashboard, vind trage queries met pg_stat_statements, krijg i...
Rails Solid Queue: Migreren van Sidekiq naar de native background job-backend van Rails 8
Rails Solid Queue in productie: setup, concurrency-controls, recurring jobs en een stapsgewijze migratiegids weg van ...