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 的值是一个字符集合,每个字符代表一类事件。理解这张表是配置的前提:
| 字符 | 含义 | 典型事件名 |
|---|---|---|
K | Keyspace 频道开关 | 无独立事件,开启 __keyspace@ 前缀 |
E | Keyevent 频道开关 | 无独立事件,开启 __keyevent@ 前缀 |
g | 通用命令(Generic) | del expire rename persist move restore |
$ | String 命令 | set incr append setrange incrby |
l | List 命令 | lpush rpop ltrim linsert |
s | Set 命令 | sadd srem spop sinterstore |
h | Hash 命令 | hset hdel hincrby hincrbyfloat |
z | ZSet 命令 | zadd zrem zincr zremrangebyscore |
x | 过期事件(Expired) | expired |
e | 淘汰事件(Evicted) | evicted |
t | Stream 命令 | xadd xtrim xdel xgroup |
d | 模块数据类型事件 | 模块自定义 |
m | Key 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) | 触发 expire | TTL 变更即事件 |
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。这意味着:
- 事件生成发生在持有命令执行主线程时,与业务命令共享同一个事件循环,没有独立线程或队列。
- 消息投递到订阅者的输出缓冲区(client output buffer)是非阻塞写,但如果订阅者读得慢,缓冲区会持续增长。
- 缓冲区超过
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 必须处理的三个坑
- 订阅重连窗口:网络抖动导致
PSubscribe断开,重连期间的变更全部丢失。补偿手段是订阅端在重连后主动清空本地缓存(宁可全量回源,也不要脏读),上面的代码里Purge()就是干这个的。 - 自身事件回环:写入方自己也会收到事件,触发一次无意义的本地失效。可以用实例 ID 打标过滤,或直接接受——失效操作本身是幂等的。
- 事件风暴:批量刷新场景下短时间内数万条事件涌入,订阅端单线程处理会成为瓶颈。要么合并(用
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 通知构建端到端变更管道的设计,都会在第一次网络抖动时暴露出数据缺口。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。