Danh sách bài viết

Bài 23: Set — Tags, Unique Members & Set Operations

Redis Set là một unordered collection trong đó mỗi member là duy nhất — không có duplicate. Đây là data structure phù hợp nhất cho các bài toán membership check, tag system, unique tracking, permission, và set operations (giao/hợp/hiệu). Bài này đi qua toàn bộ command cốt lõi, 5 use case thực chiến với code Python hoàn chỉnh, cơ chế encoding nội bộ (intset, listpack, hashtable từ Redis 7.2), performance O-notation, và các anti-pattern cần tránh khi Set lớn.

28/05/2026
0 lượt xem
1

Mục Tiêu Bài Học

  • Giải thích được đặc điểm cốt lõi của Set: unordered, members unique, không lưu thứ tự.
  • Dùng thành thạo các command cốt lõi: SADD, SREM, SISMEMBER, SMEMBERS, SCARD, SRANDMEMBER, SPOP.
  • Thiết kế tag system với reverse index để tìm post theo tag bằng SMEMBERS.
  • Dùng Set để track unique visitor/action và hiểu giới hạn memory so với HyperLogLog.
  • Implement permission check (RBAC) bằng SISMEMBER O(1).
  • Hiểu và dùng được 3 set operations: SINTER, SUNION, SDIFF và STORE variant.
  • Phân biệt SRANDMEMBER (không xóa) và SPOP (có xóa).
  • Phân biệt 3 encoding nội bộ (intset, listpack, hashtable) và ngưỡng chuyển đổi.
  • Nhận diện anti-patterns: SMEMBERS trên set lớn, SINTER hai set khổng lồ, unique count triệu member.
2

Set Là Gì & Command Cốt Lõi

Redis Set là một unordered collection of strings trong đó mỗi member xuất hiện đúng một lần. Hai tính chất then chốt:

  • Unique: SADD một member đã tồn tại không lỗi, nhưng member đó cũng không được thêm lại. Trả về số member mới thực sự được thêm.
  • Unordered: Redis không đảm bảo thứ tự trả về khi dùng SMEMBERS. Nếu cần thứ tự → dùng Sorted Set (bài 24).

Các command cốt lõi

# Thêm member (trả về số member được thêm mới)
redis> SADD tags:post:123 "redis" "cache" "backend"
(integer) 3

# Thêm lại "redis" — không có lỗi, nhưng không thêm được
redis> SADD tags:post:123 "redis"
(integer) 0          # 0 = không có member mới nào được thêm

# Kiểm tra member có tồn tại không (O(1))
redis> SISMEMBER tags:post:123 "redis"
(integer) 1          # 1 = có

redis> SISMEMBER tags:post:123 "golang"
(integer) 0          # 0 = không có

# Đếm số member (O(1))
redis> SCARD tags:post:123
(integer) 3

# Lấy tất cả member (O(N)) — cẩn thận với set lớn
redis> SMEMBERS tags:post:123
1) "redis"
2) "backend"
3) "cache"

# Xóa member
redis> SREM tags:post:123 "backend"
(integer) 1          # 1 = xóa thành công

redis> SCARD tags:post:123
(integer) 2

SMISMEMBER — kiểm tra nhiều member cùng lúc (Redis 6.2+)

redis> SMISMEMBER tags:post:123 "redis" "golang" "cache"
1) (integer) 1   # "redis" — có
2) (integer) 0   # "golang" — không
3) (integer) 1   # "cache" — có

SMISMEMBER (thêm từ Redis 6.2) cho phép kiểm tra nhiều member trong một round-trip, thay vì gọi SISMEMBER nhiều lần.

3

Use Case 1 — Tags & Reverse Index

Tag system là use case tự nhiên nhất cho Set. Mỗi post có một Set các tag; mỗi tag có một Set các post thuộc tag đó (reverse index).

Forward index — tag của một post

# Gắn tag cho post
redis> SADD tags:post:123 "redis" "cache" "backend"
(integer) 3

redis> SADD tags:post:456 "redis" "python" "tutorial"
(integer) 3

# Lấy tất cả tag của post 123
redis> SMEMBERS tags:post:123
1) "redis"
2) "cache"
3) "backend"

# Kiểm tra post 123 có tag "redis" không
redis> SISMEMBER tags:post:123 "redis"
(integer) 1

Reverse index — post nào có tag cụ thể

# Khi thêm tag, cũng ghi vào reverse index
redis> SADD posts:tag:redis "post:123" "post:456"
(integer) 2

redis> SADD posts:tag:cache "post:123"
(integer) 1

redis> SADD posts:tag:python "post:456"
(integer) 1

# Tìm tất cả post có tag "redis"
redis> SMEMBERS posts:tag:redis
1) "post:123"
2) "post:456"

Code Python — add và query tag

import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)


def add_tags(post_id: int, tags: list[str]) -> None:
    """Gắn tag cho post, đồng thời cập nhật reverse index."""
    pipe = r.pipeline()
    # Forward: tag của post này
    pipe.sadd(f"tags:post:{post_id}", *tags)
    # Reverse: post này thuộc các tag nào
    for tag in tags:
        pipe.sadd(f"posts:tag:{tag}", f"post:{post_id}")
    pipe.execute()


def get_tags(post_id: int) -> set[str]:
    """Lấy tất cả tag của một post."""
    return r.smembers(f"tags:post:{post_id}")


def get_posts_by_tag(tag: str) -> set[str]:
    """Tìm tất cả post có tag cụ thể."""
    return r.smembers(f"posts:tag:{tag}")


def remove_tag(post_id: int, tag: str) -> None:
    """Bỏ một tag khỏi post, cập nhật cả reverse index."""
    pipe = r.pipeline()
    pipe.srem(f"tags:post:{post_id}", tag)
    pipe.srem(f"posts:tag:{tag}", f"post:{post_id}")
    pipe.execute()


# Sử dụng
add_tags(123, ["redis", "cache", "backend"])
add_tags(456, ["redis", "python", "tutorial"])

print(get_posts_by_tag("redis"))     # {'post:123', 'post:456'}
print(get_tags(123))                  # {'redis', 'cache', 'backend'}

Dùng pipeline để gộp SADD forward và SADD reverse thành một batch, giảm số round-trip xuống còn 1.

4

Use Case 2 — Unique Tracking

Set đảm bảo tính unique tự nhiên, nên phù hợp để track xem ai đã thực hiện một hành động — không cần xử lý duplicate ở application.

Track unique viewer của bài viết

# User 1, 2, 3 xem post 123
redis> SADD viewers:post:123 "user:1" "user:2" "user:3"
(integer) 3

# User 1 xem lại — không tăng count
redis> SADD viewers:post:123 "user:1"
(integer) 0

# Đếm unique viewer
redis> SCARD viewers:post:123
(integer) 3          # đúng 3, dù user:1 xem 2 lần

Khác biệt với INCR (String counter)

Dùng INCR views:post:123 sẽ đếm tổng số lần xem (bao gồm cùng user xem nhiều lần). Set đếm số user phân biệt đã xem. Hai mục đích khác nhau, không thay thế nhau.

# INCR đếm tổng lần xem (có duplicate)
redis> INCR views:post:123    # lần 1 của user:1 → 1
redis> INCR views:post:123    # lần 2 của user:1 → 2  ← tăng dù cùng user
redis> INCR views:post:123    # lần 1 của user:2 → 3

# SCARD đếm unique user (không duplicate)
redis> SADD viewers:post:123 "user:1"  # lần 1 → 1
redis> SADD viewers:post:123 "user:1"  # lần 2 → 0 (không thêm)
redis> SADD viewers:post:123 "user:2"  # → 1 member mới
redis> SCARD viewers:post:123           # 2 ← chỉ 2 unique user

Giới hạn memory — khi nào nên dùng HyperLogLog

Set lưu toàn bộ member trong RAM. Với unique tracking nhỏ (vài nghìn đến vài trăm nghìn user per entity), Set hoàn toàn ổn. Tuy nhiên nếu cần đếm unique ở quy mô hàng triệu member và bạn chỉ cần con số đếm (không cần biết user nào cụ thể), thì Set là lựa chọn tốn RAM không cần thiết.

Ví dụ: 1 triệu user ID dạng "user:XXXXXXXX" (khoảng 15 bytes/member) trong một Set chiếm khoảng 50–80MB RAM cho một key. Nếu có hàng nghìn bài viết, tổng có thể lên đến hàng chục GB.

Cho trường hợp count-only với quy mô lớn, HyperLogLog (bài 26) cho sai số ~0.81% nhưng chỉ tốn tối đa 12KB per key. Bài này không đào sâu HyperLogLog; điểm cần nhớ là: nếu không cần biết member cụ thể, chỉ cần đếm, thì Set không phải lựa chọn tối ưu khi quy mô lớn.

Code Python — unique tracker

def track_viewer(post_id: int, user_id: int) -> int:
    """
    Ghi nhận user xem post.
    Trả về tổng unique viewer hiện tại.
    """
    key = f"viewers:post:{post_id}"
    r.sadd(key, f"user:{user_id}")
    return r.scard(key)


def has_viewed(post_id: int, user_id: int) -> bool:
    """Kiểm tra user đã xem post chưa."""
    return bool(r.sismember(f"viewers:post:{post_id}", f"user:{user_id}"))
5

Use Case 3 — Permission & RBAC

Membership check O(1) của SISMEMBER rất phù hợp cho permission system. Thay vì đọc DB để lấy danh sách quyền rồi duyệt mảng ở application, Redis Set cho phép check trực tiếp.

Permission trực tiếp theo user

# Gán quyền cho user 123
redis> SADD perms:user:123 "read:posts" "write:comments" "read:profile"
(integer) 3

# Kiểm tra quyền — O(1)
redis> SISMEMBER perms:user:123 "write:comments"
(integer) 1    # có quyền

redis> SISMEMBER perms:user:123 "write:posts"
(integer) 0    # không có quyền

# Lấy toàn bộ quyền của user
redis> SMEMBERS perms:user:123
1) "read:posts"
2) "write:comments"
3) "read:profile"

# Thu hồi quyền
redis> SREM perms:user:123 "write:comments"
(integer) 1

Role-based (RBAC)

# Gán role cho user
redis> SADD roles:user:123 "editor" "moderator"
(integer) 2

# Định nghĩa quyền của từng role
redis> SADD perms:role:editor "read:posts" "write:posts" "edit:posts"
(integer) 3
redis> SADD perms:role:moderator "read:comments" "delete:comments" "ban:user"
(integer) 3

# Kiểm tra user có role không
redis> SISMEMBER roles:user:123 "editor"
(integer) 1

Code Python — permission check

def check_permission(user_id: int, permission: str) -> bool:
    """Kiểm tra user có quyền cụ thể không."""
    return bool(r.sismember(f"perms:user:{user_id}", permission))


def get_user_permissions(user_id: int) -> set[str]:
    """Lấy tất cả quyền của user."""
    return r.smembers(f"perms:user:{user_id}")


def grant_permission(user_id: int, permission: str) -> None:
    r.sadd(f"perms:user:{user_id}", permission)


def revoke_permission(user_id: int, permission: str) -> None:
    r.srem(f"perms:user:{user_id}", permission)


def get_role_permissions(role: str) -> set[str]:
    """Lấy quyền của một role."""
    return r.smembers(f"perms:role:{role}")


def get_user_roles(user_id: int) -> set[str]:
    return r.smembers(f"roles:user:{user_id}")


# Kiểm tra permission qua RBAC (union quyền của tất cả role user có)
def check_permission_via_roles(user_id: int, permission: str) -> bool:
    roles = get_user_roles(user_id)
    if not roles:
        return False
    # Tập hợp tất cả permission từ mọi role
    role_perm_keys = [f"perms:role:{role}" for role in roles]
    all_perms = r.sunion(*role_perm_keys)
    return permission in all_perms

Lưu ý: check_permission_via_roles gọi SUNION — tốt cho user có ít role và role set nhỏ. Nếu cần kiểm tra thường xuyên với role set lớn, tính toán effective permission một lần rồi lưu vào một Set riêng (perms:effective:user:123), invalidate khi role thay đổi.

6

Use Case 4 — Set Operations

Đây là phần làm cho Set khác biệt so với các data structure khác. Redis cung cấp 3 set operations chuẩn của toán học tập hợp, thực hiện trực tiếp trên server — không cần kéo dữ liệu về application để xử lý.

SINTER — Giao (Intersection)

Trả về các member có mặt trong tất cả các Set.

# Followers của Alice và Bob
redis> SADD followers:alice "user:1" "user:2" "user:3" "user:4"
redis> SADD followers:bob   "user:2" "user:3" "user:5" "user:6"

# Người theo dõi cả Alice lẫn Bob (mutual followers)
redis> SINTER followers:alice followers:bob
1) "user:2"
2) "user:3"

SUNION — Hợp (Union)

Trả về tất cả member từ tất cả các Set, không trùng lặp.

# Tập hợp kỹ năng của 2 ứng viên
redis> SADD skills:user:1 "python" "redis" "postgresql"
redis> SADD skills:user:2 "python" "javascript" "mongodb"

# Tổng hợp kỹ năng — không trùng
redis> SUNION skills:user:1 skills:user:2
1) "python"
2) "redis"
3) "postgresql"
4) "javascript"
5) "mongodb"

SDIFF — Hiệu (Difference)

Trả về các member có trong Set đầu tiên nhưng không có trong các Set còn lại.

# Alice follow những ai mà Bob không follow
redis> SDIFF followers:alice followers:bob
1) "user:1"
2) "user:4"

# Bob follow những ai mà Alice không follow
redis> SDIFF followers:bob followers:alice
1) "user:5"
2) "user:6"

STORE variant — lưu kết quả vào key mới

Mỗi operation có biến thể *STORE để ghi kết quả vào một key mới thay vì trả về client. Kết quả là một Set bình thường, có thể dùng tiếp với các command Set khác.

# Lưu mutual followers vào key riêng
redis> SINTERSTORE mutual:alice:bob followers:alice followers:bob
(integer) 2         # số member trong kết quả

redis> SMEMBERS mutual:alice:bob
1) "user:2"
2) "user:3"

# mutual:alice:bob là một Set bình thường — có thể SCARD, SISMEMBER, v.v.
redis> SCARD mutual:alice:bob
(integer) 2

SINTERSTORE, SUNIONSTORE, SDIFFSTORE hữu ích khi cùng query được thực hiện nhiều lần: tính một lần, lưu kết quả, set TTL cho key kết quả, sau đó chỉ đọc key đã tính.

Code Python — set operations

def mutual_followers(user_a: str, user_b: str) -> set[str]:
    """Người follow cả user_a lẫn user_b."""
    return r.sinter(f"followers:{user_a}", f"followers:{user_b}")


def all_skills(user_ids: list[int]) -> set[str]:
    """Tập hợp kỹ năng của nhiều ứng viên."""
    keys = [f"skills:user:{uid}" for uid in user_ids]
    return r.sunion(*keys)


def exclusive_followers(user_a: str, user_b: str) -> set[str]:
    """Người chỉ follow user_a, không follow user_b."""
    return r.sdiff(f"followers:{user_a}", f"followers:{user_b}")


def cache_mutual_followers(user_a: str, user_b: str, ttl: int = 300) -> int:
    """
    Tính và cache mutual followers.
    Trả về số member.
    """
    result_key = f"mutual:{user_a}:{user_b}"
    count = r.sinterstore(result_key, f"followers:{user_a}", f"followers:{user_b}")
    r.expire(result_key, ttl)
    return count
7

Use Case 5 — Recommendation Đơn Giản

"Người follow X cũng follow Y" là một dạng collaborative filtering đơn giản, thực hiện được bằng SINTER. Giao của các follow set cho biết tập người dùng có hành vi tương đồng.

# userA follow: user:10, user:20, user:30
# userB follow: user:20, user:30, user:40
# userC follow: user:30, user:50

redis> SADD follows:userA "user:10" "user:20" "user:30"
redis> SADD follows:userB "user:20" "user:30" "user:40"
redis> SADD follows:userC "user:30" "user:50"

# userA và userB đều follow
redis> SINTER follows:userA follows:userB
1) "user:20"
2) "user:30"

# 3 user đều follow
redis> SINTER follows:userA follows:userB follows:userC
1) "user:30"

Ứng dụng thực tế: "Gợi ý tài khoản để follow" — tìm những tài khoản mà người dùng tương tự follow nhưng user hiện tại chưa follow:

def suggest_accounts(target_user: str, similar_users: list[str], limit: int = 5) -> list[str]:
    """
    Gợi ý tài khoản mà similar_users follow nhưng target_user chưa follow.
    Dùng SUNION để hợp follows của similar_users, rồi SDIFF với follows của target.
    """
    if not similar_users:
        return []

    # Hợp tất cả account mà similar_users follow
    similar_keys = [f"follows:{u}" for u in similar_users]
    combined_key = f"suggestion_tmp:{target_user}"

    # Tính union của similar users' follows
    r.sunionstore(combined_key, *similar_keys)
    r.expire(combined_key, 60)

    # Loại bỏ những account target đã follow
    result_key = f"suggestion:{target_user}"
    r.sdiffstore(result_key, combined_key, f"follows:{target_user}")
    r.expire(result_key, 300)

    # Lấy limit kết quả ngẫu nhiên
    suggestions = r.srandmember(result_key, limit)

    # Dọn dẹp key tạm
    r.delete(combined_key)

    return suggestions or []

Đây là logic đơn giản, không thay thế recommendation engine thực sự. Phù hợp cho tập dữ liệu vừa phải và khi sự đơn giản quan trọng hơn độ chính xác.

8

SRANDMEMBER vs SPOP

Hai command lấy member ngẫu nhiên từ Set, khác nhau ở một điểm quan trọng.

Command Có xóa member không? Use case
SRANDMEMBER key N Không Random sampling, gợi ý ngẫu nhiên, preview
SPOP key N Raffle/lottery, task queue (consume), one-time distribution
# Tạo pool 5 người tham gia
redis> SADD lottery:pool "user:1" "user:2" "user:3" "user:4" "user:5"
(integer) 5

# SRANDMEMBER — lấy 2 người ngẫu nhiên, pool không đổi
redis> SRANDMEMBER lottery:pool 2
1) "user:3"
2) "user:1"
redis> SCARD lottery:pool
(integer) 5    # vẫn 5

# SPOP — lấy 2 winner, xóa khỏi pool
redis> SPOP lottery:pool 2
1) "user:4"
2) "user:2"
redis> SCARD lottery:pool
(integer) 3    # còn 3

Hành vi khi N âm (SRANDMEMBER)

SRANDMEMBER key N với N dương trả về tối đa N member phân biệt (không trùng lặp). Với N âm trả về đúng |N| member nhưng có thể trùng — cho phép sampling with replacement:

redis> SADD sample "a" "b" "c"
(integer) 3

# N dương — tối đa 3 member, không trùng
redis> SRANDMEMBER sample 5     # N > SCARD → trả về hết
1) "a"
2) "b"
3) "c"

# N âm — đúng 5 phần tử, có thể trùng
redis> SRANDMEMBER sample -5
1) "b"
2) "a"
3) "b"    # trùng
4) "c"
5) "a"    # trùng

Code Python — raffle

def register_for_raffle(raffle_id: str, user_id: int) -> bool:
    """Đăng ký tham gia. Trả về True nếu đăng ký mới, False nếu đã đăng ký."""
    added = r.sadd(f"raffle:{raffle_id}", f"user:{user_id}")
    return bool(added)


def draw_winners(raffle_id: str, n_winners: int) -> list[str]:
    """Chọn ngẫu nhiên n_winners, xóa khỏi pool."""
    winners = r.spop(f"raffle:{raffle_id}", n_winners)
    return winners or []


def preview_candidates(raffle_id: str, n: int = 3) -> list[str]:
    """Xem trước một số ứng viên ngẫu nhiên, không ảnh hưởng pool."""
    return r.srandmember(f"raffle:{raffle_id}", n) or []
9

Encoding Nội Bộ

Redis tự động chọn encoding tối ưu dựa trên nội dung và kích thước Set. Không cần config tường minh; nhưng hiểu ngưỡng chuyển đổi giúp dự đoán memory và performance.

Encoding Điều kiện Đặc điểm
intset Tất cả member là integer, số lượng ≤ set-max-intset-entries (mặc định 512) Sorted array của integer 16/32/64-bit. Rất compact: không có overhead string, tìm kiếm binary search O(log N). Tự upgrade integer width khi cần.
listpack Member là non-integer string, số lượng ≤ set-max-listpack-entries (mặc định 128), mỗi member ≤ set-max-listpack-value (mặc định 64 bytes). Áp dụng từ Redis 7.2. Compact sequential encoding. Tiết kiệm RAM cho set nhỏ. SISMEMBER duyệt linear O(N) — ổn với set nhỏ.
hashtable Khi vượt ngưỡng intset hoặc listpack Hash table chuẩn. SADD/SREM/SISMEMBER O(1) average. Tốn RAM hơn vì overhead của hash table.
# Set nhỏ toàn integer → intset
redis> SADD int_set 1 2 3 4 5
(integer) 5
redis> OBJECT ENCODING int_set
"intset"

# Thêm string → chuyển sang listpack (Redis 7.2+)
redis> SADD int_set "hello"
(integer) 1
redis> OBJECT ENCODING int_set
"listpack"

# Set lớn → hashtable
redis> EVAL "for i=1,200 do redis.call('SADD', 'big_set', tostring(i)..'-suffix') end" 0
redis> OBJECT ENCODING big_set
"hashtable"

Kiểm tra và điều chỉnh

# Xem encoding hiện tại
redis> OBJECT ENCODING my_set

# Xem cấu hình ngưỡng
redis> CONFIG GET set-max-intset-entries
redis> CONFIG GET set-max-listpack-entries
redis> CONFIG GET set-max-listpack-value

# Điều chỉnh (ví dụ tăng ngưỡng intset)
redis> CONFIG SET set-max-intset-entries 1024

Trong production, nâng ngưỡng listpack/intset có thể tiết kiệm RAM cho các Set nhỏ. Trade-off: các operation trên listpack là O(N) (linear scan), nên với Set có SISMEMBER thường xuyên và số member tiệm cận ngưỡng, hashtable sẽ nhanh hơn.

10

Performance

Command Complexity Ghi chú
SADD, SREM, SISMEMBER O(1) — hashtable encoding O(log N) với intset, O(N) với listpack
SCARD O(1) Lưu sẵn trong header
SMEMBERS O(N) N = số member. Block event loop nếu set lớn
SINTER, SUNION, SDIFF O(N×M) N = tổng member, M = số set. Chậm với set lớn
SRANDMEMBER, SPOP O(N) khi lấy nhiều O(1) khi lấy 1 member
SSCAN O(1) per call, O(N) toàn bộ Cursor-based, không block

SSCAN — duyệt set lớn không block

Khi Set lớn (hàng chục nghìn member trở lên), dùng SSCAN thay vì SMEMBERS. SSCAN trả về cursor và một batch nhỏ member, cho phép duyệt incremental mà không giữ event loop.

def scan_all_members(key: str, batch_size: int = 100) -> list[str]:
    """Duyệt toàn bộ member của Set theo batches, không block event loop."""
    all_members = []
    cursor = 0
    while True:
        cursor, members = r.sscan(key, cursor=cursor, count=batch_size)
        all_members.extend(members)
        if cursor == 0:  # cursor = 0 nghĩa là đã quét xong
            break
    return all_members
11

Anti-patterns

  • SMEMBERS trên Set triệu member:
    # Sai — block event loop, trả về vài trăm MB data
    all_users = r.smembers("all_registered_users")   # ← 10 triệu member
    
    # Đúng — dùng SSCAN
    for batch in r.sscan_iter("all_registered_users", count=500):
        process(batch)
  • SINTER hai Set khổng lồ trên hot path:

    SINTER của hai Set 100K member là O(N×M) — có thể mất hàng chục mili-giây, block event loop. Giải pháp: dùng SINTERSTORE tính trước, cache kết quả với TTL, chỉ re-compute khi dữ liệu thay đổi.

    # Sai — tính lại mỗi request
    result = r.sinter("followers:A", "followers:B")  # A và B mỗi cái 100K member
    
    # Đúng — cache kết quả
    cached_key = "mutual:A:B"
    if not r.exists(cached_key):
        r.sinterstore(cached_key, "followers:A", "followers:B")
        r.expire(cached_key, 300)
    result = r.smembers(cached_key)
  • Set cho unique count quy mô lớn:

    Set lưu toàn bộ member. 10 triệu unique user ID trong một Set chiếm hàng trăm MB. Nếu chỉ cần đếm (không cần biết user cụ thể), dùng HyperLogLog.

  • Không dùng pipeline khi SADD nhiều key trong một lần:
    # Kém hiệu quả — N round-trip
    for tag in tags:
        r.sadd(f"posts:tag:{tag}", post_id)
    
    # Tốt hơn — gộp vào pipeline
    pipe = r.pipeline()
    for tag in tags:
        pipe.sadd(f"posts:tag:{tag}", post_id)
    pipe.execute()
  • Giả định SMEMBERS trả về thứ tự cố định: Set là unordered. Thứ tự trả về không được đảm bảo, có thể thay đổi giữa các lần gọi và giữa các encoding. Nếu cần thứ tự xác định → Sorted Set.
12

Best Practices

  • Membership check → SISMEMBER O(1) (hashtable encoding). Đây là thao tác hiệu quả nhất của Set — permission check, tag check, đã-xem-chưa.
  • SSCAN thay SMEMBERS cho Set lớn. Ngưỡng thực tế: với Set > 10K member và SMEMBERS được gọi trong hot path, nên chuyển sang SSCAN.
  • SINTERSTORE cache kết quả nếu query lặp lại. Tính một lần, lưu vào key riêng, set TTL, re-compute khi cần.
  • Integer member → intset encoding tự động, compact và nhanh. Nếu member chỉ cần là ID số nguyên, đừng thêm prefix string không cần thiết ("user:123" → mất intset encoding, dùng plain 123 nếu không cần phân biệt namespace).
  • Count-only unique > vài trăm nghìn → HyperLogLog. Set chỉ phù hợp khi cần biết member cụ thể (để check membership, lấy danh sách, làm set operation).
  • Pipeline khi SADD/SREM nhiều key trong cùng một batchoperation (ví dụ update tag và reverse index đồng thời).
  • Đặt TTL cho key kết quả set operation (SINTERSTORE, SUNIONSTORE) để tránh accumulate key rác.
13

Tổng Kết & Quiz

Tổng kết

  • Set là unordered collection, members unique. Thêm member trùng không lỗi, nhưng không được ghi lại.
  • Command cốt lõi: SADD/SREM/SISMEMBER O(1), SCARD O(1), SMEMBERS O(N).
  • 5 use case chính: tags + reverse index, unique tracking, permission/RBAC, set operations (SINTER/SUNION/SDIFF), recommendation đơn giản.
  • SINTERSTORE/SUNIONSTORE/SDIFFSTORE lưu kết quả vào key mới, dùng để cache kết quả set operation.
  • SPOP lấy và xóa member ngẫu nhiên (raffle, queue); SRANDMEMBER lấy không xóa (sampling, gợi ý).
  • 3 encoding: intset (integer nhỏ), listpack (string nhỏ, Redis 7.2+), hashtable (set lớn).
  • Anti-pattern lớn nhất: SMEMBERS trên set triệu member. Dùng SSCAN. Unique count khổng lồ → HyperLogLog.

Quiz 5 câu

  1. Một Set đang có encoding intset. Điều gì xảy ra khi bạn SADD my_set "hello"?
  2. Tại sao SINTER followers:A followers:B trên hai Set 100K member là vấn đề trong production? Cách giải quyết?
  3. Phân biệt SPOP key 3SRANDMEMBER key 3. Khi nào dùng cái nào?
  4. Bạn cần track 50 triệu unique user đã xem một video. Dùng Set hay HyperLogLog? Tại sao?
  5. Lệnh nào trả về số member phân biệt trong Set mà không cần duyệt toàn bộ member?

Đáp án gợi ý

  1. Redis chuyển encoding từ intset sang listpack (Redis 7.2+) hoặc hashtable (Redis cũ hơn) vì có member không phải integer. Quá trình này tự động, không thể đảo ngược về intset dù sau đó xóa hết string member.
  2. SINTER O(N×M): với 2 Set 100K member mỗi cái, Redis phải xử lý tối đa 10 tỷ phép so sánh — mất nhiều mili-giây, block event loop. Giải pháp: dùng SINTERSTORE result:key followers:A followers:B, set TTL cho result key, các request sau đọc từ result key thay vì tính lại.
  3. SPOP lấy và xóa member khỏi Set (dùng cho raffle/lottery vì winner bị loại khỏi pool sau khi chọn). SRANDMEMBER lấy không xóa (dùng khi cần sampling mà không làm thay đổi Set, ví dụ gợi ý ngẫu nhiên, preview).
  4. HyperLogLog. Set 50 triệu user string "user:XXXXX" tốn vài GB RAM cho một key. HyperLogLog tối đa 12KB, sai số ~0.81% — chấp nhận được cho use case đếm unique. Chọn Set chỉ khi cần biết user cụ thể nào đã xem (để check membership, set operation).
  5. SCARD — trả về số member được lưu sẵn trong header của Set, O(1), không duyệt member.

Bài tiếp theo

Bài 24 đi vào Sorted Set: leaderboard, ranking, và delayed queue — data structure có thứ tự theo score.

Tham khảo