ข้ามไปยังเนื้อหา

Sets

ทดสอบบน Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ # cli/... คือ session เดียวเรียงตามเอกสารที่ harness ของคอร์สรันซ้ำ ตัวเลขเวลาเป็นไมโครวินาทีฝั่งเซิร์ฟเวอร์จาก SLOWLOG (threshold 0) บนเครื่องนี้ ถือเป็นระดับขนาด ไม่ใช่ benchmark

Redis Set คือคอลเลกชันของ string ที่ ไม่ซ้ำกัน และ ไม่มีลำดับที่รับประกัน การเพิ่ม member ที่มีอยู่แล้วไม่ทำอะไร จึงไม่เกิดข้อมูลซ้ำ ทำให้ Set เหมาะกับ tag, การเช็ก “เคยเห็นแล้ว” และกลุ่มใดๆ ที่แต่ละค่าปรากฏได้ครั้งเดียว

SADD key member [member ...] เพิ่ม member หนึ่งตัวขึ้นไปและคืนจำนวน member ที่ ใหม่ (ตัวซ้ำไม่นับ) SREM key member [member ...] ลบ member และคืนจำนวนที่มีอยู่จริง

cli/sets-sorted-sets-sets-1.redis
127.0.0.1:6379> SADD article:1:tags "redis" "database" "caching"
(integer) 3
127.0.0.1:6379> SADD article:1:tags "redis"
(integer) 0
127.0.0.1:6379> SREM article:1:tags "caching"
(integer) 1
127.0.0.1:6379> SREM article:1:tags "nosql"
(integer) 0
127.0.0.1:6379> SCARD article:1:tags
(integer) 2
127.0.0.1:6379> OBJECT ENCODING article:1:tags
"listpack"

SCARD คืนจำนวน member โดยไม่ต้องดึงมา สังเกตบรรทัดสุดท้าย: set เล็กของ string ถูกเก็บเป็น listpack ไม่ใช่ hash table หัวข้อถัดไปแสดงว่ามีผลต่อลำดับอย่างไร

SADD article:1:tags "redis" "database" "caching"
SADD article:1:tags "redis"
SREM article:1:tags "caching"
SREM article:1:tags "nosql"

set เลือก encoding ภายในหนึ่งในสามแบบตามสิ่งที่มันเก็บ ลำดับที่ SMEMBERS คืนมาเป็นผลของ encoding และสิ่งเดียวที่เป็นสัญญาคือ ไม่มีลำดับที่รับประกัน:

cli/sets-sorted-sets-sets-2.redis
127.0.0.1:6379> SADD s z a m
(integer) 3
127.0.0.1:6379> SMEMBERS s
1) "z"
2) "a"
3) "m"
127.0.0.1:6379> SADD n 30 10 20
(integer) 3
127.0.0.1:6379> SMEMBERS n
1) "10"
2) "20"
3) "30"
127.0.0.1:6379> OBJECT ENCODING n
"intset"
127.0.0.1:6379> SADD n x
(integer) 1
127.0.0.1:6379> OBJECT ENCODING n
"listpack"
127.0.0.1:6379> SMEMBERS n
1) "10"
2) "20"
3) "30"
4) "x"
Encodingเมื่อไหร่ (ค่าเริ่มต้น 8.10.2)สิ่งที่เห็น
intsetเฉพาะจำนวนเต็ม ไม่เกิน set-max-intset-entries (512)30 10 20 กลับมาเรียงแล้ว: 10 20 30
listpackเล็กและผสมชนิด ไม่เกิน set-max-listpack-entries (128)z a m กลับมาตามลำดับที่ใส่
hashtableใหญ่กว่านั้นset 600 member เป็น hashtable

ลำดับทั้งสองแบบเป็นรายละเอียดของ implementation เวอร์ชันนี้ ไม่ใช่คำสัญญา: ทันทีที่ set โตเป็น hashtable ลำดับก็ไม่เกี่ยวกับลำดับที่ใส่ อะไรที่ต้องการลำดับให้ใช้ List หรือ Sorted Set (บทเวอร์ชันก่อนแสดงลำดับ SMEMBERS ที่แต่งขึ้น; transcript ในบทนี้คือ output จริง)

SMEMBERS key คืนทุก member; SCARD key คืนจำนวน SMEMBERS เป็น O(N) ใช้ได้กับ set เล็ก ไม่ใช่กับ set ใหญ่ (วัดด้านล่าง)

SADD user:42:interests "music" "hiking" "redis" "coffee"
SMEMBERS user:42:interests
SCARD user:42:interests

SISMEMBER key member คืน 1 หรือ 0 แบบ O(1) ไม่ว่า set จะใหญ่แค่ไหน SMISMEMBER key member [member ...] (6.2+) เช็กหลาย member ใน round trip เดียว และคืน flag หนึ่งตัวต่อ member ตามลำดับ:

cli/sets-sorted-sets-sets-3.redis
127.0.0.1:6379> SISMEMBER n 10
(integer) 1
127.0.0.1:6379> SMISMEMBER n 10 99 x
1) (integer) 1
2) (integer) 0
3) (integer) 1
SADD seen:urls "https://redis.io" "https://astro.build"
SISMEMBER seen:urls "https://redis.io"
SISMEMBER seen:urls "https://example.com"
SMISMEMBER seen:urls "https://redis.io" "https://example.com"

SRANDMEMBER key [count] คืน member แบบสุ่ม โดยไม่ลบ; count ติดลบ อนุญาตให้ซ้ำและคืนครบ |count| รายการเสมอ SPOP key [count] ลบและคืน member แบบสุ่ม

cli/sets-sorted-sets-sets-4.redis
127.0.0.1:6379> SRANDMEMBER n 2
1) "10"
2) "20"
127.0.0.1:6379> SRANDMEMBER n -5
1) "30"
2) "x"
3) "30"
4) "10"
5) "x"
127.0.0.1:6379> SPOP n 1
1) "x"
127.0.0.1:6379> SCARD n
(integer) 3

ผลเป็นการสุ่ม output ของคุณจึงต่างออกไป ได้ 5 รายการจาก set 4 member เพราะ count ติดลบ (30 และ x ซ้ำ)

SADD prize:pool "alpha" "beta" "gamma" "delta" "epsilon"
SRANDMEMBER prize:pool 2
SCARD prize:pool
SPOP prize:pool 1
SCARD prize:pool

SINTERCARD numkeys key [key ...] [LIMIT n] คืนขนาดของ intersection และหยุดเร็วเมื่อถึง LIMIT เรื่องพีชคณิตของ set ทั้งหมดอยู่ใน Set operations

cli/sets-sorted-sets-sets-5.redis
127.0.0.1:6379> SADD a 1 2 3 4 5
(integer) 5
127.0.0.1:6379> SADD b 3 4 5 6
(integer) 4
127.0.0.1:6379> SINTERCARD 2 a b
(integer) 3
127.0.0.1:6379> SINTERCARD 2 a b LIMIT 2
(integer) 2

ผมสร้าง set 200,000 member (user:1 .. user:200000, hashtable, 8.8 MB) แล้วถาม SLOWLOG ว่าแต่ละคำสั่งกินเซิร์ฟเวอร์เท่าไร:

คำสั่งเวลาฝั่งเซิร์ฟเวอร์Reply
SISMEMBER big user:1500005 µsinteger ตัวเดียว
SSCAN big 0 COUNT 10044 µscursor + ราว 100 member
SMEMBERS big11,116 µs (11 ms)200,000 member (ข้อความ member 2.1 MB)

SMEMBERS ทำให้ command thread ค้าง 11 ms และท่วม client; SSCAN คืนทีละหน้า บน set เล็ก SSCAN คืนทุกอย่างในการเรียกเดียว:

cli/sets-sorted-sets-sets-6.redis
127.0.0.1:6379> SADD big m1 m2 m3
(integer) 3
127.0.0.1:6379> SSCAN big 0 MATCH m* COUNT 20
1) "0"
2) 1) "m1"
2) "m2"
3) "m3"

cursor เป็น 0 ใน reply หมายความว่าสแกนเสร็จ เหมือน SCAN, SSCAN อาจคืน member เดิมซ้ำได้ และ COUNT เป็นคำใบ้ ไม่ใช่ขนาดหน้า

sadd คืนจำนวน member ใหม่ จึงใช้เป็น “เพิ่งเคยเห็นครั้งแรกไหม?” ได้ sscanStream ห่อ loop ของ cursor ไว้ให้:

src/sets-sorted-sets-sets-tags.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
await redis.del('b2:post:1:tags', 'b2:seen');
// SADD returns how many members were NEW, so it doubles as "first time seen?"
console.log(await redis.sadd('b2:post:1:tags', 'redis', 'cache', 'redis')); // 2 (the duplicate is ignored)
console.log(await redis.sismember('b2:post:1:tags', 'redis')); // 1
console.log(await redis.smismember('b2:post:1:tags', 'redis', 'sql')); // [ 1, 0 ]
// dedupe: process an event only the first time its id is added
async function firstTime(id: string) {
return (await redis.sadd('b2:seen', id)) === 1;
}
console.log(await firstTime('evt-1'), await firstTime('evt-1')); // true false
// iterate a big set with a cursor instead of SMEMBERS
for (let i = 0; i < 1000; i++) await redis.sadd('b2:seen', `evt-${i}`);
let n = 0;
for await (const batch of redis.sscanStream('b2:seen', { count: 200 })) n += (batch as string[]).length;
console.log('scanned', n, 'members'); // 1000 (evt-1 was already there)
await redis.del('b2:post:1:tags', 'b2:seen');
await redis.quit();

Output จริง: 2, 1, [ 1, 0 ], true false, scanned 1000 members

AI เขียนการเช็ก “ผู้ใช้ติดตาม tag X ไหม?” สำหรับ set ที่เก็บทุก tag ที่แพลตฟอร์มเคยเห็น:

🐛 หา bug ในโค้ดนี้

async function hasTag(tag: string) {
const tags = await redis.smembers('tags:all'); // 200,000 members
return tags.includes(tag);
}
เฉลย

ทำงานได้ แต่ดาวน์โหลด set ทั้งก้อนเพื่อตอบคำถามใช่/ไม่ใช่ บน set 200,000 member SMEMBERS กินเซิร์ฟเวอร์ 11,116 µs และส่งข้อความ member 2.1 MB ต่อครั้ง เทียบกับ 5 µs และ integer ตัวเดียวของ SISMEMBER ส่วน includes ของ array ก็เป็น O(N) ฝั่ง client อีกชั้น จงถามเซิร์ฟเวอร์ในสิ่งที่คุณอยากรู้จริงๆ:

async function hasTag(tag: string) {
return (await redis.sismember('tags:all', tag)) === 1;
}

sets-sorted-sets-sets-tags.ts ที่รันได้ข้างบนใช้ sismember/smismember และ sscanStream สำหรับกรณีที่ต้องการทุก member จริงๆ

Set คือ ถุงที่มีการทดสอบสมาชิก: “X อยู่ในนั้นไหม?” ใช้เวลาคงที่ไม่ว่าขนาดเท่าไร “ขอทั้งหมด” คิดต้นทุนตามขนาด และ “เรียงอย่างไร?” ไม่มีคำตอบ ออกแบบรอบการทดสอบ (SISMEMBER, ค่าที่ SADD คืน) ไม่ใช่รอบการเทเนื้อหาออกมา

ตัวเลือกข้อดีข้อเสีย
Set สำหรับ tag / dedupeไม่ซ้ำโดยไม่ต้องเช็กก่อน; SADD เป็น upsert ในตัวและบอกว่าเป็นของใหม่หรือไม่ไม่มีลำดับ (สิ่งที่เห็นเป็นผลของ encoding); ถ้าต้องเรียงหรือจัดอันดับให้ใช้ Sorted Set
SISMEMBER / SMISMEMBERO(1) ต่อ member, รวมเป็น round trip เดียวได้แค่ ใช่/ไม่ใช่: ข้อมูลของ member อยู่ใน key อื่น
SMEMBERSเรียกครั้งเดียวได้ทุกอย่างเซิร์ฟเวอร์ค้าง 11 ms และข้อความ 2.1 MB ที่ 200k member; ใช้ SSCAN
SPOP สำหรับจับสุ่มสุ่มและลบแบบ atomicmember หายไปเลย; บันทึกการจับแยกถ้าต้องการประวัติ
  • “SMEMBERS คืน member ในลำดับคงที่” set เล็กเป็นลำดับที่ใส่ จำนวนเต็มเป็นลำดับเรียง set ใหญ่แทบเป็นลำดับใดก็ได้ ไม่มีอะไรรับประกัน
  • “SADD คืนขนาดของ set” คืนจำนวน member ที่ใหม่ ใช้ SCARD สำหรับขนาด
  • “SRANDMEMBER พร้อม count คืน member ที่ไม่ซ้ำตามจำนวนนั้น” เฉพาะ count บวกที่ไม่ซ้ำ count ติดลบอนุญาตให้ซ้ำและคืนครบ |count| รายการ
  • “SSCAN คืนแต่ละ member ครั้งเดียวเป๊ะ” อาจคืน member ซ้ำได้ รับประกันเพียงว่า member ที่อยู่ตลอดการสแกนจะปรากฏ
  • เรียก SMEMBERS บน set ใหญ่ใน request path — ใช้ SISMEMBER สำหรับคำถาม และ SSCAN สำหรับการเดิน
  • เช็กสมาชิกทีละคำสั่ง — SMISMEMBER (หรือ pipeline) เปลี่ยน N round trip เป็นหนึ่ง
  • ใช้ Set ทั้งที่ต้องการลำดับ — ใช้ List หรือ Sorted Set ที่มี timestamp เป็น score

Real-world example

เอกสารของ Redis เอง ระบุการติด tag และ “รายการไม่ซ้ำ” เป็นการใช้งานมาตรฐานของ Set และอธิบายสาม encoding กับขีดจำกัดใน หน้า Sets data type

SADD คืนอะไรเมื่อเพิ่ม member ที่มีอยู่แล้ว?
คำสั่งไหนเช็กการเป็นสมาชิกแบบ O(1) โดยไม่ต้องดึง member ทั้งหมด?
SRANDMEMBER ต่างจาก SPOP อย่างไร?
คุณ SADD 30, 10, 20 แล้ว SMEMBERS คืน 10, 20, 30 คุณพึ่งพาอะไรได้?
คุณต้องเช็ก 50 tag ว่าอยู่ใน set เดียวหรือไม่ คำสั่งเดียวที่ดีที่สุดคือ?
SRANDMEMBER n -5 บน set 4 member คืน...