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.

Eine Bestellung mit Background JobsDer Web Request speichert die Bestellung, legt vier Jobs in die Queue und antwortet sofort. Ein eigener Workerprozess nimmt jeden Job aus der Queue und führt ihn für sich aus.Kundin drückt auf Bestellung abschickenWeb Request speichert die BestellungWeb Request legt vier Jobs in die Queueund antwortetWorker nimmt den nächsten Job aus derQueueHat der Job geklappt?neinNein: er kommt ins Retry Set und läuftspäter noch einmaljaJa: der Worker nimmt den nächsten JobAlle vier Jobs erledigt, Bestellungabgeschlossen

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

L=λWL = \lambda W

Dabei ist LL die durchschnittliche Zahl der Jobs, die gleichzeitig bearbeitet werden (also die Zahl der Threads, die sie belegen), λ\lambda die Ankunftsrate dieser Jobs in Jobs pro Sekunde und WW 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 λ=2\lambda = 2 Jobs pro Sekunde. Mit W=0,4W = 0{,}4 Sekunden ist L=2×0,4=0,8L = 2 \times 0{,}4 = 0{,}8. 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 λ=0,5\lambda = 0{,}5 pro Sekunde. Mit W=30W = 30 ist L=0,5×30=15L = 0{,}5 \times 30 = 15. 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 0,5×5=2,50{,}5 \times 5 = 2{,}5 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 6+3+2+2+1=146 + 3 + 2 + 2 + 1 = 14. Jedes Mal, wenn ein Thread Arbeit sucht, kommt critical mit einer Wahrscheinlichkeit von 6/146 / 14 zuerst, rund 43 %, und low mit einer Wahrscheinlichkeit von 1/141 / 14, 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

delay(c)=c4+15+r×(c+1)\text{delay}(c) = c^4 + 15 + r \times (c + 1)

Dabei ist cc die Zahl der bisherigen Retries, beginnend bei 0 für den ersten, c4c^4 diese Zahl hoch vier, 1515 ein fester Anteil von 15 Sekunden und rr 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 0+15=150 + 15 = 15, 1+15=161 + 15 = 16, 16+15=3116 + 15 = 31, 81+15=9681 + 15 = 96 und 256+15=271256 + 15 = 271 Sekunden, genau die Folge, die die Sidekiq Dokumentation aufführt.

Jetzt addier alle 25 Retries, für cc von 0 bis 24. Die vierten Potenzen ergeben zusammen 1.763.020 Sekunden. Der feste Anteil ist 25×15=37525 \times 15 = 375 Sekunden. Die Zufallszahl liegt im Schnitt bei 4,54{,}5, und (c+1)(c + 1) läuft von 1 bis 25, was zusammen 325 ergibt, also liegt der Zufallsanteil im Schnitt bei 4,5×325=1.462,54{,}5 \times 325 = 1.462{,}5 Sekunden. Insgesamt sind das 1.763.020+375+1.462,5=1.764.857,51.763.020 + 375 + 1.462{,}5 = 1.764.857{,}5 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

  1. Jakob Nielsen, Response times: the 3 important limits, Nielsen Norman Group, 1. Januar 1993, https://www.nngroup.com/articles/response-times-3-important-limits/

  2. Sidekiq, README (Requirements), GitHub, https://github.com/sidekiq/sidekiq/blob/main/README.md

  3. Sidekiq, Changes, GitHub, https://github.com/sidekiq/sidekiq/blob/main/Changes.md

  4. Redis, Redis adopts dual source available licensing, 20. März 2024, https://redis.io/blog/redis-adopts-dual-source-available-licensing/

  5. 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

  6. Rowan Trollope, Redis is now available under the AGPLv3 open source license, Redis, 1. Mai 2025, https://redis.io/blog/agplv3/

  7. Ruby on Rails Guides, Ruby on Rails 8.0 Release Notes, Abschnitt Solid Queue, https://guides.rubyonrails.org/8_0_release_notes.html

  8. 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

  9. Sidekiq Wiki, Error Handling (automatische Retries, das Dead Set), GitHub, https://github.com/sidekiq/sidekiq/wiki/Error-Handling

  10. 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

  11. Sidekiq Wiki, Best Practices, GitHub, https://github.com/sidekiq/sidekiq/wiki/Best-Practices

  12. Stripe, API Reference, Idempotent requests, https://docs.stripe.com/api/idempotent_requests

  13. Stripe, API Reference, Create a PaymentIntent, https://docs.stripe.com/api/payment_intents/create

  14. 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

  15. Ruby on Rails Guides, Ruby on Rails 7.2 Release Notes, Active Job Removals, https://guides.rubyonrails.org/7_2_release_notes.html

  16. Sidekiq Wiki, Advanced Options (Queues und Gewichte), GitHub, https://github.com/sidekiq/sidekiq/wiki/Advanced-Options

  17. Solid Queue, README (Concurrency Controls), GitHub, https://github.com/rails/solid_queue

  18. Mission Control Jobs, README, GitHub, https://github.com/rails/mission_control-jobs

  19. Sidekiq, Welcome to Sidekiq 7.0 (Upgrade Hinweise, strikte Argumente), GitHub, https://github.com/sidekiq/sidekiq/blob/main/docs/7.0-Upgrade.md

  20. Sidekiq Wiki, Bulk Queueing, GitHub, https://github.com/sidekiq/sidekiq/wiki/Bulk-Queueing

  21. Sidekiq Wiki, Testing, GitHub, https://github.com/sidekiq/sidekiq/wiki/Testing

Wenn deine Background Jobs auf eine Art scheitern, die niemand erklären kann, finde ich mit dir die Schwachstellen und entwerfe Queues, die unter Last halten. Fang mit dem kostenlosen Workshop an, die ersten zwei Wochen Arbeit gehen auf meine Rechnung.