SCAN & Key Operations
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) cursor ของ SCAN และลำดับ key ที่คืนมาไม่คงที่ระหว่างรัน; ตัวเลขที่คุณเห็นจะต่างจากด้านล่าง และ harness ตั้งใจไม่เปรียบเทียบมัน
การดำเนินการหลักกับ key
หัวข้อที่มีชื่อว่า “การดำเนินการหลักกับ key”คำสั่งเหล่านี้ใช้ได้กับทุก key ไม่ว่า type ใด
คืน 1 ถ้า key มีอยู่ 0 ถ้าไม่มี ตรวจหลาย key พร้อมกันได้ — ค่าที่คืนคือจำนวน key ที่มีอยู่ (key ที่ระบุซ้ำสองครั้งนับสองครั้ง)
127.0.0.1:6379> SET user:1 "Ada"OK127.0.0.1:6379> EXISTS user:1(integer) 1127.0.0.1:6379> EXISTS user:99(integer) 0127.0.0.1:6379> EXISTS user:1 user:99 user:1(integer) 2DEL และ UNLINK
หัวข้อที่มีชื่อว่า “DEL และ UNLINK”DEL ลบ key หนึ่งตัวหรือมากกว่า และคืนจำนวน key ที่ถูกลบจริง (key ที่ไม่มีอยู่ไม่ถูกนับ)
127.0.0.1:6379> SET a "1"OK127.0.0.1:6379> SET b "2"OK127.0.0.1:6379> DEL a b nonexistent(integer) 2127.0.0.1:6379> EXISTS a(integer) 0127.0.0.1:6379> SET junk "x"OK127.0.0.1:6379> UNLINK junk nonexistent(integer) 1UNLINK คืนจำนวนเท่ากัน แต่ทำส่วนที่แพง — คืนหน่วยความจำ — บน background thread สำหรับ string เล็ก ๆ ไม่ต่างกัน แต่สำหรับค่าใหญ่ต่างมหาศาล ผมสร้าง set 2,000,000 member (70 MB ตาม MEMORY USAGE) และวัดต้นทุนฝั่งเซิร์ฟเวอร์ด้วย SLOWLOG (threshold 0):
| คำสั่งกับ set 2M member | เวลาที่ command thread ถูกบล็อก |
|---|---|
DEL b1:big1 | 133,902 µs (134 ms) |
UNLINK b1:big2 | 11 µs |
นี่คือต้นทุนของ “command thread เดียว” จากบท What is Redis เป็นตัวเลข: DEL บน collection ใหญ่ทำให้ client อื่นทุกคนค้าง 134 ms; UNLINK คืนทันที และหน่วยความจำถูกคืนภายหลังโดย thread bio_lazy_free (INFO memory แสดง lazyfreed_objects) ควรใช้ UNLINK กับทุกอย่างที่อาจใหญ่ (Redis ทำแบบนี้อัตโนมัติกับ DEL, expiry และ eviction ได้ด้วยการตั้งค่า lazyfree-lazy-*)
คืน type ของ value ที่เก็บใน key: string, list, set, zset, hash หรือ stream
127.0.0.1:6379> SET mystr "hello"OK127.0.0.1:6379> TYPE mystrstring127.0.0.1:6379> TYPE nonexistent:keynone127.0.0.1:6379> DEL mystr(integer) 1เปลี่ยนชื่อ key ถ้า key ปลายทางมีอยู่แล้วจะถูกเขียนทับ ใช้ RENAMENX เพื่อเปลี่ยนชื่อเฉพาะเมื่อปลายทางยังไม่มี ค่า คง TTL ไว้ (เวลานับถอยหลังย้ายไปด้วย) และการ rename key ที่ไม่มีเป็น error:
127.0.0.1:6379> SET old:key "value" EX 100OK127.0.0.1:6379> RENAME old:key new:keyOK127.0.0.1:6379> EXISTS old:key(integer) 0127.0.0.1:6379> GET new:key"value"127.0.0.1:6379> TTL new:key(integer) 100127.0.0.1:6379> RENAME nokey other(error) ERR no such key127.0.0.1:6379> SET dest "keepme"OK127.0.0.1:6379> SET src "fromsrc"OK127.0.0.1:6379> RENAMENX src dest(integer) 0127.0.0.1:6379> GET dest"keepme"127.0.0.1:6379> RENAME src destOK127.0.0.1:6379> GET dest"fromsrc"127.0.0.1:6379> DEL new:key dest(integer) 2RENAME src dest แทนที่ dest แบบเงียบ ๆ — ค่าเดิมของ dest หายไป RENAME เป็น atomic จึงเป็นเหตุผลที่ “เขียนลง key ชั่วคราวแล้ว rename ทับตัวจริง” เป็นวิธีมาตรฐานในการเผยแพร่ค่าที่สร้างใหม่โดยไม่มีช่วงที่เขียนไม่เสร็จ
SET user:1 "Ada"
EXISTS user:1
EXISTS user:99
SET a "1"
SET b "2"
DEL a b nonexistent
SET mystr "hello"
TYPE mystr
SET old:key "value"
RENAME old:key new:key
GET new:keyKEYS — และทำไมห้ามใช้ใน production
หัวข้อที่มีชื่อว่า “KEYS — และทำไมห้ามใช้ใน production”KEYS pattern คืนทุก key ที่ตรงกับ pattern แบบ glob สะดวกมากสำหรับ development และ debug
127.0.0.1:6379> MSET user:2 "Bob" session:abc "tok" session:xyz "tok2"OK127.0.0.1:6379> KEYS user:*1) "user:1"2) "user:2"127.0.0.1:6379> KEYS *1) "user:1"2) "user:2"3) "session:abc"4) "session:xyz"ลำดับเป็นอะไรก็ได้ที่ hash table ให้มา — อย่าพึ่งพา KEYS เป็นการสแกนทั้ง keyspace แบบ บล็อกและ O(N) วัดบน 8.10.2 (ตารางอยู่ในบท First Commands): เซิร์ฟเวอร์ค้าง 16 ms ที่ 200,000 key และ 156 ms ที่ 1,000,000 ห้ามรันกับเซิร์ฟเวอร์ production — ใช้ SCAN แทน
SCAN — การวนดู keyspace อย่างปลอดภัย
หัวข้อที่มีชื่อว่า “SCAN — การวนดู keyspace อย่างปลอดภัย”SCAN คือ iterator แบบ cursor แต่ละการเรียกประมวลผล batch เล็ก ๆ ของ key และคืน:
- cursor ใหม่ (ส่งต่อให้การเรียกถัดไป)
- รายการ key จาก batch นี้
เมื่อ cursor ที่คืนเป็น 0 การวนเสร็จสมบูรณ์ นี่คือการเดินจริงครบทั้งสิบ key ด้วย COUNT 3 (cursor และลำดับ key ของคุณจะต่าง):
127.0.0.1:6379> MSET k1 a k2 b k3 c k4 d k5 e k6 f k7 g k8 h k9 i k10 jOK127.0.0.1:6379> SCAN 0 MATCH k* COUNT 31) "2"2) 1) "k8" 2) "k1"127.0.0.1:6379> SCAN 2 MATCH k* COUNT 31) "6"2) 1) "k7" 2) "k3" 3) "k10" 4) "k4"127.0.0.1:6379> SCAN 6 MATCH k* COUNT 31) "9"2) 1) "k9"127.0.0.1:6379> SCAN 9 MATCH k* COUNT 31) "15"2) 1) "k5" 2) "k2" 3) "k6"127.0.0.1:6379> SCAN 15 MATCH k* COUNT 31) "0"2) (empty array)อ่านได้สามอย่าง cursor (2, 6, 9, 15) เป็นค่าทึบ — อย่าคำนวณกับมัน และอย่าสร้างขึ้นเอง COUNT เป็น คำใบ้: การเรียกคืน 2, 4, 1 และ 3 key และ การเรียกสุดท้ายคืนลิสต์ว่างพร้อม cursor 0: batch ว่างไม่ได้แปลว่า “เสร็จ” (cursor ที่ไม่ใช่ศูนย์กับ batch ว่างเป็นเรื่องปกติ เพราะ MATCH กรอง หลัง การสุ่ม) มีแค่ cursor 0 ที่แปลว่าเสร็จ ลูปที่หยุดเมื่อ “ผลว่าง” จะข้าม key แบบเงียบ ๆ
127.0.0.1:6379> SCAN 0 MATCH k* COUNT 1001) "0"2) 1) "k8" 2) "k1" 3) "k7" 4) "k3" 5) "k10" 6) "k4" 7) "k9" 8) "k5" 9) "k2" 10) "k6"เมื่อ COUNT ใหญ่กว่า keyspace การเรียกครั้งเดียวเจอทุกอย่างและคืน cursor 0
MSET k1 a k2 b k3 c k4 d k5 e k6 f k7 g k8 h k9 i k10 j
SCAN 0 MATCH k* COUNT 3
SCAN 0 MATCH k* COUNT 100ตัวเลือกของ SCAN
หัวข้อที่มีชื่อว่า “ตัวเลือกของ SCAN”| ตัวเลือก | คำอธิบาย |
|---|---|
MATCH pattern | กรองผลด้วย glob pattern (การกรองเกิดหลังการสุ่ม) |
COUNT hint | แนะนำจำนวนรายการที่จะสุ่มต่อการเรียก (ค่าเริ่มต้น 10) |
TYPE type | คืนเฉพาะ key ของ type ที่ระบุ (Redis 6.0+) |
127.0.0.1:6379> RPUSH mylist a(integer) 1127.0.0.1:6379> SCAN 0 COUNT 100 TYPE list1) "0"2) 1) "mylist"127.0.0.1:6379> SCAN 0 COUNT 100 TYPE string MATCH user:*1) "0"2) 1) "user:1" 2) "user:2"redis-cli --scan --pattern 'k*' รันลูปทั้งหมดให้และพิมพ์หนึ่ง key ต่อบรรทัด (--scan --pattern 'b1:*' บนการทดสอบสอง key พิมพ์ b1:b, b1:a) เรียก SCAN ด้วย cursor ที่คืนมาต่อไปจนกว่า cursor จะกลับเป็น 0 และรวบรวมผลจากทุกรอบ
SCAN 0 MATCH session:* COUNT 100SCAN รับประกันอะไรเมื่อ keyspace เปลี่ยน
หัวข้อที่มีชื่อว่า “SCAN รับประกันอะไรเมื่อ keyspace เปลี่ยน”สัญญาอย่างเป็นทางการ (เอกสาร SCAN): การวนครบหนึ่งรอบคืน ทุก element ที่มีอยู่ตลอดการวน; element ที่ถูกเพิ่มหรือลบระหว่างนั้นอาจถูกคืนหรือไม่ก็ได้; และ element เดียวกันอาจถูกคืนมากกว่าหนึ่งครั้ง วัดด้วย ioredis บน 8.10.2: key คงที่ 20,000 ตัว และเขียน key ใหม่ 50 ตัวหลังทุกการเรียก SCAN ... COUNT 100:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);await redis.flushdb();
// 20,000 keys that exist for the whole scanconst seed = redis.pipeline();for (let i = 0; i < 20000; i++) seed.set(`b1:stable:${i}`, '1');await seed.exec();
const seen = new Map<string, number>();let cursor = '0', calls = 0, extra = 0;do { const [next, batch] = await redis.scan(cursor, 'MATCH', 'b1:*', 'COUNT', 100); for (const k of batch) seen.set(k, (seen.get(k) ?? 0) + 1); cursor = next; calls++; // meanwhile the keyspace keeps changing: 50 new keys per call, and the scan grows the table const w = redis.pipeline(); for (let j = 0; j < 50; j++) { w.set(`b1:new:${calls}:${j}`, '1'); extra++; } await w.exec();} while (cursor !== '0');
let missing = 0;for (let i = 0; i < 20000; i++) if (!seen.has(`b1:stable:${i}`)) missing++;const dupes = [...seen.values()].filter((n) => n > 1).length;console.log(`grow: ${calls} calls, stable keys missing: ${missing}, keys returned twice: ${dupes}, new keys seen: ${[...seen.keys()].filter((k) => k.startsWith('b1:new')).length} of ${extra}`);
// shrink: delete 95% of the keys halfway through, so Redis resizes the table downawait redis.flushdb();const seed2 = redis.pipeline();for (let i = 0; i < 100000; i++) seed2.set(`b1:s:${i}`, '1');await seed2.exec();const keep = new Set<string>();for (let i = 0; i < 100000; i += 20) keep.add(`b1:s:${i}`); // 5% surviveconst seen2 = new Map<string, number>();cursor = '0'; calls = 0; let shrunk = false;do { const [next, batch] = await redis.scan(cursor, 'COUNT', 1000); for (const k of batch) seen2.set(k, (seen2.get(k) ?? 0) + 1); cursor = next; calls++; if (calls === 20 && !shrunk) { shrunk = true; const d = redis.pipeline(); for (let i = 0; i < 100000; i++) if (!keep.has(`b1:s:${i}`)) d.del(`b1:s:${i}`); await d.exec(); }} while (cursor !== '0');let missing2 = 0;for (const k of keep) if (!seen2.has(k)) missing2++;console.log(`shrink: ${calls} calls, surviving keys missing: ${missing2}, keys returned twice: ${[...seen2.values()].filter((n) => n > 1).length}`);
await redis.flushdb();await redis.quit();output จริง: grow: 258 calls, stable keys missing: 0, keys returned twice: 0, new keys seen: 5900 of 12900 และ shrink: 24 calls, surviving keys missing: 0, keys returned twice: 0 การรับประกันเป็นจริงทั้งสองครั้ง: ไม่พลาด key คงที่แม้แต่ตัวเดียว และมีเพียง 5,900 จาก 12,900 key ที่สร้างระหว่างสแกนปรากฏ (key ใหม่เป็นทางเลือก) ผม ไม่สามารถ ทำให้เกิด duplicate บน 8.10.2 ได้ — แต่สัญญาอนุญาตให้เกิด ดังนั้นโค้ดที่ห้ามประมวลผล key สองครั้งต้อง dedupe (ด้วย Set) หรือเป็น idempotent อย่าอ่าน “ไม่มี duplicate ที่นี่” เป็นการรับประกัน
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI ถูกขอ “ฟังก์ชันล้างรายการ cache ทั้งหมด” แล้วเขียนว่า:
🐛 หา bug ในโค้ดนี้
async function purge(pattern: string) {// find all matching keys and delete themconst keys = await redis.keys(pattern);return redis.del(...keys);}
await purge('cache:*');เฉลย
มีสอง bug ที่เกิดขึ้นจริงทั้งคู่ ข้อแรก เมื่อไม่มีอะไรตรงกัน keys เป็น [] และ del() ถูกส่งโดยไม่มี key: เรารันแล้วได้ ERR wrong number of arguments for 'del' command — งานล้างพังในวันที่ cache ว่างอยู่แล้วพอดี ข้อสอง KEYS ทำให้ Redis ค้างตลอดการสแกน (156 ms ต่อล้าน key) และส่งชื่อทุกตัวผ่านเครือข่าย และ DEL บนค่าใหญ่บล็อกอีกรอบ การกระจาย array ล้านตัวเป็น argument ยังอาจเกินขีดจำกัด วิธีแก้: SCAN ทีละ batch, dedupe, ข้าม batch ว่าง และใช้ UNLINK:
// @runimport { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
// the AI-written version: KEYS + DELasync function purgeBuggy(pattern: string) { const keys = await redis.keys(pattern); return redis.del(...keys);}console.log('nothing to purge ->', await purgeBuggy('b1:cache:*').catch((e: Error) => e.message));
// seed 5000 keys, plus 5 keys that must surviveconst seed = redis.pipeline();for (let i = 0; i < 5000; i++) seed.set(`b1:cache:${i}`, '1');for (let i = 0; i < 5; i++) seed.set(`b1:keep:${i}`, '1');await seed.exec();
// the fix: SCAN in batches, UNLINK each batch (UNLINK frees memory off the main thread)async function purge(pattern: string) { let removed = 0; for await (const batch of redis.scanStream({ match: pattern, count: 500 })) { const keys = [...new Set(batch as string[])]; // SCAN may return a key more than once if (keys.length === 0) continue; // empty batches are normal removed += await redis.unlink(...keys); } return removed;}console.log('purged:', await purge('b1:cache:*'), 'left:', await redis.dbsize()); // 5000, 5
await redis.flushdb();await redis.quit();output จริง: nothing to purge -> ERR wrong number of arguments for 'del' command แล้ว purged: 5000 left: 5
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”KEYS คือ การอ่านสมุดโทรศัพท์ทั้งเล่มออกเสียงขณะที่ทุกคนรอ; SCAN คือ พลิกทีละไม่กี่หน้าแล้วคั่นที่คั่นหนังสือไว้ (cursor) ที่คั่นเป็นคำมั่น ไม่ใช่ snapshot: คนที่อยู่ในสมุดตลอดจะถูกอ่านอย่างน้อยหนึ่งครั้ง; คนที่ย้ายเข้าหรือออกระหว่างนั้นอาจถูกอ่านหรือไม่ก็ได้; และหลังจัดสมุดใหม่คุณอาจได้ยินชื่อเดียวกันสองครั้ง ที่คั่นเป็นค่าทึบ — ทำได้แค่ส่งคืน และการลบก็เป็นเรื่องเดียวกัน: DEL ฉีกหน้าที่ใหญ่โตด้วยตัวเองขณะทุกคนรอ ส่วน UNLINK มอบให้ผู้ช่วย
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
SCAN (วนแบบ cursor) | ไม่บล็อกเซิร์ฟเวอร์ กระจายงานเป็น batch เล็ก ปลอดภัยสำหรับ production | ไม่ใช่ snapshot ที่แน่นอน — key ที่เพิ่ม/ลบระหว่างสแกนอาจพลาด และ key หนึ่งอาจปรากฏสองครั้ง; เวลารวมนานกว่า KEYS (1.3 วินาที เทียบ 0.6 วินาทีที่ 1M key) |
KEYS * (สแกนเต็มแบบบล็อก) | ง่าย เรียกครั้งเดียวได้ผลครบ ใช้ได้กับ dataset dev ขนาดเล็ก | บล็อก command thread จนสแกนทุก key เสร็จ (156 ms ที่ 1M key); ห้ามใช้บน production ที่มี key เยอะ |
ตั้ง COUNT สูงใน SCAN เพื่อลด round trip | round trip น้อยลง เร็วขึ้นโดยรวม | แต่ละรอบทำงานมากขึ้น ซึ่งกระทบ latency ของคำสั่งอื่นที่รันพร้อมกัน |
UNLINK แทน DEL | คืนหน่วยความจำเบื้องหลัง; 11 µs เทียบ 134 ms บน set 2M member | หน่วยความจำไม่ถูกคืนทันที; ต้องใช้ Redis 4.0+ |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
COUNT 10คืน 10 key” มันเป็นคำใบ้; เราได้ 2, 4, 1 และ 3 ด้วยCOUNT 3 - “
SCANที่ตอบว่างแปลว่าสแกนจบแล้ว” มีแค่ cursor0เท่านั้น - “
SCANให้ snapshot ที่คงที่” มันให้การรับประกันการครอบคลุมสำหรับ key ที่คงอยู่ ไม่มากกว่านั้น - “
DELถูกเสมอ” เป็น O(1) สำหรับ string แต่ O(N) ตามจำนวน element ของ collection; 134 ms สำหรับ 2M member
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- ใช้
KEYS *ดู key ทั้งหมดใน production — บล็อกเซิร์ฟเวอร์จนสแกนทุก key เสร็จ บน dataset ใหญ่อาจทำให้ client อื่นค้างเป็นวินาที ใช้SCANแทนเสมอ - คิดว่า
COUNTเป็นขีดจำกัดตายตัวของจำนวน key ที่คืน —COUNTเป็นแค่คำใบ้ Redis อาจคืนมากหรือน้อยกว่าในแต่ละรอบ - หยุดลูป
SCANก่อนที่ cursor จะกลับเป็น0— ผลจะไม่ครบ การวนเสร็จสมบูรณ์เมื่อ cursor ที่คืนเป็น0เท่านั้น - เรียก
DELด้วย spread ของ array ว่าง —redis.del(...[])เป็น error ไม่ใช่ no-op ป้องกันด้วยif (keys.length)