Sorted Sets
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ# cli/...คือ session เดียวเรียงตามเอกสารที่ harness ของคอร์สรันซ้ำ
Redis Sorted Set คืออะไร?
หัวข้อที่มีชื่อว่า “Redis Sorted Set คืออะไร?”Sorted Set คล้าย Set แต่ทุก member มี score (float 64 บิต) Redis เรียง member ตาม score ตลอดเวลา ด้วย insert และ range query แบบ O(log N) member ไม่ซ้ำกัน แต่ score ซ้ำได้ member ที่ score เท่ากันเรียงตาม byte ของชื่อ (แสดงด้านล่าง)
ZADD — เพิ่ม member พร้อม score
หัวข้อที่มีชื่อว่า “ZADD — เพิ่ม member พร้อม score”ZADD key [NX|XX] [GT|LT] [CH] score member [score member ...] เพิ่ม member ถ้า member มีอยู่แล้ว score จะ ถูกอัปเดต reply นับเฉพาะ member ใหม่ เว้นแต่ใส่ CH (changed) เพื่อนับการอัปเดตด้วย
127.0.0.1:6379> ZADD game:scores 4200 "alice" 3800 "bob" 5100 "carol"(integer) 3127.0.0.1:6379> ZADD game:scores 4700 "alice"(integer) 0127.0.0.1:6379> ZSCORE game:scores "alice""4700"127.0.0.1:6379> ZADD game:scores NX 1 "alice" 100 "dave"(integer) 1127.0.0.1:6379> ZADD game:scores XX 9999 "erin"(integer) 0127.0.0.1:6379> ZADD game:scores GT 4000 "alice"(integer) 0127.0.0.1:6379> ZADD game:scores GT CH 6000 "alice"(integer) 1127.0.0.1:6379> ZADD game:scores LT CH 5000 "alice"(integer) 1127.0.0.1:6379> ZRANGE game:scores 0 -1 WITHSCORES1) "dave"2) "100"3) "bob"4) "3800"5) "alice"6) "5000"7) "carol"8) "5100"อ่านทีละบรรทัด:
ZADD ... 4700 "alice"อัปเดต member เดิม reply จึงเป็น0member ใหม่ และZSCOREแสดง"4700"ซึ่งเป็น stringNX= เพิ่มเฉพาะ member ใหม่ (alice ไม่ถูกแตะ, dave ถูกเพิ่ม:1)XX= อัปเดตเฉพาะที่มีอยู่ (erin ไม่มี จึงไม่เกิดอะไร:0)GT= อัปเดตเมื่อ score ใหม่ มากกว่า;LT= เมื่อ น้อยกว่า ทั้งคู่ยังเพิ่ม member ที่ไม่มีอยู่GT 4000กับ 4700 ไม่เปลี่ยนอะไร (0);GT CH 6000ยกขึ้นและCHทำให้ reply เป็น1จากนั้นLT CH 5000ลดลง ใช้GT/LT/NXร่วมกันไม่ได้
ZADD game:scores 4200 "alice" 3800 "bob" 5100 "carol"
ZADD game:scores 4700 "alice"
ZSCORE game:scores "alice"
ZADD game:scores GT CH 6000 "alice"ZRANGE — ตาม rank, ตาม score, กลับด้าน, แบ่งหน้า
หัวข้อที่มีชื่อว่า “ZRANGE — ตาม rank, ตาม score, กลับด้าน, แบ่งหน้า”ตั้งแต่ 6.2 คำสั่งเดียวทำได้ทุก range: ZRANGE key start stop [BYSCORE|BYLEX] [REV] [LIMIT offset count] [WITHSCORES] REV กลับลำดับ (สูงสุดก่อน); เมื่อใช้ BYSCORE ขอบเขตคือ score และเมื่อรวมกับ REV ต้องส่ง ค่าสูงก่อน เครื่องหมาย ( นำหน้าทำให้ขอบเขตไม่รวมค่านั้น
127.0.0.1:6379> ZRANGE game:scores 0 2 REV WITHSCORES1) "carol"2) "5100"3) "alice"4) "5000"5) "bob"6) "3800"127.0.0.1:6379> ZRANGE game:scores 4000 +inf BYSCORE LIMIT 0 2 WITHSCORES1) "alice"2) "5000"3) "carol"4) "5100"127.0.0.1:6379> ZRANGE game:scores "(4000" +inf BYSCORE1) "alice"2) "carol"127.0.0.1:6379> ZRANGE game:scores +inf 4000 BYSCORE REV WITHSCORES1) "carol"2) "5100"3) "alice"4) "5000"127.0.0.1:6379> ZRANGESTORE top2 game:scores 0 1 REV(integer) 2127.0.0.1:6379> ZRANGE top2 0 -1 WITHSCORES1) "alice"2) "5000"3) "carol"4) "5100"ZRANGESTORE dst src start stop [BYSCORE|BYLEX] [REV] [LIMIT ...] (6.2+) เขียน slice ของ sorted set ลงอีก key หนึ่ง ซึ่งเป็นวิธีที่เก็บ snapshot “top 2” ข้างบน
ZADD leaderboard 900 "carol" 1500 "alice" 2100 "bob" 750 "dave"
ZRANGE leaderboard 0 -1 WITHSCORES
ZRANGE leaderboard 0 2 REV WITHSCORESZADD temps 18.5 "london" 32.1 "bangkok" 24.0 "berlin" 28.7 "tokyo"
ZRANGE temps 20 30 BYSCORE WITHSCORESZRANK และ ZSCORE — หา member
หัวข้อที่มีชื่อว่า “ZRANK และ ZSCORE — หา member”ZRANK key member [WITHSCORE] คืน rank แบบ 0-based จากน้อยไปมาก; ZREVRANK คืน rank จากมากไปน้อย (score สูงสุด = rank 0) WITHSCORE (7.2+) คืน [rank, score] ในการเรียกเดียว member ที่ไม่มีคืน (nil)
127.0.0.1:6379> ZRANK game:scores "alice"(integer) 2127.0.0.1:6379> ZRANK game:scores "alice" WITHSCORE1) (integer) 22) "5000"127.0.0.1:6379> ZREVRANK game:scores "alice"(integer) 1127.0.0.1:6379> ZREVRANK game:scores "alice" WITHSCORE1) (integer) 12) "5000"127.0.0.1:6379> ZRANK game:scores "nobody"(nil)ZADD players 300 "x" 600 "y" 900 "z"
ZRANK players "y"
ZREVRANK players "y" WITHSCORE
ZSCORE players "z"Score เท่ากันและความแม่นยำ — สองอย่างที่ score ไม่ช่วยคุณ
หัวข้อที่มีชื่อว่า “Score เท่ากันและความแม่นยำ — สองอย่างที่ score ไม่ช่วยคุณ”ผลเสมอถูกตัดสินด้วย byte ของ member ไม่ใช่เวลาที่ใส่ z (score 0) มาก่อน; a, b, c มี score 1 และออกมาตามตัวอักษร เป็นการเทียบ byte: "10" มาก่อน "9" และ B ตัวใหญ่มาก่อน a ตัวเล็ก score เป็น double แบบ IEEE-754: จำนวนเต็มแม่นยำถึง 2^53 (9,007,199,254,740,992) เท่านั้น; ZADD big 9007199254740993 x เก็บ ...992 ทศนิยมมีความคลาดเคลื่อนของ float ตามปกติ (0.1 + 0.2 = 0.30000000000000004)
127.0.0.1:6379> ZADD ties 1 b 1 a 1 c 0 z(integer) 4127.0.0.1:6379> ZRANGE ties 0 -1 WITHSCORES1) "z"2) "0"3) "a"4) "1"5) "b"6) "1"7) "c"8) "1"127.0.0.1:6379> ZADD ties2 1 a 1 B 1 9 1 10(integer) 4127.0.0.1:6379> ZRANGE ties2 0 -11) "10"2) "9"3) "B"4) "a"127.0.0.1:6379> ZADD big 9007199254740993 x(integer) 1127.0.0.1:6379> ZSCORE big x"9007199254740992"127.0.0.1:6379> ZADD big 0.1 y(integer) 1127.0.0.1:6379> ZINCRBY big 0.2 y"0.30000000000000004"127.0.0.1:6379> ZADD bad notanumber m(error) ERR value is not a valid floatscore ที่ไม่ใช่ตัวเลขถูกปฏิเสธ (ERR value is not a valid float) ถ้าต้องจัดอันดับด้วยจำนวนเต็มใหญ่ เช่น timestamp ระดับไมโครวินาที ให้อยู่ต่ำกว่า 2^53 หรือแยกค่า; บท leaderboards เข้ารหัสตัวตัดสินเสมอไว้ใน score โดยไม่ข้ามเส้นนั้น
ZINCRBY, ZCARD และ ZREM
หัวข้อที่มีชื่อว่า “ZINCRBY, ZCARD และ ZREM”ZINCRBY key increment member บวกเข้า score ของ member แบบ atomic (สร้างที่ increment ถ้ายังไม่มี) ZCARD key นับ member, ZREM key member [member ...] ลบและคืนจำนวนที่มีอยู่
127.0.0.1:6379> ZADD votes 10 "post:1" 5 "post:2" 8 "post:3"(integer) 3127.0.0.1:6379> ZINCRBY votes 3 "post:2""8"127.0.0.1:6379> ZCARD votes(integer) 3127.0.0.1:6379> ZREM votes "post:3"(integer) 1127.0.0.1:6379> ZCARD votes(integer) 2ZADD votes 10 "post:1" 5 "post:2" 8 "post:3"
ZINCRBY votes 3 "post:2"
ZCARD votes
ZREM votes "post:3"
ZCARD votesจาก Node.js: รูปร่างของผลลัพธ์และ race แบบ lost-update
หัวข้อที่มีชื่อว่า “จาก Node.js: รูปร่างของผลลัพธ์และ race แบบ lost-update”ioredis คืน ZRANGE ... WITHSCORES เป็น flat array [member, score, member, score, ...] โดย score เป็น string สคริปต์ยังแข่งการโหวตพร้อมกัน 50 ครั้งสองแบบ:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const key = 'b2:z:scores';await redis.del(key, 'b2:z:votes-bug', 'b2:z:votes-ok');await redis.zadd(key, 4200, 'alice', 3800, 'bob', 5100, 'carol');
// ZRANGE ... REV WITHSCORES returns a FLAT array: [member, score, member, score, ...], scores are stringsconst flat = await redis.zrange(key, 0, '1', 'REV', 'WITHSCORES'); // ioredis types want stop as a stringconsole.log(flat); // [ 'carol', '5100', 'alice', '4200' ]const top = [];for (let i = 0; i < flat.length; i += 2) top.push({ member: flat[i], score: Number(flat[i + 1]) });console.log(top);
// range by score, paged: score >= 4000, first 2console.log(await redis.zrange(key, 4000, '+inf', 'BYSCORE', 'LIMIT', 0, 2)); // [ 'alice', 'carol' ]
// ZADD flags: GT only raises a score, CH reports changed members (not just new ones)console.log(await redis.zadd(key, 'GT', 'CH', 1000, 'alice')); // 0 (lower score ignored)console.log(await redis.zadd(key, 'GT', 'CH', 6000, 'alice')); // 1
// rank with score (7.2+); null for a missing memberconsole.log(await redis.zrank(key, 'alice', 'WITHSCORE')); // [ 2, '6000' ]console.log(await redis.zrank(key, 'nobody')); // null
// BUG vs FIX: 50 concurrent "vote +1" on one memberconst bug = async () => { const cur = Number((await redis.zscore('b2:z:votes-bug', 'post')) ?? 0); await redis.zadd('b2:z:votes-bug', cur + 1, 'post'); };await Promise.all(Array.from({ length: 50 }, bug));await Promise.all(Array.from({ length: 50 }, () => redis.zincrby('b2:z:votes-ok', 1, 'post')));console.log('read-modify-write ZADD:', await redis.zscore('b2:z:votes-bug', 'post')); // far below 50console.log('ZINCRBY:', await redis.zscore('b2:z:votes-ok', 'post')); // 50
await redis.del(key, 'b2:z:votes-bug', 'b2:z:votes-ok');await redis.quit();ใน type ของ ioredis stop เป็น string | Buffer จึงต้องส่ง '1' หรือ '-1' ไม่ใช่ตัวเลข Output จริง (Mac เครื่องนี้):
[ 'carol', '5100', 'alice', '4200' ][ { member: 'carol', score: 5100 }, { member: 'alice', score: 4200 } ][ 'alice', 'carol' ]01[ 2, '6000' ]nullread-modify-write ZADD: 1ZINCRBY: 50Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI เขียน handler upvote สำหรับการจัดอันดับโพสต์:
🐛 หา bug ในโค้ดนี้
async function upvote(postId: string) {const cur = Number((await redis.zscore('posts:votes', postId)) ?? 0);await redis.zadd('posts:votes', cur + 1, postId); // set the new score}เฉลย
ZADD ตั้ง score เป็นค่าที่คุณบอก; ค่านั้นคำนวณจากการอ่านก่อนหน้า ดังนั้นสอง request ที่อ่าน cur เดียวกันจะเขียน cur + 1 ทั้งคู่ และหนึ่งโหวตหายไป เมื่อ upvote พร้อมกัน 50 ครั้ง สคริปต์ข้างบนจบที่ 1 ไม่ใช่ 50 ZINCRBY ทำการบวกบนเซิร์ฟเวอร์แบบ atomic และจบที่ 50:
async function upvote(postId: string) { await redis.zincrby('posts:votes', 1, postId);}Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Sorted Set คือ index ที่คุณดูแลเอง: score คือ sort key, member คือ row id และ Redis รักษาลำดับให้ อะไรก็ตามที่พูดได้ว่า “ตัวเลขหนึ่งตัว เรียงน้อยไปมาก” (คะแนน, timestamp, ความสำคัญ, ระยะทาง) กลายเป็น range query ได้ เลือกตัวเลขให้ดี: ต้องพอดีกับ double, เสมอกันจะกลับไปเรียงตาม byte และการเปลี่ยนมันหมายถึงอัปเดตทุก member
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
Sorted Set แทนตาราง SQL ที่ ORDER BY | insert และอ่าน range แบบ O(log N) ไม่ต้องดูแล index | score เป็น float64 ตัวเดียว: ไม่มีการเรียงหลายคอลัมน์ และจำนวนเต็มเกิน 2^53 ไม่แม่น |
ZRANGE ... BYSCORE สำหรับ range query | ดึงช่วง score ได้โดยไม่ต้องสแกน | ต้องออกแบบ score ล่วงหน้า; การเปลี่ยนหมายถึง re-score |
ZINCRBY สำหรับ counter | atomic, round trip เดียว | บวกอย่างเดียว; ถ้าต้อง compare-and-set ใช้ flag GT/LT หรือ script |
| ลำดับเมื่อเสมอ = byte ของ member | แน่นอน | ไม่ใช่ลำดับการใส่ และ "10" มาก่อน "9" |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
ZADDกับ member ที่มีอยู่สร้างตัวซ้ำ” มันอัปเดต score และ reply0(หรือ1เมื่อใส่CH) - “
ZRANGEเรียงน้อยไปมากตาม rank เท่านั้น” ตั้งแต่ 6.2 มัน รับREV,BYSCORE,BYLEXและLIMIT; คำสั่งZREV*/ZRANGEBY*แยกต่างหาก deprecated แล้ว - “score เท่ากันเรียงตามเวลาที่ใส่” เรียงตาม byte ของ member
- “score เก็บจำนวนเต็ม 64 บิตได้ทุกค่า” เป็น double: แม่นยำถึง 2^53 เท่านั้น
- “
ZREVRANKก็ deprecated” ไม่ใช่ และรองรับWITHSCOREด้วย
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- read-modify-write ด้วย
ZADD—ZSCOREแล้วZADD cur+1ทำให้ update หาย (รอดแค่ 1 จาก 50) ใช้ZINCRBY - สอนหรือคัดลอก
ZREVRANGE/ZRANGEBYSCORE— ใช้ได้ แต่ควรใช้ZRANGE ... REV/BYSCORE - สลับขอบเขต — กับ
BYSCORE REVขอบเขต สูง มาก่อน - คิดว่า
WITHSCORESให้เป็นคู่ — client คืน flat array; ioredis ปล่อย score เป็น string
Real-world example
เอกสารของ Redis เอง นำเสนอ leaderboard, rate limiter และ priority queue เป็นการใช้งานหลักของ Sorted Set และระบุ deprecation ในหน้าคำสั่ง (Sorted sets, ZRANGE)