ruby 70 lines · 3 tabs

Debounce Expensive Counter Cache Updates with a Throttled Redis Buffer in Rails

Shared by codesnips Aug 2026
3 tabs
class CounterBuffer
  DELTA_HASH = "counter:deltas".freeze

  READ_RESET = <<~LUA.freeze
    local v = redis.call('HGET', KEYS[1], ARGV[1])
    if v then redis.call('HDEL', KEYS[1], ARGV[1]) end
    return v
  LUA

  def self.redis
    @redis ||= Redis.new(url: ENV.fetch("COUNTER_REDIS_URL"))
  end

  def self.increment(key, by = 1)
    redis.hincrby(DELTA_HASH, key, by)
  end

  def self.flush_key(key)
    raw = redis.eval(READ_RESET, keys: [DELTA_HASH], argv: [key])
    raw.to_i
  end

  def self.debounce_lock(key, ttl)
    # SET NX returns true only for the first caller in the window.
    redis.set("counter:lock:#{key}", 1, nx: true, ex: ttl)
  end

  def self.clear_lock(key)
    redis.del("counter:lock:#{key}")
  end
end
3 files · ruby Explain with highlit

Counter caches keep an aggregate count (like a post's comment count) denormalized on a row so reads avoid an expensive COUNT(*). Rails' built-in counter_cache works well until writes get hot: every insert and delete fires a synchronous UPDATE on the parent row, creating lock contention and write amplification on a single tuple. This snippet shows a debounced alternative where increments accumulate in Redis and are flushed to Postgres at most once per interval per record.

In CounterBuffer, the buffer is treated as a per-key pending delta plus a dirty set. increment uses an atomic HINCRBY on a Redis hash so concurrent writers never lose updates, then registers the key in a SADD set so the flusher knows which records are dirty. The flush_key method leans on HGETSET-style semantics via a small Lua script: it reads and resets the pending delta atomically, guaranteeing no counts are dropped even if new increments arrive mid-flush. This read-and-reset atomicity is the crux of correctness.

In Post model, bump_comment_count is the public API models call instead of touching the column directly. It writes to the buffer and then enqueues CounterFlushJob with a debounce guard: set(wait: ...) combined with a Redis SET NX lock key means only one job is scheduled per record per window, collapsing a burst of a thousand increments into a single delayed flush. The NX guard is what makes this a true debounce rather than a naive per-event job.

In CounterFlushJob, perform pops the pending delta, applies it with a single relative UPDATE ... SET comments_count = comments_count + ? so the DB math stays authoritative and concurrent-safe, and then clears the debounce lock so the next burst can re-arm. Using a relative update rather than writing an absolute value avoids clobbering concurrent flushes.

The trade-off is eventual consistency: the cached count lags by up to the debounce window, so this pattern suits view counts, reaction tallies, and analytics — not balances that must be exact on read. Edge cases to watch are Redis eviction (make the buffer a durable, non-evictable namespace) and job failures, where leaving the delta in Redis until a successful UPDATE makes retries safe and idempotent.


Related snips

Share this code

Here's the card — post it anywhere.

Debounce Expensive Counter Cache Updates with a Throttled Redis Buffer in Rails — share card
Link copied