Background Jobs in Rails: wie ein fehlerhafter Job nicht die ganze Anwendung mitreißt
Kurz gesagt
Niveau: Fortgeschritten
- Ein Background Job holt langsame Arbeit (Mails, Zahlungen, externe APIs) aus dem Web Request, damit die Kundschaft nie darauf wartet.
- Rails 8 bringt Solid Queue als Standard mit, in der Datenbank. Sidekiq auf Redis oder Valkey bleibt die reife Wahl für sehr hohes Volumen. Die Regeln sind für beide dieselben.
- Jeder Job ist unabhängig, idempotent und rechnet damit, neu versucht zu werden. Zahlungsaufrufe tragen einen Idempotency Key.
- Eine langsame Abhängigkeit kann jeden Thread belegen. Littles Gesetz zeigt, wie viele: Ankunftsrate mal Haltezeit. Kurze Timeouts und eigene Prozesse für externe APIs halten die Zahlungen am Laufen.
- Beobachte Queue Latenz, tote Jobs, das Retry Set und die Queue Tiefe.
Ein einzelner Background Job hat einmal eine ganze eCommerce Plattform für sechs Stunden lahmgelegt, am Black Friday. Hier erfährst du, was Background Jobs sind, wie Sidekiq und Solid Queue, der Standard in Rails 8, sie ausführen, und welche wenigen Designregeln verhindern, dass ein fehlerhafter Job alle anderen anhält.
Ein Job, sechs Stunden, Black Friday
Lass mich dir von dem Tag erzählen, an dem ein einzelner Background Job eine komplette eCommerce Plattform für sechs Stunden lahmgelegt hat, ausgerechnet am Black Friday. Ein Job. Sechs Stunden. Millionen an entgangenem Umsatz. Der Job verarbeitete Bestellbestätigungen, stieß auf eine kaputte Mailadresse und warf eine Exception, die niemand abfing. Er war so eingestellt, dass er es endlos neu versuchte, ohne Circuit Breaker, und er blockierte die ganze Queue. Jeder andere Job (Versandbenachrichtigungen, Bestandsupdates, Zahlungsbestätigungen) stand einfach da und wartete. Das ist kein Gedankenspiel. Es ist passiert, und genau deshalb schreibe ich diesen Text.
Background Worker erledigen alles, wofür deine Kundschaft sonst auf einen Ladekringel starren müsste: Mails verschicken, Bilder verarbeiten, Daten abgleichen, Berichte erzeugen, Karten belasten. Sie sind aber auch eine der größten Quellen für Ausfälle in der Produktion, wenn sie nicht sauber entworfen sind. Ich betreibe Sidekiq seit über zehn Jahren in der Produktion, in Dutzenden Anwendungen, von kleinen Startups bis zu Systemen mit Millionen Jobs am Tag. Die Muster hier stammen aus echten Ausfällen.
In diesem Artikel geht es darum, was ein Background Job ist, wie Sidekiq und Solid Queue, der neue Standard in Rails 8, ihn ausführen, und um die wenigen Regeln, die verhindern, dass ein schlechter Job alle anderen mit in den Abgrund zieht.
Warum es Background Worker gibt
Wer auf deiner Website einen Knopf drückt, erwartet, dass sofort etwas passiert. Jakob Nielsens bewährte Faustregel: Rund 0,1 Sekunden sind die Grenze, bis zu der ein System sich unmittelbar anfühlt, und rund 1 Sekunde die Grenze, bis zu der der Gedankenfluss nicht abreißt.1 Die Psychologie des Wartens erkläre ich ausführlicher in Conversion und UX Psychologie.
Manche Arbeit dauert einfach länger. Eine Mail zu verschicken heißt, mit einem Mailserver zu reden. Ein hochgeladenes Bild zu verkleinern dauert ein oder zwei Sekunden. Ein PDF Bericht kann zehn dauern. Ein Aufruf der API eines anderen dauert so lange, wie dessen Server gerade Lust haben.
Erledigst du diese Arbeit synchron (der Nutzer wartet, bis sie fertig ist), fühlt sich die Anwendung zäh an. Schlimmer noch: Innerhalb eines Web Requests blockierst du einen Serverprozess, der gerade jemand anderen bedienen könnte. Unter Last wird daraus ein Stau von Requests, dann kommen Timeouts, und am Ende steht die ganze Anwendung still.
Ein Background Worker holt die langsame Arbeit aus dem Request heraus. Statt sie jetzt zu erledigen, schreibt der Webprozess einen kleinen Zettel in eine Queue ("schick die Bestätigung für Bestellung 4812") und antwortet dem Nutzer sofort. Ein eigener Prozess nimmt den Zettel und erledigt die Arbeit.
Beispiel: eine Bestellung bei der Speicherbohne
Die Speicherbohne ist eine erfundene Kaffeerösterei in Hamburg, die ihre Bohnen online unter speicherbohne.example verkauft. Eine Kundin drückt auf "Bestellung abschicken". Im Request speichert der Shop die Bestellung und legt vier Zettel in die Queue: Karte belasten, Bestand senken, Lager informieren, Bestätigung schicken. Die Kundin sieht nach ein paar hundert Millisekunden "Bestellung bestätigt". Die vier Arbeiten laufen in den folgenden Sekunden im Workerprozess, und auf keine davon muss sie warten.
Das Diagramm liest sich von oben nach unten. Der Web Request endet beim dritten Kasten, deshalb muss die Kundin nicht warten. Alles darunter passiert im Worker, und schlägt dort etwas fehl, dreht nur dieser eine Job eine weitere Runde, ohne die anderen zu berühren.
Der Stack: Sidekiq, Redis und Valkey
In der Ruby Welt ist Sidekiq seit weit über zehn Jahren der meistgenutzte Job Prozessor. Er hält seine Queues in Redis, einem Datenspeicher im Arbeitsspeicher, der genau das sehr schnell kann, was eine Queue braucht: einen Job hineinlegen, einen Job herausnehmen und den Zustand mitführen. Aktuelle Sidekiq Versionen brauchen Redis 7.0 oder neuer und unterstützen Valkey ab 7.2 und Dragonfly als Alternativen. Das eigene Changelog zeigt die aktuelle Hauptversion, 8.1.23
Kurz zu Valkey
Im März 2024 wechselte Redis ab Version 7.4 von der freizügigen BSD Lizenz zu einer Doppellizenz aus Redis Source Available License (RSALv2) und Server Side Public License (SSPLv1).4 Wenige Tage später startete die Linux Foundation Valkey, einen Community Fork von Redis 7.2.4, der unter der BSD Lizenz mit drei Klauseln bleibt.5 Im Mai 2025 nahm Redis ab Redis 8 die GNU Affero General Public License (AGPLv3) als dritte Option dazu.6
Für Sidekiq verhält sich Valkey wie ein direkter Ersatz: dasselbe Protokoll, dieselben Befehle, dieselbe Konfiguration. Startest du ein neues Projekt oder ist dein Unternehmen bei den Redis Lizenzen vorsichtig, ist Valkey eine solide Wahl.
# Gemfile
gem "sidekiq", "~> 8.1"
# Sidekiq spricht über das Gem redis-client mit Redis oder Valkey
# und installiert es selbst. Ein eigenes redis Gem brauchst du nicht.
# config/initializers/sidekiq.rb
# Dieselben Einstellungen funktionieren für Redis und für Valkey.
Sidekiq.configure_server do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
end
Sidekiq.configure_client do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
end
Rails 8 bringt eine eigene Queue mit: Solid Queue
Seit Rails 8.0 nutzt eine neue Anwendung Solid Queue als Standard Backend für Active Job. Solid Queue legt die Jobs in deine Datenbank statt in Redis und stützt sich bei hohem Volumen auf FOR UPDATE SKIP LOCKED, das es in PostgreSQL ab 9.5 und in MySQL ab 8.0 gibt. Es läuft auch mit SQLite.7 Es kann verzögerte Jobs, Prioritäten, Steuerung der Nebenläufigkeit, wiederkehrende Jobs und das Einreihen in großen Mengen, und in einer neuen App ist es ab Werk für die Produktion eingerichtet, auf einer eigenen Queue Datenbank.8
Active Job ist die gemeinsame Schnittstelle vor beiden. Du schreibst einen Job einmal, als Klasse, die von ApplicationJob erbt, und Active Job reicht ihn an das Backend weiter, das du einstellst: standardmäßig Solid Queue, sonst Sidekiq oder einen anderen Adapter.
Also welches? Solid Queue heißt ein Stück Infrastruktur weniger, und für einen Shop oder ein SaaS mit überschaubarem Jobvolumen ist das eine echte Ersparnis. Sidekiq ist die reifere Wahl für sehr hohen Durchsatz und bringt ein großes Ökosystem mit. Alles im Rest dieses Artikels, Unabhängigkeit, Idempotenz, Isolation und Monitoring, gilt für beide. Wo der Code sich unterscheidet, zeige ich beide Fassungen.
Dein erster Background Job
Ein einfacher Job, der eine Willkommensmail verschickt, in Sidekiqs eigener Schnittstelle:
# app/sidekiq/send_welcome_email_job.rb
class SendWelcomeEmailJob
include Sidekiq::Job
def perform(user_id)
user = User.find(user_id)
UserMailer.welcome(user).deliver_now
end
end
Und das Einreihen aus dem Controller:
# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
# Schreibt den Job nach Redis und kehrt sofort zurück.
SendWelcomeEmailJob.perform_async(@user.id)
redirect_to dashboard_path, notice: "Welcome aboard."
else
render :new, status: :unprocessable_entity
end
end
end
Der Schlüssel ist perform_async. Es verschickt die Mail nicht. Es schreibt den Klassennamen und die Argumente als JSON nach Redis und kehrt nach Millisekunden zurück. Ein separat laufender Sidekiq Prozess nimmt den Job und ruft perform auf. Braucht die Mail eine halbe Sekunde, sieht der Nutzer längst sein Dashboard.
Das Problem: Jobs sind keine Inseln
Dieser einfache Job hat Schwächen, die der ganzen Anwendung schaden können.
Schwäche eins: den Nutzer gibt es vielleicht nicht mehr
Zwischen Einreihen und Ausführen kann der Nutzer gelöscht worden sein, oder (mit Redis) die Transaktion, die ihn angelegt hat, ist noch gar nicht committet. User.find wirft ActiveRecord::RecordNotFound, und Sidekiq versucht es neu. Standardmäßig 25 Mal über rund drei Wochen, bevor der Job ins Dead Set wandert.9 Das sind 25 Exceptions in deinem Error Tracker für einen Job, der nie gelingen kann.
Schwäche zwei: der Mailanbieter ist vielleicht down
Mailserver fallen aus, Verbindungen laufen in Timeouts, Rate Limits greifen. Jedes davon wirft eine Exception. Hat dein Anbieter eine Störung, schlagen alle Mailjobs gleichzeitig fehl und landen zusammen im Retry Set. Sidekiq addiert auf jede Wartezeit einen Zufallsanteil, der sie ein wenig verteilt, aber der Anbieter kommt trotzdem zurück zu einer Wand aus Arbeit.
Schwäche drei: ein Job, der vier Dinge tut
Die fiesesten Probleme entstehen, wenn ein Job mehrere Dinge tut, die jedes für sich scheitern können:
# app/sidekiq/process_order_job.rb
# Ein Job, vier Wege zu scheitern. Siehst du das Problem?
class ProcessOrderJob
include Sidekiq::Job
def perform(order_id)
order = Order.find(order_id)
return if order.processed?
charge_payment(order) # ruft den Zahlungsanbieter auf
update_inventory(order) # Datenbankupdates, kann in einen Deadlock laufen
notify_warehouse(order) # ruft eine externe API auf, kann in einen Timeout laufen
send_confirmation(order) # verschickt eine Mail, kann scheitern
order.update!(processed: true)
end
end
Läuft notify_warehouse in einen Timeout, scheitert der ganze Job und wird neu versucht. Die Karte ist aber schon belastet, also belastet der neue Versuch sie noch einmal. Und gerät update_inventory in einen Deadlock mit einem anderen Bestelljob, der dasselbe Produkt aktualisiert, scheitern beide, versuchen es beide neu und können beide wieder im Deadlock landen.
Warum ein Job eine Queue blockieren kann
Sidekiqs eigene Retries halten eine Queue nicht auf: Ein gescheiterter Job wandert in ein eigenes Retry Set, und der Thread macht weiter. Was eine Queue blockiert, ist Arbeit, die die Threads belegt. Ein Job, der an einem Aufruf ohne Timeout hängt, oder ein Job, der innerhalb von perform seine eigenen Wiederholungen in einer Schleife dreht, hält einen Thread beschäftigt. Sind genug Threads mit derselben kaputten Abhängigkeit beschäftigt, läuft nichts anderes mehr.
Wie viele Threads das braucht, folgt aus Littles Gesetz, einem Ergebnis der Warteschlangentheorie, das für jedes stabile System gilt:10
Dabei ist die durchschnittliche Zahl der Jobs, die gleichzeitig bearbeitet werden (also die Zahl der Threads, die sie belegen), die Ankunftsrate dieser Jobs in Jobs pro Sekunde und die durchschnittliche Zeit in Sekunden, die jeder Job einen Thread festhält. Die Rate ist dieselbe Idee wie jede andere Rate, Ereignisse pro Zeit, erklärt in Grundlagen: Verhältnisse und Raten.
Beispiel: das Lager der Speicherbohne wird langsam
Die Annahmen des Beispiels: Am Black Friday nimmt die Speicherbohne 1.800 Bestellungen pro Stunde an, jede Bestellung reiht vier Jobs ein, und ein Sidekiq Prozess läuft mit 10 Threads. Ein normaler Job dauert 0,4 Sekunden.
Zuerst der normale Tag. 1.800 Bestellungen pro Stunde mal vier Jobs ergibt 7.200 Jobs pro Stunde. Geteilt durch 3.600 Sekunden sind das Jobs pro Sekunde. Mit Sekunden ist . Im Schnitt ist weniger als einer der 10 Threads beschäftigt. Reichlich Luft.
Jetzt wird die Lager API langsam, und jeder Aufruf wartet auf einen Timeout von 30 Sekunden. Betroffen sind nur die Lagerjobs: einer pro Bestellung, also 1.800 pro Stunde, das sind pro Sekunde. Mit ist . Die Lagerjobs allein wollen 15 Threads, und es gibt nur 10. Innerhalb einer Minute wartet jeder Thread auf das Lager, und die Zahlungs, Bestands und Mailjobs stauen sich dahinter. Noch ist nichts fehlgeschlagen, und der Shop steht trotzdem schon. Senk den Timeout auf fünf Sekunden, und dieselbe Rechnung ergibt Threads. Leg die Lagerjobs in einen eigenen Prozess, und selbst ein Totalausfall des Lagers kann die Zahlungen nicht berühren. Beide Lösungen folgen unten.
Die goldene Regel: jeder Job ist unabhängig und idempotent
Das ist die wichtigste Lektion dieses Artikels:
Jeder Job muss unabhängig sein. Jeder Job muss idempotent sein. Jeder Job muss davon ausgehen, dass er scheitert und neu versucht wird.
Die Sidekiq Dokumentation sagt in ihren Best Practices dasselbe: Job Argumente klein und einfach halten, Jobs idempotent und transaktional machen.11
Unabhängigkeit
Ein Job darf sich nicht auf einen Zustand verlassen, den ein anderer Job hinterlassen hat, oder darauf, dass Jobs in einer bestimmten Reihenfolge laufen. Er holt sich beim Ausführen, was er braucht.
# SCHLECHT: geht davon aus, dass ein anderer Job shipped_at schon gesetzt hat
class SendShippingNotificationJob
include Sidekiq::Job
def perform(order_id)
order = Order.find(order_id)
raise "Not shipped yet" unless order.shipped_at
OrderMailer.shipped(order).deliver_now
end
end
# GUT: prüft den Zustand und behandelt jeden Fall
class SendShippingNotificationJob
include Sidekiq::Job
def perform(order_id)
order = Order.find_by(id: order_id)
return unless order # Bestellung weg, nichts zu tun
return unless order.shipped_at # nicht versandt, der Versandjob reiht uns später ein
return if order.shipping_notification_sent_at # schon verschickt, kein Spam
OrderMailer.shipped(order).deliver_now
order.update!(shipping_notification_sent_at: Time.current)
end
end
Idempotenz
Ein Job ist idempotent, wenn zweimal Ausführen dieselbe Wirkung hat wie einmal. Das zählt, weil Retries, Netzwerkfehler und abstürzende Worker alle bedeuten, dass ein Job mehr als einmal laufen kann.
Bei Zahlungen hilft der Anbieter. Stripe nimmt mit jedem Request einen Idempotency Key an: Wird ein Request mit demselben Key wiederholt, gibt Stripe das gespeicherte Ergebnis des ersten Requests zurück, statt die Operation noch einmal auszuführen. Ein Key darf bis zu 255 Zeichen lang sein, sollte keine personenbezogenen Daten wie eine Mailadresse enthalten und kann nach 24 Stunden gelöscht werden.12 In der Ruby Bibliothek gehört der Key in die Request Optionen, das zweite Argument, nicht zu den Zahlungsparametern.
# app/sidekiq/charge_order_job.rb
class ChargeOrderJob
include Sidekiq::Job
sidekiq_options queue: "critical", retry: 10
def perform(order_id)
order = Order.find_by(id: order_id)
return unless order
return if order.paid?
intent = Stripe::PaymentIntent.create(
{
amount: order.total_cents,
currency: "eur",
customer: order.user.stripe_customer_id,
payment_method: order.payment_method_id,
off_session: true,
confirm: true
},
{ idempotency_key: "order-#{order.id}-charge" }
)
order.update!(paid: true, stripe_payment_intent_id: intent.id, paid_at: Time.current)
rescue Stripe::CardError => e
# Eine Ablehnung wird durch einen neuen Versuch nicht besser. Festhalten und der Kundin Bescheid geben.
order.update!(payment_failed: true, payment_error: e.message)
PaymentFailedMailer.notify(order).deliver_later
end
end
Der Betrag steht in Cent, so wie Stripe ihn erwartet. Stürzt der Worker ab, nachdem Stripe das Geld eingezogen hat, aber bevor update! läuft, schickt der neue Versuch denselben Key, bekommt das ursprüngliche Ergebnis zurück, und die Kundin wird einmal belastet.13
Sauber scheitern
Manche Fehler gehen nie weg, und sie neu zu versuchen füllt nur den Error Tracker. Andere sind vorübergehend und verdienen einen neuen Versuch. Ein Job sollte die beiden auseinanderhalten. In diesem Beispiel ist MailingList die eigene kleine Hülle des Shops um seinen Newsletteranbieter, mit eigenen Fehlerklassen, das ist die Annahme des Beispiels.
class SyncUserToMailingListJob
include Sidekiq::Job
sidekiq_options retry: 5
def perform(user_id)
user = User.find_by(id: user_id)
return unless user&.email.present?
MailingList.subscribe(email: user.email, first_name: user.first_name)
rescue MailingList::AlreadySubscribed
Rails.logger.info("User #{user_id} already on the list")
rescue MailingList::InvalidEmail => e
# Dauerhaft: diese Adresse wird nie funktionieren. Festhalten, nicht neu versuchen.
user.update!(mailing_list_error: e.message)
rescue MailingList::RateLimited, MailingList::ServerError
raise # vorübergehend: Sidekiq mit Backoff neu versuchen lassen
end
end
Einen Job zerlegen, der zu viel tut
Zurück zum Bestelljob, der vier Dinge tat. Zerleg ihn in vier Jobs, die jeder für sich scheitern können:
# app/sidekiq/process_order_job.rb
# Koordiniert nur. Jeder Schritt ist ein eigener Job.
class ProcessOrderJob
include Sidekiq::Job
def perform(order_id)
order = Order.find_by(id: order_id)
return unless order
return if order.processing_started_at
order.update!(processing_started_at: Time.current)
ChargeOrderJob.perform_async(order_id)
UpdateInventoryJob.perform_async(order_id)
NotifyWarehouseJob.perform_async(order_id)
SendOrderConfirmationJob.perform_async(order_id)
end
end
# app/sidekiq/update_inventory_job.rb
class UpdateInventoryJob
include Sidekiq::Job
sidekiq_options queue: "default", retry: 5
def perform(order_id)
order = Order.find_by(id: order_id)
return unless order
return if order.inventory_updated_at
ActiveRecord::Base.transaction do
# Produkte in der Reihenfolge ihrer id sperren, damit zwei Bestellungen nie im Kreis aufeinander warten.
order.line_items.includes(:product).sort_by(&:product_id).each do |item|
item.product.lock!
item.product.decrement!(:stock, item.quantity)
end
order.update!(inventory_updated_at: Time.current)
end
end
end
# app/sidekiq/notify_warehouse_job.rb
class NotifyWarehouseJob
include Sidekiq::Job
sidekiq_options queue: "external_apis", retry: 15
def perform(order_id)
order = Order.find_by(id: order_id)
return unless order
return if order.warehouse_notified_at
shipment = WarehouseApi.new(timeout: 5).create_shipment(
order_id: order.id,
items: order.line_items.map(&:to_warehouse_format),
address: order.shipping_address
)
order.update!(warehouse_notified_at: Time.current, warehouse_shipment_id: shipment.id)
rescue WarehouseApi::InvalidAddress => e
# Dauerhaft: diese Adresse muss sich ein Mensch ansehen.
order.update!(warehouse_error: e.message)
AdminAlertJob.perform_async("warehouse_address_error", order_id)
end
end
Der Job für die Bestätigungsmail folgt derselben Form: Bestellung suchen, zurückkehren, wenn sie weg ist oder confirmation_sent_at gesetzt ist, verschicken, Zeitpunkt festhalten.
Ist die Lager API jetzt down, scheitert nur NotifyWarehouseJob und wird neu versucht. Die Zahlung geht durch, der Bestand sinkt, und die Kundin bekommt ihre Bestätigung. Das Lager holt auf, sobald seine API wieder läuft. Scheitert die Mail, bleiben Belastung und Sendung unberührt, und die Mail lässt sich für sich ansehen. Fehler sind isoliert. Ein kaputtes Teil macht nicht mehr alles kaputt.
Zwei Details lohnen einen zweiten Blick. Der Lagerclient hat einen Timeout von fünf Sekunden, die Lösung aus dem Beispiel zu Littles Gesetz. Und der Bestandsjob sperrt die Produkte in fester Reihenfolge (nach id), was das Warten im Kreis beseitigt, aus dem ein Deadlock entsteht.
Derselbe Job, geschrieben für Solid Queue
Mit Active Job wandern die Regeln für Retries in Deklarationen. Ein gescheiterter Job wird nur dann neu versucht, wenn du es sagst: retry_on versucht es neu (standardmäßig fünf Versuche im Abstand von drei Sekunden), und discard_on verwirft den Job.14 Seit Rails 7.2 heißt die Backoff Option :polynomially_longer, das ältere :exponentially_longer wurde entfernt.15 Ist ein Datensatz, der einem Job übergeben wurde, inzwischen gelöscht, wirft Active Job ActiveJob::DeserializationError, ein guter Kandidat zum Verwerfen.14
# app/jobs/notify_warehouse_job.rb
class NotifyWarehouseJob < ApplicationJob
queue_as :external_apis
retry_on WarehouseApi::Unavailable, wait: :polynomially_longer, attempts: 15
discard_on ActiveJob::DeserializationError
def perform(order)
return if order.warehouse_notified_at
shipment = WarehouseApi.new(timeout: 5).create_shipment(order_id: order.id)
order.update!(warehouse_notified_at: Time.current, warehouse_shipment_id: shipment.id)
end
end
Active Job kann die Bestellung selbst als Argument nehmen, weil es Datensätze als GlobalID serialisiert und beim Ausführen wieder nachschlägt.14 Sidekiqs eigene Schnittstelle kann das nicht, wie die Lektionen weiter unten zeigen.
Queue Design: kritische Jobs in Bewegung halten
Nicht alle Jobs sind gleich. Ein Passwort Reset ist wichtiger als eine wöchentliche Auswertung. Eine Zahlung ist wichtiger als ein Abgleich mit dem CRM. In Sidekiq kannst du mehrere Queues aufführen, jede mit einem optionalen Gewicht. Eine Queue mit Gewicht zwei wird doppelt so oft geprüft wie eine mit Gewicht eins. Ohne Gewichte werden Queues in strenger Reihenfolge abgearbeitet: Ein Job in einer späteren Queue wartet, bis die früheren leer sind.16
# config/sidekiq.yml
:concurrency: 10
:queues:
- [critical, 6]
- [default, 3]
- [mailers, 2]
- [external_apis, 2]
- [low, 1]
Die Gewichte reservieren keine Threads für eine Queue, und sobald Gewichte gesetzt sind, spielt die Reihenfolge der Liste keine Rolle. Sie legen fest, wie oft jede Queue die erste ist, in die ein freier Thread schaut.
Beispiel: was die Gewichte bedeuten
Die Gewichte in dieser Datei ergeben zusammen . Jedes Mal, wenn ein Thread Arbeit sucht, kommt critical mit einer Wahrscheinlichkeit von zuerst, rund 43 %, und low mit einer Wahrscheinlichkeit von , rund 7 %. Ist die erste Queue, in die er schaut, leer, geht er zu den anderen weiter, und so kommt auch Arbeit mit niedriger Priorität voran. Wahrscheinlichkeiten als Anteile auf lange Sicht erkläre ich in Grundlagen: Wahrscheinlichkeit.
Was die Gewichte nicht können: critical schützen, wenn jeder Thread in einem langsamen external_apis Job feststeckt. Dafür brauchst du getrennte Prozesse, jeder mit eigenen Threads:
# Prozess eins: nur Zahlungen und andere kritische Arbeit
bundle exec sidekiq -q critical -c 5
# Prozess zwei: Alltagsarbeit und Mails
bundle exec sidekiq -q default -q mailers -c 10
# Prozess drei: langsame externe APIs und Massenarbeit
bundle exec sidekiq -q external_apis -q low -c 5
Jetzt kann ein langsamer Drittanbieter immer nur die fünf Threads von Prozess drei füllen. Die Zahlungen laufen weiter.
In Solid Queue steht das Gegenstück in config/queue.yml. Die Reihenfolge der Queues in der Liste eines Workers legt die Abfragereihenfolge fest, und ein Worker nimmt keine Jobs aus einer späteren Queue, bevor die früheren leer sind.8 Getrennte Worker für getrennte Queues bringen dieselbe Isolation wie getrennte Sidekiq Prozesse. Solid Queue kann außerdem begrenzen, wie viele Jobs einer Art gleichzeitig laufen, mit limits_concurrency, zum Beispiel ein Export pro Konto.17
# config/queue.yml
production:
workers:
- queues: [critical]
threads: 5
- queues: [default, mailers]
threads: 10
- queues: [external_apis, low]
threads: 5
Retries und das Dead Set
Sidekiq versucht einen gescheiterten Job mit exponentiellem Backoff neu. Die Dokumentation gibt die Wartezeit vor jedem neuen Versuch so an:9
Dabei ist die Zahl der bisherigen Retries, beginnend bei 0 für den ersten, diese Zahl hoch vier, ein fester Anteil von 15 Sekunden und eine zufällige ganze Zahl von 0 bis 9, die die Retries verteilt, damit sie nicht alle auf einmal kommen. Das Ergebnis ist in Sekunden. Den Zufallsanteil denkt man am besten über seinen Durchschnitt, den Erwartungswert, erklärt in Grundlagen: Erwartungswert.
Beispiel: woher die "rund drei Wochen" kommen
Lass den Zufallsanteil zuerst weg. Die ersten fünf Wartezeiten sind , , , und Sekunden, genau die Folge, die die Sidekiq Dokumentation aufführt.
Jetzt addier alle 25 Retries, für von 0 bis 24. Die vierten Potenzen ergeben zusammen 1.763.020 Sekunden. Der feste Anteil ist Sekunden. Die Zufallszahl liegt im Schnitt bei , und läuft von 1 bis 25, was zusammen 325 ergibt, also liegt der Zufallsanteil im Schnitt bei Sekunden. Insgesamt sind das Sekunden. Geteilt durch 86.400 Sekunden pro Tag sind das rund 20,4 Tage. Das Sidekiq Wiki rundet an einer Stelle auf rund 20 Tage, an einer anderen auf 21.9 So oder so: Ein Job, der nie gelingen kann, wird drei Wochen lang neu versucht, wenn du ihm nichts anderes sagst.
Du kannst ihm etwas anderes sagen, pro Job:
class SomeJob
include Sidekiq::Job
sidekiq_options retry: 10, backtrace: 10
sidekiq_retry_in do |count, exception|
case exception
when RateLimitError then 60 * (count + 1) # eine Minute, zwei Minuten, drei...
when PermanentError then :kill # direkt ins Dead Set
end # nil: der Standard Backoff
end
end
Nach dem letzten Retry landet der Job im Dead Set. In der Web UI kannst du tote Jobs von Hand neu starten, und Sidekiq behält sie sechs Monate, bevor es sie verwirft.9 Behalte das Dead Set im Blick. Füllt es sich, stimmt etwas nicht. Ein Hook auf ausgeschöpfte Retries ist der richtige Ort für einen Alarm:
# app/sidekiq/concerns/dead_job_alerting.rb
module DeadJobAlerting
extend ActiveSupport::Concern
included do
sidekiq_retries_exhausted do |job, exception|
Rails.logger.error("#{job['class']} died after #{job['retry_count']} retries: #{exception.message}")
AlertNotifier.notify("Job died: #{job['class']}: #{exception.message}")
end
end
end
class ImportantJob
include Sidekiq::Job
include DeadJobAlerting
end
Bind Sidekiq::Job vor dem Concern ein, denn sidekiq_retries_exhausted kommt von dort. AlertNotifier steht für das, was bei dir Alarme verschickt.
Monitoring und Alarme
Was du nicht siehst, kannst du nicht reparieren. Sidekiq bringt eine Web UI mit. Häng sie hinter eine Anmeldung:
# config/routes.rb
require "sidekiq/web"
Rails.application.routes.draw do
authenticate :user, ->(user) { user.admin? } do
mount Sidekiq::Web => "/sidekiq"
end
end
Für Solid Queue ist Mission Control Jobs das Gegenstück: eine Rails Engine, die Queues und Jobs zeigt und gescheiterte Jobs neu starten oder verwerfen lässt.18
Die UI ist zum Hinschauen. In der Produktion willst du Zahlen und Alarme. Diese vier beobachte ich:
- Queue Latenz: wie lange der älteste Job schon wartet. Steigt sie, brauchst du mehr Worker, oder deine Jobs sind zu langsam. Sidekiq liefert sie pro Queue als
Sidekiq::Queue.new("critical").latency. - Tote Jobs: sollten nahe null sein. Jeder Anstieg verdient einen Blick.
- Größe des Retry Sets: eine dauerhaft hohe Zahl heißt, dass etwas systematisch scheitert.
- Queue Tiefe: gelegentliche Spitzen sind in Ordnung. Dauerhaftes Wachstum heißt, dass du nicht hinterherkommst.
Lektionen aus der Produktion
Lektion eins: ids übergeben, keine Objekte
Sidekiq speichert Job Argumente als JSON in Redis. Ein Ruby Objekt übersteht diese Reise nicht, und selbst wenn, wäre es veraltet, bis der Job läuft. Sidekiq rät, einfache Kennungen zu übergeben und den Datensatz in perform nachzuschlagen.11 Seit Sidekiq 7.0 führen Argumente, die keine nativen JSON Typen sind, zu einem Fehler statt zu einer Warnung.19
SendEmailJob.perform_async(@user) # wirft ab Sidekiq 7 einen Fehler
SendEmailJob.perform_async(@user.id) # richtig
Active Job ist dank GlobalID die Ausnahme, wie oben beschrieben.
Lektion zwei: in großen Mengen einreihen
# Langsam: ein Roundtrip zu Redis pro Bestellposition
@order.line_items.each { |item| UpdateStockJob.perform_async(item.id) }
# Besser: ein Job, der die ganze Bestellung erledigt
UpdateInventoryJob.perform_async(@order.id)
# Oder, wenn du wirklich einen Job pro Position brauchst, alle auf einmal einreihen
UpdateStockJob.perform_bulk(@order.line_items.ids.map { |id| [id] })
perform_bulk reiht viele Jobs auf einmal ein, standardmäßig 1.000 pro Stapel.20
Lektion drei: auf den Speicher achten
Worker sind Prozesse, die lange laufen, und wer zu viel auf einmal lädt, lässt sie dauerhaft wachsen.
# SCHLECHT: lädt jede offene Bestellung in den Speicher
Order.where(status: "pending").to_a.each { |order| process(order) }
# GUT: arbeitet sie in Hunderterschritten ab
Order.where(status: "pending").find_each(batch_size: 100) { |order| process(order) }
Lektion vier: Jobs testen
Der Willkommensjob vom Anfang verträgt keinen zweiten Lauf, und ein Test macht das sofort sichtbar. Eine korrigierte Fassung hält fest, dass die Mail verschickt wurde:
class SendWelcomeEmailJob
include Sidekiq::Job
def perform(user_id)
user = User.find_by(id: user_id)
return unless user
return if user.welcome_sent_at
UserMailer.welcome(user).deliver_now
user.update!(welcome_sent_at: Time.current)
end
end
# spec/sidekiq/send_welcome_email_job_spec.rb
require "rails_helper"
RSpec.describe SendWelcomeEmailJob do
let(:user) { create(:user) }
it "sends the welcome email" do
expect { described_class.new.perform(user.id) }
.to change { ActionMailer::Base.deliveries.count }.by(1)
end
it "does nothing for a user who no longer exists" do
expect { described_class.new.perform(0) }.not_to raise_error
end
it "sends only once when it runs twice" do
described_class.new.perform(user.id)
expect { described_class.new.perform(user.id) }
.not_to change { ActionMailer::Base.deliveries.count }
end
end
Wer perform direkt aufruft, testet die Logik ohne Redis. Um zu testen, dass ein Controller den Job einreiht, versetzt require "sidekiq/testing" Sidekiq in einen Fake Modus, in dem sich Jobs in einem Array sammeln, das du prüfen kannst, statt nach Redis zu gehen.21 Den größeren Testansatz beschreibe ich in TDD und BDD in Ruby on Rails.
Entwirf die Jobs, bevor sie deine Ausfälle entwerfen
Background Worker sind in einer modernen Webanwendung nicht optional, und sie bringen echte Komplexität mit. Ob ein System hält oder ein Kartenhaus ist, entscheidet sich daran, wie die Jobs entworfen sind.
- Jobs sind unabhängig. Sie verlassen sich nie darauf, dass ein anderer Job vorher gelaufen ist.
- Jobs sind idempotent. Zweimal laufen hat dieselbe Wirkung wie einmal.
- Jobs scheitern sauber. Dauerhafte Fehler werden festgehalten, vorübergehende neu versucht, und jeder externe Aufruf hat einen Timeout.
- Queues sind isoliert. Langsame externe Arbeit läuft in einem eigenen Prozess, weit weg von den Zahlungen.
- Alles wird beobachtet. Latenz, tote Jobs, Retries und Tiefe.
Der Ausfall am Black Friday vom Anfang war vollständig vermeidbar. Ein paar Prüfungen, vernünftige Grenzen für Retries und eine saubere Trennung der Queues hätten ihn laufen lassen. Entwirf deine Jobs von Anfang an richtig, und lass andere diese Lektionen auf die harte Tour lernen.
Footnotes
-
Jakob Nielsen, Response times: the 3 important limits, Nielsen Norman Group, 1. Januar 1993, https://www.nngroup.com/articles/response-times-3-important-limits/
-
Sidekiq, README (Requirements), GitHub, https://github.com/sidekiq/sidekiq/blob/main/README.md
-
Sidekiq, Changes, GitHub, https://github.com/sidekiq/sidekiq/blob/main/Changes.md
-
Redis, Redis adopts dual source available licensing, 20. März 2024, https://redis.io/blog/redis-adopts-dual-source-available-licensing/
-
The Linux Foundation, Linux Foundation launches open source Valkey community, 28. März 2024, https://www.linuxfoundation.org/press/linux-foundation-launches-open-source-valkey-community
-
Rowan Trollope, Redis is now available under the AGPLv3 open source license, Redis, 1. Mai 2025, https://redis.io/blog/agplv3/
-
Ruby on Rails Guides, Ruby on Rails 8.0 Release Notes, Abschnitt Solid Queue, https://guides.rubyonrails.org/8_0_release_notes.html
-
Ruby on Rails Guides, Active Job Basics, Abschnitte 6 (Standard Backend: Solid Queue) und 6.3 (Reihenfolge und Priorität der Queues), https://guides.rubyonrails.org/active_job_basics.html
-
Sidekiq Wiki, Error Handling (automatische Retries, das Dead Set), GitHub, https://github.com/sidekiq/sidekiq/wiki/Error-Handling
-
John D. C. Little, Little's law as viewed on its 50th anniversary, Operations Research 59(3), Seiten 536 bis 549, 2011, https://people.cs.umass.edu/~emery/classes/cmpsci691st/readings/OS/Littles-Law-50-Years-Later.pdf
-
Sidekiq Wiki, Best Practices, GitHub, https://github.com/sidekiq/sidekiq/wiki/Best-Practices
-
Stripe, API Reference, Idempotent requests, https://docs.stripe.com/api/idempotent_requests
-
Stripe, API Reference, Create a PaymentIntent, https://docs.stripe.com/api/payment_intents/create
-
Ruby on Rails Guides, Active Job Basics, Abschnitte 2.3 (GlobalID), 8.3 (gescheiterte Jobs neu versuchen oder verwerfen) und 8.4 (fehlende Datensätze), https://guides.rubyonrails.org/active_job_basics.html
-
Ruby on Rails Guides, Ruby on Rails 7.2 Release Notes, Active Job Removals, https://guides.rubyonrails.org/7_2_release_notes.html
-
Sidekiq Wiki, Advanced Options (Queues und Gewichte), GitHub, https://github.com/sidekiq/sidekiq/wiki/Advanced-Options
-
Solid Queue, README (Concurrency Controls), GitHub, https://github.com/rails/solid_queue
-
Mission Control Jobs, README, GitHub, https://github.com/rails/mission_control-jobs
-
Sidekiq, Welcome to Sidekiq 7.0 (Upgrade Hinweise, strikte Argumente), GitHub, https://github.com/sidekiq/sidekiq/blob/main/docs/7.0-Upgrade.md
-
Sidekiq Wiki, Bulk Queueing, GitHub, https://github.com/sidekiq/sidekiq/wiki/Bulk-Queueing
-
Sidekiq Wiki, Testing, GitHub, https://github.com/sidekiq/sidekiq/wiki/Testing