class CounterFlushJob
include Sidekiq::Job
sidekiq_options queue: :counters, retry: 3
def perform
CounterBuffer.flush_all.each do |field, delta|
next if delta.zero?
model_name, attribute, id = field.split(":", 3)
klass = model_name.constantize
klass.update_counters(id.to_i, attribute.to_sym => delta)
rescue ActiveRecord::RecordNotFound, NameError
# Row (or model) went away between buffering and flush; drop the delta.
next
end
end
end
class CounterBuffer
KEY = "counter_buffer".freeze
def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1"))
end
def self.increment(model:, attribute:, id:, by: 1)
field = "#{model}:#{attribute}:#{id}"
redis.hincrby(KEY, field, by)
end
def self.flush_all
snapshot = redis.hgetall(KEY)
return {} if snapshot.empty?
# Atomically clear what we just read so concurrent increments start fresh.
redis.multi { |tx| tx.del(KEY) }
snapshot.transform_values(&:to_i)
end
end
class CounterReconciliationJob
include Sidekiq::Job
sidekiq_options queue: :low, retry: 1
def perform
# Flush pending deltas first so we reconcile against a settled column.
CounterFlushJob.new.perform
Post.find_in_batches(batch_size: 1_000) do |batch|
ids = batch.map(&:id)
actual = Like.where(post_id: ids).group(:post_id).count
batch.each do |post|
truth = actual.fetch(post.id, 0)
next if post.likes_count == truth
Rails.logger.warn(
"[reconcile] post=#{post.id} cached=#{post.likes_count} actual=#{truth}"
)
post.update_column(:likes_count, truth)
end
end
end
end
class Post < ApplicationRecord
has_many :likes, dependent: :delete_all
# likes_count is a plain integer column reads can trust directly.
def bump_likes_counter(by: 1)
CounterBuffer.increment(
model: self.class.name,
attribute: :likes_count,
id: id,
by: by
)
end
def like_by!(user)
likes.create!(user: user)
bump_likes_counter(by: 1)
end
def unlike_by!(user)
return unless likes.where(user: user).delete_all.positive?
bump_likes_counter(by: -1)
end
end
Counter caches in Rails are convenient but every increment issues its own UPDATE against the same row, and under load that turns a hot record (a viral post, a popular product) into a lock-contention hotspot. This snippet shows a common production pattern: absorb high-frequency increments into a Redis buffer, flush the coalesced deltas periodically, and run a nightly job that reconciles the cached count against the source of truth so drift never accumulates.
The CounterBuffer service in the first tab is a thin wrapper over Redis. increment uses HINCRBY to accumulate a signed delta per model/attribute/id in a single hash, so a thousand likes on one post become one field being bumped a thousand times in memory rather than a thousand row locks. flush_all snapshots the hash with HGETALL, deletes it in the same pipeline via MULTI/EXEC, and yields the parsed deltas. Reading and clearing atomically is the crucial detail — it guarantees increments arriving mid-flush are either fully captured or left for the next cycle, never lost or double-counted.
CounterFlushJob in the second tab drains the buffer on a schedule. For each key it parses the composite model:attribute:id field and applies the accumulated delta with a single update_counters call, which emits an atomic SET col = col + n rather than reading then writing. Zero deltas are skipped, and a record_not_found rescue quietly drops counters for rows that were deleted between buffering and flushing.
Because buffered systems can still drift — a lost flush, a crash between Redis clearing and the DB write, a manual data edit — CounterReconciliationJob in the third tab recomputes the truth. It walks records in batches with find_in_batches, counts the real associated rows with a GROUP BY, and only writes when the cached value disagrees, logging every correction. Running it nightly keeps errors bounded to a single day.
The Post model tab wires it together: bump_likes_counter writes to the buffer instead of touching the column directly, while likes_count stays a plain cached column that reads are free to trust. The trade-off is eventual consistency — the displayed count lags by up to one flush interval — in exchange for eliminating write contention. This pattern fits any counter that is written far more often than it must be exact to the millisecond.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.