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

Pattern Subscriptions

ทดสอบบน Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24 บน Mac (Apple silicon) ทุกอย่างด้านล่างคือ output ของสคริปต์ @run และ transcript แบบ # cli/... ที่รันซ้ำ พื้นฐานของ Pub/Sub (at-most-once, subscriber แบบ RESP2 เทียบ RESP3) อยู่ใน Pub/Sub

PSUBSCRIBE pattern [pattern ...] คือ SUBSCRIBE ที่ใช้ glob pattern แทนชื่อ channel ตรงตัว การ subscribe ครั้งเดียวจึงติดตาม channel ทั้งตระกูลได้ รวมถึงที่ยังไม่มีอยู่

Patternความหมาย
*ลำดับตัวอักษรใดๆ รวมถึงว่างเปล่า
?ตัวอักษรเดียวพอดี
[abc], [a-c]หนึ่งตัวอักษรจากเซ็ตหรือช่วง
[^x]หนึ่งตัวอักษรที่ไม่ใช่ x
\** ตัวอักษรจริง

ผม subscribe connection หนึ่งด้วย news.*, h?llo, log.[ew]*, raw.[^x] แล้ว publish ไปยัง 11 channel แต่ละแถวคือจำนวนการส่งที่ PUBLISH รายงาน:

Channel ที่ publishจำนวนที่ส่งเพราะ
news.sports1match news.*
news.1* match สตริงว่างด้วย
news.a.b1. เป็นตัวอักษรธรรมดา * ครอบคลุมมัน
newsflash0ไม่มี prefix news.
hello, hallo1, 1? คือตัวอักษรเดี่ยวใดก็ได้
heello0? คือหนึ่งตัวพอดี
log.error1[ew] = e หรือ w
log.info0i ไม่อยู่ในเซ็ต
raw.a, raw.x1, 0[^x] ยกเว้น x

ข้อความที่มาทาง pattern ไม่ใช่ message แต่เป็น pmessage: ["pmessage", pattern, channel, payload] ใน ioredis สอง event มี signature ของ handler ต่างกัน และ publish ครั้งเดียวอาจทำให้เกิดทั้งสอง: ผม subscribe channel ตรงตัว news.tech ด้วย แล้ว publish ที่นั่น ซึ่ง match ทั้ง subscription ตรงตัวและ news.* PUBLISH คืน 2 และ client เห็นสอง event:

src/pubsub-streams-pubsub-patterns-pmessage.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const sub = new Redis(url);
const pub = new Redis(url);
const seen: string[] = [];
// 'message' has (channel, message); 'pmessage' has FOUR arguments: (pattern, channel, message)
sub.on('message', (channel, message) => seen.push(`message ${channel} ${message}`));
sub.on('pmessage', (pattern, channel, message) => seen.push(`pmessage ${pattern} ${channel} ${message}`));
console.log(await sub.psubscribe('news.*', 'h?llo', 'log.[ew]*', 'raw.[^x]')); // 4 patterns -> count 4
console.log(await sub.subscribe('news.tech')); // + 1 literal channel -> count 5
// which channels does each pattern catch? PUBLISH returns the number of deliveries
for (const ch of ['news.sports', 'news.', 'news.a.b', 'newsflash', 'hello', 'hallo', 'heello', 'log.error', 'log.info', 'raw.a', 'raw.x']) {
console.log(ch.padEnd(12), await pub.publish(ch, 'x'));
}
// news.tech matches BOTH the pattern news.* and the literal subscription: two deliveries, two events
console.log('news.tech'.padEnd(12), await pub.publish('news.tech', 'both'));
await new Promise((r) => setTimeout(r, 50));
console.log(seen.filter((s) => s.includes('news.tech'))); // [ 'message news.tech both', 'pmessage news.* news.tech both' ]
// a match that came only through a pattern fires 'pmessage' and NOT 'message'
console.log(seen.filter((s) => s.includes('news.sports'))); // [ 'pmessage news.* news.sports x' ]
sub.disconnect();
pub.disconnect();

Output จริง (Mac เครื่องนี้):

4
5
news.sports 1
news. 1
news.a.b 1
newsflash 0
hello 1
hallo 1
heello 0
log.error 1
log.info 0
raw.a 1
raw.x 0
news.tech 2
[ 'message news.tech both', 'pmessage news.* news.tech both' ]
[ 'pmessage news.* news.sports x' ]

อีกสองข้อเท็จจริงจากตัวเลขเหล่านั้น ค่าที่ psubscribe/subscribe คืน (4 แล้ว 5) คือ จำนวน subscription ทั้งหมดบน connection รวม channel และ pattern และการ match ที่มาทาง pattern อย่างเดียวจะยิง pmessage และ ไม่ยิง message client ที่ฟังเฉพาะ message จะไม่เห็นอะไรเลย

PUBLISH news.sports "score update"
PUBLISH news.tech "new release"
PUBLISH weather.today "sunny"
PUBSUB NUMPAT

PUNSUBSCRIBE [pattern ...] ลบ pattern subscription โดยตอบหนึ่งครั้งต่อ pattern ด้วย ["punsubscribe", pattern, remaining] remaining นับ subscription ที่เหลือบน connection ทั้งหมด (channel และ pattern) ตามที่ 4/5 ข้างบนแสดง และ connection ออกจากโหมด subscribe เมื่อถึง 0 ถ้าไม่ใส่อาร์กิวเมนต์จะลบทุก pattern

ทุก PUBLISH ต้องถูกเทียบกับ ทุก pattern ที่ active ขณะที่ channel ตรงตัวเป็นการ lookup ใน hash ผมวัดค่าเฉลี่ยฝั่งเซิร์ฟเวอร์ของ PUBLISH orders:1 x แบบ pipeline 20,000 ครั้งจาก INFO commandstats โดย subscriber มี pattern ที่ไม่มีวัน match:

src/pubsub-streams-pubsub-patterns-cost.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const pub = new Redis(url);
// average server time of PUBLISH over 20,000 publishes, from INFO commandstats
async function usPerPublish(label: string) {
await pub.config('RESETSTAT');
const batch = pub.pipeline();
for (let i = 0; i < 20000; i++) batch.publish('orders:1', 'x');
await batch.exec();
const line = (await pub.info('commandstats')).split('\r\n').find((l) => l.startsWith('cmdstat_publish'));
console.log(label.padEnd(24), line?.match(/usec_per_call=([\d.]+)/)?.[1], 'us per PUBLISH');
}
await usPerPublish('no subscriptions');
for (const n of [100, 10000]) {
const sub = new Redis(url);
await sub.psubscribe(...Array.from({ length: n }, (_, i) => `p${i}:*`)); // none of them match orders:1
await usPerPublish(`${n} patterns`);
sub.disconnect();
await new Promise((r) => setTimeout(r, 300));
}
const literal = new Redis(url);
await literal.subscribe(...Array.from({ length: 10000 }, (_, i) => `c${i}`));
await usPerPublish('10000 literal channels');
literal.disconnect();
pub.disconnect();

Output จริง (Mac เครื่องนี้):

no subscriptions 0.04 us per PUBLISH
100 patterns 0.77 us per PUBLISH
10000 patterns 115.57 us per PUBLISH
10000 literal channels 0.09 us per PUBLISH

100 pattern จึงแพงกว่าฐานราว 20 เท่า (ยังต่ำกว่าหนึ่งไมโครวินาที), 10,000 pattern แพงกว่าราว 2,900 เท่า คือราว 0.1 ms ของ command thread ต่อทุกการ publish สำหรับ ทุก publisher; channel ตรงตัว 10,000 ตัวไม่มีต้นทุนที่วัดได้ ต้นทุนเป็นต่อ pattern และจ่ายบนเส้นทาง publish จึงควรมี pattern น้อยและแคบ และใช้ channel ตรงตัวเมื่อรู้ชื่อ

Redis publish event ภายในบน channel ในตัว ซึ่งเป็นสิ่งที่ pattern subscription ถูกใช้บ่อยที่สุด: __keyevent@0__:expired (key ใน db 0 หมดอายุ) และ __keyspace@0__:mykey (เกิดอะไรบางอย่างกับ mykey) ปิดอยู่เป็นค่าเริ่มต้น CONFIG GET รายงาน flag ตามลำดับมาตรฐาน KEA จึงกลับมาเป็น AKE:

cli/pubsub-streams-pubsub-patterns-1.redis
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> CONFIG SET notify-keyspace-events ""
OK

บทเต็มพร้อม event expired ที่จับเวลา คือ Keyspace notifications

AI เขียน subscriber สำหรับทุก channel ของ alert:

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

const sub = new Redis(process.env.REDIS_URL!);
await sub.psubscribe('alerts.*');
sub.on('message', (channel, message) => {
notifyOncall(channel, message); // never runs
});
เฉลย

subscription สำเร็จและ PUBLISH alerts.billing ... รายงานว่าส่ง 1 แต่ handler ผูกกับ message; pattern subscription ปล่อย pmessage ที่มีอาร์กิวเมนต์แรกเพิ่ม จึงไม่มีใครถูกแจ้งเลย ในการรันของเรา การ match ผ่าน pattern อย่างเดียวให้ [ 'pmessage news.* news.sports x' ] และไม่มี event message เลย วิธีแก้คือ event และ signature ที่ถูก:

sub.on('pmessage', (pattern, channel, message) => {
notifyOncall(channel, message);
});

pattern subscription คือ ตัวกรองที่เซิร์ฟเวอร์ประเมินให้ทุกข้อความ มันซื้อความสามารถที่ไม่ต้องรู้ชื่อ channel ล่วงหน้า และคุณจ่ายด้วย CPU ทุกครั้งที่ publish บน command thread เดียว ถือว่า pattern เป็นทรัพยากรที่หายาก และถือว่า pmessage เป็น event แยกที่มีอาร์กิวเมนต์ของตัวเอง

ตัวเลือกข้อดีข้อเสีย
PSUBSCRIBE พร้อม globsubscription เดียวสำหรับทั้งตระกูล รวม channel ในอนาคตถูกเทียบกับทุกการ publish: 0.04 us ถึง 115 us ต่อการ publish จาก 0 เป็น 10,000 pattern
SUBSCRIBE channel ตรงตัวlookup ใน hash ต้นทุนคงที่แม้ 10,000 channel (0.09 us)ต้องระบุชื่อเอง
Keyspace notification ผ่าน Pub/Subevent แบบ real-time โดยไม่ต้อง pollงานเพิ่มกับคำสั่งที่แตะ key และยังเป็นส่งแล้วลืม
subscribe channel ตรงตัวที่ pattern ครอบคลุมอยู่แล้วบางครั้งตั้งใจข้อความเดียวกันถูกส่งสองครั้ง (PUBLISH คืน 2)
  • “การ match ของ pattern มาถึงเป็น message” มันมาถึงเป็น pmessage สี่ส่วน
  • “ตัวเลขที่ PSUBSCRIBE คืนนับ pattern” มันนับทุก subscription บน connection รวม channel ด้วย
  • “pattern ฟรี” มันเป็นเชิงเส้นตามจำนวน pattern ที่ active: 115 us ต่อการ publish ที่ 10,000
  • “* ต้องมีอย่างน้อยหนึ่งตัวอักษร” news.* match news. ได้
  • ฟัง message บน pattern subscription — ผูก pmessage (ดูแบบฝึกหัด)
  • subscribe * หรือ pattern เป็นพัน — ทุกการ publish จ่ายให้ทุก pattern
  • เปิด KEA (ทุกอย่าง) บน production ที่วุ่นวาย — เปิดเฉพาะ class ของ event ที่คุณใช้

Real-world example

เอกสารของ Redis เอง อธิบาย pattern subscription แบบ glob, รูปแบบ pmessage และความหมายของตัวนับ subscription ใน Redis Pub/Sub; กฎของ pattern ก็คือ glob เดียวกับ KEYS (PSUBSCRIBE)

wildcard แบบ glob ตัวไหนที่ match หนึ่งตัวอักษรพอดีใน pattern ของ PSUBSCRIBE?
push แบบ pmessage มีกี่ส่วน?
element ที่สองใน push แบบ pmessage คืออะไร?
connection หนึ่ง subscribe pattern news.* และ channel news.tech PUBLISH news.tech x คืน...
คุณ PSUBSCRIBE 10,000 pattern ที่ไม่มีวัน match ต้นทุนของทุก PUBLISH จะเป็นอย่างไร?
psubscribe('a.*') แล้ว subscribe('b') บน connection ioredis เดียว การเรียกที่สอง resolve เป็นอะไร?