RUBY ON RAILS · 15 MIN READ ·

Rails Counter Cache: N+1 COUNT-queries elimineren zonder productievalkuilen

Rails counter cache elimineert N+1 COUNT-queries op has_many-associaties. Stel hem juist in, reset achterhaalde tellers en vermijd valkuilen in productie.

Rails Counter Cache: N+1 COUNT-queries elimineren zonder productievalkuilen

Een B2B-marktplaats die ik adviseer raakte op een dinsdagmiddag de 94 procent CPU op Postgres, en pgHero liet met afstand één query als grootste boosdoener zien: SELECT COUNT(*) FROM messages WHERE messages.conversation_id = $1, 41.000 keer per uur uitgevoerd. De inbox-view toonde elk gesprek in een lijst met het berichtenaantal ernaast, de ontwikkelaar had conversation.messages.count in de partial gebruikt, en Rails vuurde keurig één COUNT-query af per rij, bij elke render, voor elke gebruiker, twee jaar lang. De fix was een migratie van vier regels en één counter_cache: true op de associatie. De volgende ochtend zat Postgres op 31 procent. De Rails counter cache is een van de optimalisaties met de hoogste hefboom in het framework, en tegelijk een van de stilst bug-gevoelige als je niet begrijpt wat het werkelijk is.

Na negentien jaar Rails heb ik counter caches uitgerold die bedrijven gered hebben, en ik heb counter caches gedebugd die buiten sync liepen en een volledig admin-dashboard zes maanden lang lieten liegen. Deze post is alles wat ik wou dat een junior engineer wist voordat ze hun eerste counter_cache: true toevoegen.

Wat een Rails counter cache echt is

Een Rails counter cache is een gedenormaliseerde integer-kolom op het bovenliggende model die Rails synchroon houdt met het aantal kinderen. Wanneer je belongs_to :conversation, counter_cache: true op Message schrijft, neemt Rails de verantwoordelijkheid op zich om conversations.messages_count te verhogen bij elke Message.create en te verlagen bij elke Message.destroy. De read wordt een enkele kolom-lookup. De write wordt een extra UPDATE.

De afweging is precies wat elke denormalisatie-afweging is. Je betaalt een kleine schrijfprijs om een potentieel enorme leesprijs te elimineren. Op een marktplaats-inbox, een feedpagina, een forum-index of welke lijstweergave dan ook waar je een telling naast een ouder rendert, klopt die afweging vrijwel altijd. Een count-query is O(n) op de kinderen. Een kolomlezing is O(1). Bij 18.000 gesprekken die per minuut gerenderd worden is de wiskunde niet subtiel.

De counter cache is ook een van de weinige denormalisaties in Rails waarbij het framework het meeste boekhoudwerk voor je doet. Dat maakt het makkelijk om toe te voegen, en makkelijk om verkeerd te gebruiken. Bijna elke counter cache-bug die ik heb uitgerold of zien uitrollen is terug te voeren op engineers die dachten dat het framework meer deed dan het werkelijk doet.

Een Rails counter cache correct opzetten

Een nette counter cache-setup is een migratie, een modelaanpassing en een eenmalige backfill. De volgorde is belangrijk.

# 1. Migratie — voeg de kolom toe met een veilige default.
class AddMessagesCountToConversations < ActiveRecord::Migration[7.1]
  def change
    add_column :conversations, :messages_count, :integer, default: 0, null: false
  end
end

Een null: false met default van nul is niet onderhandelbaar. Een NULL-counter cache vergiftigt elke leesplek die er rekenkundig mee werkt, en Rails zal nil niet voor je naar 0 casten in views.

# 2. Model — declareer de counter cache op het kind.
class Message < ApplicationRecord
  belongs_to :conversation, counter_cache: true
end

# 3. Ouder voor volledigheid — leesbaar, niet strikt vereist.
class Conversation < ApplicationRecord
  has_many :messages
end

De declaratie hoort op de belongs_to, niet op de has_many. Dit is de meest gemaakte fout in code review. Rails gebruikt de belongs_to-callbacks om de boekhouding te doen, omdat dat de kant is die inserts en deletes voor het kind ziet.

# 4. Backfill — eenmalige datacorrectie.
Conversation.find_each do |conversation|
  Conversation.reset_counters(conversation.id, :messages)
end

Voor tabellen met miljoenen rijen doe je de backfill in een background job met batching en een kleine slaap tussen batches. Een gecorreleerde update via ruwe SQL is sneller maar lastiger te dempen, en op een drukke productiedatabase is “sneller” niet altijd het juiste antwoord. Onze post over Rails strong migrations gaat dieper in op de onveilige migratiepatronen die je hier wilt vermijden.

Hoe Rails de counter daadwerkelijk bijwerkt

Onder de motorkap voert Rails een UPDATE ... SET messages_count = COALESCE(messages_count, 0) + 1 WHERE id = ? uit na elke succesvolle insert, en de bijbehorende decrement na elke succesvolle destroy. Dit is niet atomair met je business-write tenzij ze dezelfde transactie delen. Standaard wikkelt ActiveRecord de create en de counter-update in één transactie, dus dat zijn ze. Op het moment dat je buiten die transactie treedt, verdampt de garantie.

Twee gevallen waarin de garantie geruisloos verdampt. Ten eerste, alles wat update_all, delete_all, insert_all of upsert_all gebruikt, omzeilt callbacks en de counter cache volledig. Ten tweede, elke custom SQL die de kinderen-tabel direct raakt doet hetzelfde. We hebben een hele post over Rails insert_all en upsert_all die dit aanstipt, maar het is de moeite waard om het in de counter cache-context te herhalen: bulk-inserts verhogen de counter niet. Als je 4.000 berichten bulk-inserts in een gesprek, drift je messages_count met 4.000.

# Fout — drift stilzwijgend de counter cache.
Message.insert_all(rows)

# Goed — bulk-insert, daarna reset de counters voor de betrokken ouders.
Message.insert_all(rows)
Conversation.where(id: rows.map { |r| r[:conversation_id] }.uniq).find_each do |c|
  Conversation.reset_counters(c.id, :messages)
end

De valkuilen die in productie toeslaan

Er zijn precies vijf counter cache-faalmodi die ik in echte Rails-apps zie. Op volgorde van hoe vaak ze ontploffen.

Drift door ruwe SQL of bulk-operaties. Hierboven al behandeld. De remedie is om bulk-operaties op counter-cached associaties te vermijden, of om een periodieke reconciliatiejob te plannen die reset_counters voor de betrokken ouders draait.

Touch-gedrag en stale caches. Wanneer een counter cache vuurt, touched Rails standaard ook de updated_at op de ouder. Dit is meestal wat je wilt — je fragment-caches verlopen correct — maar het kan je vermoorden als je ouder een zware after_update-callback heeft. Ik heb counter cache-increments een webhook-cascade zien triggeren die uitwaaierde naar 80 downstream calls per bericht. Zet touch: false op de belongs_to als je de touch niet wilt, of verplaats de callback naar een expliciet codepad.

class Message < ApplicationRecord
  belongs_to :conversation, counter_cache: true, touch: false
end

Conditionele creates met validatie die stilzwijgend falen. Een counter cache verhoogt alleen wanneer het kind daadwerkelijk wordt opgeslagen. Message.create geeft het niet-opgeslagen object terug bij validatiefout, en ontwikkelaars negeren soms de returnwaarde. De counter blijft correct, maar de gebruikerservaring klopt niet. Gebruik create! of check persisted? als je applicatielogica afhangt van het bewegen van de telling.

Race conditions bij gelijktijdige destroys. Twee background jobs die elk het laatste bericht in een gesprek vernietigen, en die racen, kunnen beide decrement uitvoeren en de counter op -1 achterlaten. Rails gebruikt al lang COALESCE en concurrent-veilige SQL, maar ik zie nog steeds af en toe negatieve counters in legacy-apps. Als je ze ziet, is de oorzaak meestal code die de counter handmatig aanpaste met increment! in plaats van Rails het te laten doen.

Polymorfe associaties en STI. Rails ondersteunt counter_cache op polymorfe belongs_to sinds 5.0, maar met een scherpe rand. De cache-kolom leeft op één concrete oudertabel, dus de polymorfe counter heeft alleen zin als je hem goed afbakent. Lees de source als je die kant op gaat. In tien jaar heb ik twee keer een polymorfe counter cache gebruikt en in beide gevallen kreeg ik er spijt van.

Een stale counter cache resetten

Wanneer de cache drift — en dat gebeurt uiteindelijk, in elke voldoende grote app — is reset_counters de canonieke fix. Hij draait een SELECT COUNT(*) tegen de kinderen en overschrijft de ouder-kolom. Draai hem vanuit een Rake-taak, een background job, of een eenmalige console-sessie.

namespace :counters do
  desc "Reconcile conversation message counters"
  task reconcile_messages: :environment do
    drift = []
    Conversation.find_each(batch_size: 500) do |conversation|
      actual = conversation.messages.count
      next if actual == conversation.messages_count
      drift << { id: conversation.id, was: conversation.messages_count, is: actual }
      Conversation.reset_counters(conversation.id, :messages)
    end
    Rails.logger.info("Counter drift reconciled: #{drift.size} rows")
    drift.each { |row| Rails.logger.info(row.inspect) }
  end
end

Ik draai deze Rake-taak wekelijks op apps waar counter-precisie ertoe doet en waar de datavolumes klein genoeg zijn dat een volledige scan redelijk is. Op apps met honderden miljoenen rijen draai ik ‘s nachts een gesampelde versie en alarmeer ik op drift boven een drempel. Het alarm is het punt. Een counter die er één naast zit doet er voor een inbox-header niet toe. Een counter die er tweeduizend naast zit betekent dat je ergens upstream een bug hebt en dat de cache de kanarie is.

Counter cache met aangepaste condities

Soms wil je niet het aantal van alle kinderen. Je wilt het aantal actieve kinderen, of ongelezen kinderen, of kinderen die in de afgelopen zeven dagen zijn aangemaakt. Rails ondersteunt out-of-the-box aangepaste counter cache-kolommen. De truc is dat Rails ze alleen onderhoudt voor inserts en destroys, niet voor toestandsveranderingen.

class Message < ApplicationRecord
  belongs_to :conversation, counter_cache: :unread_messages_count
end

class Conversation < ApplicationRecord
  has_many :messages
end

Als de telling afhangt van een muteerbare kolom — zeg messages.read_at IS NULL — helpt counter cache je niet. Het kind kan van ongelezen naar gelezen overgaan zonder insert of destroy, en Rails zal het niet weten. Je hebt een custom callback nodig of een model-concern die de cache zelf onderhoudt, en op dat punt moet je jezelf afvragen of een kleine materialized view of een SUM(CASE WHEN read_at IS NULL THEN 1 ELSE 0 END)-query tegen een covering index niet een beter antwoord is.

# Een onderhouden custom counter, met mate gebruikt.
class Message < ApplicationRecord
  belongs_to :conversation

  after_update_commit :bump_unread_counter, if: :saved_change_to_read_at?

  private

  def bump_unread_counter
    if read_at_before_last_save.nil? && read_at.present?
      Conversation.where(id: conversation_id).update_all("unread_messages_count = GREATEST(unread_messages_count - 1, 0)")
    elsif read_at_before_last_save.present? && read_at.nil?
      Conversation.where(id: conversation_id).update_all("unread_messages_count = unread_messages_count + 1")
    end
  end
end

Die code is prima. Het is ook het soort code dat ik zorgvuldig review. Elke regel handgemaakt counter-onderhoud is een toekomstige drift-bug.

Wanneer je een Rails counter cache NIET moet gebruiken

Counter cache is het verkeerde gereedschap wanneer de telling vooral aan de schrijfkant zit en zelden gelezen wordt, wanneer de kinderen vaak van categorie wisselen, of wanneer je totalen over een datumbereik nodig hebt. Voor “berichten in de afgelopen 24 uur” wil je een query met een partial index, geen counter cache. Voor totalen over veel ouders — “toon me de top tien gesprekken op berichtenaantal” — is een counter cache prachtig, want de sortering wordt een normale geïndexeerde sortering op messages_count DESC.

Weersta ook de verleiding om reflexmatig een counter cache toe te voegen op elke has_many. Elk daarvan is een extra UPDATE per schrijfactie op de kinderen-tabel. Op een zwaar schrijfpad met vijf counter caches zijn dat zes writes per logische insert. Profileer met pg_stat_statements voor en na. Soms wordt de read die je probeerde te optimaliseren drie keer per dag afgevuurd vanaf een adminpagina en kost de counter cache meer dan hij oplevert.

Een productie-checklist voor counter cache

Voordat ik een counter cache naar productie stuur, loop ik deze lijst door. Hij is met opzet kort.

De kolom heeft null: false en een default van nul. De declaratie staat op de belongs_to. Er is een backfill die succesvol heeft gedraaid en die geverifieerd is door tien ouders steekproefsgewijs te checken. Bulk-operaties (insert_all, upsert_all, update_all, delete_all) die de kinderen raken bestaan niet, of zijn gekoppeld aan reset_counters. Een wekelijkse of nachtelijke reconciliatiejob bestaat voor gevallen waarin drift telt. Eventuele after_update-callbacks op de ouder zijn beoordeeld op de touch-cascade. De nieuwe kolom is alleen geïndexeerd als hij in ORDER BY of WHERE wordt gebruikt, niet uit reflex.

Dat is de hele lijst. Een counter cache die hem volgt draait jaren stil. Een counter cache die er zelfs twee items van overslaat, drift, liegt tegen je gebruikers en duikt op in een Slack-draadje om 23:00 uur op een vrijdag.

Veelgestelde vragen

Werkt Rails counter cache met destroy_all en delete_all?

destroy_all draait callbacks per record en update de counter cache wel correct, tegen de prijs van N queries. delete_all slaat callbacks volledig over en update de counter cache niet. Na elke delete_all tegen een counter-cached kinderen-tabel draai je reset_counters voor de betrokken ouders, of je verwacht drift.

Hoe reset ik een Rails counter cache voor alle records?

Gebruik Model.reset_counters(id, :association_name) per ouder. Voor een volledige reconciliatie itereer je met find_each in batches. Vermijd één enkele transactie over miljoenen rijen. Op grote tabellen plan je de reconciliatie als een background job die in chunks draait en tussen batches yieldt zodat de database responsief blijft.

Waarom toont mijn counter_cache negatieve getallen?

Bijna altijd omdat iets de counter handmatig heeft verlaagd met increment! of update_columns zonder een bijbehorende verhoging elders, of omdat callbacks aan de create-kant zijn omzeild maar aan de destroy-kant zijn gedraaid. Draai reset_counters om de werkelijke waarde te herstellen, audit dan je codebase op directe schrijfacties naar de counter-kolom.

Moet ik een database-index op de counter_cache-kolom zetten?

Alleen als je erop leest of sorteert. Elke counter cache-kolom uit reflex indexeren verspilt schrijfdoorvoer. Als je homepagina gesprekken op messages_count DESC sorteert, indexeer hem. Als je de waarde alleen rendert in een rij die al is gekoppeld via conversation_id, doe het dan niet.

Werkt Rails counter cache met soft-deletes?

Niet out-of-the-box. Een gem als paranoia of discard markeert een rij als verwijderd zonder destroy te vuren, dus de counter beweegt niet. Je hebt een callback nodig op de soft-delete kolomverandering, of je moet reconciliatie draaien. Dit is een van de sterkste argumenten tegen soft-deletes in counter-cached tabellen.

Hulp nodig bij het ontwarren van Rails performance-bottlenecks of een counter cache die al maanden stilletjes ligt te liegen? TTB Software is gespecialiseerd in Rails performance-audits en pragmatische fixes. We doen dit al negentien jaar.

#rails-counter-cache #counter-cache-rails #rails-n-plus-one #rails-association-count #counter-cache-reset #rails-performance-tuning #has-many-counter-cache

Related Articles

Laatste sectie. Bel dan alsjeblieft.

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

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

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

OF
info@ttb.software