Lists
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) ตัวเลขเวลาเป็นไมโครวินาทีฝั่งเซิร์ฟเวอร์จากSLOWLOG(threshold 0) บนเครื่องนี้ ถือเป็นระดับขนาด ไม่ใช่ benchmark
Redis List คืออะไร?
หัวข้อที่มีชื่อว่า “Redis List คืออะไร?”Redis List คือคอลเลกชันของ string ที่เรียงลำดับ การ push และ pop ที่ปลายด้านใดด้านหนึ่งเป็น O(1) จึงเหมาะกับ queue, stack และ activity feed การเข้าไปกลางลิสต์แพงกว่า ดังนั้น list เหมาะเมื่อคุณทำงานที่หัวหรือท้ายเป็นหลัก
เก็บอย่างไร? เอกสารเก่า (รวมถึงบทนี้ในเวอร์ชันก่อน) บอกว่า “doubly linked list” บน 8.10.2 นั่นจริงแค่ครึ่งเดียว list เล็กคือบล็อกกะทัดรัดก้อนเดียวที่เรียก listpack; list ใหญ่คือ quicklist ซึ่งเป็น linked list ที่แต่ละ node เป็น listpack:
127.0.0.1:6379> RPUSH tasks "buy milk"(integer) 1127.0.0.1:6379> OBJECT ENCODING tasks"listpack"การสลับขับเคลื่อนด้วย list-max-listpack-size (ค่าเริ่มต้น -2 หมายถึงแต่ละ node เก็บได้มากสุด 8 KB) ผม push item ขนาด 30 byte ทีละตัวและถาม encoding หลังทุกครั้ง:
after 1 items of 30 bytes: listpack, MEMORY USAGE 62 bytesafter 256 items of 30 bytes: quicklist, MEMORY USAGE 8357 bytes2 items x 100 bytes -> listpack+ one 9000-byte item -> quicklist (a single large element also forces it)ดังนั้นเส้นแบ่งคือ ขนาด (≈ 8 KB) ไม่ใช่จำนวน คุณไม่ได้เลือก encoding แต่มันอธิบายตัวเลขประสิทธิภาพด้านล่าง: element ถูกหาโดยเดินผ่าน node และมี node ไม่มาก
LPUSH และ RPUSH
หัวข้อที่มีชื่อว่า “LPUSH และ RPUSH”LPUSH key val [val ...] เติมค่าหนึ่งตัวหรือมากกว่าที่หัวของ list เมื่อ push หลายค่าพร้อมกัน argument ตัวสุดท้ายจะอยู่ที่หัว — แต่ละค่าถูก push ทีละตัวจากซ้ายไปขวา
RPUSH key val [val ...] ต่อท้ายค่าหนึ่งตัวหรือมากกว่าที่ท้าย
127.0.0.1:6379> RPUSH tasks "write code" "review PR"(integer) 3127.0.0.1:6379> LPUSH tasks "urgent: fix bug"(integer) 4127.0.0.1:6379> LPUSH order a b c(integer) 3127.0.0.1:6379> LRANGE order 0 -11) "c"2) "b"3) "a"127.0.0.1:6379> LPUSHX nolist a(integer) 0127.0.0.1:6379> EXISTS nolist(integer) 0LPUSH order a b c ให้ c b a — พิสูจน์ว่าค่าถูก push ทีละตัว ตัวสุดท้ายจึงอยู่ที่หัว LPUSHX/RPUSHX push เฉพาะเมื่อ list มีอยู่แล้ว (และคืน 0 ถ้าไม่มี) สังเกตว่า list ใน Redis ไม่เคย “ว่าง”: list ที่มี element ศูนย์ตัว ไม่มีอยู่ — Redis ลบ key เมื่อ pop element สุดท้าย
RPUSH tasks "buy milk"
RPUSH tasks "write code" "review PR"
LPUSH tasks "urgent: fix bug"
LRANGE tasks 0 -1
LLEN tasks
LINDEX tasks 0
LINDEX tasks -1LRANGE, LLEN และ LINDEX
หัวข้อที่มีชื่อว่า “LRANGE, LLEN และ LINDEX”เมื่อมี list แล้ว สามคำสั่งนี้ครอบคลุมรูปแบบการอ่านส่วนใหญ่:
LRANGE key start stop— คืน element จาก indexstartถึงstop(รวมทั้งสองปลาย, เริ่มที่ 0) ใช้-1สำหรับ element สุดท้าย,-2สำหรับตัวรองสุดท้าย และต่อไปเรื่อย ๆLLEN key— คืนจำนวน element ใน listLINDEX key index— คืน element เดียวที่ index ที่ระบุ index ติดลบนับจากท้าย; index เกินท้ายได้(nil)
127.0.0.1:6379> LRANGE tasks 0 -11) "urgent: fix bug"2) "buy milk"3) "write code"4) "review PR"127.0.0.1:6379> LLEN tasks(integer) 4127.0.0.1:6379> LINDEX tasks 0"urgent: fix bug"127.0.0.1:6379> LINDEX tasks -1"review PR"127.0.0.1:6379> LINDEX tasks 99(nil)LRANGE tasks 0 -1 คือวิธีมาตรฐานในการดึงทุก element ของ list ขนาดเล็ก
”O(N) ตรงกลาง” ช้าแค่ไหน?
หัวข้อที่มีชื่อว่า “”O(N) ตรงกลาง” ช้าแค่ไหน?”เอกสารระบุ LINDEX, LREM, LPOS และ LINSERT เป็น O(N) ผมสร้าง list 1,000,000 string (value-<n>, 14 MB, quicklist) และอ่านเวลาฝั่งเซิร์ฟเวอร์ของแต่ละคำสั่ง:
| คำสั่งบน list 1M element | เวลาฝั่งเซิร์ฟเวอร์ |
|---|---|
LINDEX list 500000 (ตรงกลาง) | 15 µs |
LRANGE list 500000 500099 | 8 µs |
RPUSH / LPOP ที่ปลาย | 3 µs / 9 µs |
LREM list 0 nothere (สแกนเต็ม) | 13,092 µs (13 ms) |
LPOS list nothere (สแกนเต็ม) | 13,152 µs |
คำเตือนคลาสสิกจึงเป็นจริงแต่ต้องคมขึ้น การกระโดดไป index ถูกที่นี่ เพราะเดินผ่านไม่กี่ร้อย node ไม่ใช่ล้าน pointer สิ่งที่เจ็บคืออะไรก็ตามที่ต้อง ดูทุก element — LREM, LPOS, LINSERT ... pivot — เซิร์ฟเวอร์ค้าง 13 ms ที่หนึ่งล้าน element และเพิ่มเป็นเส้นตรง และกับดักที่ ใหญ่ที่สุด คือ LRANGE key 0 -1 บน list ใหญ่:
LRANGE b1:big 0 -1 server 35,370 us client: 1,000,000 items in 490 msLRANGE b1:big 0 19 server 8 us client: 20 items in 0.8 msLSET, LREM, LPOS และ LINSERT
หัวข้อที่มีชื่อว่า “LSET, LREM, LPOS และ LINSERT”LSET key index value เขียนทับ element ที่ index ที่ระบุ list ต้องมี element ที่ตำแหน่งนั้นอยู่แล้ว ไม่เช่นนั้น Redis คืน error
LREM key count value ลบการปรากฏของ value:
count > 0— ลบสูงสุดcountตัว สแกนจากหัวไปท้ายcount < 0— ลบสูงสุด|count|ตัว สแกนจากท้ายไปหัวcount = 0— ลบ ทุก ตัว
LPOS หา index ของค่า (6.0.6+), RANK ข้ามตัวที่ตรง, COUNT 0 คืนทั้งหมด; LINSERT แทรกโดยอ้างอิงค่า pivot:
127.0.0.1:6379> LSET tasks 1 "buy oat milk"OK127.0.0.1:6379> LSET tasks 10 "nope"(error) ERR index out of range127.0.0.1:6379> LRANGE tasks 0 -11) "urgent: fix bug"2) "buy oat milk"3) "write code"4) "review PR"127.0.0.1:6379> RPUSH tasks "write code"(integer) 5127.0.0.1:6379> LREM tasks 0 "write code"(integer) 2127.0.0.1:6379> LPOS tasks "review PR"(integer) 2127.0.0.1:6379> LPOS tasks "nothere"(nil)127.0.0.1:6379> RPUSH dup a b a c a(integer) 5127.0.0.1:6379> LPOS dup a(integer) 0127.0.0.1:6379> LPOS dup a RANK 2(integer) 2127.0.0.1:6379> LPOS dup a COUNT 01) (integer) 02) (integer) 23) (integer) 4127.0.0.1:6379> LINSERT tasks BEFORE "review PR" "test it"(integer) 4127.0.0.1:6379> LINSERT tasks AFTER "nothere" "x"(integer) -1127.0.0.1:6379> LRANGE tasks 0 -11) "urgent: fix bug"2) "buy oat milk"3) "test it"4) "review PR"LREM tasks 0 "write code" ลบไป 2 ตัว (มีสองสำเนา) LINSERT คืนความยาวใหม่ หรือ -1 ถ้าไม่พบ pivot (ไม่ใช่ error พลาดได้ง่าย)
RPUSH tasks "buy milk"
RPUSH tasks "write code" "review PR"
LPUSH tasks "urgent: fix bug"
LSET tasks 1 "buy oat milk"
LRANGE tasks 0 -1
RPUSH tasks "write code"
LREM tasks 0 "write code"
LRANGE tasks 0 -1LTRIM — เก็บเฉพาะช่วงที่ต้องการ
หัวข้อที่มีชื่อว่า “LTRIM — เก็บเฉพาะช่วงที่ต้องการ”LTRIM key start stop แก้ list ในที่เดิม เก็บเฉพาะ element ใน [start, stop] และทิ้งทุกอย่างนอกช่วง คืน OK ทำให้ปลอดภัยที่จะเรียกหลังทุกการเขียน ช่วงที่ไม่เหลืออะไรจะลบ key
127.0.0.1:6379> DEL recent(integer) 0127.0.0.1:6379> RPUSH recent "page-A" "page-B" "page-C" "page-D" "page-E"(integer) 5127.0.0.1:6379> LTRIM recent 0 2OK127.0.0.1:6379> LRANGE recent 0 -11) "page-A"2) "page-B"3) "page-C"127.0.0.1:6379> LTRIM recent 5 10OK127.0.0.1:6379> EXISTS recent(integer) 0DEL recent
RPUSH recent "page-A" "page-B" "page-C" "page-D" "page-E"
LRANGE recent 0 -1
LTRIM recent 0 2
LRANGE recent 0 -1LMPOP — pop จาก list แรกที่ไม่ว่าง (Redis 7.0+)
หัวข้อที่มีชื่อว่า “LMPOP — pop จาก list แรกที่ไม่ว่าง (Redis 7.0+)”LMPOP numkeys key [key ...] LEFT|RIGHT [COUNT n] pop สูงสุด n element จาก list แรกที่ไม่ว่างในบรรดา key ที่ระบุ และบอกว่าใช้ key ไหน — “หยิบงานจาก queue ไหนก็ได้ที่มี” ในคำสั่งเดียว:
127.0.0.1:6379> RPUSH q1 a b c(integer) 3127.0.0.1:6379> RPUSH q2 x y(integer) 2127.0.0.1:6379> LMPOP 2 q1 q2 LEFT COUNT 21) "q1"2) 1) "a" 2) "b"127.0.0.1:6379> LMPOP 2 q1 q2 RIGHT1) "q1"2) 1) "c"127.0.0.1:6379> LMPOP 2 q1 q2 LEFT1) "q2"2) 1) "x"127.0.0.1:6379> LMPOP 2 q1 q2 LEFT1) "q2"2) 1) "y"127.0.0.1:6379> EXISTS q1 q2(integer) 0จาก Node.js: feed ที่มีขอบเขต
หัวข้อที่มีชื่อว่า “จาก Node.js: feed ที่มีขอบเขต”คำสั่งเดียวกันผ่าน ioredis lpush + ltrim ใน multi(), หนึ่งหน้าคือ slice (lrange(key, 10, 19)) และ lmpop คืน [key, items] หรือ null:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);const key = 'b1:feed:user:42';await redis.del(key);
// bounded feed: newest first, keep the latest 50. LPUSH + LTRIM in one MULTI so a crash can't skip the trim.async function addEvent(event: string) { await redis.multi().lpush(key, event).ltrim(key, 0, 49).exec();}for (let i = 1; i <= 120; i++) await addEvent(`event-${i}`);console.log('length after 120 events:', await redis.llen(key)); // 50console.log('newest, oldest kept:', await redis.lindex(key, 0), await redis.lindex(key, -1)); // event-120 event-71
// a page is a slice, not the whole listconst page2 = await redis.lrange(key, 10, 19);console.log('page 2:', page2.length, 'items, first', page2[0]); // 10 items, first event-110
// LMPOP: take up to 2 items from the first non-empty list, or nullawait redis.del('b1:qa', 'b1:qb');await redis.rpush('b1:qb', 'job-1', 'job-2', 'job-3');console.log(await redis.lmpop(2, 'b1:qa', 'b1:qb', 'LEFT', 'COUNT', 2)); // [ 'b1:qb', [ 'job-1', 'job-2' ] ]console.log(await redis.lmpop(2, 'b1:qa', 'b1:qa2', 'LEFT')); // null
await redis.del(key, 'b1:qa', 'b1:qb');await redis.quit();output จริง: 50, event-120 event-71, 10 items, first event-110, [ 'b1:qb', [ 'job-1', 'job-2' ] ], null
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI สร้าง API “กิจกรรมล่าสุด” สำหรับหน้าโปรไฟล์ หน้าละ 20 รายการ:
🐛 หา bug ในโค้ดนี้
app.get('/activity', async (req, res) => {const page = Number(req.query.page ?? 0);const all = await redis.lrange('activity:user:42', 0, -1); // fetch everythingres.json(all.slice(page * 20, page * 20 + 20));});
// writer sideawait redis.lpush('activity:user:42', JSON.stringify(event)); // never trimmedเฉลย
สองปัญหาที่ส่งเสริมกัน ฝั่งเขียนไม่เคย trim list จึงโตไม่สิ้นสุด และฝั่งอ่านดาวน์โหลด list ทั้งหมด เพื่อแสดง 20 รายการ บน list 1,000,000 รายการเราวัด LRANGE 0 -1 ได้ command thread ค้าง 35 ms และ 490 ms ในการส่ง payload ไปยัง client — ต่อการเปิดหน้าหนึ่งครั้ง — เทียบกับ 8 µs และ 0.8 ms ของ LRANGE 0 19 วิธีแก้คือขอ slice จาก Redis และจำกัด list ตอนเขียน:
// reader: LRANGE with the page windowconst items = await redis.lrange('activity:user:42', page * 20, page * 20 + 19);// writer: newest first, keep the latest 500, one MULTIawait redis.multi().lpush('activity:user:42', JSON.stringify(event)).ltrim('activity:user:42', 0, 499).exec();เวอร์ชันที่มีขอบเขตและแบ่งหน้าคือ lists-hashes-lists-feed.ts ที่รันได้ข้างบน (เก็บ 50 รายการ, หน้า 2 = LRANGE 10 19, แสดง output แล้ว)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”list คือ คิวของ string ที่เปิดสองปลาย: การ push และ pop ที่ปลายใช้งานคงที่; การถามว่า “ตรงกลางคืออะไร?” ต้องเดิน และการถามว่า “มีอยู่ไหม?” (LREM, LPOS) ต้องดูทุกตัว ขนาดของมันเป็นความรับผิดชอบของคุณ: ไม่มีอะไร trim ให้ ออกแบบการดำเนินการให้เส้นทางที่ร้อนแตะเฉพาะปลายทั้งสองและหน้าต่างที่มีขอบเขต
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
| Redis List (listpack แล้ว quicklist ของ listpack) | push/pop ที่ปลายทั้งสองเป็น O(1) — เหมาะกับ queue, stack, feed; กะทัดรัดสำหรับ list เล็ก | การดำเนินการที่ดูทุก element (LREM, LPOS, LINSERT) เป็น O(N): 13 ms ที่ 1M element |
LPUSH + LTRIM สำหรับ feed รายการล่าสุด | คง list ขนาดคงที่อัตโนมัติ ไม่ต้องมี cleanup job แยก | ต้องเรียก LTRIM หลังทุก push (ใส่ทั้งคู่ใน MULTI) — ลืมแล้ว list โตไม่จำกัด |
LRANGE key 0 -1 | เรียกครั้งเดียวได้ทั้ง list | งาน O(N) และ byte O(N): 35 ms ฝั่งเซิร์ฟเวอร์ + 490 ms ในการส่งที่ 1M รายการ — แบ่งหน้าด้วย LRANGE start stop |
LREM เพื่อลบตามค่า | ลบ item ที่ไม่รู้ index | สแกนทั้ง list, O(N) — ไม่เหมาะกับ list ใหญ่ที่ลบบ่อย |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “Redis list คือ doubly linked list” list เล็กคือ listpack ก้อนเดียว; list ใหญ่คือ linked list ของ listpack (quicklist)
OBJECT ENCODINGบอกว่าเป็นแบบไหน - “
LINDEXช้า” ตรงกลางของ list 1M ใช้ 15 µs ตัวที่ช้าคือตัวที่สแกน:LREM,LPOS,LINSERT - “list ว่างยังมีอยู่” pop element สุดท้ายแล้ว key ถูกลบ;
EXISTSเป็น0 - “
LPUSH a b cให้a b c” ให้c b a
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- ดึงทั้ง list เพื่อแสดงหน้าเดียว — ใช้
LRANGE key start stopกับหน้าต่างของหน้า;LRANGE 0 -1บน list ใหญ่บล็อกเซิร์ฟเวอร์และท่วม client - ใช้
LPUSHสำหรับ activity feed โดยไม่มีLTRIMตาม — list โตไม่จำกัดและกินหน่วยความจำ ทั้งที่ต้องการแค่ N รายการล่าสุด - คาดหวังให้
LSETทำงานเหมือน “แทรกที่ index” — มันเขียนทับ element ที่มีอยู่ที่ตำแหน่งนั้นเท่านั้น ถ้า index ไม่มีจะได้ERR index out of rangeส่วนLINSERTแทรกโดยอ้างอิงค่า - ไม่สนใจ
-1ของLINSERT— pivot ที่หายไปไม่ใช่ error แค่ reply เป็น-1
Real-world example
เอกสารของ Redis เอง อธิบาย list ว่าเป็น type สำหรับ queue และคอลเลกชัน “รายการล่าสุด” แบบมีขอบเขต และแสดง
LPUSH+LTRIMเป็นสูตรเก็บเฉพาะ N รายการใหม่สุด (Redis lists)