CQRS 双库架构:给事件存储单独开一个数据库,到底值不值?

最近在给手上的 Rust 项目上事件溯源,遇到了一个绕不开的架构问题:事件存储和读模型,放一个数据库还是两个?

一开始图省事,觉得一个 PostgreSQL 里分两个 schema 不就行了------es schema 放事件表,public schema 放业务表。一个数据库实例、一套 backup、一个 docker run,多清爽。

跑了一周后发现不是那么回事。本文聊聊这个决策的真实体验------两个 PostgreSQL 到底值不值。

提前声明:本文基于个人项目实践,架构选择有上下文依赖(单机部署、小团队),仅供参考。

一、为什么一个库不够?

先看两种写入模式的差异。

事件存储的写入:append-only,纯顺序写

事件存储的表结构极其简单------disintegrate_postgres 就一张 event 表,核心字段是事件 ID、stream 标识、事件类型、JSON payload。写操作永远是 INSERT,没有 UPDATE,没有 DELETE

sql 复制代码
-- disintegrate_postgres 在 ES 库里创建的核心表
INSERT INTO event (id, stream_id, event_type, payload, created_at)
VALUES (...);

这个写入模式的特点是:高频、顺序、不可变。WAL 日志一直往前追加,不需要担心 vacuum、不需要担心死锁、不需要担心索引膨胀(只有 event_id 和 stream_id 上有索引)。

读模型的写入:随机更新,带索引维护

读模型这边就复杂多了。拿订单投影举例,同一个事件流过来,读模型的行为是:

rust 复制代码
// 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_iduuidstatuscustomer_uuidinserted_at 一堆索引,每次 UPDATE 都要维护索引。还有 order_change_logs 表的 before/after JSON 快照写入------每次事件变更都附带一条 changelog。

事件存储说:我只 INSERT,其他事别找我。读模型说:我既要 INSERT 又要 UPDATE 还要维护索引还要写审计日志。

这两种写入模式混在同一个 PostgreSQL 实例里,谁也没碍着谁,但也没帮到谁。尤其当订单量和事件量在同一个数据库里争夺 shared buffer 和 WAL 带宽时,你就得开始操心 IO 隔离了。

查询侧的考量

读模型面向的是业务查询:

sql 复制代码
-- 前端列表页:按商户、状态、时间范围查订单
SELECT * FROM orders
WHERE merchant_id = $1 AND status = $2
ORDER BY inserted_at DESC
LIMIT 20;

这些查询依赖复合索引、依赖统计信息准确、依赖连接池里有足够的可用连接。

事件存储从来不面向业务查询------它只被三个地方访问:命令端写事件、投影器读事件、状态重建加载事件流。这三种访问都是按 stream_id 精确查找,从来不跑全表扫描。

一句话总结:事件存储和读模型的 IO 特征、索引策略、连接池需求完全不一样,混在一个库里意味着你永远要按更严格的那个来调参,另一头在凑合。

二、双库架构怎么落的

项目目前的双库架构长这样:

arduino 复制代码
┌──────────────────────────────┐
│         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 里的启动顺序很清楚:

rust 复制代码
// ① 加载 .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") 读取环境变量:

rust 复制代码
// 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 库上建表:

rust 复制代码
// 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(())
}

三种事件类型各自注册:

rust 复制代码
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 文件,建业务表:merchantsusersordersschedulesservice_requestscontacts 等等。

两边各管各的 migration,互不干扰。事件存储的 schema 完全由 disintegrate_postgresPgEventStore::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 容器,建两个数据库":

bash 复制代码
# 一个实例,两个 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,现在要注意两个环境变量都得配:

env 复制代码
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 实例。

实际的应对:

rust 复制代码
// 投影器的幂等守卫:即使重复消费也不会写乱
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_cqrsenv::var("ES_DATABASE_URL") 直接 panic。翻 .env.example 搜不到这个变量名,只能去源码里找答案。双库之后配置项翻倍,模板失配的概率也跟着翻倍。

四、什么时候不该用双库

说实话,双库不是银弹。如果你满足以下条件,单库可能更合适

  • 团队规模小,没有多实例部署的计划------投影选主、连接池隔离的需求都不存在,加一个库只加了心智负担
  • 事件量不大(日均几千条以内)------IO 隔离的收益很小,不值得
  • 项目还在验证阶段------先跑通业务逻辑,等 event 表真的开始有压力了再拆分也来得及

Pico-CRM 之所以选了双库,很大原因是用了 disintegrate_postgres 框架,它天然支持独立的事件存储库,接入成本极低(一个 ES_DATABASE_URL 环境变量 + 一个 OnceCell 懒加载连接池)。如果你的框架或语言生态没有这么成熟的 CQRS 基础设施,自己搓一遍事件存储 + 投影监听 + 选主 + 重试的成本可能会让你觉得"单库也挺好"。

总结

回过头看,给一个项目配上两个 PostgreSQL,核心权衡就两个维度:

  1. 物理分离的收益:连接池隔离、运维独立、IO 特征对齐
  2. 物理分离的代价:最终一致性、本地开发多一步、跨库无法事务

事件存储和读模型的流量模式完全不同,放在一起省了一时之力,长期来看是互相迁就。拆开之后,事件存储只管追加,读模型只管查询,各干各的,互不掺和。这个干净的边界,就是双库架构的核心价值。

如果你也在用 CQRS 或事件溯源,你的事件存储和读模型是放一个库还是分开的?遇到了什么坑?欢迎评论区聊聊。

相关推荐
YF02111 小时前
深度解构Android OkDownload断点续传
android·数据库·okhttp
测试员周周1 小时前
【Appium 系列】第04节-Page Object 模式 — BasePage 基类设计
开发语言·数据库·人工智能·python·语言模型·appium·web app
海棠Flower未眠1 小时前
Spring Boot 2.4后,特定配置文件不能再使用spring.profiles.include的解决思路
数据库·spring boot·spring
jran-1 小时前
MySQL单表操作
数据库·mysql
北秋,1 小时前
SQL Server(Microsoft 数据库)基础用法 + 数字型 + 字符型 完整联合注入
数据库·microsoft
June`1 小时前
多线程redis项目基石
数据库·redis·缓存
重生之小比特2 小时前
【MySQL 数据库】事务
数据库·mysql
云边有个稻草人2 小时前
金仓数据库KingbaseES:自动创建表空间目录,简化部署适配云原生
数据库·kingbasees·数据库运维·国产化数据库·云原生适配·表空间管理
坐吃山猪2 小时前
SqlLite数据库-思路拓展
数据库·sqlite