最近在给手上的 Rust 项目上事件溯源,遇到了一个绕不开的架构问题:事件存储和读模型,放一个数据库还是两个?
一开始图省事,觉得一个 PostgreSQL 里分两个 schema 不就行了——es schema 放事件表,public schema 放业务表。一个数据库实例、一套 backup、一个 docker run,多清爽。
跑了一周后发现不是那么回事。本文聊聊这个决策的真实体验——两个 PostgreSQL 到底值不值。
提前声明:本文基于个人项目实践,架构选择有上下文依赖(单机部署、小团队),仅供参考。
一、为什么一个库不够?
先看两种写入模式的差异。
事件存储的写入:append-only,纯顺序写
事件存储的表结构极其简单——disintegrate_postgres 就一张 event 表,核心字段是事件 ID、stream 标识、事件类型、JSON payload。写操作永远是 INSERT,没有 UPDATE,没有 DELETE:
-- disintegrate_postgres 在 ES 库里创建的核心表
INSERT INTO event (id, stream_id, event_type, payload, created_at)
VALUES (...);
这个写入模式的特点是:高频、顺序、不可变。WAL 日志一直往前追加,不需要担心 vacuum、不需要担心死锁、不需要担心索引膨胀(只有 event_id 和 stream_id 上有索引)。
读模型的写入:随机更新,带索引维护
读模型这边就复杂多了。拿订单投影举例,同一个事件流过来,读模型的行为是:
// OrderCreated 事件 → INSERT
let active = orders::ActiveModel { ... };
active.insert(txn).await?;
// OrderStatusChanged 事件 → UPDATE
let mut active = model.into_active_model();
active.status = Set(status);
active.updated_at = Set(updated_at);
active.event_id = Set(event_id);
active.update(txn).await?;
再加上订单表上有 merchant_id、uuid、status、customer_uuid、inserted_at 一堆索引,每次 UPDATE 都要维护索引。还有 order_change_logs 表的 before/after JSON 快照写入——每次事件变更都附带一条 changelog。
事件存储说:我只 INSERT,其他事别找我。读模型说:我既要 INSERT 又要 UPDATE 还要维护索引还要写审计日志。
这两种写入模式混在同一个 PostgreSQL 实例里,谁也没碍着谁,但也没帮到谁。尤其当订单量和事件量在同一个数据库里争夺 shared buffer 和 WAL 带宽时,你就得开始操心 IO 隔离了。
查询侧的考量
读模型面向的是业务查询:
-- 前端列表页:按商户、状态、时间范围查订单
SELECT * FROM orders
WHERE merchant_id = $1 AND status = $2
ORDER BY inserted_at DESC
LIMIT 20;
这些查询依赖复合索引、依赖统计信息准确、依赖连接池里有足够的可用连接。
事件存储从来不面向业务查询——它只被三个地方访问:命令端写事件、投影器读事件、状态重建加载事件流。这三种访问都是按 stream_id 精确查找,从来不跑全表扫描。
一句话总结:事件存储和读模型的 IO 特征、索引策略、连接池需求完全不一样,混在一个库里意味着你永远要按更严格的那个来调参,另一头在凑合。
二、双库架构怎么落的
项目目前的双库架构长这样:
┌──────────────────────────────┐
│ Server Process │
│ │
│ ┌─────────┐ ┌───────────┐ │
│ │ 命令端 │ │ 查询端 │ │
│ │ (写事件) │ │ (查投影表) │ │
│ └────┬─────┘ └─────▲─────┘ │
│ │ │ │
│ ┌────▼─────────┐ ┌──┴──────┐│
│ │ sqlx::PgPool │ │ SeaORM ││
│ │ (es_db) │ │(read_db)││
│ └──────┬───────┘ └──┬──────┘│
└─────────┼─────────────┼───────┘
│ │
┌─────▼────┐ ┌─────▼─────┐
│EventStore│ │ Read Model│
│ DB │ │ DB │
│ pico_crm │ │ pico_crm │
│ _es_dev │ │ _dev │
└──────────┘ └───────────┘
两个连接池,两套技术栈:
- 事件存储:
sqlx::PgPool,直接走原生 SQL,因为disintegrate_postgres框架内部用 sqlx - 读模型:
sea_orm::DatabaseConnection,走 ORM,因为业务查询和 CRUD 操作更习惯用 SeaORM 的 query builder
启动流程串起来
server/src/main.rs 里的启动顺序很清楚:
// ① 加载 .env 文件(里面有 DATABASE_URL 和 ES_DATABASE_URL)
let env_file = format!(".env.{}", env::var("APP_ENV").unwrap_or("dev".into()));
dotenvy::from_filename(&env_file).unwrap();
// ② 连接读模型库,跑 SeaORM migration
let db = Database::new().await; // 读 DATABASE_URL
Migrator::up(db.get_connection(), None).await?; // 建业务表
// ③ 初始化事件存储、选主、启动投影监听器
bootstrap_cqrs(db.connection.clone()).await?; // 读 ES_DATABASE_URL
你可能会问——bootstrap_cqrs 是怎么拿到 ES_DATABASE_URL 的? 答案是它不通过参数传,而是直接 env::var("ES_DATABASE_URL") 读取环境变量:
// backend/src/infrastructure/event_store/mod.rs
static EVENT_STORE_POOL: OnceCell<sqlx::PgPool> = OnceCell::const_new();
pub(crate) async fn event_store_pool() -> Result<sqlx::PgPool, String> {
EVENT_STORE_POOL
.get_or_try_init(|| async {
let database_url = env::var("ES_DATABASE_URL")?; // 直接读环境变量
sqlx::PgPool::connect(&database_url).await
})
.await
.cloned()
}
这里用了一个 OnceCell 做懒初始化——事件存储的连接池只在第一次需要时创建,之后每次 .cloned() 返回同一个池的引用。sqlx::PgPool 内部是 Arc 包装的,clone 很便宜。
事件存储 schema 的初始化
bootstrap_cqrs 的第一步是 event_store::initialize(),它负责在 ES 库上建表:
// backend/src/infrastructure/event_store/mod.rs
pub async fn initialize() -> Result<(), String> {
let pool = event_store_pool().await?;
EVENT_STORE_INIT.get_or_try_init(|| async move {
// ① 为三种事件类型创建 disintegrate 的 schema(event 表 + 索引)
initialize_registered_event_schemas(pool.clone()).await?;
// ② 创建投影监听器的基础设施(NOTIFY 触发器 + listener_progress 表)
initialize_listener_infra(pool.clone()).await?;
// ③ 历史数据迁移:把旧的 order_id 回填成 order_uuid
backfill_schedule_event_order_uuid(pool).await?;
Ok(())
}).await?;
Ok(())
}
三种事件类型各自注册:
async fn initialize_registered_event_schemas(pool: sqlx::PgPool) -> Result<(), String> {
initialize_event_schema::<ServiceRequestEventEnvelope>(pool.clone(), "service request").await?;
initialize_event_schema::<OrderEventEnvelope>(pool.clone(), "order").await?;
initialize_event_schema::<ScheduleEventEnvelope>(pool.clone(), "schedule").await?;
Ok(())
}
读模型 migration 是另一套系统
读模型这边,用的是 SeaORM 的 Migrator。启动时 Migrator::up() 跑 migration/src/ 下的 20 个 migration 文件,建业务表:merchants、users、orders、schedules、service_requests、contacts 等等。
两边各管各的 migration,互不干扰。事件存储的 schema 完全由 disintegrate_postgres 的 PgEventStore::try_new() 和 Migrator::init_listener() 管理,读模型的 schema 完全由 SeaORM 的 Migrator::up() 管理。
这其实是双库架构最舒服的一点:你不会因为给事件存储加一个新的事件类型而担心影响业务表结构,也不会因为改业务表结构而担心事件存储的 schema 变更。
三、真实的账本:双库到底带来了什么
省心的地方
1. 连接池隔离
投影监听器需要长期持有数据库连接(轮询事件流、监听 PG NOTIFY),命令端写入需要快速获取连接执行决策,查询端需要应对前端请求的并发连接。三种连接需求如果共用一个池,要么池太大浪费资源,要么池太小互相抢占。
分开之后,事件存储的连接池只管事件读写和投影轮询,读模型的连接池只管业务查询和投影写入。谁也不抢谁的。
2. 运维独立
ES 库不需要定期 vacuum(几乎只有 INSERT 和少量 SELECT),读模型库需要正常的 vacuum 维护。ES 库的备份策略可以更简单——WAL 归档就够了,因为几乎没有 UPDATE。读模型库需要更频繁的备份。
3. 开发环境隔离
本地开发时,两个库互不污染。要重置事件存储?DROP DATABASE pico_crm_es_dev; CREATE DATABASE pico_crm_es_dev; 就行了,读模型库完全不受影响。
烦人的地方
1. 本地开发需要两个 PostgreSQL 数据库
开发环境配置从"起一个 Postgres 容器"变成了"起一个 Postgres 容器,建两个数据库":
# 一个实例,两个 database
sudo podman run --name pico-crm-pg \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 -d postgres:latest
# 建两个库
sudo podman exec pico-crm-pg createdb -U postgres pico_crm_dev
sudo podman exec pico-crm-pg createdb -U postgres pico_crm_es_dev
说实话不算麻烦,但多了一步。如果你之前只用一个 .env.dev,现在要注意两个环境变量都得配:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/pico_crm_dev
ES_DATABASE_URL=postgres://postgres:postgres@localhost:5432/pico_crm_es_dev
2. 跨库没有事务
这是最根本的取舍。事件写入和投影更新不在同一个事务里。 这意味着:
- 命令端写完事件返回 HTTP 200 的时候,读模型还没更新
- 如果投影器挂了(bug / panic / OOM),读模型会滞后甚至停更
- 你不能在一个数据库事务里"写了事件同时查最新状态"
这就是 CQRS 的最终一致性,不是双库架构特有的,但双库让这个边界变得物理可见——你没法用 BEGIN; ... COMMIT; 跨两个独立的 PostgreSQL 实例。
实际的应对:
// 投影器的幂等守卫:即使重复消费也不会写乱
if model.event_id >= event_id {
return Ok(()); // 已处理过,跳过
}
配合 250ms 轮询 + PG NOTIFY 的混合监听机制,实际延迟通常在几十毫秒量级。对于家政 CRM 这种业务场景来说,完全在可接受范围内。
3. 两套技术栈的心智负担
事件存储用 sqlx(原生 SQL),读模型用 SeaORM(ORM),代码里两套查询风格并存。虽然在实际项目中,事件存储的 SQL 都由 disintegrate_postgres 框架管理,业务代码根本看不到原生 SQL,但在调试和问题排查时,你需要理解两套体系的日志和错误信息。
另一个容易忽略的点是环境变量模板的同步。ES_DATABASE_URL 是后加事件溯源时引入的,.env.dev 里有,但 .env.example 漏了。新部署的人照着模板改完启动,bootstrap_cqrs 里 env::var("ES_DATABASE_URL") 直接 panic。翻 .env.example 搜不到这个变量名,只能去源码里找答案。双库之后配置项翻倍,模板失配的概率也跟着翻倍。
四、什么时候不该用双库
说实话,双库不是银弹。如果你满足以下条件,单库可能更合适:
- 团队规模小,没有多实例部署的计划——投影选主、连接池隔离的需求都不存在,加一个库只加了心智负担
- 事件量不大(日均几千条以内)——IO 隔离的收益很小,不值得
- 项目还在验证阶段——先跑通业务逻辑,等 event 表真的开始有压力了再拆分也来得及
Pico-CRM 之所以选了双库,很大原因是用了 disintegrate_postgres 框架,它天然支持独立的事件存储库,接入成本极低(一个 ES_DATABASE_URL 环境变量 + 一个 OnceCell 懒加载连接池)。如果你的框架或语言生态没有这么成熟的 CQRS 基础设施,自己搓一遍事件存储 + 投影监听 + 选主 + 重试的成本可能会让你觉得"单库也挺好"。
总结
回过头看,给一个项目配上两个 PostgreSQL,核心权衡就两个维度:
- 物理分离的收益:连接池隔离、运维独立、IO 特征对齐
- 物理分离的代价:最终一致性、本地开发多一步、跨库无法事务
事件存储和读模型的流量模式完全不同,放在一起省了一时之力,长期来看是互相迁就。拆开之后,事件存储只管追加,读模型只管查询,各干各的,互不掺和。这个干净的边界,就是双库架构的核心价值。
如果你也在用 CQRS 或事件溯源,你的事件存储和读模型是放一个库还是分开的?遇到了什么坑?欢迎评论区聊聊。