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

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 ไม่ว่า type ใด

คืน 1 ถ้า key มีอยู่ 0 ถ้าไม่มี ตรวจหลาย key พร้อมกันได้ — ค่าที่คืนคือจำนวน key ที่มีอยู่ (key ที่ระบุซ้ำสองครั้งนับสองครั้ง)

cli/keys-strings-scan-keys-1.redis
127.0.0.1:6379> SET user:1 "Ada"
OK
127.0.0.1:6379> EXISTS user:1
(integer) 1
127.0.0.1:6379> EXISTS user:99
(integer) 0
127.0.0.1:6379> EXISTS user:1 user:99 user:1
(integer) 2

DEL ลบ key หนึ่งตัวหรือมากกว่า และคืนจำนวน key ที่ถูกลบจริง (key ที่ไม่มีอยู่ไม่ถูกนับ)

cli/keys-strings-scan-keys-2.redis
127.0.0.1:6379> SET a "1"
OK
127.0.0.1:6379> SET b "2"
OK
127.0.0.1:6379> DEL a b nonexistent
(integer) 2
127.0.0.1:6379> EXISTS a
(integer) 0
127.0.0.1:6379> SET junk "x"
OK
127.0.0.1:6379> UNLINK junk nonexistent
(integer) 1

UNLINK คืนจำนวนเท่ากัน แต่ทำส่วนที่แพง — คืนหน่วยความจำ — บน background thread สำหรับ string เล็ก ๆ ไม่ต่างกัน แต่สำหรับค่าใหญ่ต่างมหาศาล ผมสร้าง set 2,000,000 member (70 MB ตาม MEMORY USAGE) และวัดต้นทุนฝั่งเซิร์ฟเวอร์ด้วย SLOWLOG (threshold 0):

คำสั่งกับ set 2M memberเวลาที่ command thread ถูกบล็อก
DEL b1:big1133,902 µs (134 ms)
UNLINK b1:big211 µ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

cli/keys-strings-scan-keys-3.redis
127.0.0.1:6379> SET mystr "hello"
OK
127.0.0.1:6379> TYPE mystr
string
127.0.0.1:6379> TYPE nonexistent:key
none
127.0.0.1:6379> DEL mystr
(integer) 1

เปลี่ยนชื่อ key ถ้า key ปลายทางมีอยู่แล้วจะถูกเขียนทับ ใช้ RENAMENX เพื่อเปลี่ยนชื่อเฉพาะเมื่อปลายทางยังไม่มี ค่า คง TTL ไว้ (เวลานับถอยหลังย้ายไปด้วย) และการ rename key ที่ไม่มีเป็น error:

cli/keys-strings-scan-keys-4.redis
127.0.0.1:6379> SET old:key "value" EX 100
OK
127.0.0.1:6379> RENAME old:key new:key
OK
127.0.0.1:6379> EXISTS old:key
(integer) 0
127.0.0.1:6379> GET new:key
"value"
127.0.0.1:6379> TTL new:key
(integer) 100
127.0.0.1:6379> RENAME nokey other
(error) ERR no such key
127.0.0.1:6379> SET dest "keepme"
OK
127.0.0.1:6379> SET src "fromsrc"
OK
127.0.0.1:6379> RENAMENX src dest
(integer) 0
127.0.0.1:6379> GET dest
"keepme"
127.0.0.1:6379> RENAME src dest
OK
127.0.0.1:6379> GET dest
"fromsrc"
127.0.0.1:6379> DEL new:key dest
(integer) 2

RENAME 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:key

KEYS pattern คืนทุก key ที่ตรงกับ pattern แบบ glob สะดวกมากสำหรับ development และ debug

cli/keys-strings-scan-keys-5.redis
127.0.0.1:6379> MSET user:2 "Bob" session:abc "tok" session:xyz "tok2"
OK
127.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 คือ iterator แบบ cursor แต่ละการเรียกประมวลผล batch เล็ก ๆ ของ key และคืน:

  1. cursor ใหม่ (ส่งต่อให้การเรียกถัดไป)
  2. รายการ key จาก batch นี้

เมื่อ cursor ที่คืนเป็น 0 การวนเสร็จสมบูรณ์ นี่คือการเดินจริงครบทั้งสิบ key ด้วย COUNT 3 (cursor และลำดับ key ของคุณจะต่าง):

cli/keys-strings-scan-keys-6.redis
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 j
OK
127.0.0.1:6379> SCAN 0 MATCH k* COUNT 3
1) "2"
2) 1) "k8"
2) "k1"
127.0.0.1:6379> SCAN 2 MATCH k* COUNT 3
1) "6"
2) 1) "k7"
2) "k3"
3) "k10"
4) "k4"
127.0.0.1:6379> SCAN 6 MATCH k* COUNT 3
1) "9"
2) 1) "k9"
127.0.0.1:6379> SCAN 9 MATCH k* COUNT 3
1) "15"
2) 1) "k5"
2) "k2"
3) "k6"
127.0.0.1:6379> SCAN 15 MATCH k* COUNT 3
1) "0"
2) (empty array)

อ่านได้สามอย่าง cursor (2, 6, 9, 15) เป็นค่าทึบ — อย่าคำนวณกับมัน และอย่าสร้างขึ้นเอง COUNT เป็น คำใบ้: การเรียกคืน 2, 4, 1 และ 3 key และ การเรียกสุดท้ายคืนลิสต์ว่างพร้อม cursor 0: batch ว่างไม่ได้แปลว่า “เสร็จ” (cursor ที่ไม่ใช่ศูนย์กับ batch ว่างเป็นเรื่องปกติ เพราะ MATCH กรอง หลัง การสุ่ม) มีแค่ cursor 0 ที่แปลว่าเสร็จ ลูปที่หยุดเมื่อ “ผลว่าง” จะข้าม key แบบเงียบ ๆ

cli/keys-strings-scan-keys-7.redis
127.0.0.1:6379> SCAN 0 MATCH k* COUNT 100
1) "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
ตัวเลือกคำอธิบาย
MATCH patternกรองผลด้วย glob pattern (การกรองเกิดหลังการสุ่ม)
COUNT hintแนะนำจำนวนรายการที่จะสุ่มต่อการเรียก (ค่าเริ่มต้น 10)
TYPE typeคืนเฉพาะ key ของ type ที่ระบุ (Redis 6.0+)
cli/keys-strings-scan-keys-8.redis
127.0.0.1:6379> RPUSH mylist a
(integer) 1
127.0.0.1:6379> SCAN 0 COUNT 100 TYPE list
1) "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 100

สัญญาอย่างเป็นทางการ (เอกสาร SCAN): การวนครบหนึ่งรอบคืน ทุก element ที่มีอยู่ตลอดการวน; element ที่ถูกเพิ่มหรือลบระหว่างนั้นอาจถูกคืนหรือไม่ก็ได้; และ element เดียวกันอาจถูกคืนมากกว่าหนึ่งครั้ง วัดด้วย ioredis บน 8.10.2: key คงที่ 20,000 ตัว และเขียน key ใหม่ 50 ตัวหลังทุกการเรียก SCAN ... COUNT 100:

src/keys-strings-scan-keys-mutation.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
await redis.flushdb();
// 20,000 keys that exist for the whole scan
const 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 down
await 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% survive
const 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 ที่นี่” เป็นการรับประกัน

AI ถูกขอ “ฟังก์ชันล้างรายการ cache ทั้งหมด” แล้วเขียนว่า:

🐛 หา bug ในโค้ดนี้

async function purge(pattern: string) {
// find all matching keys and delete them
const 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:

src/keys-strings-scan-keys-purge.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
// the AI-written version: KEYS + DEL
async 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 survive
const 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

KEYS คือ การอ่านสมุดโทรศัพท์ทั้งเล่มออกเสียงขณะที่ทุกคนรอ; SCAN คือ พลิกทีละไม่กี่หน้าแล้วคั่นที่คั่นหนังสือไว้ (cursor) ที่คั่นเป็นคำมั่น ไม่ใช่ snapshot: คนที่อยู่ในสมุดตลอดจะถูกอ่านอย่างน้อยหนึ่งครั้ง; คนที่ย้ายเข้าหรือออกระหว่างนั้นอาจถูกอ่านหรือไม่ก็ได้; และหลังจัดสมุดใหม่คุณอาจได้ยินชื่อเดียวกันสองครั้ง ที่คั่นเป็นค่าทึบ — ทำได้แค่ส่งคืน และการลบก็เป็นเรื่องเดียวกัน: DEL ฉีกหน้าที่ใหญ่โตด้วยตัวเองขณะทุกคนรอ ส่วน UNLINK มอบให้ผู้ช่วย

ตัวเลือกข้อดีข้อเสีย
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 tripround trip น้อยลง เร็วขึ้นโดยรวมแต่ละรอบทำงานมากขึ้น ซึ่งกระทบ latency ของคำสั่งอื่นที่รันพร้อมกัน
UNLINK แทน DELคืนหน่วยความจำเบื้องหลัง; 11 µs เทียบ 134 ms บน set 2M memberหน่วยความจำไม่ถูกคืนทันที; ต้องใช้ Redis 4.0+
  • “COUNT 10 คืน 10 key” มันเป็นคำใบ้; เราได้ 2, 4, 1 และ 3 ด้วย COUNT 3
  • “SCAN ที่ตอบว่างแปลว่าสแกนจบแล้ว” มีแค่ cursor 0 เท่านั้น
  • “SCAN ให้ snapshot ที่คงที่” มันให้การรับประกันการครอบคลุมสำหรับ key ที่คงอยู่ ไม่มากกว่านั้น
  • “DEL ถูกเสมอ” เป็น O(1) สำหรับ string แต่ O(N) ตามจำนวน element ของ collection; 134 ms สำหรับ 2M member
  • ใช้ 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)
SCAN คืนอะไรเมื่อการวนเสร็จสมบูรณ์?
ทำไม KEYS จึงอันตรายบนเซิร์ฟเวอร์ Redis ของ production?
EXISTS user:1 user:1 user:2 คืนอะไรถ้า user:1 มีอยู่และ user:2 ไม่มี?
TYPE คืนอะไรสำหรับ key ที่ไม่มีอยู่?
ระหว่าง SCAN เต็มรอบ มี key ถูกเพิ่มบางตัว และบางตัวอยู่ตลอด อะไรที่ถูกรับประกัน?
ต้องลบ set ขนาด 2 ล้าน member บนเซิร์ฟเวอร์ที่ใช้งานอยู่ วิธีไหนดีที่สุด?