Keyspace 通知与变更捕获:从事件订阅到可靠 CDC 补偿

Redis Keyspace 通知与变更捕获实战:事件类别字符详解、__keyspace@0__ 与 __keyevent@0__ 双通道订阅、CONFIG SET 动态开启与同步派发代价、集群下通知只在本地节点触发的问题、事件驱动本地缓存失效、Pub/Sub 不保证送达与无重放的本质局限、Streams 自建可靠 CDC、Debezium 方案对比与对账兜底

Redis 没有 binlog。MySQL 可以用 binlog 订阅把每一次行变更变成下游可消费的流,MongoDB 有 Change Streams,Kafka 本身就是日志——而 Redis 的命令执行结果是不落任何面向消费的变更日志的。AOF 只是命令回放,RDB 只是快照,两者都不是变更流。

但 Redis 提供了一个被低估的机制:Keyspace 通知(Keyspace Notifications)。它能在键被修改、过期、淘汰的瞬间向 Pub/Sub 频道投递一条事件。很多人第一次接触它是为了做「本地缓存失效」,也有人试图用它搭一套完整的变更数据捕获(Change Data Capture,CDC)管道。这两种用法的可靠性边界差别极大,混为一谈会在生产上踩坑。

本文从 notify-keyspace-events 的事件类别字符讲起,逐层剖析双通道事件模型、同步派发的性能代价、集群下的行为差异,再给出两类真实用法与各自的补偿手段,最后落到「事件驱动只做加速、可靠 CDC 必须另建通道」的工程结论。

一、事件模型:两个频道,两种视角

Keyspace 通知不是一种通知,而是同一次变更的两种镜像。开启 K 与 E 后,对 SET user:1001 zhangsan 这一次写操作,Redis 会同时向两个频道投递消息:

PUBLISH __keyspace@0__:user:1001  set
PUBLISH __keyevent@0__:set        user:1001
  • Keyspace 频道 __keyspace@<db>__:<key>:频道名里带 key,消息体是事件名。适合「只关心某个键发生了什么」。
  • Keyevent 频道 __keyevent@<db>__:<event>:频道名里带事件,消息体是 key 名。适合「关心某类事件发生在哪些键上」。

<db> 是数据库编号,必须显式写出,没有省略形式。这也意味着 Cluster 模式下每个节点只发自己槽位上的事件,而且 db 永远是 0。

订阅示例:

# 观察某个键的全部事件
redis-cli psubscribe '__keyspace@0__:user:1001'

# 观察所有键的过期事件
redis-cli psubscribe '__keyevent@0__:expired'

# CSV 格式输出,便于管道处理
redis-cli --csv psubscribe '__keyevent@0__:*'

# 计数模式:不打印消息体,只看每秒事件量
redis-cli --csv psubscribe '__keyevent@0__:*' | pv -l -i 1 > /dev/null

PSUBSCRIBE 用的是 glob 模式匹配,* 匹配任意字符、? 匹配单字符、[abc] 匹配字符集。生产上建议尽量收窄模式,__keyevent@0__:* 这种全通配只用于排障。

1.1 事件类别字符表

notify-keyspace-events 的值是一个字符集合,每个字符代表一类事件。理解这张表是配置的前提:

字符含义典型事件名
KKeyspace 频道开关无独立事件,开启 __keyspace@ 前缀
EKeyevent 频道开关无独立事件,开启 __keyevent@ 前缀
g通用命令(Generic)del expire rename persist move restore
$String 命令set incr append setrange incrby
lList 命令lpush rpop ltrim linsert
sSet 命令sadd srem spop sinterstore
hHash 命令hset hdel hincrby hincrbyfloat
zZSet 命令zadd zrem zincr zremrangebyscore
x过期事件(Expired)expired
e淘汰事件(Evicted)evicted
tStream 命令xadd xtrim xdel xgroup
d模块数据类型事件模块自定义
mKey miss 事件keymiss
n新键事件(New key)new
A别名,等价于 g$lshzxet—

A 是别名而不是超集:它不包含 m(keymiss)、n(new)和 d(模块)。KEA 是最常被写进配置的字符串,但严格说它并不等于「全部事件」。

1.2 哪些命令会触发事件

一个常见误解是「所有写命令都触发事件」。实际规则是:只有当键的数据结构真正发生变化时才触发。

操作是否触发说明
SET k v(值相同)触发无条件派发 set
SETNX k v(已存在)不触发未修改数据
EXPIRE k 60(已有更短 TTL)触发 expireTTL 变更即事件
LPUSH k a触发 lpush每次命令一条事件
HSET k f1 v1 f2 v2触发 1 条 hset按命令粒度,非按 field
DEL k1 k2 k3触发 3 条 del按 key 粒度
GET k不触发(除非开 m)读命令默认无事件
GET k(键不存在,开 m)触发 keymiss需显式开 m

两个关键点:写命令按命令粒度派发(一次 HSET 多个 field 只有一条事件),而 DEL/UNLINK 按 key 粒度派发(删 1 万个键就是 1 万条事件)。批量删除是事件风暴的主要来源。

1.3 动态开启与观察

notify-keyspace-events 支持运行时修改,无需重启:

CONFIG SET notify-keyspace-events "KEA"
CONFIG GET notify-keyspace-events

若返回空字符串,说明通知完全关闭。生产环境建议只开需要的事件类别,例如只做缓存失效时用 Egx(通用 + 过期 + 淘汰),而不是 KEA:

# 只关心删除、过期、淘汰三类失效信号
CONFIG SET notify-keyspace-events "Egxe"

二、同步派发:性能代价藏在哪里

Keyspace 通知最容易被忽视的一点是:它不是异步队列,而是在命令执行路径上同步派发的。

以 SET 为例,Redis 在 setGenericCommand 里写完后调用 notifyKeyspaceEvent(NOTIFY_STRING, "set", key, db),后者内部最终执行 pubsubPublishMessage。这意味着:

  1. 事件生成发生在持有命令执行主线程时,与业务命令共享同一个事件循环,没有独立线程或队列。
  2. 消息投递到订阅者的输出缓冲区(client output buffer)是非阻塞写,但如果订阅者读得慢,缓冲区会持续增长。
  3. 缓冲区超过 client-output-buffer-limit pubsub 阈值后,Redis 会直接断开订阅者连接。

相关配置:

# 订阅客户端缓冲区:硬限制 32mb,软限制 8mb / 60 秒
CONFIG SET client-output-buffer-limit "pubsub 32mb 8mb 60"

被断开这件事非常危险:订阅端通常感知不到「我漏了一段事件」。重连后订阅恢复,但中间窗口的变更永久丢失——这是 at-most-once 语义的直接体现。

2.1 开销量级估算

场景每秒事件数额外 CPU说明
只开 Egx,写入 5 万 QPS~5 万约 3%~6%通常可接受
开 KEA,写入 20 万 QPS~40 万(双通道)约 15%~25%需压测评估
大 Key 批量操作(10 万元素 LPUSH)1 条低按命令粒度
批量 DEL 1 万个键1 万条 del高按 key 粒度
EXPIRE 到期集中触发与 TTL 分布相关波动大见下节

最隐蔽的开销来自 x 与 e:如果一个实例上有 50 万个键在同一分钟到期(常见于「整点刷新」类业务),expired 事件会在短时间内集中爆发,叠加主动淘汰循环的 CPU 占用,造成明显的延迟毛刺。

2.2 过期事件的时机陷阱

expired 事件不是定时器精确触发的,它绑定在两种删除路径上:

  • 惰性删除:某个键被访问时发现已过期,删除并触发事件。
  • 主动淘汰循环:serverCron 里的 activeExpireCycle 抽样扫描,抽到才删。

后果是:一个 TTL 到期的键,如果再也没有人访问它,它的 expired 事件可能要等几分钟甚至更久(取决于内存压力与抽样频率)。如果业务逻辑依赖「TTL 到期即触发某个动作」,Keyspace 通知是不可靠的——应该改用「有序集合 + 定时扫描」方案,或者用延迟队列。

实测过的一个典型例子:某业务用 expired 事件驱动「订单超时关闭」。压测时正常,上线后在低峰期出现订单长时间未关闭——因为低峰期键访问少,惰性删除不触发,主动淘汰循环抽到该键的概率也低。换成 ZSet 延迟队列(ZADD order:delay <deadline> <orderId> + 每秒 ZRANGEBYSCORE 扫描)后问题消失。

三、键空间事件与内存事件的分工

除了键空间通知,Redis 还暴露了一组内存与实例级事件,两者常被混淆:

事件源触发点用途
Keyspace 通知键被修改 / 删除 / 过期缓存失效、轻量审计
maxmemory 淘汰内存超限时按策略淘汰需要监控 evicted_keys
WAIT / 复制偏移主从同步进度一致性校验
CLIENT TRACKING客户端读过的键被改客户端缓存

生产上真正需要「变更信号」的场景,八成落在第一行;但排查内存问题时看的是第二行。两者的关系是:淘汰会同时产生 evicted 事件,所以订阅 __keyevent@0__:evicted 可以在缓存被大量驱逐时立刻感知——这比等监控告警更快。

四、用法一:跨节点本地缓存失效

这是 Keyspace 通知最成熟的应用。多实例应用各自维护一份进程内缓存(Caffeine、Guava、Go 的 sync.Map),当某个实例写库后,需要让其他实例的本地副本失效。

Go 侧的完整实现:

type Invalidator struct {
    rdb        *redis.Client
    localCache *lru.Cache
    instanceID string
}

// 写入方:更新 Redis 后,其他实例通过订阅感知
func (s *Service) UpdateUser(ctx context.Context, u User) error {
    if err := s.rdb.HSet(ctx, "user:"+u.ID, "name", u.Name).Err(); err != nil {
        return err
    }
    return nil
}

// 订阅方:监听 keyevent 事件,清理本地缓存
func (inv *Invalidator) Run(ctx context.Context) error {
    for {
        sub := inv.rdb.PSubscribe(ctx, "__keyevent@0__:hset",
            "__keyevent@0__:del", "__keyevent@0__:expired",
            "__keyevent@0__:evicted")
        ch := sub.Channel()
        // 重连后先全量失效,兜住断线窗口
        inv.localCache.Purge()
        for msg := range ch {
            key := msg.Payload // keyevent 通道的消息体就是 key
            inv.localCache.Remove(key)
        }
        sub.Close()
        if ctx.Err() != nil {
            return ctx.Err()
        }
        time.Sleep(time.Second) // 退避后重连
    }
}

这套模式与本专题 客户端缓存与失效广播 中讲的 RESP3 CLIENT TRACKING 是同一类思路。区别在于:

维度Keyspace 通知RESP3 Client Tracking
协议要求RESP2 即可需 RESP3 连接
粒度按事件类别全量广播只跟踪该客户端读过的键
精确性键级,不区分谁读过键级,精确到客户端
模式广播式默认失效模式,也支持 BCAST
额外带宽每键每事件一条消息相近
服务端内存无额外跟踪表需维护 tracking table

如果只是想给自己的连接做本地缓存,优先用 CLIENT TRACKING(服务端精确跟踪、无需订阅逻辑);如果要给任意数量的异构消费者广播失效信号(例如 Python 服务、Go 服务、Node 服务都要感知),Keyspace 通知更通用。

4.1 必须处理的三个坑

  1. 订阅重连窗口:网络抖动导致 PSubscribe 断开,重连期间的变更全部丢失。补偿手段是订阅端在重连后主动清空本地缓存(宁可全量回源,也不要脏读),上面的代码里 Purge() 就是干这个的。
  2. 自身事件回环:写入方自己也会收到事件,触发一次无意义的本地失效。可以用实例 ID 打标过滤,或直接接受——失效操作本身是幂等的。
  3. 事件风暴:批量刷新场景下短时间内数万条事件涌入,订阅端单线程处理会成为瓶颈。要么合并(用 SADD 收集待失效键,定时批量清理),要么用 redis-cli --csv 加外部消费者分流。

4.2 Java / Spring 侧的实现要点

Spring Data Redis 用 RedisMessageListenerContainer 承载订阅,天然带重连与线程池:

@Configuration
public class KeyspaceNotifyConfig {

    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory factory,
                                            CacheInvalidateListener listener) {
        RedisMessageListenerContainer c = new RedisMessageListenerContainer();
        c.setConnectionFactory(factory);
        c.addMessageListener(listener,
            new PatternTopic("__keyevent@0__:del"),
            new PatternTopic("__keyevent@0__:hset"));
        // 订阅线程池,避免单线程成为瓶颈
        c.setTaskExecutor(Executors.newFixedThreadPool(4));
        c.setRecoveryInterval(2000L);
        return c;
    }
}

@Component
public class CacheInvalidateListener implements MessageListener {
    private final LocalCache cache;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String key = new String(message.getBody(), StandardCharsets.UTF_8);
        cache.invalidate(key);   // 必须幂等
    }
}

注意两个参数:setRecoveryInterval 决定断线重连的探测间隔(越小恢复越快,但空转开销略高);setTaskExecutor 决定并发消费能力(默认单线程,事件量大时必须扩)。

4.3 批量失效的合并技巧

高频写场景下,逐条失效会把订阅端打满。常见的合并方案是用一个 Set 收集待失效键,定时批量清理:

-- 消费者侧:先把 key 攒进待处理集合,带 TTL 防泄漏
SADD invalidate:pending "user:1001" "user:1002"
EXPIRE invalidate:pending 30
每 200ms 执行一次:
  keys = SMEMBERS invalidate:pending
  localCache.invalidateAll(keys)
  DEL invalidate:pending

代价是失效延迟从「毫秒级」放宽到「百毫秒级」,换来的是事件风暴下的稳定性。是否接受取决于业务对陈旧数据窗口的容忍度。

五、用法二:轻量 CDC——能做什么,不能做什么

把 Keyspace 事件转发到 Kafka,看起来就是一条 CDC 管道:

Redis ──(keyspace events)──> 消费者 ──> Kafka topic ──> 下游

但这里有一个结构性缺陷:keyevent 消息体只有 key 名,没有值。

__keyevent@0__:set  →  "user:1001"     # 新值是什么?不知道
__keyevent@0__:hset →  "user:1001"     # 改了哪个 field?不知道

消费者收到事件后必须回读 Redis 才能拿到值:

def on_event(key: str) -> None:
    value = r.get(key)          # 竞态:读到的可能已经是后续版本
    if value is None:
        return                  # 可能是删除,也可能只是过期
    producer.send("cdc-topic", {
        "key": key, "value": value, "ts": time.time(),
    })

回读引入了两个问题:

  • 版本错乱:SET k v1 与 SET k v2 间隔 1ms,消费者处理 v1 事件时回读,读到的是 v2,丢失了 v1 这个中间状态。
  • 删除语义丢失:DEL k 事件到达时回读返回 nil,无法区分「键被删除」与「键刚刚过期」。

所以 Keyspace 通知能做的是**「键级失效信号」,不是「值级变更日志」**。把它当 CDC 用,只在一种情况下成立:下游只关心「这个键变脏了,请重新拉取」,而不关心「变成了什么」。这正是缓存失效的语义。

六、可靠性边界:一张对照表

特性Keyspace 通知真正的 CDC(binlog / oplog)
投递保证At-most-once(可能丢)At-least-once(可续传)
断线重放不支持支持(位点 / offset)
顺序保证单连接内有序,重连后不确定有严格位点顺序
变更前值 / 后值无有(row-based binlog)
事务边界无(MULTI 内各命令各自触发)有
过期 / 淘汰事件延迟且不确定不适用
性能开销主线程同步派发独立线程 / 外部组件
集群支持仅本地节点,不跨槽视实现而定
消费者隔离慢消费者拖累缓冲区消费者组独立位点

其中最容易被低估的是 Cluster 下的广播缺口。Redis 7 之前,Cluster 模式的 Pub/Sub 消息会广播到所有节点;Redis 7 引入分片 Pub/Sub(SSUBSCRIBE)后,普通 PUBLISH 仍然全局广播,但 Keyspace 通知只在键所在的那个分片节点上产生。如果你的订阅端只连了某一个节点,就会漏掉其他分片的事件。

正确做法是:订阅端连接集群中的所有主节点(或用支持分片订阅的客户端),每个节点都建立一个 PSubscribe。这与 Pub/Sub 与 Streams 选型 中讨论的分片订阅问题是同一件事。

七、可靠 CDC 的替代与补偿

既然 Keyspace 通知不可靠,做真正的变更捕获该选什么?

7.1 方案一:应用层 Outbox + Streams

在业务写入 Redis 的同时,把变更写入一个 Stream:

-- 原子写业务键 + 追加变更日志
local key, val = KEYS[1], ARGV[1]
redis.call('SET', key, val)
redis.call('XADD', 'cdc:stream', 'MAXLEN', '~', '1000000', '*',
           'op', 'set', 'key', key, 'val', val)
return 1

消费组用 XREADGROUP 读取,处理完 XACK,未 ACK 的消息由 XAUTOCLAIM 接管。这套方案的可靠性来自 Streams 的持久化与消费组语义,Keyspace 通知只是可选的加速信号。Lua 脚本的原子性细节需要单独设计,确保业务写入与日志追加在同一脚本内完成。

7.2 方案二:以业务库为准的 Debezium

大多数「Redis CDC」的真实需求其实是**「数据库变更后同步到 Redis」**,方向是反的。这时应该用 Debezium CDC 管道 订阅 MySQL/PostgreSQL 的 binlog,把变更推到 Kafka,再由消费者写 Redis。这条链路的可靠性与 Redis 无关,Redis 只是终点。

对比三者:

方案变更源可靠性适用场景
Keyspace 通知Redis 自身低缓存失效广播
Outbox + Streams应用显式写中高Redis 内部需要审计 / 回放
Debezium关系库 binlog高库到缓存的同步

结论是:Keyspace 通知做「信号」,Streams 与 binlog 做「事实」。两者叠加使用,而不是互相替代。

八、生产实践清单

  • 只开启需要的事件类别,用 Egx 而非 KEA,避免无谓的双通道派发。
  • 订阅端必须实现重连后全量失效兜底,不要假设事件连续。
  • 为订阅连接单独设置 client-output-buffer-limit pubsub,并在监控中跟踪 pubsub_channels 与客户端缓冲区使用量,相关指标可接入 Redis 监控与可观测性 的指标体系。
  • Cluster 模式下为每个主节点建立订阅,或改用 SSUBSCRIBE 系列命令。
  • 不要把 expired 事件当定时器用,延迟不可控;需要精确触发时改用 ZSet 延迟队列。
  • 事件消费逻辑必须幂等:同一 key 的失效可以被重复执行。
  • 对关键业务补一条定时对账(如每 5 分钟全量比对热点键),作为最终一致性兜底。
  • 上线前用 --csv 加 pv -l 实测事件速率,确认订阅端处理能力有 3 倍以上余量。

九、压测与容量验证

上线前必须实测事件速率与订阅端处理能力,方法如下。

第一步,用 redis-benchmark 或业务流量制造写入压力,同时统计事件速率:

# 统计 10 秒内的事件条数
timeout 10 redis-cli --csv psubscribe '__keyevent@0__:*' | wc -l

# 观察订阅端缓冲区与连接状态
redis-cli info clients | grep -E 'connected_clients|client_recent_max_output_buffer'
redis-cli info stats  | grep -E 'pubsub_channels|pubsub_patterns'

第二步,观察是否有订阅连接被踢:

redis-cli info stats | grep -E 'total_net_output_bytes|rejected_connections'
redis-cli client list type pubsub

若 client list type pubsub 里出现 omem 持续增长,说明订阅端消费不过来,需要扩容消费者或收窄事件类别。

第三步,容量估算。设每秒事件数为 E、单条事件平均消息体为 S 字节,则订阅端需要承载的带宽为 E × S,再乘以 2(K 与 E 双通道各一份)。例如 E = 20 万、S = 40 字节,双通道下约 16 MB/s——这个量级对单个订阅进程已经不轻松,必须做合并或分流。

规模事件速率建议架构
小(< 1 万/s)单订阅进程足够应用内直接订阅
中(1 万~10 万/s)需批量合并订阅进程 + 定时批量失效
大(> 10 万/s)单点必崩收窄事件类别 + 分片订阅 + 专用消费者

小结

Keyspace 通知是 Redis 提供的一个轻量、零依赖的变更信号通道:双频道(__keyspace@ / __keyevent@)、按事件类别字符精确开关、主线程同步派发。它的强项是广播失效信号,弱项是可靠性与值语义——不保证送达、不支持重放、没有前值后值、过期事件时机不确定。

工程上的正确姿势是分层:用 Keyspace 通知做低成本的缓存失效加速,用 Streams 消费组或数据库 binlog 做真正的可靠 CDC,再用定时对账兜住所有已知的丢失窗口。任何试图只靠 Keyspace 通知构建端到端变更管道的设计,都会在第一次网络抖动时暴露出数据缺口。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「redis」更多文章

  1. 多租户隔离与资源配额:共享 Redis 的边界设计
  2. Key 设计与命名规范:Redis 里唯一的结构约束
  3. 代理与路由方案:客户端直连之外的另一种选择