As33
@periodic/
arsenic
redis_sunionstore
⚠️ Warning

Set union with write — same cost as SUNION plus an additional write operation

SUNIONSTORE computes the union of multiple sets and writes the result to a destination key. The cost is identical to SUNION (O(N) across all input sets) plus a write of the result. When called on a hot path rather than as a background cache-refresh operation, it adds both the computation overhead and a write amplification cost to every request.

Common Causes

  • Using SUNIONSTORE as an inline cache-aside on hot endpoints
  • Refreshing the stored union on every read rather than on a schedule
  • Background jobs that refresh too frequently — e.g. every second on a stable set

How to Fix

  1. 1.Run SUNIONSTORE in a background worker on a schedule, not per request
  2. 2.Set a TTL on the destination key and only refresh when the TTL nears expiry
  3. 3.Combine with a distributed lock to prevent thundering herd on expiry
  4. 4.Read from the destination key on hot paths — the key is the cache

SUNIONSTORE is the right caching pattern for SUNION

The signal is not that SUNIONSTORE is wrong — it is the correct tool for pre-computing expensive set unions. The signal is when it appears on hot paths rather than in background refresh workers.

Correct usage pattern

typescript
// Background worker — refreshes the cached union periodically
async function refreshFeedCache(redis: Redis, userId: string) {
  const dest = `feed:cache:${userId}`;
  await redis.sunionstore(dest, `following:${userId}`, `trending`, `recommended:${userId}`);
  await redis.expire(dest, 120); // 2 minute TTL
}

// Schedule refresh
setInterval(() => refreshFeedCache(redis, userId), 90_000);

// Hot path reads from the pre-computed key — O(1) or bounded SMEMBERS/SSCAN
app.get('/api/feed', async (req, res) => {
  const feed = await redis.smembers(`feed:cache:${req.user.id}`);
  res.json(feed.slice(0, 20));
});

// With distributed lock to prevent thundering herd on expiry
async function getOrRefreshFeed(redis: Redis, userId: string) {
  const dest = `feed:cache:${userId}`;
  const ttl = await redis.ttl(dest);

  if (ttl < 10) { // near expiry
    const lockKey = `lock:feed:${userId}`;
    const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
    if (acquired) {
      await refreshFeedCache(redis, userId);
      await redis.del(lockKey);
    }
  }

  return redis.smembers(dest);
}