Rails Postgres LISTEN/NOTIFY: Real-Time Events Zonder Redis of Message Broker
Rails Postgres LISTEN/NOTIFY gids: real-time events met ActionCable, connectiepatronen, productie-valkuilen. Wanneer je het inzet in plaats van Redis of Kafka.
Vorig voorjaar stuurde een founder mij een specifieke vraag: hij had voor precies één feature een Redis-instance van vijftig dollar per maand op Upstash draaien — een “nieuwe reactie”-toast die in de browser verscheen zodra iemand anders op hetzelfde document een reactie plaatste. De rest van de applicatie was een normale Rails 8 monoliet op Postgres bij RDS. Hij wilde weten of die Redis-instance het operationele oppervlak waard was voor die ene feature. Dat was het niet. De Postgres-database waar hij toch al voor betaalde kon die events zelf pushen, in minder dan een milliseconde, met een Ruby-vriendelijke API die de meeste Rails-developers nog nooit hebben aangeraakt. Dit is de post die ik hem had willen sturen: een fatsoenlijke uiteenzetting van Rails Postgres LISTEN/NOTIFY in productie.
Na negentien jaar Rails heb ik veel teams gezien die naar een message broker grepen zodra ze “iets real-time” nodig hadden. Soms is dat de juiste keuze. Vaak niet — het volume is laag, de ordening-garanties zijn zwak genoeg dat één database round-trip prima is, en de operationele kosten van weer een bewegend deel wegen niet op tegen de engineering-kosten van het leren van de tool die je al bezit. Rails Postgres LISTEN/NOTIFY is het patroon voor dat middengebied, en de valkuilen zijn de moeite waard om te kennen voor je live gaat.
Wat Rails Postgres LISTEN/NOTIFY Eigenlijk Is
Postgres heeft sinds 2010 een lichtgewicht pub/sub-mechanisme. Een sessie die LISTEN kanaal_naam uitvoert wordt gewekt zodra een andere sessie op dezelfde database NOTIFY kanaal_naam, 'payload' uitvoert. De payload is een string van maximaal acht kilobytes, delivery is at-most-once binnen de huidige transactie, en berichten worden weggegooid als er geen listener verbonden is. Het is geen Kafka. Het is geen RabbitMQ. Het is een deurbel die klinkt zolang je webserver een database-verbinding open heeft.
Voor Rails betekent dat één specifieke superkracht: je kunt een real-time UI-update triggeren vanuit elke plek die al naar de database schrijft — een controller, een background job, een database-trigger — zonder een tweede broker te introduceren. De subscriber-kant is een Ruby-thread die een ruwe PG::Connection in een wait-loop vasthoudt, en de brug naar de rest van je app is wat jij wilt: een ActionCable-broadcast, een Rails.event.publish, of een gewone method call.
De Trigger-Kant: NOTIFY Vanuit Rails
De schrijfkant is triviaal. Je kunt het inline vanuit een controller of een after_commit callback doen:
class Comment < ApplicationRecord
after_commit :publish_created_event, on: :create
private
def publish_created_event
payload = {
id: id,
document_id: document_id,
author_id: author_id,
created_at: created_at.iso8601
}.to_json
self.class.connection.execute(
ActiveRecord::Base.sanitize_sql([
"NOTIFY comments_created, ?", payload
])
)
end
end
Twee dingen om op te merken. Ten eerste vuurt dit pas nadat de transactie gecommit is, wat is wat je wilt — je wilt niet dat subscribers een rij lezen die een latere ROLLBACK zou verwijderen. Ten tweede is sanitize_sql niet optioneel. NOTIFY accepteert een string-literal, en als je een payload interpoleert die je uit user content hebt opgebouwd zonder single quotes te escapen, heb je een SQL-injectie.
Je kunt Rails ook helemaal overslaan en Postgres zelf laten notificeren via een trigger. Dit is het patroon dat ik aanraad als het schrijfpad niet altijd via Rails loopt — denk aan jobs in Go, bulk-imports via COPY, of een tweede app die dezelfde database deelt:
CREATE OR REPLACE FUNCTION notify_comment_created() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify(
'comments_created',
json_build_object(
'id', NEW.id,
'document_id', NEW.document_id,
'author_id', NEW.author_id,
'created_at', NEW.created_at
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER comments_notify_after_insert
AFTER INSERT ON comments
FOR EACH ROW EXECUTE FUNCTION notify_comment_created();
pg_notify is de functievariant van het NOTIFY-commando, en dat is wat je binnen PL/pgSQL wilt gebruiken omdat het de payload als bind-parameter accepteert. De trigger vuurt binnen de transactie — subscribers zien het pas als de transactie commit, omdat NOTIFY-berichten queuen en pas bij commit worden geflushed.
De Listen-Kant: Een Threaded Loop Die Productie Overleeft
De listener is waar het echte werk zit. Je hebt een Ruby-thread nodig die een dedicated Postgres-verbinding vasthoudt — niet één uit de ActiveRecord-pool — en blocking wacht op wait_for_notify. Dit is de vorm die ik in productie zet:
# app/services/pg_notifier_listener.rb
class PgNotifierListener
CHANNELS = %w[comments_created documents_updated].freeze
def self.start!
Thread.new { new.run }
end
def run
loop do
connect_and_listen
rescue PG::ConnectionBad, PG::UnableToSend => e
Rails.logger.warn("pg_listener: reconnecting after #{e.class}: #{e.message}")
sleep 1
retry
end
end
private
def connect_and_listen
conn = raw_connection
CHANNELS.each { |c| conn.exec("LISTEN #{c}") }
loop do
conn.wait_for_notify(30) do |channel, _pid, payload|
dispatch(channel, payload)
end
# ping zodat de verbinding NAT/firewall idle timeouts overleeft
conn.exec("SELECT 1")
end
ensure
conn&.close
end
def raw_connection
config = ActiveRecord::Base.connection_db_config.configuration_hash
PG::Connection.new(
host: config[:host],
port: config[:port],
dbname: config[:database],
user: config[:username],
password: config[:password],
sslmode: config[:sslmode] || "prefer"
)
end
def dispatch(channel, payload)
parsed = JSON.parse(payload)
ActiveSupport::Notifications.instrument("pg_notify.#{channel}", parsed)
PgNotifierRouter.route(channel, parsed)
rescue JSON::ParserError => e
Rails.logger.error("pg_listener: bad payload on #{channel}: #{e.message}")
end
end
Een paar productie-hardening opmerkingen. De dedicated PG::Connection is essentieel — je kunt er geen lenen uit ActiveRecord::Base.connection_pool want een verbinding die op wait_for_notify blokkeert kan geen queries meer bedienen. De timeout van dertig seconden op wait_for_notify met een SELECT 1 ping houdt de verbinding open door cloud load balancers heen die anders na vijf minuten stilte de TCP-verbinding stilletjes weggooien. De ActiveSupport::Notifications.instrument-aanroep zorgt dat je bestaande OpenTelemetry-setup — zie mijn post over OpenTelemetry in Rails — deze events gratis oppikt.
Bekabel de listener vanuit een initializer, maar alleen in server-processen:
# config/initializers/pg_notifier.rb
Rails.application.config.after_initialize do
next unless defined?(Puma) || defined?(Rails::Server)
next if Rails.env.test?
PgNotifierListener.start!
end
Je wilt geen listener-thread in elke Sidekiq-worker, in rails console, of in rake db:migrate. Guard op het proces-type.
Het PgBouncer-Probleem Waarvoor Je Moet Plannen
Hier komt de valkuil waar elk team over struikelt dat dit in productie probeert: LISTEN/NOTIFY overleeft PgBouncer in transaction pooling mode niet. LISTEN bindt de subscriptie aan één specifieke backend-verbinding; de transaction pooler van PgBouncer geeft je bij de volgende query een andere backend, dus je LISTEN stopt stilzwijgend met ontvangen. Ik schreef hier uitvoerig over in mijn post over PgBouncer transaction pooling en prepared statements — dezelfde architectonische beperking geldt hier.
Er zijn drie werkbare opties. De eerste is PgBouncer omzeilen voor alleen de listener-verbinding, waarbij de listener-thread direct met Postgres verbindt op poort 5432 terwijl de rest van de app via de pooler op 6432 gaat. Dit is wat ik meestal doe — je accepteert één long-lived verbinding met de database in ruil voor een strakke reguliere pool. De tweede is PgBouncer in session pooling mode draaien, wat LISTEN behoudt maar de meeste multiplexing-voordelen tenietdoet. De derde is PgBouncer helemaal overslaan en leunen op Rails’ eigen connection pool.
Wat je ook kiest, documenteer het in de runbook. Over zes maanden verhuist iemand de app achter een nieuwe pooler en vraagt zich af waarom de toasts zijn gestopt.
Bridging Naar ActionCable
De listener-thread draait in hetzelfde proces als ActionCable, dus de brug is één method call:
# app/services/pg_notifier_router.rb
class PgNotifierRouter
def self.route(channel, payload)
case channel
when "comments_created"
DocumentChannel.broadcast_to(
Document.find(payload["document_id"]),
type: "comment_created",
comment: payload
)
when "documents_updated"
DocumentChannel.broadcast_to(
Document.find(payload["id"]),
type: "document_updated",
document: payload
)
end
end
end
Hier verdient het patroon zijn geld terug. Je had geen Redis nodig, je had geen Solid Cable nodig, en de round-trip van INSERT naar browser-toast is een paar milliseconden. Als je al bent overgestapt op Solid Cable voor het transport, dan compose’t dit netjes — het LISTEN/NOTIFY-kanaal triggert de broadcast, en Solid Cable regelt de bezorging tussen webprocessen.
Payload-Grootte, Ordening en Delivery-Garanties
Postgres cap’t de NOTIFY-payload op acht kilobytes — dit is een harde limiet, en pogingen om meer te sturen leveren een error op. In de praktijk moet je de payload behandelen als pointer, niet als document. Stuur het record-id en een paar velden waarmee de subscriber kan beslissen of hij iets moet doen; als de subscriber de volledige rij nodig heeft, moet die maar queryen.
def publish_created_event
payload = { id: id, document_id: document_id }.to_json
self.class.connection.execute(
ActiveRecord::Base.sanitize_sql(["NOTIFY comments_created, ?", payload])
)
end
Delivery is at-most-once. Als de listener-thread niet verbonden is — omdat het proces herstart, omdat het netwerk hikte, omdat je deploy’t — dan zijn berichten die tijdens dat venster afgevuurd worden weg. Dat is prima voor UI-notificaties, presence-indicators en cache-invalidaties. Het is niet prima voor iets dat gereconcilieerd moet worden — order fulfillment, billing events, audit trails. Gebruik daarvoor een durable queue zoals Solid Queue en behandel NOTIFY als een low-latency zetje, met een durable job als vangnet.
Ordening is per-kanaal, in volgorde van transactie-commit. Twee notificaties gepubliceerd binnen dezelfde transactie komen samen aan, in de volgorde waarin ze in de queue kwamen. Notificaties uit twee gelijktijdige transacties zijn geordend op commit-tijd, wat bijna altijd is wat je wilt.
Debouncing en Deduplicatie
Een veelvoorkomend patroon is een document dat tien updates per seconde krijgt — je wilt dat de browser één refresh ziet, niet tien. Debounce aan de subscriber-kant in plaats van te proberen writes te reduceren. De listener is een gewone Ruby-thread; een kleine in-memory coalescing map is prima:
class DebouncedRouter
DEBOUNCE_MS = 200
def initialize
@pending = {}
@mutex = Mutex.new
start_flusher
end
def route(channel, payload)
key = "#{channel}:#{payload['id']}"
@mutex.synchronize { @pending[key] = [channel, payload, monotonic_ms] }
end
private
def start_flusher
Thread.new do
loop do
sleep 0.05
flush
end
end
end
def flush
now = monotonic_ms
ready = @mutex.synchronize do
due, kept = @pending.partition { |_, (_c, _p, t)| now - t >= DEBOUNCE_MS }
@pending = kept.to_h
due
end
ready.each { |_key, (channel, payload, _)| PgNotifierRouter.route(channel, payload) }
end
def monotonic_ms
(Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
end
end
Dit dropt bewust tussentijdse updates voor dezelfde id — de laatste write wint na 200ms stilte. Als je wél elke update wilt behouden, gebruik dan een durable queue.
Wanneer Je LISTEN/NOTIFY Overslaat En Grijpt Naar Redis Of Kafka
LISTEN/NOTIFY is de juiste tool als events efemeer zijn, het volume onder een paar honderd per seconde blijft, subscribers gemiste berichten tijdens restarts kunnen tolereren, en je niet nog een stuk infrastructuur wilt toevoegen. Als je durable delivery over restarts heen nodig hebt, geordende fan-out naar honderden workers, replay van het laatste uur aan events, of cross-region distributie, dan zit je buiten de comfortzone. Redis Streams is een logische volgende stap; Kafka woont nog een tier verder.
Het andere geval waarin ik het overslaat is bij hoog write-volume tegen tabellen die al contention hebben. Elke NOTIFY neemt een lichtgewicht lock op een Postgres-interne queue; bij enkele duizenden per seconde zie je dat verschijnen in pg_stat_activity. In de praktijk heb ik dat plafond nog nooit geraakt in een Rails-app, maar als je een event bus draait voor een vloot microservices is het reëel.
FAQ
Werkt Postgres LISTEN/NOTIFY met connection pooling?
Niet met transaction pooling. LISTEN bindt aan één specifieke backend-verbinding, en een transaction pooler zoals PgBouncer in transaction mode geeft je bij de volgende query een andere backend, dus je subscriptie ontvangt stilzwijgend niets meer. Verbind de listener direct met Postgres (pooler omzeilen) of draai de pooler in session mode.
Hoe groot mag een NOTIFY-payload zijn?
Acht kilobytes. Dat is een harde Postgres-limiet. Behandel de payload als pointer — stuur het id en een paar velden waarop de subscriber kan filteren, en laat de subscriber de volledige rij ophalen als hij meer nodig heeft.
Is LISTEN/NOTIFY een vervanging voor ActionCable?
Nee. LISTEN/NOTIFY is server-naar-server, binnen je Rails-proces. ActionCable is server-naar-browser, over WebSockets. Je gebruikt LISTEN/NOTIFY om een event uit te waaieren naar elk Rails-proces, en dan ActionCable om het naar de verbonden browsers te pushen. Op Rails 8 met Solid Cable compose’n de twee netjes — Postgres doet in beide gevallen de pub/sub.
Wat gebeurt er met notificaties tijdens een deploy?
Ze worden weggegooid. LISTEN/NOTIFY is at-most-once; elke notificatie die vuurt terwijl het listener-proces herstart is verloren. Dat is prima voor UI-toasts en cache-invalidatie. Voor alles wat niet verloren mag gaan — orders, betalingen, audit events — gebruik je een durable queue zoals Solid Queue, en behandel je NOTIFY als het low-latency zetje eroverheen.
Hulp nodig bij de beslissing of jouw Rails-app Redis kan overslaan voor real-time features, of wil je een review van je Postgres- en ActionCable-topologie? TTB Software shipt sinds 2007 Rails in productie en werkt als fractional CTO met founders en engineering-teams. Negentien jaar verder zijn de saaie antwoorden nog steeds meestal de juiste.
Related Articles
Rails Materialized Views met Scenic: Snelle Postgres Read Models Zonder Writes Om Zeep Te Helpen
Rails materialized views met de Scenic gem: bouw snelle read models, versioneer SQL in migraties en refresh concurren...
Rails Audit Logging met PaperTrail: Wijzigingshistorie, Compliance Trails en Verwijderde Records Terughalen
Rails audit logging met PaperTrail: track elke wijziging, herstel verwijderde records, voldoe aan SOC 2/AVG en bevraa...
Rails Cursor Pagination: Keyset-paginatie Die Blijft Werken Waar OFFSET Onderuitgaat
Rails cursor pagination goed geregeld. Weg met OFFSET, kies keyset-paginatie voor stabiele, snelle infinite scroll en...