项目的投影监听器跑了一段时间后,我开始认真对待一个问题------投影挂了怎么办。
背景先交代一下:项目用事件溯源 + CQRS,订单、排班、服务需求各有一个投影监听器。它们在后台不停地消费事件流、更新读模型表。前端能 SELECT * FROM orders 全仰仗它们。
但数据库总有不靠谱的时候------连接抖一下、查询超时、PG 短暂不可用,哪个都能让投影停摆。一停,用户写的订单查不到,排班冲突检测失效。
更关键的是,我不想为这件事引入额外的消息队列。技术栈已经够多了------Rust + Leptos + WASM + PostgreSQL + SeaORM + disintegrate,再加个 Kafka 或 RabbitMQ?一个人维护不起。
下面说说我是怎么用 PG NOTIFY + 250ms 轮询 + 指数退避 这三层机制,在零额外依赖的前提下,把投影可靠性拉满的。
提前声明:这是我个人的实践经验,不代表最佳实践,仅供参考。
先看看出问题的地方长什么样
三个投影监听器,跑在独立的 tokio 任务里:
rust
// backend/src/infrastructure/projections/crm/mod.rs
pub async fn spawn_crm_listeners(read_model_db: DatabaseConnection) -> Result<(), String> {
service_request_projection::spawn_service_request_listener(read_model_db.clone()).await?;
order_projection::spawn_order_listener(read_model_db.clone()).await?;
schedule_projection::spawn_schedule_listener(read_model_db).await?;
Ok(())
}
每个 spawn_*_listener 内部做的事都一样:创建一个 PgEventListener,注册投影处理器,启动无限循环消费事件流。这三个任务是系统的"消化系统"------事件是输入,读模型表是输出,投影监听器是中间的管道。
问题在于:这根管道总有堵塞的时候。
第一层:PG NOTIFY------最快的路径,但不是最稳的
disintegrate-postgres 框架在事件表上自动建了数据库触发器,每次 INSERT 事件后发一条 NOTIFY。投影监听器在启动时通过 .with_notifier() 注册了 LISTEN,收到通知后立即醒来处理。
rust
// backend/src/infrastructure/projections/crm/order_projection.rs
PgEventListener::builder(listener_event_store)
.uninitialized()
.register_listener(
projection,
PgEventListenerConfig::poller(Duration::from_millis(250))
.with_notifier() // ← 启用 PG NOTIFY
.with_retry(|err, attempts| {
super::projection_listener_retry("order", err, attempts)
}),
)
.start()
.await
正常情况下的延迟非常理想------事件提交后几十毫秒内投影就消费完了,前端刷新列表数据已经在,用户完全无感。
但 NOTIFY 有个短板:它是无状态的推送,丢了就丢了。连接中断、进程重启、LISTEN 还没注册完这短短间隙里发的 NOTIFY------这些情况都会丢消息。如果只靠 NOTIFY,丢了的消息要到下次有人写事件才能被"顺带"发现。
所以 NOTIFY 是最快路径,但必须有人兜底。
第二层:250ms 轮询------稳定可预测的最后防线
兜底方案就是轮询。Poller(250ms) 意味着不管 NOTIFY 有没有丢,投影监听器每 250ms 会主动扫一次事件流,检查有没有未处理的新事件。
rust
PgEventListenerConfig::poller(Duration::from_millis(250))
250ms 这个值不是拍脑袋定的。之前试过 100ms------CPU 涨了大约 5%,因为三个监听器各有各的轮询周期,频繁扫一张表。试过 500ms------投影延迟肉眼可见,创建订单后刷新可能要等半秒才出来。
250ms 是一个"不浪费 CPU,但用户感觉不到延迟"的平衡点。家政 SaaS 不是支付系统,250ms 的投影延迟在业务上完全可以接受。
这里有一个关键设计:轮询是兜底,NOTIFY 是加速。正常情况 NOTIFY 在几十毫秒内就拉起了消费,轮询只是默默在那儿待命。NOTIFY 丢了才轮到轮询顶上。两者是互补关系,不是替代关系。
第三层:指数退避重试------不急不躁地恢复
前面两层解决的是"新事件到达"的问题。但还有另一种场景------数据库临时不可用,投影处理器读事件或写读模型失败。这种失败不能死循环重试------每次重试都是一次数据库查询,越重试压力越大。
我写的重试公式:
rust
// backend/src/infrastructure/projections/crm/mod.rs
pub(crate) fn projection_listener_retry<HE: Debug>(
label: &str,
err: PgEventListenerError<HE>,
attempts: usize,
) -> RetryAction {
let backoff_ms = (200_u64 * 2_u64.pow(attempts.min(5) as u32)).min(5_000);
if attempts >= 10 {
eprintln!("{} projection listener aborted after repeated errors: {:?}", label, err);
return RetryAction::Abort; // 10 次全失败,放弃等人介入
}
eprintln!("{} retrying after transient error (attempt {}): {:?}", label, attempts + 1, err);
RetryAction::Wait {
duration: Duration::from_millis(backoff_ms),
}
}
退避节奏是这样的:
| 尝试次数 | 等待时间 | 累计耗时 |
|---|---|---|
| 第 1 次 | 200ms | 0.2s |
| 第 2 次 | 400ms | 0.6s |
| 第 3 次 | 800ms | 1.4s |
| 第 4 次 | 1.6s | 3.0s |
| 第 5 次 | 3.2s | 6.2s |
| 第 6-10 次 | 每次 5s | 共约 31s |
这个公式里有三个设计决策值得展开说:
1. 为什么是 200ms 起步? 瞬态故障(比如连接池耗尽、死锁重试)通常几百毫秒内就能自愈。200ms 够快,但不会在 PG 挣扎的时候雪上加霜。
2. 为什么第 5 次就封顶 5 秒? attempts.min(5) 让翻倍在第 5 次之后停止增长,退避封顶在 5 秒。如果不封顶,按照 200 * 2^n 一直翻------第 9 次要等 51.2 秒,第 10 次要等 102.4 秒。等那么久不如直接放弃,让人介入排查。
3. 为什么 10 次就 abort? 投影监听器一直失败意味着有系统性问题------可能是 PG 宕机、表结构错乱、磁盘满了。无限重试只会让运维人员以为"一切正常",实际上读模型可能已经滞后了半小时。Abort 后打印一行醒目的错误日志,反而更早暴露问题。
之前试过固定 1 秒重试------有次 PG 重启 30 秒,投影打了三十几次失败日志,纯浪费 IO。也试过无上限翻倍------第 10 次要等近两分钟,等着等着自己都忘了在等什么。指数退避 + 封顶 + 有限次数,是踩了两次坑之后收敛到的方案。
三个投影监听器共用同一个重试函数,通过闭包传入不同的 label 来区分日志:
rust
// order_projection.rs
.with_retry(|err, attempts| {
super::projection_listener_retry("order", err, attempts)
})
// schedule_projection.rs
.with_retry(|err, attempts| {
super::projection_listener_retry("schedule", err, attempts)
})
// service_request_projection.rs
.with_retry(|err, attempts| {
super::projection_listener_retry("service request", err, attempts)
})
翻日志的时候一眼就能看出是哪个投影挂了、重试了几次、当前什么状态,排错很快。
三层怎么协作
把这三层放在一起看,分工很明确:
- NOTIFY 管速度:正常路径,事件写入后几十毫秒内消费。最快响应。
- 轮询管兜底:NOTIFY 丢了也最多等 250ms。最稳保证。
- 退避管容错:数据库异常时不急着连环重试加重负担,优雅等待恢复。
三者互为补充,管好各自的故障域。
开发期间我故意断过几次 PG 来验证这套机制。对于个人开发者来说最关心的不是这套东西高级不高级,而是出了问题能不能自己恢复 。实测每次断 PG 30 秒以内,投影都会在 PG 恢复正常后的几百毫秒内自动追上。翻日志看到 projection listener retrying after transient error,下一秒就恢复正常,相当省心。
当然,这套方案也有局限:10 次重试全失败后 abort,没有死信队列、没有持久化重试状态。如果真到了那一步,需要登录服务器看日志排查根因。但对于个人维护的中型项目来说,在引入消息队列之前,DB 自带机制能搞定的事就别加依赖。
总结
投影重试这件事,核心原则就一条:不同的故障原因,用不同的恢复策略。
- 通知丢了 → 轮询兜底
- 瞬时故障 → 指数退避自愈
- 持续故障 → 有限次数后放弃,避免雪崩
这套 PG NOTIFY + 250ms 轮询 + 指数退避的三层方案,零额外中间件依赖,三个投影监听器共用同一份重试逻辑,出问题该恢复的自己恢复,该报警的留足日志。
你对投影重试是怎么处理的?是走消息队列做死信重试,还是跟我在 DB 层搞定?欢迎在评论区聊聊你的方案。