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

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 คือคอลเลกชันของ string ที่เรียงลำดับ การ push และ pop ที่ปลายด้านใดด้านหนึ่งเป็น O(1) จึงเหมาะกับ queue, stack และ activity feed การเข้าไปกลางลิสต์แพงกว่า ดังนั้น list เหมาะเมื่อคุณทำงานที่หัวหรือท้ายเป็นหลัก

เก็บอย่างไร? เอกสารเก่า (รวมถึงบทนี้ในเวอร์ชันก่อน) บอกว่า “doubly linked list” บน 8.10.2 นั่นจริงแค่ครึ่งเดียว list เล็กคือบล็อกกะทัดรัดก้อนเดียวที่เรียก listpack; list ใหญ่คือ quicklist ซึ่งเป็น linked list ที่แต่ละ node เป็น listpack:

cli/lists-hashes-lists-1.redis
127.0.0.1:6379> RPUSH tasks "buy milk"
(integer) 1
127.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 bytes
after 256 items of 30 bytes: quicklist, MEMORY USAGE 8357 bytes
2 items x 100 bytes -> listpack
+ one 9000-byte item -> quicklist (a single large element also forces it)

ดังนั้นเส้นแบ่งคือ ขนาด (≈ 8 KB) ไม่ใช่จำนวน คุณไม่ได้เลือก encoding แต่มันอธิบายตัวเลขประสิทธิภาพด้านล่าง: element ถูกหาโดยเดินผ่าน node และมี node ไม่มาก

LPUSH key val [val ...] เติมค่าหนึ่งตัวหรือมากกว่าที่หัวของ list เมื่อ push หลายค่าพร้อมกัน argument ตัวสุดท้ายจะอยู่ที่หัว — แต่ละค่าถูก push ทีละตัวจากซ้ายไปขวา

RPUSH key val [val ...] ต่อท้ายค่าหนึ่งตัวหรือมากกว่าที่ท้าย

cli/lists-hashes-lists-2.redis
127.0.0.1:6379> RPUSH tasks "write code" "review PR"
(integer) 3
127.0.0.1:6379> LPUSH tasks "urgent: fix bug"
(integer) 4
127.0.0.1:6379> LPUSH order a b c
(integer) 3
127.0.0.1:6379> LRANGE order 0 -1
1) "c"
2) "b"
3) "a"
127.0.0.1:6379> LPUSHX nolist a
(integer) 0
127.0.0.1:6379> EXISTS nolist
(integer) 0

LPUSH 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 -1

เมื่อมี list แล้ว สามคำสั่งนี้ครอบคลุมรูปแบบการอ่านส่วนใหญ่:

  • LRANGE key start stop — คืน element จาก index start ถึง stop (รวมทั้งสองปลาย, เริ่มที่ 0) ใช้ -1 สำหรับ element สุดท้าย, -2 สำหรับตัวรองสุดท้าย และต่อไปเรื่อย ๆ
  • LLEN key — คืนจำนวน element ใน list
  • LINDEX key index — คืน element เดียวที่ index ที่ระบุ index ติดลบนับจากท้าย; index เกินท้ายได้ (nil)
cli/lists-hashes-lists-3.redis
127.0.0.1:6379> LRANGE tasks 0 -1
1) "urgent: fix bug"
2) "buy milk"
3) "write code"
4) "review PR"
127.0.0.1:6379> LLEN tasks
(integer) 4
127.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 ขนาดเล็ก

เอกสารระบุ 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 5000998 µ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 ms
LRANGE b1:big 0 19 server 8 us client: 20 items in 0.8 ms

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:

cli/lists-hashes-lists-4.redis
127.0.0.1:6379> LSET tasks 1 "buy oat milk"
OK
127.0.0.1:6379> LSET tasks 10 "nope"
(error) ERR index out of range
127.0.0.1:6379> LRANGE tasks 0 -1
1) "urgent: fix bug"
2) "buy oat milk"
3) "write code"
4) "review PR"
127.0.0.1:6379> RPUSH tasks "write code"
(integer) 5
127.0.0.1:6379> LREM tasks 0 "write code"
(integer) 2
127.0.0.1:6379> LPOS tasks "review PR"
(integer) 2
127.0.0.1:6379> LPOS tasks "nothere"
(nil)
127.0.0.1:6379> RPUSH dup a b a c a
(integer) 5
127.0.0.1:6379> LPOS dup a
(integer) 0
127.0.0.1:6379> LPOS dup a RANK 2
(integer) 2
127.0.0.1:6379> LPOS dup a COUNT 0
1) (integer) 0
2) (integer) 2
3) (integer) 4
127.0.0.1:6379> LINSERT tasks BEFORE "review PR" "test it"
(integer) 4
127.0.0.1:6379> LINSERT tasks AFTER "nothere" "x"
(integer) -1
127.0.0.1:6379> LRANGE tasks 0 -1
1) "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 -1

LTRIM key start stop แก้ list ในที่เดิม เก็บเฉพาะ element ใน [start, stop] และทิ้งทุกอย่างนอกช่วง คืน OK ทำให้ปลอดภัยที่จะเรียกหลังทุกการเขียน ช่วงที่ไม่เหลืออะไรจะลบ key

cli/lists-hashes-lists-5.redis
127.0.0.1:6379> DEL recent
(integer) 0
127.0.0.1:6379> RPUSH recent "page-A" "page-B" "page-C" "page-D" "page-E"
(integer) 5
127.0.0.1:6379> LTRIM recent 0 2
OK
127.0.0.1:6379> LRANGE recent 0 -1
1) "page-A"
2) "page-B"
3) "page-C"
127.0.0.1:6379> LTRIM recent 5 10
OK
127.0.0.1:6379> EXISTS recent
(integer) 0
DEL recent
RPUSH recent "page-A" "page-B" "page-C" "page-D" "page-E"
LRANGE recent 0 -1
LTRIM recent 0 2
LRANGE recent 0 -1

LMPOP numkeys key [key ...] LEFT|RIGHT [COUNT n] pop สูงสุด n element จาก list แรกที่ไม่ว่างในบรรดา key ที่ระบุ และบอกว่าใช้ key ไหน — “หยิบงานจาก queue ไหนก็ได้ที่มี” ในคำสั่งเดียว:

cli/lists-hashes-lists-6.redis
127.0.0.1:6379> RPUSH q1 a b c
(integer) 3
127.0.0.1:6379> RPUSH q2 x y
(integer) 2
127.0.0.1:6379> LMPOP 2 q1 q2 LEFT COUNT 2
1) "q1"
2) 1) "a"
2) "b"
127.0.0.1:6379> LMPOP 2 q1 q2 RIGHT
1) "q1"
2) 1) "c"
127.0.0.1:6379> LMPOP 2 q1 q2 LEFT
1) "q2"
2) 1) "x"
127.0.0.1:6379> LMPOP 2 q1 q2 LEFT
1) "q2"
2) 1) "y"
127.0.0.1:6379> EXISTS q1 q2
(integer) 0

คำสั่งเดียวกันผ่าน ioredis lpush + ltrim ใน multi(), หนึ่งหน้าคือ slice (lrange(key, 10, 19)) และ lmpop คืน [key, items] หรือ null:

src/lists-hashes-lists-feed.ts
// @run
import { 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)); // 50
console.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 list
const 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 null
await 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

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 everything
res.json(all.slice(page * 20, page * 20 + 20));
});
// writer side
await 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 window
const items = await redis.lrange('activity:user:42', page * 20, page * 20 + 19);
// writer: newest first, keep the latest 500, one MULTI
await 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 แล้ว)

list คือ คิวของ string ที่เปิดสองปลาย: การ push และ pop ที่ปลายใช้งานคงที่; การถามว่า “ตรงกลางคืออะไร?” ต้องเดิน และการถามว่า “มีอยู่ไหม?” (LREM, LPOS) ต้องดูทุกตัว ขนาดของมันเป็นความรับผิดชอบของคุณ: ไม่มีอะไร trim ให้ ออกแบบการดำเนินการให้เส้นทางที่ร้อนแตะเฉพาะปลายทั้งสองและหน้าต่างที่มีขอบเขต

ตัวเลือกข้อดีข้อเสีย
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 ใหญ่ที่ลบบ่อย
  • “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
  • ดึงทั้ง 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)

LRANGE tasks 0 -1 คืนอะไร?
คำสั่ง push ไหนเพิ่ม element ที่ HEAD ของ list?
LREM key 0 val ทำอะไร?
หลัง LTRIM mylist 0 2 เหลือกี่ element?
OBJECT ENCODING ของ list เล็กแสดงอะไร และเมื่อ list โตเกินราว 8 KB จะกลายเป็นอะไร?
การดำเนินการ list ไหนแพงบน list 1,000,000 element?