07 · Sets: Membership, Set Algebra & Random Picks¶
A set is an unordered collection of unique strings. Adding a member that's already there does nothing, checking membership is O(1), and Redis can intersect, union and subtract sets on the server. That makes sets the right tool for tags, "who liked this", deduplicating events, matching skills to jobs, and anywhere you'd otherwise write "check if it's already in the list" by hand.
Adding, checking, removing¶
127.0.0.1:6379> SADD post:1:tags redis database cache
(integer) 3
127.0.0.1:6379> SADD post:1:tags redis nosql
(integer) 1
127.0.0.1:6379> SMEMBERS post:1:tags
1) "redis"
2) "database"
3) "cache"
4) "nosql"
127.0.0.1:6379> SISMEMBER post:1:tags cache
(integer) 1
127.0.0.1:6379> SMISMEMBER post:1:tags cache sql nosql
1) (integer) 1
2) (integer) 0
3) (integer) 1
127.0.0.1:6379> SCARD post:1:tags
(integer) 4
127.0.0.1:6379> SREM post:1:tags nosql
(integer) 1
SADD returns how many members were new: the second call added only nosql because redis was already
there. That return value is useful on its own. SADD seen:emails <message-id> returning 1 means "first time
we've seen this message, process it", and 0 means "duplicate, skip it". It's an atomic dedupe check in a single
command.
SMISMEMBER checks several candidates in one round trip. SCARD is O(1) because the count is stored, not
computed.
Set algebra on the server¶
Two candidates and a job:
127.0.0.1:6379> SADD user:ana:skills python redis sql docker
(integer) 4
127.0.0.1:6379> SADD user:ben:skills go redis kubernetes docker
(integer) 4
127.0.0.1:6379> SADD job:42:needs redis docker kubernetes
(integer) 3
What each candidate has in common with the job, and what Ana is missing:
127.0.0.1:6379> SINTER user:ana:skills job:42:needs
1) "redis"
2) "docker"
127.0.0.1:6379> SINTER user:ben:skills job:42:needs
1) "redis"
2) "docker"
3) "kubernetes"
127.0.0.1:6379> SDIFF job:42:needs user:ana:skills
1) "kubernetes"
127.0.0.1:6379> SUNION user:ana:skills user:ben:skills
1) "redis"
2) "sql"
3) "python"
4) "go"
5) "docker"
6) "kubernetes"
SDIFF A B means "in A but not in B", so the order of the arguments matters. When you only need a count, use
SINTERCARD (Redis 7.0+), which doesn't build or send the members. Its LIMIT stops early once it has found
enough:
127.0.0.1:6379> SINTERCARD 2 user:ben:skills job:42:needs
(integer) 3
127.0.0.1:6379> SINTERCARD 2 user:ben:skills job:42:needs LIMIT 2
(integer) 2
"Does this candidate match at least 2 of the requirements?" is a LIMIT 2 question. The *STORE variants
write the result into a new key instead of returning it, which is useful for caching an expensive computation or
for chaining operations:
Random members¶
127.0.0.1:6379> SADD raffle alice bob carol dave erin
(integer) 5
127.0.0.1:6379> SRANDMEMBER raffle 2
1) "alice"
2) "dave"
127.0.0.1:6379> SRANDMEMBER raffle -4
1) "bob"
2) "bob"
3) "dave"
4) "alice"
127.0.0.1:6379> SPOP raffle
"erin"
127.0.0.1:6379> SCARD raffle
(integer) 4
127.0.0.1:6379> SMOVE raffle winners bob
(integer) 1
SRANDMEMBER key nwith a positive n returns up to n distinct members and leaves the set alone.- With a negative n it returns exactly |n| members and may repeat (
bobtwice above), like sampling with replacement. SPOPremoves and returns random members. It's the atomic "draw a winner", and two clients can never draw the same one.SMOVEmoves a member from one set to another atomically.
Randomness here is good enough for picking samples or shuffling work. It isn't a cryptographic or audited lottery.
How expensive is set algebra?¶
Two sets of a million visitor IDs each (half overlapping) and a 4-member vip set, timed from Python:
import redis, time
r = redis.Redis()
p = r.pipeline(transaction=False)
for i in range(0, 1_000_000, 5000):
p.sadd("visitors:mon", *range(i, i + 5000))
p.sadd("visitors:tue", *range(i + 500_000, i + 505_000))
p.execute()
r.sadd("vip", 7, 123456, 750000, 2_000_000)
def t(label, fn):
s = time.perf_counter(); res = fn()
print(f"{label:46} {(time.perf_counter()-s)*1000:8.2f} ms -> {res}")
t("SINTER vip visitors:mon visitors:tue (size)", lambda: len(r.sinter("vip", "visitors:mon", "visitors:tue")))
t("SINTER visitors:mon visitors:tue vip (size)", lambda: len(r.sinter("visitors:mon", "visitors:tue", "vip")))
t("SINTERCARD 2 visitors:mon visitors:tue", lambda: r.sintercard(2, ["visitors:mon", "visitors:tue"]))
t("SINTERSTORE both visitors:mon visitors:tue", lambda: r.sinterstore("both", ["visitors:mon", "visitors:tue"]))
t("SUNIONSTORE either visitors:mon visitors:tue", lambda: r.sunionstore("either", ["visitors:mon", "visitors:tue"]))
t("SISMEMBER visitors:mon 999999", lambda: r.sismember("visitors:mon", 999999))
SINTER vip visitors:mon visitors:tue (size) 0.23 ms -> 1
SINTER visitors:mon visitors:tue vip (size) 0.21 ms -> 1
SINTERCARD 2 visitors:mon visitors:tue 131.70 ms -> 500000
SINTERSTORE both visitors:mon visitors:tue 232.29 ms -> 500000
SUNIONSTORE either visitors:mon visitors:tue 337.91 ms -> 1500000
SISMEMBER visitors:mon 999999 0.33 ms -> 1
- An intersection that includes a small set is fast regardless of argument order (0.2 ms both ways): Redis sorts the inputs by size and iterates the smallest one, probing the others.
- Intersecting two large sets costs time proportional to the smaller one. That's 132 ms just to count 500,000 common members, and the server was busy for all of it.
- A union has to touch every member of every input. Storing 1.5 million members took 338 ms.
Those numbers are fine for an analytics job at 3 a.m. and a problem on a hot request path. For big sets, run the
algebra rarely and cache the result with *STORE + EXPIRE, keep the sets smaller (per day rather than per
year), or switch to an approximate structure. HyperLogLog (Level 2 · 03) counts unique visitors in 12 KB instead
of the 32 MB that one of these sets occupies:
How It Actually Works¶
Sets have three encodings:
127.0.0.1:6379> SADD ids 1 2 3 1000
(integer) 4
127.0.0.1:6379> OBJECT ENCODING ids
"intset"
127.0.0.1:6379> SADD ids hello
(integer) 1
127.0.0.1:6379> OBJECT ENCODING ids
"listpack"
127.0.0.1:6379> OBJECT ENCODING user:ana:skills
"listpack"
The limits, from CONFIG GET 'set-max*' (shown as name and value pairs):
- intset — when every member is an integer and there are at most 512 of them, the set is a sorted array of integers using the smallest width that fits (16, 32 or 64 bits). Membership is a binary search. It's extremely compact: 500 integers took 1,032 bytes.
- listpack — small sets of short strings (at most 128 members of at most 64 bytes each) are a flat listpack with linear search, as with small hashes.
- hashtable — anything bigger is a hash table whose keys are the members and whose values are empty. That's O(1) membership, but every member pays for a table entry and its own string: 500 short strings took 15,567 bytes, fifteen times the intset.
Conversions go one way, towards the general encoding, as soon as a limit is crossed. Adding hello to an intset
converted it on the spot.
SINTER sorts its inputs by cardinality, then for each member of the smallest set checks membership in each of
the others, stopping at the first miss. That's why its cost depends on the smallest set. SUNION and SDIFF
have to walk all of the inputs. SRANDMEMBER with a positive count picks distinct members with a strategy that
depends on how large the count is relative to the set. For small counts it picks random buckets, so the
distribution is close to uniform but not exactly uniform.
Common mistakes¶
SMEMBERSon a huge set. It's O(N) and sends everything. UseSSCANto iterate,SCARDto count,SRANDMEMBERto sample.- Set algebra on million-member sets in request handlers. Precompute with
*STOREand cache it, or use smaller per-period sets. - Using a set when order matters. Sets are unordered. If you need "most recent" or "top", use a sorted set (next lesson).
- Expecting
SDIFFto be symmetric.SDIFF A B≠SDIFF B A. - Counting unique events forever in a set. It grows without bound. Use a TTL per period, or HyperLogLog when an approximate count is enough.
- Storing IDs as strings like
"id42"when plain integers would let small sets use the intset encoding.
Exercise¶
- Build a tag system: posts have tag sets, and each tag has a set of post IDs. Write commands to add a tag to a
post (both directions), find posts tagged both
redisandpython, and find posts taggedredisbut notbeginner. Why should the two-direction update run insideMULTI? - Use
SADDas a dedupe filter for a stream of 100,000 event IDs where 10% are repeats. Process each event only whenSADDreturns 1, and confirm the processed count. How would you stop the dedupe set from growing forever? - Make the timing script's
vipset 100,000 members and rerun the firstSINTER. How does the time scale with the smallest set? - Find the point where a set of integers switches from
intsettohashtableon your server, and recordMEMORY USAGEjust before and just after.