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

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

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ความหมาย
Kchannel แบบ keyspace (__keyspace@..)
Echannel แบบ keyevent (__keyevent@..)
gคำสั่งทั่วไป: DEL, EXPIRE, RENAME, …
$ l s h z t aคำสั่งของ string, list, set, hash, sorted-set, stream, array
xevent expired (หมดอายุ)
eevent evicted (key ถูกลบโดย maxmemory ดู Eviction Policies)
devent ของ key type จากโมดูล
Aalias ของ g$lshztdxea (ทั้งหมดข้างบน)
n m o ckey ใหม่, key miss, ถูกเขียนทับ, เปลี่ยน type: ไม่ อยู่ใน A

เซิร์ฟเวอร์เก็บสตริงตามลำดับ canonical สิ่งที่อ่านกลับมาจึงไม่ใช่สิ่งที่คุณเขียน:

cli/persistence-expiration-keyspace-notifications-1.redis
127.0.0.1:6379> CONFIG GET notify-keyspace-events
1) "notify-keyspace-events"
2) ""
127.0.0.1:6379> CONFIG SET notify-keyspace-events Ex
OK
127.0.0.1:6379> CONFIG GET notify-keyspace-events
1) "notify-keyspace-events"
2) "xE"
127.0.0.1:6379> CONFIG SET notify-keyspace-events KEA
OK
127.0.0.1:6379> CONFIG GET notify-keyspace-events
1) "notify-keyspace-events"
2) "AKE"
127.0.0.1:6379> SET demo:token "hello" EX 5
OK
127.0.0.1:6379> TTL demo:token
(integer) 5
127.0.0.1:6379> PTTL demo:token
(integer) 4858
127.0.0.1:6379> CONFIG SET notify-keyspace-events ""
OK
127.0.0.1:6379> CONFIG GET notify-keyspace-events
1) "notify-keyspace-events"
2) ""

Ex กลับมาเป็น xE และ KEA เป็น AKE (A แทนที่ class ย่อย) TTL พิมพ์ 5 ขณะที่ PTTL พิมพ์ 4858: TTL ปัดเป็นวินาทีที่ใกล้ที่สุด

สคริปต์ตั้ง demo:token ด้วย EX 3 subscribe ด้วย PSUBSCRIBE __keyevent@0__:expired บน connection ที่สอง และวัดว่า event มาถึงเมื่อไร จากนั้นบันทึกว่า event ใดถูกยิงกับ KEA และ KEAnm:

src/persistence-expiration-keyspace-notifications-expired.ts
// @run
import { 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 events
await 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 s
await expired;
// 2) event classes. A = every generic class, but n (new key) and m (key miss) are NOT part of A
async 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 ms
KEA : [
'__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 ไม่มี event new และ ไม่มี keymiss; มันปรากฏเฉพาะกับ n และ m GET key ที่ไม่มีทำให้เกิด 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:token

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 เดียวกัน:

src/persistence-expiration-keyspace-notifications-lazy.ts
// @run
import { 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 millisecond
const 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 10
untouched-hz10 PX 300 -> event after 474 ms
touched-hz10 PX 300 -> event after 338 ms
hz 1
untouched-hz1 PX 300 -> event after 2043 ms
touched-hz1 PX 300 -> event after 407 ms
load: 200000 events, first 54 ms and last 920 ms after the shared deadline
  • ไม่ถูกแตะเทียบกับถูกแตะ ที่ hz 10 event ของ 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

  • มัน เป็นของแต่ละ 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 นี้อย่างเดียว

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 delivery

Keyspace notification คือ กริ่งที่ดังเมื่อเซิร์ฟเวอร์สังเกตเห็นการเปลี่ยนของ key ไม่ใช่นาฬิกา มันดังสำหรับ class ของ event ที่คุณเปิด ดังเฉพาะกับคนที่ยืนอยู่หน้าประตู และสำหรับ expired มันดังเมื่อเซิร์ฟเวอร์ ไปถึงเวลา ลบ key ซึ่งมักภายในเสี้ยววินาที และบางครั้งช้ากว่ามาก ถือทุกเสียงกริ่งเป็นแค่คำใบ้

ตัวเลือกข้อดีข้อเสีย
ชุด 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 ที่คุณอ่านอยู่แล้วเท่านั้น
  • “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 ที่ออฟไลน์พลาด
  • ลืมเปิด 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”)

flag ของ CONFIG ตัวไหนเปิด channel แบบ keyevent (__keyevent@...)?
channel ไหนส่ง event การหมดอายุบน database 0?
คุณรัน CONFIG SET notify-keyspace-events Ex แล้ว CONFIG GET คุณเห็นอะไร?
เมื่อ notify-keyspace-events เป็น KEA การ GET key ที่ไม่มีอยู่...
key ที่มี TTL 300 ms ไม่เคยถูกอ่าน กับ hz 1 event expired มาถึงหลัง 2,043 ms ทำไมช้าขนาดนั้น?
service ใช้ event expired เพื่อยกเลิกออเดอร์ที่ยังไม่จ่าย และถูก restart หนึ่งนาที เกิดอะไรกับออเดอร์ที่หมดอายุระหว่างนั้น?