news 2026/5/16 19:55:24

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
我给我的家政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即可找到完整代码,包含双库架构的完整启动链路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/16 19:55:16

猫拽低代码是如何实现的Agent结合

官网&#xff1a;猫拽低代码平台 一个基于 ReAct 模式 MCP 协议的低代码智能体实现 猫拽低代码是一个「自带 AI 大脑」的可视化开发平台&#xff0c;而其最新的 AI Agent 插件&#xff0c;则把低代码的智能化推向新高度——不只对话&#xff0c;更能自动执行页面搭建、工作流配…

作者头像 李华
网站建设 2026/5/16 19:54:10

激光雷达距离传感器:智能感知时代的“千里眼“

在万物互联的智能时代&#xff0c;激光雷达距离传感器正以厘米级的精准测距能力&#xff0c;重塑自动驾驶、机器人导航与智慧城市的感知边界。它不仅是一款传感器&#xff0c;更是智能系统的"第三只眼"——看得远、看得清、看得准。一、硬核原理&#xff1a;光速丈量…

作者头像 李华
网站建设 2026/5/16 19:53:11

用Ray处理270万条NYC Taxi数据,我总结了这几个提升效率的Parquet读取技巧

用Ray高效处理270万条NYC Taxi数据的5个Parquet优化技巧 当面对海量数据时&#xff0c;每个字节的I/O和内存消耗都可能成为性能瓶颈。在最近的一个项目中&#xff0c;我使用Ray处理了包含270万条记录的NYC Taxi数据集&#xff0c;深刻体会到优化Parquet读取的重要性。本文将分享…

作者头像 李华
网站建设 2026/5/16 19:47:31

MarkText实战指南:专业Markdown编辑器深度配置与效率提升方案

MarkText实战指南&#xff1a;专业Markdown编辑器深度配置与效率提升方案 【免费下载链接】marktext &#x1f4dd;A simple and elegant markdown editor, available for Linux, macOS and Windows. 项目地址: https://gitcode.com/gh_mirrors/ma/marktext MarkText是一…

作者头像 李华
网站建设 2026/5/16 19:44:29

看不见的电气杀手!智慧安全用电,把风险扼杀在萌芽前

摘要随着智能照明、智慧楼宇、弱电智能化系统大规模普及&#xff0c;建筑用电设备趋于密集化、常态化、长期化。传统用电保护依赖空开、漏保被动跳闸&#xff0c;存在隐患隐蔽、预警缺失、运维滞后、风险不可控等痛点&#xff0c;已无法适配现代商业、园区、市政的智能化用电场…

作者头像 李华
网站建设 2026/5/16 19:41:25

企业微信多账号协同管理方案:矩阵如何统一管理?

账号越来越多、运营越来越乱&#xff1f;通过企业微信 API&#xff0c;实现多账号统一管理与自动化调度。很多私域团队在业务增长后&#xff0c;都会开始运营多个企业微信账号。 但账号一多&#xff0c;人工切换、消息管理、客户分配都会变得非常混乱。QiWe 开放平台通过标准化…

作者头像 李华