ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis键空间通知实战:轻量级事件订阅转发工具设计解析

Redis键空间通知实战:轻量级事件订阅转发工具设计解析 oh-my-hermes 这个名字一眼就能看出是跟 oh-my-zsh 那套命名学的。实际上它也确实是个挺轻量的开源小工具订阅 Redis 的键空间通知Keyspace Notifications把 Redis 内部发生的键写入、删除、过期、淘汰这类事件像信使一样原样转达给下游系统。我在内部项目里维护这个工具也有大半年了期间踩了不少坑也重构过两版今天干脆把完整的设计思路和落地过程整理出来包括配置、源码逻辑、参数解释和真实遇到的问题希望对想走同样路子的人有帮助。先说它解决的核心痛点很多业务系统用了 Redis 做缓存或者临时状态存储但状态一变业务侧往往只能靠轮询去感知。轮询就是笨办法延迟高、空转多、代码还丑。Redis 本身提供了 pub/sub 机制来广播键变化事件但原生能力比较裸光靠 redis-cli 去订阅只能看到一串频道名没法过滤、没法转发、也没法管理多个实例。oh-my-hermes 做的事情就是把这一层补上稳定订阅按规则过滤再通过 webhook 或者消息管道把事件推给下游。适合想低成本实现数据变更感知又不想为了一两个场景直接上消息队列的团队。1. 这个项目到底解决什么问题1.1 灵感来源与命名逻辑先说说名字。“oh-my-”这个前缀做后端的人应该都不陌生最有名的就是 oh-my-zsh一个把 zsh 配置管理做到极致的社区项目。后来社区里各种 “oh-my-xxx” 都有本质上都是同一个意思把你的工作流收拾得服服帖帖开箱即用。Hermes 是希腊神话里的信使神在技术圈里也不陌生React Native 用的 JS 引擎就叫 Hermes。搁在这个项目里Hermes 的意象非常准Redis 是数据的来源事件就是它发出的消息而这个工具就是负责把消息准确送到目的地的那个信使。这个命名也代表了项目定位。我一开始就没打算把它做成一个重型的消息中间件目标就三条轻量、好配、能干活。轻量指的是依赖少、内存占用低好配指的是一个 yaml 文件搞定所有订阅规则和转发目标能干活指的是断线重连、消息缓冲、规则过滤这些实际生产中绕不开的问题必须内置解决。1.2 Redis 键空间通知的痛点Redis 的键空间通知机制本身不复杂核心就两样东西。第一服务器配置里开启 notify-keyspace-events告诉 Redis 哪些事件要广播第二客户端通过 SUBSCRIBE 或 PSUBSCRIBE 订阅特定频道接收事件消息。但它有几个很现实的问题直接用原生接口做生产级服务是撑不住的。第一个问题频道格式容易把人绕晕。Redis 事件频道分两类keyspace: 这类频道会告诉你“某个键发生了某个操作”比如keyspace0:user:10086 上收到一条 set 消息意思是这个键被 set 了keyevent: 这类频道维度是事件类型比如keyevent0:expired 上每来一条消息就意味着有一个键过期了。但不管是哪类你收到的都只是一个字符串消息没有结构化字段实际使用还得自己解析。第二个问题键名是直接拼在频道名里的。什么意思如果你的业务把手机号、身份证这类信息塞进 Redis 键名那么所有订阅了这个频道的人都能从频道名里看到完整明文。这个在内部系统问题不大但涉及用户隐私数据时就是个红线必须提前规范键名。第三个问题也是最重要的pub/sub 是即发即弃fire-and-forget的。消费者断线了消息就丢了消费者处理慢了Redis 的发送缓冲区还可能堆积极端情况下会拖垮 Redis 实例。这些东西用 redis-cli 手动订阅是感受不到的写成一个常驻服务以后全都会暴露出来。1.3 适合用的场景和不适合用的场景我自己实际用下来最适合 oh-my-hermes 的场景有几类。缓存一致性通知订单服务更新了数据库顺手把 Redis 里的缓存键删掉或者改写其他服务通过订阅捕获这个动作主动刷新本地缓存。临时状态过期提醒最常见的订单超时未支付自动关闭、优惠券过期提醒、验证码过期清理直接在键上设置 EXPIRE由 expired 事件触发下游逻辑。数据变更触发外部动作比如某个配置键一变自动通知所有业务节点 reload 配置省去每台机器跑一遍运维脚本。不适合的场景也要说清楚。要求严格不丢消息的核心资金链路别用它请老老实实上 MQ需要消息回溯和堆积消费别用它pub/sub 没有历史消息订阅方太多或者消费太慢也请慎重Redis 的 pub/sub 性能在大量消费者下会明显下降。它适合的是那些“丢了可以重试、晚一点没关系、但千万别轮询”的场景。2. 整体设计思路与核心架构2.1 消息流向订阅端到输出端oh-my-hermes 的整体架构非常简单一条消息从 Redis 出发到下游业务系统中间只经过四个阶段订阅接收、格式解析、规则过滤、路由发送。订阅接收阶段做的事情是持有到 Redis 的长连接通过 PSUBSCRIBE 订阅一批频道模式。这里我会用 Redis 的 RESP2 协议的 psubscribe 机制而不是 subscribe原因很简单subscribe 只能订阅固定频道而键名是动态的没法提前枚举完psubscribe 支持通配符一个keyevent*:* 就能覆盖所有库、所有事件类型配置起来省心得多。格式解析阶段把 Redis 推送的原始消息拆成结构化字段。一条原始消息大概长这样pmessage __keyevent0__:expired __keyevent0__:expired order:timeout:298374前面是匹配到的模式中间是具体频道名最后才是消息内容。解析的时候需要从频道名里抠出 db 编号和事件动作这样后面过滤才有的放矢。规则过滤是核心也是和裸订阅最大的区别。配置里写清楚“什么样的消息才值得转发”比如只看 db 0、只看 expired 事件、键名只关心 order:timeout: 前缀、剩下的全部丢弃。这个阶段会把绝大多数无关消息挡在外面避免下游被打爆。路由发送阶段最灵活也是我觉得这个小工具最实用的地方。默认支持三类输出目标HTTP Webhook、标准输出方便调试、转发到另一个 Redis 的 pub/sub 上。实际用的时候 Webhook 用得最多把事件 POST 到业务接口业务侧收到再去做后续处理。2.2 为什么选择客户端订阅而不是别的方案设计的时候我对比过三条路。第一条路就是用现成的消息队列比如 RabbitMQ、Kafka、Redis Stream业务系统把变更事件主动发到队列里。这条路可靠性强功能全但它要求业务系统主动发消息侵入性强很多老系统改不起。第二条路是定时轮询 Redis 键用 SCAN 扫描出所有匹配前缀的键再比对状态。这条路对现有代码零侵入但延迟完全取决于轮询间隔而且扫描大 keyspace 对 Redis 本身有负担当你有几十万上百万个键时根本扛不住。第三条路就是 oh-my-hermes 选择的客户端订阅方案既不侵入业务代码又能做到准实时延迟基本在毫秒到百毫秒级别。订阅方案最大的优势是“旁观者清”。你的业务代码不需要知道有一个信使在监听 RedisRedis 本身也不需要改动任何数据流逻辑只是额外发一些通知出来。对于那种“我只想知道某个键过期了没”的诉求这几乎是当前成本最低的做法。2.3 关键模块拆解三个模块我认为最值得分享连接管理、事件管线、发送缓冲。连接管理解决的是可靠性问题。正常运行时维护一条到 Redis 的长连接一旦检测到连接断开按指数退避策略重连重连成功后重新执行 psubscribe。这里有个细节特别容易踩坑Redis 的 pub/sub 连接如果长时间空闲可能被中间的网络设备断开但 TCP 层不会立刻感知所以必须启用应用层的心跳。我的做法是每隔 30 秒发一条 PING如果连续三次没有 PONG就主动断开重连。事件管线是单线程还是多线程我纠结了很久。第一版用的是每个 Webhook 目标一个线程后来发现回调变慢时线程越积越多反而把 CPU 耗在上下文切换上。第二版改成 asyncio 事件循环Redis 订阅协程只负责把消息解析后丢进 asyncio.Queue后面由独立的发送 worker 从队列取消息再异步发 HTTP 请求。这样订阅和发送彻底解耦Redis 侧的消息读取永远不被下游拖慢。发送缓冲是实际生产环境教会我的。Webhook 接口可能响应慢如果发送速度跟不上接收速度消息就会堆积。缓冲队列撑不住的时候怎么办我的策略是队列长度超过阈值后新消息直接记录到本地日志文件等队列有空间了再重放。毕竟这类事件消息的实时性要求没那么苛刻晚几十秒也能接受但不能丢。3. 从零配置一个可用实例3.1 Redis 端必须先做的配置任何客户端工具都只是半成品Redis 那一半配置不弄好一切都白搭。先确认 Redis 版本最好是 5.0 以上老版本对部分事件类型支持不完整。然后设置 notify-keyspace-events方式有两种临时生效用命令redis-cli config set notify-keyspace-events KEA永久生效就改 redis.conf 里的同名配置项然后重启 Redis。这个参数值是一串字母每个字母代表一类事件。实际生产我建议直接开 KEAK 代表 keyspace 事件也就是频道名里带具体键名的那类E 代表 keyevent 事件也就是按事件类型分类的那类A 是“所有事件类型”的别名展开后等价于 g$lshzxe覆盖字符串、列表、集合、哈希、有序集合、过期、淘汰等等。注意一点配置成 KEA 之后每个键的每次操作可能产生多条事件消息消息量会比默认状态大不少。如果有环境是压测或者敏感集群要评估一下网络开销。但对我们常规业务量来说这点开销完全可以接受换来的是完整的可观测性。3.2 安装 oh-my-hermes 与最小启动项目用 Python 写的依赖非常克制核心就是 redis-py 和 httpx都走 asyncio。安装直接用 pippip install oh-my-hermes装完之后先验证版本oh-my-hermes --version最小配置只需要一个 hermes.yaml放在当前目录或者通过 --config 指定路径。最基础的内容长这样redis: host: 127.0.0.1 port: 6379 db: 0 subscribe: - __keyevent*__:expired - __keyevent*__:set - __keyevent*__:del rules: - name: debug-print event: * key_prefix: * output: stdout然后直接跑oh-my-hermes run --config hermes.yaml看到日志输出 “subscribed to 3 patterns” 就算启动成功。先订阅全部事件再打印到 stdout目的是确认 Redis 的事件本身通没通。3.3 配置一个订单超时提醒的完整例子确认基础链路没问题后再来配一个真实的业务场景。假设我们有一个电商订单系统订单创建后往 Redis 写一个短生命周期的键键名规则是 order:timeout:{orderId}存活时间 30 分钟。过期之后我们希望下游的订单关闭服务收到通知执行关单操作。Redis 侧保持 notify-keyspace-events KEA 不变。配置改成只关心 db 0 的 expired 事件并且键名前缀限死到 order:timeout:redis: host: 127.0.0.1 port: 6379 db: 0 subscribe: - __keyevent0__:expired rules: - name: order-timeout event: expired key_prefix: order:timeout: output: webhook webhook_url: http://localhost:8080/api/orders/close-timeout webhook_template: | {order_id: {{ key | replace(order:timeout:, ) }}, source: redis-keyspace}这个配置的语义是从键空间通知里接收所有 db 0 的 expired 事件但只有消息内容是 order:timeout: 开头的键名才会被 POST 到本地 8080 端口的接口。模板里做了个简单的字符串替换把 orderId 从键名里提取出来下游接口只管收 order_id 字段不用关心 Redis 的键名规则。为了验证我用一个临时接口打印请求体然后手动造一个过期键redis-cli -n 0 set order:timeout:10086 abc EX 3030 秒后接口就会收到类似这样的 POST 请求{order_id: 10086, source: redis-keyspace}整个过程不需要改任何业务代码纯粹在 Redis 和 oh-my-hermes 之间搭了一条线。3.4 事件解析后的字段说明解析结构是模板和规则过滤的基础我先列一下每条消息解析后到底有哪些字段。字段来源示例db频道名解析0event频道名解析expiredkey消息体order:timeout:10086channel完整频道名keyevent0:expiredpattern匹配到的订阅模式keyevent0:expired这几个字段看起来简单但在配置里用处极大。db 字段可以用在规则里限定某些库不处理key 字段用来匹配键名前缀event 字段用来区分动作类型。如果还想拿到键对应的值就得在 Webhook 规则里配置一个额外的 lookup_keys 选项工具收到事件后主动从 Redis 里读一次这个键的值再拼到请求体里。不过要注意对 expired 事件来说键已经没了是读不到值的所以这个功能只建议用于 set、rename 这类事件。4. 核心代码与关键配置参数解读4.1 订阅与解析部分的核心逻辑我摘一段核心的订阅循环代码做了简化但保留了主干逻辑import asyncio import redis.asyncio as aioredis from dataclasses import dataclass dataclass class RedisEvent: db: str event: str key: str channel: str pattern: str async def listen(rd: aioredis.Redis, patterns: list[str]): pubsub rd.pubsub() await pubsub.psubscribe(*patterns) async for message in pubsub.listen(): if message[type] ! pmessage: continue pattern message[channel].decode() if isinstance(message[channel], bytes) else message[channel] data message[data].decode() if isinstance(message[data], bytes) else message[data] # 频道格式: __keyevent{db}__:{event} 或 __keyspace{db}__:{key} meta, _, right pattern.rpartition(:) if meta.startswith(__keyevent): db meta.split()[1].split(__)[0] ev right key data yield RedisEvent(dbdb, eventev, keykey, channelpattern, patternpattern) else: # keyspace 类型需要再从频道名里抠键名 # 形如 __keyspace0__:order:timeout:10086 left, _, _ pattern.partition(:) meta left[len(__keyspace):] db meta.split(__)[0] ev data key pattern.split(:, 1)[1] yield RedisEvent(dbdb, eventev, keykey, channelpattern, patternpattern)这个循环本身不做事只做“接住事件、拍平字段”的工作。注意我特意区分了 keyevent 和 keyspace 两种频道的解析规则因为它们的事件内容语义完全不同keyevent 频道的消息体是键名keyspace 频道的消息体是动作。第一次写代码时没看清文档在这上面浪费了不少时间。4.2 规则过滤的匹配逻辑有了结构化字段之后过滤规则就很好写了。核心的匹配逻辑我用的是“简单优先、命中即止”的策略配置里每条规则有 event、key_prefix、db 三个可选的匹配条件消息进来后逐条规则判断只要有一条全部匹配就用这条规则的输出配置不再继续往下匹配。event 的匹配支持精确值和通配符例如 expired、set、del或者直接写 * 匹配所有。key_prefix 做的是字符串前缀匹配所以配置成 order:timeout: 能同时覆盖 order:timeout:10086 和 order:timeout:99999。db 字段默认不设置等于匹配所有库但一旦设置就必须精确相等。这个设计的取舍在于把复杂度放在规则上代码保持简单。如果后面需要更复杂的正则匹配我预留了 key_pattern 字段用 re.search 做正则匹配优先级高于 key_prefix。但用下来的感觉是之前配置的几十条规则里用到正则的不超过三条前缀匹配已经覆盖了绝大部分场景。4.3 Webhook 发送器为什么必须带缓冲接收和发送如果不解耦整个工具会变成一根脆弱的链条下游接口慢订阅协程就卡住Redis 的发送缓冲区会膨胀甚至触发 Redis 的 client output buffer limit把订阅连接断开。这个坑我在联调阶段真实遇到过现象是运行一小时后 Redis 日志里出现 “Client closed connection” 还有 “output buffer limit reached” 的报错。后来设计发送模块时我加了一个固定大小的 asyncio.Queue 作为缓冲发送 worker 从队列里取消息发 HTTP。队列满的时候采用丢弃且落盘的策略而不是阻塞生产端。async def send_worker(queue: asyncio.Queue, client: httpx.AsyncClient): while True: item await queue.get() try: resp await client.post(item[url], jsonitem[payload], timeout5) if resp.status_code 500: # 服务端5xx重试一次 await asyncio.sleep(1) await client.post(item[url], jsonitem[payload], timeout5) except Exception as exc: log_message_to_disk(item, exc)每次发送超时控制到 5 秒避免某个慢接口拖死整个 worker。下游服务如果持续抖动消息会落在本地磁盘上至少比全丢强。4.4 几个容易忽略的配置细节第一个细节是 db 编号。Redis 键空间通知的频道里带 db 编号比如 db 0 就是keyevent0:expireddb 1 就是keyevent1:expired。用通配符 * 能覆盖所有库但通配符订阅会让解析逻辑多一层判断性能略有下降。如果业务明确只用 db 0直接订阅keyevent0:* 是更稳的选择。第二个细节是 rename 事件会导致事件顺序变化。Redis 的 RENAME 命令会触发 rename_from 和 rename_to 两个独立事件而且两个事件的发布顺序在不同 Redis 版本里不一定保持一致。如果你的下游逻辑对“哪个键先通知”有顺序要求尽量绕开 rename或者在下游做状态收敛。第三个细节是 expired 事件的实际触发时机。很多人以为 EXPIRE 到了就立刻收到 expired 事件实际不是。Redis 对过期键的删除是惰性的只有在键被访问或者专门的过期扫描线程跑到时才真正删除所以 expired 事件可能比预期延迟几十秒。这个不是 bug是 Redis 的设计。对毫秒级精确过期有要求的场景等这个事件是不现实的。5. 常见问题与排查实录5.1 明明配置了规则为什么收不到事件这是被问得最多的一个问题。我排查时按顺序检查四件事。第一Redis 的 notify-keyspace-events 到底有没有生效。用 CONFIG GET notify-keyspace-events 看一下如果返回空字符串说明配置没落上事件永远发不出来。配置文件改了要重启CONFIG SET 只对当前运行期生效重启后会丢。第二psubscribe 的 pattern 和实际频道对没对上。比如业务用的是 db 1你订阅的是keyevent0:expired那就永远收不到。用 redis-cli 手动 psubscribekeyevent1:* 验证一下如果手动能收到说明 Redis 端没问题问题在配置的 db 号。第三检查事件类型缩写。Redis 的 notify-keyspace-events 有一个特别容易踩的坑A 参数是 g$lshzxe 的别名但不包含 m键未命中和 n新键。如果你需要 new key 事件必须显式加上 n不能指望 A 帮你覆盖。第四检查客户端是不是老版本导致订阅后连接被自动断开。老版本的 redis-py 对 pubsub 的 keepalive 处理有 bug长时间空闲会被网络设备掐断。升级到最新版本并在配置里把 socket_keepalive 打开。5.2 expired 事件延迟特别高正常吗正常但需要判断延迟是来自 Redis 还是来自工具本身。Redis 键过期后删除动作依赖惰性删除和周期性扫描的配合周期扫描默认每秒跑 10 次每次取一部分过期键删除所以延迟理论上在百毫秒级到秒级波动极端情况下如果你的键一直不被访问可能有几十秒的差距。工具这一侧的延迟我压测过从 Redis 发布事件到 Webhook 发出请求p99 在 50 毫秒以内。所以如果你测出几分钟甚至更久的延迟先别怀疑工具先用 redis-cli psubscribe 手动订阅确认 Redis 的动作。如果手动订阅也收得晚那就是 Redis 自己的行为。要解决精确性问题只能改造业务侧比如创建键的时候同时写入一个延迟队列让队列在指定时间之后主动触发。5.3 高并发下消息会不会丢重复怎么办会丢的是极端情况发送队列满了之后新消息会落盘而不是阻塞。如果落盘也失败那就是真的丢。但正常情况下只要磁盘可写消息最多是延迟处理而不是消失。重复是另一种情况需要特别注意。pub/sub 本身没有消息确认机制客户端重连后重新订阅不会自动补发之前的消息但这不代表下游不会收到重复事件。比如 Redis 主从切换后客户端重连到新的主节点旧连接上的消息可能重发一次再比如下游业务接口超时之后又重试了一次也会造成重复。所以下游处理事件时一定要保证幂等订单关闭接口收到两次相同 orderId 的请求第二次应该直接返回成功不应再次改变订单状态。我在工具文档里专门加了一节幂等建议这是生产环境必须养成的习惯。5.4 常见问题速查表现象可能原因解决办法收不到任何事件notify-keyspace-events 未开启config set notify-keyspace-events KEA只能收到 set收不到 expired订阅频道用了 keyevent0 但键实际写在其他 db检查业务连接 Redis 用的 db 编号延迟高Redis 过期删除的惰性机制改为延迟队列或业务主动感知Webhook 偶发超时下游接口慢增加超时、队列缓冲、落盘重放连接频繁断开TCP 层空闲断连开启 socket keepalive 和应用层 PING键名里带敏感信息频道名暴露明文规范键名使用脱敏 ID6. 一些真实使用体会和扩展方向6.1 怎么把它接进已有的缓存链路如果你已经在用缓存 数据库的模式接入这个工具不需要大改。最常见的接法是这样业务更新数据库成功后删除或更新 Redis 缓存键。同时 Redis 的键事件会推给 oh-my-hermes下游服务收到 DEL 事件后主动清理自己的 JVM 本地缓存。整个过程业务代码只加了原来删除缓存那一行其他完全不用动。另外一个可以扩展的方向是“缓存预热”。比如运营后台改了一批配置项写入 Redis 后触发 set 事件oh-my-hermes 把事件广播到所有业务节点每个节点收到后主动重新读取配置。这个比定时刷新实时得多也比每次请求都穿透去数据库查要省很多。6.2 和消息队列的边界在哪里这是很多同事问过的问题有了 oh-my-hermes是不是可以不用 Kafka 了我的答案一直是不能它替代不了 MQ。消息队列的核心能力是持久化、回溯消费、堆积削峰、多消费者组这些 oh-my-hermes 都不具备。它适合的永远是“轻量、准实时、可容忍偶发丢失”的场景。但有时候两者可以互补。比如 upstream 系统负责推送数据变更到 Kafka下游系统消费 Kafka 做数据同步这里面如果还牵扯到 Redis 状态变更理论上也可以用 oh-my-hermes 做一条旁路通知专门给需要快速响应的模块用。这种“MQ 做主链路、事件通知做旁路”的组合在实践中比把一切都堆到 MQ 上更好维护。6.3 关于工具边界和个人体会用这个工具大半年最大的感受是Redis 键空间通知是一把被低估的利器但它有非常明确的适用边界。它擅长的场景是让已有系统以极低的改造成本获得变更感知能力它不擅长的场景是要求强一致、强可靠的任何链路。我个人现在定制了一个小习惯所有新服务的 Redis 键名统一加上业务前缀并且在文档里写清楚哪些前缀会被事件订阅监听、哪些前缀涉及敏感信息不能裸放。同时把 oh-my-hermes 的 Webhook 地址统一收敛到一个网关入口下游接口变更时只需要网关转发不需要挨个改配置。这些习惯比工具本身的代码更重要因为工具只是把消息送到你面前最终怎么用、用在哪里还是要靠你自己对业务边界的理解。如果你也在为“Redis 状态变化如何低成本通知下游”发愁可以先拿它跑一版试试动手验证过才知道这套方案适不适合你。
返回列表