Keyspace Notifications
ทดสอบบน Redis 8.10.2 (
redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) transcript แบบ# cli/...ถูก harness ของคอร์สรันซ้ำ; การรัน notification เป็นสคริปต์ ioredis แบบ@run(subscriber และ writer บนสอง connection) เวลาเป็นมิลลิวินาทีนาฬิกาจริงจากเครื่องนี้ และต่างกันได้หลักสิบถึงหลักร้อย ms ในแต่ละรอบ เอกสารอ้างอิง: Redis keyspace notifications บทนี้ต่อยอดจาก Pub/Sub และ Pattern Subscriptions
Keyspace notification คืออะไร?
หัวข้อที่มีชื่อว่า “Keyspace notification คืออะไร?”Redis publish ข้อความ Pub/Sub ทุกครั้งที่มีอะไรเกิดกับ key: ถูก set, ถูกลบ, หมดอายุ, ถูก evict แอปพลิเคชัน subscribe และตอบสนองโดยไม่ต้อง poll (ล้างแคชในเครื่องเมื่อ key หมดอายุ, เก็บกวาด session) เพราะเป็น Pub/Sub event จึงเป็น at-most-once: subscriber ที่ออฟไลน์จะพลาดมัน
แต่ละ event มีสองตระกูล channel:
- channel Keyspace หนึ่งต่อ key; payload คือชื่อ event:
__keyspace@<db>__:<key>ที่ส่งset - channel Keyevent หนึ่งต่อ event; payload คือชื่อ key:
__keyevent@<db>__:<event>ที่ส่งuser:1
การเปิดใช้งาน
หัวข้อที่มีชื่อว่า “การเปิดใช้งาน”ปิดเป็นค่าเริ่มต้น (ต้นทุน CPU ของทุก event) notify-keyspace-events รับสตริงของตัวอักษร flag ต้องมี K หรือ E อย่างน้อยหนึ่งตัว ไม่เช่นนั้นจะไม่ส่งอะไรเลย
| Flag | ความหมาย |
|---|---|
K | channel แบบ keyspace (__keyspace@..) |
E | channel แบบ keyevent (__keyevent@..) |
g | คำสั่งทั่วไป: DEL, EXPIRE, RENAME, … |
$ l s h z t a | คำสั่งของ string, list, set, hash, sorted-set, stream, array |
x | event expired (หมดอายุ) |
e | event evicted (key ถูกลบโดย maxmemory ดู Eviction Policies) |
d | event ของ key type จากโมดูล |
A | alias ของ g$lshztdxea (ทั้งหมดข้างบน) |
n m o c | key ใหม่, key miss, ถูกเขียนทับ, เปลี่ยน type: ไม่ อยู่ใน A |
เซิร์ฟเวอร์เก็บสตริงตามลำดับ canonical สิ่งที่อ่านกลับมาจึงไม่ใช่สิ่งที่คุณเขียน:
127.0.0.1:6379> CONFIG GET notify-keyspace-events1) "notify-keyspace-events"2) ""127.0.0.1:6379> CONFIG SET notify-keyspace-events ExOK127.0.0.1:6379> CONFIG GET notify-keyspace-events1) "notify-keyspace-events"2) "xE"127.0.0.1:6379> CONFIG SET notify-keyspace-events KEAOK127.0.0.1:6379> CONFIG GET notify-keyspace-events1) "notify-keyspace-events"2) "AKE"127.0.0.1:6379> SET demo:token "hello" EX 5OK127.0.0.1:6379> TTL demo:token(integer) 5127.0.0.1:6379> PTTL demo:token(integer) 4858127.0.0.1:6379> CONFIG SET notify-keyspace-events ""OK127.0.0.1:6379> CONFIG GET notify-keyspace-events1) "notify-keyspace-events"2) ""Ex กลับมาเป็น xE และ KEA เป็น AKE (A แทนที่ class ย่อย) TTL พิมพ์ 5 ขณะที่ PTTL พิมพ์ 4858: TTL ปัดเป็นวินาทีที่ใกล้ที่สุด
event expired พร้อมเวลา
หัวข้อที่มีชื่อว่า “event expired พร้อมเวลา”สคริปต์ตั้ง demo:token ด้วย EX 3 subscribe ด้วย PSUBSCRIBE __keyevent@0__:expired บน connection ที่สอง และวัดว่า event มาถึงเมื่อไร จากนั้นบันทึกว่า event ใดถูกยิงกับ KEA และ KEAnm:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const redis = new Redis(url);const sub = new Redis(url);
// 1) the expired event, with timing. "Ex" = keyevent channel + expired eventsawait redis.config('SET', 'notify-keyspace-events', 'Ex');console.log(await redis.config('GET', 'notify-keyspace-events')); // [ 'notify-keyspace-events', 'xE' ] (canonical order)
let setAt = 0;const expired = new Promise<void>((resolve) => { sub.on('pmessage', (_pattern, channel, key) => { console.log(`${channel} -> ${key} after ${Date.now() - setAt} ms`); resolve(); });});await sub.psubscribe('__keyevent@0__:expired');setAt = Date.now();await redis.set('demo:token', 'abc', 'EX', 3); // the TTL says 3 sawait expired;
// 2) event classes. A = every generic class, but n (new key) and m (key miss) are NOT part of Aasync function record(flags: string) { await redis.config('SET', 'notify-keyspace-events', flags); const seen: string[] = []; sub.removeAllListeners('pmessage'); sub.on('pmessage', (_p, channel, payload) => seen.push(`${channel} ${payload}`)); await sub.punsubscribe(); await sub.psubscribe('__key*@0__:*'); await redis.set('user:1', 'a'); await redis.get('nope'); await redis.del('user:1'); await new Promise((r) => setTimeout(r, 100)); return seen;}console.log('KEA :', await record('KEA'));console.log('KEAnm:', await record('KEAnm'));
await redis.config('SET', 'notify-keyspace-events', '');sub.disconnect();await redis.quit();Output จริง (Mac เครื่องนี้):
[ 'notify-keyspace-events', 'xE' ]__keyevent@0__:expired -> demo:token after 3056 msKEA : [ '__keyspace@0__:user:1 set', '__keyevent@0__:set user:1', '__keyspace@0__:user:1 del', '__keyevent@0__:del user:1']KEAnm: [ '__keyspace@0__:user:1 new', '__keyevent@0__:new user:1', '__keyspace@0__:user:1 set', '__keyevent@0__:set user:1', '__keyspace@0__:nope keymiss', '__keyevent@0__:keymiss nope', '__keyspace@0__:user:1 del', '__keyevent@0__:del user:1']สิ่งที่แสดง:
- TTL 3 วินาทีทำให้ event มาที่ 3,056 ms ที่นี่ (3,100 และ 3,196 ในอีกสองรอบ): ช้ากว่า TTL เล็กน้อย ไม่เคยเร็วกว่า มันคือการแจ้งว่า key ถูก ลบ ไม่ใช่ตัวจับเวลา
- การดำเนินการเดียวกันให้ event เป็น คู่ ต่อ class หนึ่งบน channel keyspace และหนึ่งบน channel keyevent
- กับ
KEAไม่มี eventnewและ ไม่มีkeymiss; มันปรากฏเฉพาะกับnและmGETkey ที่ไม่มีทำให้เกิดkeymiss(ตัวนับ cache miss โดยไม่ต้องแตะ client) ผมตรวจoและcด้วยวิธีเดียวกัน: การเขียนทับ key ให้overwrittenและการแทนที่ list ด้วย string ให้overwrittenแล้วtype_changed; ทั้งสองไม่อยู่ในA
CONFIG SET notify-keyspace-events Ex
SET demo:token "hello" EX 5
TTL demo:token
PTTL demo:tokenevent expired ยิงเมื่อไร?
หัวข้อที่มีชื่อว่า “event expired ยิงเมื่อไร?”key ที่มี TTL ถูกลบสองวิธี (เอกสาร): แบบ lazy เมื่อคำสั่งแตะมันและพบว่าหมดอายุ และ แบบ active โดยรอบเบื้องหลังที่ sample key ที่มี TTL event ยิงเมื่อ key ถูก ลบ ดังนั้นถ้าไม่มีใครแตะ ความล่าช้าขึ้นกับรอบเบื้องหลัง ซึ่งควบคุมด้วย hz (server รันงานประจำบ่อยแค่ไหน ค่าเริ่มต้น 10) สคริปต์ถัดไปใช้ TTL 300 ms ครั้งหนึ่งปล่อยไว้และอีกครั้งอ่านหลัง 400 ms ที่ hz 10 และ hz 1 แล้วต่อด้วย key 200,000 ตัวที่หมดอายุใน millisecond เดียวกัน:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const redis = new Redis(url);const sub = new Redis(url);await redis.config('SET', 'notify-keyspace-events', 'Ex');
const firedAt = new Map<string, number>();sub.on('message', (_channel, key) => firedAt.set(key, Date.now()));await sub.subscribe('__keyevent@0__:expired');
// A key with a 300 ms TTL: when does the event arrive if nobody looks at it (active expiry) vs if a GET touches it (lazy expiry)?async function trial(name: string, touchAfterMs?: number) { const t = Date.now(); await redis.set(name, 'v', 'PX', 300); if (touchAfterMs) { await new Promise((r) => setTimeout(r, touchAfterMs)); await redis.get(name); } await new Promise((r) => setTimeout(r, 2300)); console.log(name.padEnd(16), 'PX 300 -> event after', (firedAt.get(name) ?? 0) - t, 'ms');}for (const hz of ['10', '1']) { // hz = how often the server runs its background jobs, active expiry included await redis.config('SET', 'hz', hz); console.log(`hz ${hz}`); await trial(`untouched-hz${hz}`); await trial(`touched-hz${hz}`, 400);}await redis.config('SET', 'hz', '10');
// 200,000 keys that all expire at the very same millisecondconst N = 200000;const deadline = Date.now() + 3000;let count = 0, first = 0, last = 0;sub.removeAllListeners('message');sub.on('message', (_c, key) => { if (!key.startsWith('load:')) return; count++; last = Date.now() - deadline; first ||= last;});await redis.eval("for i=1,ARGV[1] do redis.call('SET','load:'..i,'v','PXAT',ARGV[2]) end return 1", 0, N, deadline);await new Promise((r) => setTimeout(r, 6500));console.log(`load: ${count} events, first ${first} ms and last ${last} ms after the shared deadline`);
await redis.config('SET', 'notify-keyspace-events', '');await redis.flushall();sub.disconnect();await redis.quit();Output จริง (Mac เครื่องนี้):
hz 10untouched-hz10 PX 300 -> event after 474 mstouched-hz10 PX 300 -> event after 338 mshz 1untouched-hz1 PX 300 -> event after 2043 mstouched-hz1 PX 300 -> event after 407 msload: 200000 events, first 54 ms and last 920 ms after the shared deadline- ไม่ถูกแตะเทียบกับถูกแตะ ที่
hz 10event ของ key ที่ไม่ถูกแตะมาถึง 474 ms หลัง SET (174 ms หลัง TTL); ที่hz 1ใช้ 2,043 ms สำหรับ TTL 300 ms key เดียวกันที่GETอ่านหลัง 400 ms ให้ event ทันที (407 ms): lazy expiry ลบเมื่อถูกเข้าถึง - ภายใต้โหลด key 200,000 ตัวที่มี deadline เดียวกันให้ครบ 200,000 event แต่กระจายไปราว หนึ่งวินาที (ตัวแรก 54 ms ตัวสุดท้าย 920 ms หลัง deadline): active expiry ทำงานเป็นช่วงที่จำกัด เพื่อไม่ให้เซิร์ฟเวอร์ค้าง โดยแลกกับความล่าช้า
ดังนั้นถือว่า expired คือ “key นี้หายไปแล้ว ณ ช่วงใดช่วงหนึ่งหลัง TTL” ไม่ใช่ตัวตั้งเวลาที่แม่นระดับ millisecond ถ้าต้องการงานในเวลาที่แม่นยำ ให้ใช้ Sorted Set ของเวลาครบกำหนดหรือ delayed queue
Keyspace notification เหมาะกับอะไร และไม่เหมาะกับอะไร
หัวข้อที่มีชื่อว่า “Keyspace notification เหมาะกับอะไร และไม่เหมาะกับอะไร”- มัน เป็นของแต่ละ node ใน cluster: แต่ละ node publish event ของ key ตัวเอง และต่างจาก Pub/Sub ปกติ มันไม่ถูก broadcast จึงต้อง subscribe ทุก node (เอกสาร)
- event ส่ง เฉพาะชื่อ key ไม่ใช่ value: value ถูกลบไปแล้วเมื่อ
expiredยิง จึงเก็บสิ่งที่ handler ต้องใช้ไว้ในชื่อ key หรือใน shadow key - มันเป็น at-most-once: subscriber ที่หลุดการเชื่อมต่อพลาด event สิ่งที่ต้องเกิดขึ้น (ส่งเตือน, ปล่อยที่นั่ง) ต้องมีการ reconcile เป็นระยะหรือ Stream ไม่ใช่ event นี้อย่างเดียว
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”AI ทำ “ยกเลิกออเดอร์ที่ยังไม่จ่ายหลัง 15 นาที” ด้วย TTL ของ key และ event expired:
🐛 หา bug ในโค้ดนี้
await redis.set(`order:${id}:hold`, '1', 'EX', 900);
sub.on('message', async (_channel, key) => {const id = key.split(':')[1];await cancelIfUnpaid(id); // runs when the hold key expires});await sub.subscribe('__keyevent@0__:expired');เฉลย
มีสองปัญหา ทั้งคู่มาจากการรันข้างบน ข้อแรก ฟีเจอร์ไม่มีวันทำงานบนเซิร์ฟเวอร์ค่าเริ่มต้น: notify-keyspace-events ว่าง subscribe จึงสำเร็จและเงียบตลอดไป (CONFIG GET แรกใน transcript คืน "") ข้อสอง แม้เปิดแล้วมัน at-most-once และมาช้า: ถ้า service restart หรือ subscriber เชื่อมต่อใหม่ ออเดอร์ที่ hold หมดอายุในช่วงนั้นจะไม่ถูกยกเลิกเลย และภายใต้โหลด event ทยอยมาตลอดหนึ่งวินาที ให้เปิด flag เป็นส่วนของการ deploy (Ex) และ reconcile ด้วย query เป็นระยะหาออเดอร์ที่เลยกำหนด แทนที่จะเชื่อ event อย่างเดียว:
await redis.config('SET', 'notify-keyspace-events', 'Ex'); // or in redis.conf: notify-keyspace-events "Ex"setInterval(cancelOverdueOrders, 60_000); // the safety net does not depend on deliveryMental Model
หัวข้อที่มีชื่อว่า “Mental Model”Keyspace notification คือ กริ่งที่ดังเมื่อเซิร์ฟเวอร์สังเกตเห็นการเปลี่ยนของ key ไม่ใช่นาฬิกา มันดังสำหรับ class ของ event ที่คุณเปิด ดังเฉพาะกับคนที่ยืนอยู่หน้าประตู และสำหรับ expired มันดังเมื่อเซิร์ฟเวอร์ ไปถึงเวลา ลบ key ซึ่งมักภายในเสี้ยววินาที และบางครั้งช้ากว่ามาก ถือทุกเสียงกริ่งเป็นแค่คำใบ้
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ข้อดี | ข้อเสีย |
|---|---|---|
ชุด flag แคบ (Ex) | overhead น้อย เฉพาะ event ที่คุณใช้ | เพิ่ม flag ทีหลังถ้าต้องการ class อื่น |
KEA | ทุกอย่าง (ยกเว้น n, m, o, c) โดยไม่ต้องตั้งค่าเพิ่ม | ทุก event ของทุกการเขียนถูก publish: CPU และ traffic ของ subscriber |
| event แทนการ poll | ไม่ต้อง query ซ้ำ | at-most-once และ expired มาช้า (474 ms สำหรับ TTL 300 ms; เพิ่มได้ถึง 920 ms ภายใต้ burst 200k) |
| Lazy + active expiry | ถูก: ไม่มี timer ต่อ key | ไม่มีเวลาแม่นยำ; key ที่ไม่ถูกแตะรอรอบเบื้องหลัง |
| อ่าน key เพื่อบังคับให้หมดอายุ | event ทันที (407 ms ที่ hz 1) | เปลี่ยน notification เป็น polling; ใช้กับ key ที่คุณอ่านอยู่แล้วเท่านั้น |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “event expired ยิงทันทีที่ TTL เป็นศูนย์” มันยิงเมื่อ key ถูกลบ: 474 ms สำหรับ TTL 300 ms, 2,043 ms ที่
hz 1 - “
KEAหมายถึงทุก event” มันไม่รวม event ของ key ใหม่, key miss, ถูกเขียนทับ และเปลี่ยน type - “
CONFIG GETคืนสิ่งที่ฉันตั้ง”Exอ่านกลับเป็นxEและKEAเป็นAKE - “event บอก value ที่หมดอายุ” บอกแค่ชื่อ key; value หายไปแล้ว
- “notification เชื่อถือได้” มันคือ Pub/Sub: subscriber ที่ออฟไลน์พลาด
Common mistakes
หัวข้อที่มีชื่อว่า “Common mistakes”- ลืมเปิด
notify-keyspace-events—subscribeสำเร็จและไม่มีอะไรมาถึง - ใช้เป็นตัวตั้งเวลาที่แม่นยำ — ใช้ Sorted Set ของเวลาครบกำหนด
- เปิด
KEAบนเซิร์ฟเวอร์ที่วุ่นวาย — publish เฉพาะ class ที่ใช้ - ไม่มี fallback สำหรับ event ที่พลาด — reconcile เป็นระยะ
- subscribe เพียง node เดียวของ cluster — event เป็นของแต่ละ node
Real-world example
เอกสารของ Redis เอง อธิบายสองตระกูล channel, flag และระบุว่า “there are no guarantees that the Redis server will be able to generate the expired event at the time the key time to live reaches the value of zero” (Redis keyspace notifications หัวข้อ “Timing of expired events”)