Leaderboards
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ# cli/...คือ session เดียวเรียงตามเอกสารที่ harness ของคอร์สรันซ้ำ ใช้ZRANGE ... REV(ตระกูลZREVRANGEdeprecated ตั้งแต่ 6.2 ดู Sorted Sets)
รูปแบบ leaderboard
หัวข้อที่มีชื่อว่า “รูปแบบ leaderboard”leaderboard ของเกมหรือแอปต้องการ: เก็บคะแนน, จัดอันดับผู้เล่น, เพิ่มคะแนนตามเวลา, แสดงเป็นหน้าหรือ “รอบตัวฉัน” และ สรุปตามช่วงเวลา Sorted Set ครอบคลุมทั้งหมดด้วยการเขียนแบบ O(log N) และอ่าน range แบบ O(log N + M) บทนี้คือ session เดียวที่ทำงานได้จริงตั้งแต่ฐานข้อมูลว่าง
ขั้นที่ 1 — เพิ่มคะแนนและดึง top N
หัวข้อที่มีชื่อว่า “ขั้นที่ 1 — เพิ่มคะแนนและดึง top N”score เป็น float64 จึงเป็นคะแนน, เปอร์เซ็นต์ หรือค่าที่เข้ารหัสได้ ZRANGE key 0 9 REV WITHSCORES คือ top 10: index คือ offset ของ rank 0 9 หมายถึงอันดับ 1 ถึง 10
127.0.0.1:6379> ZADD game:lb 3200 "alice" 4100 "bob" 1750 "carol" 5800 "dave" 2900 "eve"(integer) 5127.0.0.1:6379> ZRANGE game:lb 0 9 REV WITHSCORES 1) "dave" 2) "5800" 3) "bob" 4) "4100" 5) "alice" 6) "3200" 7) "eve" 8) "2900" 9) "carol"10) "1750"127.0.0.1:6379> ZCARD game:lb(integer) 5ZADD game:lb 3200 "alice" 4100 "bob" 1750 "carol" 5800 "dave" 2900 "eve"
ZRANGE game:lb 0 9 REV WITHSCORESขั้นที่ 2 — ค้นหา rank และให้คะแนน
หัวข้อที่มีชื่อว่า “ขั้นที่ 2 — ค้นหา rank และให้คะแนน”ZREVRANK key member [WITHSCORE] คืนอันดับแบบ 0-based จากบนสุด (0 = ที่หนึ่ง) บวก 1 เพื่อแสดงผล ZINCRBY บวกคะแนนแบบ atomic จึงไม่ต้อง read-modify-write และผู้เขียนพร้อมกันไม่ทำคะแนนหาย (บท Sorted Sets วัดได้ 50 จาก 50 เทียบกับ 1 จาก 50) rank เปลี่ยนทันที:
127.0.0.1:6379> ZREVRANK game:lb "alice" WITHSCORE1) (integer) 22) "3200"127.0.0.1:6379> ZREVRANK game:lb "carol"(integer) 4127.0.0.1:6379> ZINCRBY game:lb 1500 "carol""3250"127.0.0.1:6379> ZINCRBY game:lb 800 "alice""4000"127.0.0.1:6379> ZREVRANK game:lb "carol"(integer) 3127.0.0.1:6379> ZRANGE game:lb 0 4 REV WITHSCORES 1) "dave" 2) "5800" 3) "bob" 4) "4100" 5) "alice" 6) "4000" 7) "carol" 8) "3250" 9) "eve"10) "2900"127.0.0.1:6379> ZRANK game:lb "dave"(integer) 4127.0.0.1:6379> ZREVRANK game:lb "dave"(integer) 0สังเกตสองบรรทัดสุดท้าย: ZRANK นับจาก score ต่ำสุด ผู้เล่นอันดับหนึ่ง dave จึงเป็น rank 4 ที่นั่น และ 0 กับ ZREVRANK การใช้ผิดตัวคือ bug ผิดทิศทางแบบคลาสสิก (ดูแบบฝึกหัดด้านล่าง)
ZADD game:lb 3200 "alice" 4100 "bob" 1750 "carol" 5800 "dave" 2900 "eve"
ZREVRANK game:lb "carol"
ZINCRBY game:lb 1500 "carol"
ZREVRANK game:lb "carol"ขั้นที่ 3 — หน้าและ “รอบตัวฉัน”
หัวข้อที่มีชื่อว่า “ขั้นที่ 3 — หน้าและ “รอบตัวฉัน””การแบ่งหน้าคือเลขคณิตบน rank: หน้า p ขนาด s คือ ZRANGE key p*s p*s+s-1 REV WITHSCORES เพื่อนบ้านของผู้เล่นคือ rank r-1 .. r+1 โดย r มาจาก ZREVRANK หน้าที่เกินปลายแค่สั้นลงหรือว่าง และผู้เล่นที่ไม่รู้จักไม่มี rank (nil)
127.0.0.1:6379> ZRANGE game:lb 0 1 REV WITHSCORES1) "dave"2) "5800"3) "bob"4) "4100"127.0.0.1:6379> ZRANGE game:lb 2 3 REV WITHSCORES1) "alice"2) "4000"3) "carol"4) "3250"127.0.0.1:6379> ZRANGE game:lb 4 5 REV WITHSCORES1) "eve"2) "2900"127.0.0.1:6379> ZREVRANK game:lb "eve"(integer) 4127.0.0.1:6379> ZRANGE game:lb 3 5 REV WITHSCORES1) "carol"2) "3250"3) "eve"4) "2900"127.0.0.1:6379> ZREVRANK game:lb "ghost"(nil)การหา rank เป็น O(log N) (skiplist เก็บ span ไว้) หน้าที่ลึกจึงมีต้นทุน O(log N + ขนาดหน้า) หน้าตาม rank เลื่อนได้เมื่อผู้เล่นแซงกันระหว่าง request; ถ้าต้องการ feed ที่เสถียรให้แบ่งหน้าตาม score (BYSCORE โดยใช้ score สุดท้ายที่เห็นเป็นขอบเขตถัดไป) เพราะ BYSCORE ... LIMIT offset count เดินผ่าน offset รายการจริง
ZADD game:lb 5800 "dave" 4100 "bob" 4000 "alice" 3250 "carol" 2900 "eve"
ZRANGE game:lb 0 1 REV WITHSCORES
ZRANGE game:lb 2 3 REV WITHSCORES
ZREVRANK game:lb "eve"ขั้นที่ 4 — เสมอกัน: ใครได้อันดับสูงกว่าเมื่อคะแนนเท่ากัน?
หัวข้อที่มีชื่อว่า “ขั้นที่ 4 — เสมอกัน: ใครได้อันดับสูงกว่าเมื่อคะแนนเท่ากัน?”กับ score ที่เป็นคะแนนล้วนๆ score ที่เท่ากันจะย้อนไปเทียบ byte ของ member และเมื่อใช้ REV ลำดับนั้น กลับด้าน: bob มาก่อน alice ซึ่งเป็นค่าตามอำเภอใจจากมุมมองผู้เล่น เพื่อให้ “ถึงคะแนนก่อนชนะ” ให้ใส่เวลาในหลักล่างของ score:
score = points * 1e9 + (1e9 - 1 - secondsSinceLaunch)
คะแนนสูงชนะ; เมื่อคะแนนเท่ากัน เวลาที่น้อยกว่าให้ส่วนล่างมากกว่า ผู้เล่นที่มาก่อนจึงอยู่สูงกว่า ให้คะแนนภายหลังด้วย ZINCRBY key (delta * 1e9) member ซึ่งไม่แตะส่วนล่าง (ตัวตัดสินคือ “ใครขึ้นกระดานก่อน” ไม่ใช่ “ใครถึงคะแนนนี้เป๊ะก่อน” ซึ่งอย่างหลังต้องใช้ script) ถอดรหัสคะแนนด้วย Math.floor(score / 1e9)
127.0.0.1:6379> ZADD ties:plain 3200 alice 3200 bob(integer) 2127.0.0.1:6379> ZRANGE ties:plain 0 -1 REV1) "bob"2) "alice"127.0.0.1:6379> ZADD ties:fixed 3200999999899 alice 3200999999949 bob(integer) 2127.0.0.1:6379> ZRANGE ties:fixed 0 -1 REV WITHSCORES1) "bob"2) "3200999999949"3) "alice"4) "3200999999899"127.0.0.1:6379> ZINCRBY ties:fixed 500000000000 alice"3700999999899"127.0.0.1:6379> ZRANGE ties:fixed 0 -1 REV WITHSCORES1) "alice"2) "3700999999899"3) "bob"4) "3200999999949"งบประมาณคือ 53 บิตที่แม่นยำของ double: points * 1e9 แม่นยำตราบที่ points < 9,000,000 (9e6 * 1e9 = 9e15 < 2^53 = 9.007e15) เกินนั้นต้องลดส่วนเวลา (เช่นใช้นาทีแทนวินาที) ไม่เช่นนั้นการเข้ารหัสจะปัดเศษเงียบๆ
ขั้นที่ 5 — คะแนนรายวันรวมเป็นรายสัปดาห์
หัวข้อที่มีชื่อว่า “ขั้นที่ 5 — คะแนนรายวันรวมเป็นรายสัปดาห์”เก็บ Sorted Set หนึ่งอันต่อช่วงเวลาและรวมด้วย ZUNIONSTORE dest numkeys key... [WEIGHTS w...] [AGGREGATE SUM|MIN|MAX] ค่าเริ่มต้นรวม score ของ member ข้ามทุก set; WEIGHTS คูณแต่ละอินพุต; AGGREGATE MAX เก็บวันที่ดีที่สุดวันเดียว ผลลัพธ์เป็น Sorted Set ปกติ (snapshot) จึงควรตั้ง TTL
127.0.0.1:6379> ZADD lb:day:1 100 alice 50 bob(integer) 2127.0.0.1:6379> ZADD lb:day:2 30 alice 80 bob 10 carol(integer) 3127.0.0.1:6379> ZADD lb:day:3 20 carol 40 bob(integer) 2127.0.0.1:6379> ZUNIONSTORE lb:week 3 lb:day:1 lb:day:2 lb:day:3(integer) 3127.0.0.1:6379> ZRANGE lb:week 0 -1 REV WITHSCORES1) "bob"2) "170"3) "alice"4) "130"5) "carol"6) "30"127.0.0.1:6379> ZUNIONSTORE lb:week:w 3 lb:day:1 lb:day:2 lb:day:3 WEIGHTS 1 1 2(integer) 3127.0.0.1:6379> ZRANGE lb:week:w 0 -1 REV WITHSCORES1) "bob"2) "210"3) "alice"4) "130"5) "carol"6) "50"127.0.0.1:6379> EXPIRE lb:week 604800(integer) 1127.0.0.1:6379> ZUNIONSTORE lb:max 3 lb:day:1 lb:day:2 lb:day:3 AGGREGATE MAX(integer) 3127.0.0.1:6379> ZRANGE lb:max 0 -1 REV WITHSCORES1) "alice"2) "100"3) "bob"4) "80"5) "carol"6) "20"weight 1 1 2 ทำให้วันที่ 3 นับสองเท่า: bob จาก 170 เป็น 210 (50 + 80 + 40 * 2) ZUNIONSTORE เป็น O(N) + O(M log M) ตามขนาดอินพุต ดังนั้นสร้างกระดานรายสัปดาห์ตามตารางเวลา ไม่ใช่ทุก request
ZADD lb:day:1 100 alice 50 bob
ZADD lb:day:2 30 alice 80 bob 10 carol
ZUNIONSTORE lb:week 2 lb:day:1 lb:day:2
ZRANGE lb:week 0 -1 REV WITHSCORESขั้นที่ 6 — จำกัดขนาด
หัวข้อที่มีชื่อว่า “ขั้นที่ 6 — จำกัดขนาด”กระดานไม่ค่อยต้องการประชากรทั้งหมด ZREMRANGEBYRANK key 0 -(N+1) ทิ้งทุกอย่างยกเว้น top N (rank นับจาก score ต่ำสุด 0 -4 จึงลบทั้งหมดยกเว้น 3 อันดับที่ดีที่สุด):
127.0.0.1:6379> ZREMRANGEBYRANK game:lb 0 -4(integer) 2127.0.0.1:6379> ZRANGE game:lb 0 -1 REV WITHSCORES1) "dave"2) "5800"3) "bob"4) "4100"5) "alice"6) "4000"จาก Node.js: top-N พร้อมรูปร่างผลลัพธ์, รอบตัวฉัน และการรวมรายสัปดาห์
หัวข้อที่มีชื่อว่า “จาก Node.js: top-N พร้อมรูปร่างผลลัพธ์, รอบตัวฉัน และการรวมรายสัปดาห์”// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const lb = 'b2:lb:game';await redis.del(lb, 'b2:lb:d1', 'b2:lb:d2', 'b2:lb:week');
// score = points * 1e9 + (1e9 - 1 - secondsSinceLaunch): equal points, earlier player ranks higher.// stays exact while points < 9,000,000 (9e6 * 1e9 < 2^53)const SCALE = 1e9;const encode = (points: number, secs: number) => points * SCALE + (SCALE - 1 - secs);const decode = (score: string) => Math.floor(Number(score) / SCALE);
await redis.zadd(lb, encode(3200, 100), 'alice', encode(3200, 50), 'bob', encode(1750, 10), 'carol', encode(5800, 200), 'dave', encode(2900, 300), 'eve');
// award points: ZINCRBY by points * SCALE keeps the tie-break part untouched and stays atomicawait redis.zincrby(lb, 1500 * SCALE, 'carol');
// top N: FLAT array [member, score, ...] with string scoresasync function top(n: number) { const flat = await redis.zrange(lb, 0, String(n - 1), 'REV', 'WITHSCORES'); const rows: { rank: number; player: string; points: number }[] = []; for (let i = 0; i < flat.length; i += 2) rows.push({ rank: i / 2 + 1, player: flat[i]!, points: decode(flat[i + 1]!) }); return rows;}console.log(await top(3));
// "around me": my rank is 0-based, the window is ranks r-1 .. r+1const r = (await redis.zrevrank(lb, 'alice'))!;console.log('alice is rank', r + 1, 'window:', await redis.zrange(lb, Math.max(0, r - 1), String(r + 1), 'REV'));
// daily -> weekly: ZUNIONSTORE sums the scores per member; weights make today count doubleawait redis.zadd('b2:lb:d1', 100, 'alice', 50, 'bob');await redis.zadd('b2:lb:d2', 30, 'alice', 80, 'bob', 10, 'carol');console.log(await redis.zunionstore('b2:lb:week', 2, 'b2:lb:d1', 'b2:lb:d2', 'WEIGHTS', 1, 2)); // 3 membersconsole.log(await redis.zrange('b2:lb:week', 0, '-1', 'REV', 'WITHSCORES')); // bob 210, alice 160, carol 20
await redis.del(lb, 'b2:lb:d1', 'b2:lb:d2', 'b2:lb:week');await redis.quit();Output จริง (Mac เครื่องนี้): top 3 เป็นออบเจ็กต์ { rank, player, points } (dave 5800, carol 3250, bob 3200), alice is rank 4 window: [ 'bob', 'alice', 'eve' ], 3 และ [ 'bob', '210', 'alice', '160', 'carol', '20' ] alice กับ bob มี 3200 คะแนนเท่ากัน และ bob (มาก่อน 50 วินาที) อยู่สูงกว่าเธอ (100 วินาที) ioredis คืน WITHSCORES เป็น flat array ของ string ลูปจึงก้าวทีละ 2 และแปลงค่า
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI เขียนป้าย “อันดับของคุณ” สำหรับ HUD ของเกม:
🐛 หา bug ในโค้ดนี้
async function myRank(player: string) {const rank = await redis.zrank('game:lb', player); // "position on the board"return rank === null ? 'unranked' : `#${rank}`;}เฉลย
ผิดสองอย่างในบรรทัดเดียว ZRANK นับจาก score ต่ำสุด ผู้เล่นที่ดีที่สุดจึงอยู่ที่สุดท้าย (ใน transcript dave 5800 คะแนนคือ ZRANK 4 และ ZREVRANK 0); และทั้งคู่เป็น 0-based แม้คำสั่งที่ถูกก็จะแสดงผู้ชนะเป็น #0 ใช้ ZREVRANK แล้วบวก 1:
async function myRank(player: string) { const rank = await redis.zrevrank('game:lb', player); return rank === null ? 'unranked' : `#${rank + 1}`;}sets-sorted-sets-leaderboards-top.ts ที่รันได้ข้างบนรายงาน alice is rank 4 ด้วยวิธีนี้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”leaderboard คือ ตัวเลขหนึ่งตัวต่อผู้เล่น ที่ฐานข้อมูลเรียงให้ และทุกหน้าจอคือ slice ของ rank: top คือ rank 0..N-1, หน้าคือหน้าต่าง, “รอบตัวฉัน” คือหน้าต่างที่มี ZREVRANK เป็นศูนย์กลาง อะไรก็ตามที่คุณต้องการจัดอันดับ (เสมอ, เวลา, หลายเกณฑ์) ต้องถูกพับเข้าตัวเลขตัวเดียวนั้นภายใน 53 บิต
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
| Sorted Set สำหรับ leaderboard | จัดอันดับ real-time, เขียนและหา rank แบบ O(log N), ไม่มี ORDER BY | score เป็น float64 ตัวเดียว: ต้องเข้ารหัสการเสมอและเกณฑ์รอง |
ZINCRBY สำหรับให้คะแนน | atomic ปลอดภัยกับผู้เขียนหลายคน | ไม่มีประวัติว่าใครให้อะไร; เก็บ event แยกถ้าต้องการ audit |
| ตัวตัดสินเสมอที่เข้ารหัสใน score | ”มาก่อนชนะ” แน่นอนโดยไม่ต้องค้นเพิ่ม | จำกัดช่วงคะแนน (< 9,000,000 กับส่วนเวลา 1e9) และเปลี่ยนสิ่งที่แสดง (ต้องถอดรหัส) |
| แบ่งหน้าตาม rank | เลขคณิตง่าย, O(log N + หน้า) | หน้าเลื่อนเมื่อผู้เล่นแซงกันระหว่าง request |
รวมรายสัปดาห์ด้วย ZUNIONSTORE | ใช้กระดานรายวันซ้ำ, weights และ MAX ฟรี | O(N) + O(M log M) รันตามตารางเวลาและตั้ง TTL ให้ผลลัพธ์ |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
ZRANKคืน rank ของผู้เล่นที่ดีที่สุด” มันนับจาก score ต่ำสุด;ZREVRANKคือ rank ของ leaderboard - “rank เริ่มที่ 1” ทั้งคู่เป็น 0-based บวก 1 เพื่อแสดงผล
- “ใครทำคะแนนก่อนชนะเมื่อเสมอ” เรียงตาม byte ของ member (กลับด้านเมื่อใช้
REV) เว้นแต่คุณเข้ารหัสเวลาเอง - “ใส่ timestamp ใน score ไม่มีต้นทุน” มันใช้บิตจาก 53 บิตที่คุณมี;
points * 1e9พังเมื่อเกิน 9 ล้านคะแนน
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- ใช้
ZADDตอนให้คะแนน — มันเขียนทับ ใช้ZINCRBY - ดึงทั้งกระดานแล้วตัดในแอป — ขอ
ZRANGE start stop REV - ลืมว่าตัวตัดสินเสมออยู่ใน score — ถอดรหัสก่อนแสดงและเพิ่มด้วย
delta * 1e9 - ไม่เคยตัดแต่ง — กระดานที่ไม่จำกัดโตไม่สิ้นสุด ใช้
ZREMRANGEBYRANKหลังเขียนหรือตามตารางเวลา
Real-world example
เอกสารของ Redis เอง แสดง leaderboard เป็นกรณีใช้งานหลักของ Sorted Set และให้คำสั่งจัดอันดับที่ใช้ในบทนี้ (Sorted sets)