当前位置: 首页 > news >正文

我给我的家政CRM配了两个PostgreSQL,聊聊双库架构的真实账本

最近在给 Pico-CRM(一个用 Rust + Leptos 写的家政行业 CRM)上事件溯源的时候,遇到了一个绕不开的架构问题:事件存储和读模型,放一个数据库还是两个?

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

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

提前声明:本文基于个人项目的实践,架构选择有上下文依赖(单机部署、家政行业、团队规模 = 1),仅供参考。

一、为什么一个库不够?

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

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

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

-- disintegrate_postgres 在 ES 库里创建的核心表INSERTINTOevent(id,stream_id,event_type,payload,created_at)VALUES(...);

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

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

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

// OrderCreated 事件 → INSERTletactive=orders::ActiveModel{...};active.insert(txn).await?;// OrderStatusChanged 事件 → UPDATEletmutactive=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 隔离了。

查询侧的考量

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

-- 前端列表页:按商户、状态、时间范围查订单SELECT*FROMordersWHEREmerchant_id=$1ANDstatus=$2ORDERBYinserted_atDESCLIMIT20;

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

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

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

二、双库架构怎么落的

Pico-CRM 现在的双库架构长这样:

┌──────────────────────────────┐ │ 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)letenv_file=format!(".env.{}",env::var("APP_ENV").unwrap_or("dev".into()));dotenvy::from_filename(&env_file).unwrap();// ② 连接读模型库,跑 SeaORM migrationletdb=Database::new().await;// 读 DATABASE_URLMigrator::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.rsstaticEVENT_STORE_POOL:OnceCell<sqlx::PgPool>=OnceCell::const_new();pub(crate)asyncfnevent_store_pool()->Result<sqlx::PgPool,String>{EVENT_STORE_POOL.get_or_try_init(||async{letdatabase_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.rspubasyncfninitialize()->Result<(),String>{letpool=event_store_pool().await?;EVENT_STORE_INIT.get_or_try_init(||asyncmove{// ① 为三种事件类型创建 disintegrate 的 schema(event 表 + 索引)initialize_registered_event_schemas(pool.clone()).await?;// ② 创建投影监听器的基础设施(NOTIFY 触发器 + listener_progress 表)initialize_listener_infra(pool.clone()).await?;// ③ 历史数据迁移:把旧的 order_id 回填成 order_uuidbackfill_schedule_event_order_uuid(pool).await?;Ok(())}).await?;Ok(())}

三种事件类型各自注册:

asyncfninitialize_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 容器,建两个数据库":

# 一个实例,两个 databasesudopodmanrun--namepico-crm-pg\-ePOSTGRES_PASSWORD=postgres\-p5432:5432-dpostgres:latest# 建两个库sudopodmanexecpico-crm-pg createdb-Upostgres pico_crm_devsudopodmanexecpico-crm-pg createdb-Upostgres 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 实例。

实际的应对:

// 投影器的幂等守卫:即使重复消费也不会写乱ifmodel.event_id>=event_id{returnOk(());// 已处理过,跳过}

配合 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 基础设施,自己搓一遍事件存储 + 投影监听 + 选主 + 重试的成本可能会让你觉得"单库也挺好"。

总结

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

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

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

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


项目开源在 GitHub,搜Pico-CRM即可找到完整代码,包含双库架构的完整启动链路。

http://www.jsqmd.com/news/830175/

相关文章:

  • 5个Whisky替代方案终极指南:当你的macOS Windows应用管理器停止更新后该怎么办?
  • 防水RJ45连接器选型实战:IP67/IP68等级、全牙结构、屏蔽接地与工业户外部署全解析
  • 如何实现抖音弹幕实时抓取:基于系统代理的技术突破指南
  • 手把手教你模拟登录豆瓣并爬取个人书影音数据:从Cookie解析到反爬攻防实战
  • 如何用自然语言控制你的电脑:UI-TARS-desktop终极AI桌面助手指南
  • 面向医疗对话系统的症状推理与问诊策略,从“你哪里不舒服”到精准推断:医疗对话系统中的症状推理与动态问诊策略
  • 云尖信息分布式存储解决方案:释放AI算力潜能,构筑高效数据底座
  • 【技术解析】从总线到片上网络:互联网络的核心原理与设计权衡
  • KMS智能激活脚本:Windows和Office的一站式解决方案
  • 算法设计三大经典策略:贪心 / 分治 / 动态规划 详解与实战
  • Hermes Agent框架接入Taotoken自定义供应商的配置要点详解
  • 谷歌 AI 战略多维度推进:Gemini 更新、智能代理与创意 AI 齐头并进
  • 开源AI代码助手本地化部署:从Cursor10x看私有化编程助手实践
  • 专业的PLM系统生产厂家
  • 基于深度学习的苹果产量预测的系统设计与实现
  • 【WinForm UI控件系列】ComboTreeView下拉树选择控件
  • 知乎API开发指南:5分钟掌握Python数据采集的完整解决方案
  • Ragent AI:从 0 到 1 打造企业级 Agentic RAG 智能体
  • 通过curl快速调试stm32项目的大模型api请求与响应格式
  • 新手也能搞定!用Simulink搭建晶闸管直流调速系统(附完整模型文件)
  • Arduino开发环境搭建与LED控制实战:从零开始硬件编程
  • 基于Matlab元胞自动机模拟(CA)动态再结晶过程
  • QQ截图独立版:免费获取专业级屏幕工具集的完整指南
  • 声明式无侵入爬虫框架Clawless:零代码实现网页数据采集
  • 用Ray处理270万条NYC Taxi数据,我总结了这几个提升效率的Parquet读取技巧
  • JetBrains IDE试用期重置完整指南:快速恢复30天免费使用权限
  • CircuitPython物联网开发实战:从点灯到LoRa无线通信
  • java之集合
  • 关于ImToken智能合约交互
  • 如何用开源缠论量化工具实现几何交易可视化:从算法到实战的完整指南