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

Sorted Sets

ทดสอบบน Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ # cli/... คือ session เดียวเรียงตามเอกสารที่ harness ของคอร์สรันซ้ำ

Sorted Set คล้าย Set แต่ทุก member มี score (float 64 บิต) Redis เรียง member ตาม score ตลอดเวลา ด้วย insert และ range query แบบ O(log N) member ไม่ซ้ำกัน แต่ score ซ้ำได้ member ที่ score เท่ากันเรียงตาม byte ของชื่อ (แสดงด้านล่าง)

ZADD key [NX|XX] [GT|LT] [CH] score member [score member ...] เพิ่ม member ถ้า member มีอยู่แล้ว score จะ ถูกอัปเดต reply นับเฉพาะ member ใหม่ เว้นแต่ใส่ CH (changed) เพื่อนับการอัปเดตด้วย

cli/sets-sorted-sets-sorted-sets-1.redis
127.0.0.1:6379> ZADD game:scores 4200 "alice" 3800 "bob" 5100 "carol"
(integer) 3
127.0.0.1:6379> ZADD game:scores 4700 "alice"
(integer) 0
127.0.0.1:6379> ZSCORE game:scores "alice"
"4700"
127.0.0.1:6379> ZADD game:scores NX 1 "alice" 100 "dave"
(integer) 1
127.0.0.1:6379> ZADD game:scores XX 9999 "erin"
(integer) 0
127.0.0.1:6379> ZADD game:scores GT 4000 "alice"
(integer) 0
127.0.0.1:6379> ZADD game:scores GT CH 6000 "alice"
(integer) 1
127.0.0.1:6379> ZADD game:scores LT CH 5000 "alice"
(integer) 1
127.0.0.1:6379> ZRANGE game:scores 0 -1 WITHSCORES
1) "dave"
2) "100"
3) "bob"
4) "3800"
5) "alice"
6) "5000"
7) "carol"
8) "5100"

อ่านทีละบรรทัด:

  • ZADD ... 4700 "alice" อัปเดต member เดิม reply จึงเป็น 0 member ใหม่ และ ZSCORE แสดง "4700" ซึ่งเป็น string
  • NX = เพิ่มเฉพาะ 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"

ตั้งแต่ 6.2 คำสั่งเดียวทำได้ทุก range: ZRANGE key start stop [BYSCORE|BYLEX] [REV] [LIMIT offset count] [WITHSCORES] REV กลับลำดับ (สูงสุดก่อน); เมื่อใช้ BYSCORE ขอบเขตคือ score และเมื่อรวมกับ REV ต้องส่ง ค่าสูงก่อน เครื่องหมาย ( นำหน้าทำให้ขอบเขตไม่รวมค่านั้น

cli/sets-sorted-sets-sorted-sets-2.redis
127.0.0.1:6379> ZRANGE game:scores 0 2 REV WITHSCORES
1) "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 WITHSCORES
1) "alice"
2) "5000"
3) "carol"
4) "5100"
127.0.0.1:6379> ZRANGE game:scores "(4000" +inf BYSCORE
1) "alice"
2) "carol"
127.0.0.1:6379> ZRANGE game:scores +inf 4000 BYSCORE REV WITHSCORES
1) "carol"
2) "5100"
3) "alice"
4) "5000"
127.0.0.1:6379> ZRANGESTORE top2 game:scores 0 1 REV
(integer) 2
127.0.0.1:6379> ZRANGE top2 0 -1 WITHSCORES
1) "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 WITHSCORES
ZADD temps 18.5 "london" 32.1 "bangkok" 24.0 "berlin" 28.7 "tokyo"
ZRANGE temps 20 30 BYSCORE WITHSCORES

ZRANK key member [WITHSCORE] คืน rank แบบ 0-based จากน้อยไปมาก; ZREVRANK คืน rank จากมากไปน้อย (score สูงสุด = rank 0) WITHSCORE (7.2+) คืน [rank, score] ในการเรียกเดียว member ที่ไม่มีคืน (nil)

cli/sets-sorted-sets-sorted-sets-3.redis
127.0.0.1:6379> ZRANK game:scores "alice"
(integer) 2
127.0.0.1:6379> ZRANK game:scores "alice" WITHSCORE
1) (integer) 2
2) "5000"
127.0.0.1:6379> ZREVRANK game:scores "alice"
(integer) 1
127.0.0.1:6379> ZREVRANK game:scores "alice" WITHSCORE
1) (integer) 1
2) "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)

cli/sets-sorted-sets-sorted-sets-4.redis
127.0.0.1:6379> ZADD ties 1 b 1 a 1 c 0 z
(integer) 4
127.0.0.1:6379> ZRANGE ties 0 -1 WITHSCORES
1) "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) 4
127.0.0.1:6379> ZRANGE ties2 0 -1
1) "10"
2) "9"
3) "B"
4) "a"
127.0.0.1:6379> ZADD big 9007199254740993 x
(integer) 1
127.0.0.1:6379> ZSCORE big x
"9007199254740992"
127.0.0.1:6379> ZADD big 0.1 y
(integer) 1
127.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 float

score ที่ไม่ใช่ตัวเลขถูกปฏิเสธ (ERR value is not a valid float) ถ้าต้องจัดอันดับด้วยจำนวนเต็มใหญ่ เช่น timestamp ระดับไมโครวินาที ให้อยู่ต่ำกว่า 2^53 หรือแยกค่า; บท leaderboards เข้ารหัสตัวตัดสินเสมอไว้ใน score โดยไม่ข้ามเส้นนั้น

ZINCRBY key increment member บวกเข้า score ของ member แบบ atomic (สร้างที่ increment ถ้ายังไม่มี) ZCARD key นับ member, ZREM key member [member ...] ลบและคืนจำนวนที่มีอยู่

cli/sets-sorted-sets-sorted-sets-5.redis
127.0.0.1:6379> ZADD votes 10 "post:1" 5 "post:2" 8 "post:3"
(integer) 3
127.0.0.1:6379> ZINCRBY votes 3 "post:2"
"8"
127.0.0.1:6379> ZCARD votes
(integer) 3
127.0.0.1:6379> ZREM votes "post:3"
(integer) 1
127.0.0.1:6379> ZCARD votes
(integer) 2
ZADD votes 10 "post:1" 5 "post:2" 8 "post:3"
ZINCRBY votes 3 "post:2"
ZCARD votes
ZREM votes "post:3"
ZCARD votes

ioredis คืน ZRANGE ... WITHSCORES เป็น flat array [member, score, member, score, ...] โดย score เป็น string สคริปต์ยังแข่งการโหวตพร้อมกัน 50 ครั้งสองแบบ:

src/sets-sorted-sets-sorted-sets-scores.ts
// @run
import { 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 strings
const flat = await redis.zrange(key, 0, '1', 'REV', 'WITHSCORES'); // ioredis types want stop as a string
console.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 2
console.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 member
console.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 member
const 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 50
console.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' ]
0
1
[ 2, '6000' ]
null
read-modify-write ZADD: 1
ZINCRBY: 50

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);
}

Sorted Set คือ index ที่คุณดูแลเอง: score คือ sort key, member คือ row id และ Redis รักษาลำดับให้ อะไรก็ตามที่พูดได้ว่า “ตัวเลขหนึ่งตัว เรียงน้อยไปมาก” (คะแนน, timestamp, ความสำคัญ, ระยะทาง) กลายเป็น range query ได้ เลือกตัวเลขให้ดี: ต้องพอดีกับ double, เสมอกันจะกลับไปเรียงตาม byte และการเปลี่ยนมันหมายถึงอัปเดตทุก member

ตัวเลือกข้อดีข้อเสีย
Sorted Set แทนตาราง SQL ที่ ORDER BYinsert และอ่าน range แบบ O(log N) ไม่ต้องดูแล indexscore เป็น float64 ตัวเดียว: ไม่มีการเรียงหลายคอลัมน์ และจำนวนเต็มเกิน 2^53 ไม่แม่น
ZRANGE ... BYSCORE สำหรับ range queryดึงช่วง score ได้โดยไม่ต้องสแกนต้องออกแบบ score ล่วงหน้า; การเปลี่ยนหมายถึง re-score
ZINCRBY สำหรับ counteratomic, round trip เดียวบวกอย่างเดียว; ถ้าต้อง compare-and-set ใช้ flag GT/LT หรือ script
ลำดับเมื่อเสมอ = byte ของ memberแน่นอนไม่ใช่ลำดับการใส่ และ "10" มาก่อน "9"
  • “ZADD กับ member ที่มีอยู่สร้างตัวซ้ำ” มันอัปเดต score และ reply 0 (หรือ 1 เมื่อใส่ CH)
  • “ZRANGE เรียงน้อยไปมากตาม rank เท่านั้น” ตั้งแต่ 6.2 มัน รับ REV, BYSCORE, BYLEX และ LIMIT; คำสั่ง ZREV*/ZRANGEBY* แยกต่างหาก deprecated แล้ว
  • “score เท่ากันเรียงตามเวลาที่ใส่” เรียงตาม byte ของ member
  • “score เก็บจำนวนเต็ม 64 บิตได้ทุกค่า” เป็น double: แม่นยำถึง 2^53 เท่านั้น
  • “ZREVRANK ก็ deprecated” ไม่ใช่ และรองรับ WITHSCORE ด้วย
  • 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)

จะดึง 3 อันดับแรก คะแนนสูงสุดก่อน ด้วยคำสั่งปัจจุบัน (ไม่ deprecated) ทำอย่างไร?
ZRANK คืนอะไรสำหรับ member?
เกิดอะไรขึ้นเมื่อ ZADD score ให้ member ที่มีอยู่แล้ว (ไม่มี flag)?
client 50 ตัวต่างทำ ZSCORE, บวก 1 ใน Node แล้ว ZADD score สุดท้ายคือ?
สาม member มี score 1: b, a, c ZRANGE เรียงอย่างไร?
คุณเก็บ score 9007199254740993 (2^53 + 1) ZSCORE คืนอะไร?