多数据源配置与切换.md 26 KB

多数据源配置与切换

本文引用的文件

  • DataSourceConfig.java
  • CaseRoutingDataSource.java
  • CaseDataSourceRegistry.java
  • DuckdbUnpooledDataSource.java
  • application.yaml
  • application-dev.yaml
  • application-pro.yaml
  • pom.xml(ai-server)
  • RagVectorMapper.java
  • AiCleanTemplateVectorIndex.java
  • case_table_1.sql

目录

  1. 引言
  2. 项目结构定位
  3. 核心组件
  4. 架构总览
  5. 详细组件分析
  6. 依赖关系分析
  7. pgvector 向量表约束与性能注意事项
  8. 典型用法示例
  9. 常见问题排查
  10. 结论

引言

本文面向后端开发者,说明本项目中 MyBatis-Plus + Dynamic Datasource 的多数据源设计:PostgreSQL/pgvector 作为主业务数据源,DuckDB 作为每个案件的临时/本地计算数据源。文档重点包括:

  • 主数据源与案件 DuckDB 的用途差异;
  • 如何在 Spring 配置中声明多个数据源;
  • Service 层按场景切换数据源的机制;
  • pgvector 向量表的约束、索引策略和性能注意事项;
  • 常见错误及排查建议。

项目结构定位

本项目的多数据源能力集中在 ai-server 模块中:

  • 数据源装配在 common/config 下;
  • 动态路由与案件数据源生命周期管理在 common/datasource 下;
  • PostgreSQL 连接信息放在 application-dev.yaml / application-pro.yaml;
  • DuckDB 驱动由 ai-server/pom.xml 引入;
  • pgvector 相关 SQL 和 Java 访问入口位于 module/agent/mapper 与 module/aiclean/index;
  • 案件 DuckDB 的初始建表脚本位于 sql/case_table_1.sql。

    graph TB
    A["Spring Boot 应用"] --> B["MyBatis-Plus"]
    B --> C["Dynamic Datasource<br/>DynamicRoutingDataSource"]
    C --> D["CaseRoutingDataSource<br/>自定义路由"]
    D --> E["master<br/>PostgreSQL/pgvector"]
    D --> F["case{caseId}<br/>DuckDB 案件库"]
    G["Service/Mapper"] --> C
    H["模板向量索引"] --> E
    I["清洗/临时分析"] --> F
    

图表来源

  • DataSourceConfig.java:1-38
  • CaseRoutingDataSource.java:1-68
  • CaseDataSourceRegistry.java:1-200

章节来源

  • DataSourceConfig.java:1-38
  • application.yaml:1-193
  • application-dev.yaml:1-87
  • application-pro.yaml:1-87

核心组件

本节列出与多数据源直接相关的核心类及其职责。

组件 职责 关键行为
DataSourceConfig 替换 Dynamic Datasource 自动装配的数据源 Bean 构造 CaseRoutingDataSource,并把 spring.datasource.dynamic.* 中的 primary、strict、strategy、p6spy、seata、graceDestroy 等属性复制到自定义路由数据源上
CaseRoutingDataSource 把逻辑 key slave 映射到当前案件的物理 key case{caseId} 未打开案件时返回 400;已打开但物理 key 不存在时也返回 400;其他 key 走默认路由
CaseDataSourceRegistry 管理已打开案件的 DuckDB 数据源生命周期 开案时校验文件、分配资源预算、注册 case{caseId}、补建 file_ai_profile;关案时移除并关闭
DuckdbUnpooledDataSource DuckDB 单实例数据源包装 通过 duplicate() 创建独立连接;设置全局内存和线程数;支持按进程分摊的资源预算参数
RagVectorMapper 对 pgvector 向量表执行原生 SQL 建表、插入、删除、cosine 相似度检索、计数、判断表是否存在
AiCleanTemplateVectorIndex 模板元数据的 pgvector 向量索引服务 增量索引、全量重建、fail-open 降级

章节来源

  • DataSourceConfig.java:1-38
  • CaseRoutingDataSource.java:1-68
  • CaseDataSourceRegistry.java:1-200
  • DuckdbUnpooledDataSource.java:1-200
  • RagVectorMapper.java:1-113
  • AiCleanTemplateVectorIndex.java:1-200

架构总览

系统使用两个不同性质的数据库:

数据源 角色 存储位置 典型用途
PostgreSQL 主业务数据源 外部 PostgreSQL 服务器 用户、权限、模板、元数据、向量索引表、系统配置
DuckDB 临时/本地计算数据源 每个案件一个独立文件 导入原始表格、清洗中间结果、临时分析、快照导出
flowchart TD
    Start["请求进入 Service 层"] --> CheckCase{"是否操作案件数据?"}
    CheckCase -->|否| Master["写入/查询 master<br/>PostgreSQL/pgvector"]
    CheckCase -->|是| OpenCase{"案件 DuckDB 是否已打开?"}
    OpenCase -->|否| ErrorNoCase["抛出 400:请先打开案件"]
    OpenCase -->|是| Route["CaseRoutingDataSource<br/>slave → case{caseId}"]
    Route --> DuckDB["写入/查询 DuckDB 案件库"]
    Master --> End["返回结果"]
    DuckDB --> End
    ErrorNoCase --> End

图表来源

  • CaseRoutingDataSource.java:20-68
  • CaseDataSourceRegistry.java:40-120

该设计的关键是:

  • 主数据源 master 始终指向 PostgreSQL;
  • 所有需要读案件数据的 Mapper 都标注逻辑 key slave;
  • CaseRoutingDataSource 在运行时把 slave 转换为 case{caseId};
  • DuckDB 不是全局共享数据源,而是“每案一库”。

章节来源

  • application-dev.yaml:8-26
  • application-pro.yaml:8-26
  • CaseRoutingDataSource.java:12-68

详细组件分析

数据源装配与替换

DataSourceConfig 没有重新定义连接池,而是接管了 Dynamic Datasource 的装配过程:它创建一个 CaseRoutingDataSource,并将 DynamicDataSourceProperties 中的主要开关复制过去。这样既保留了 dynamic-datasource 的自动发现能力,又让路由逻辑由项目自己控制。

需要注意:

  • primary 仍为 master;
  • strict=true 能防止未来新增 Mapper 误写 @DS 后静默回退主库;
  • CaseRoutingDataSource 对 slave 分支做额外校验,不受 strict 的通用容错影响。

    classDiagram
    class DataSourceConfig {
        +caseRoutingDataSource(providers, properties) CaseRoutingDataSource
    }
    class CaseRoutingDataSource {
        -routingDataSource : DynamicRoutingDataSource
        +getDataSource(ds) DataSource
    }
    class CaseDataSourceRegistry {
        +open(caseId, dbPath, ownerUserId) void
        +close(caseId) void
        +physicalKey(caseId) String
    }
    class DuckdbUnpooledDataSource {
        +getConnection() Connection
        +close() void
    }
    DataSourceConfig --> CaseRoutingDataSource : "创建并注入"
    CaseDataSourceRegistry --> CaseRoutingDataSource : "注册/移除 case{caseId}"
    CaseDataSourceRegistry --> DuckdbUnpooledDataSource : "构造 DuckDB 实例"
    

图表来源

  • DataSourceConfig.java:1-38
  • CaseRoutingDataSource.java:1-68
  • CaseDataSourceRegistry.java:1-120
  • DuckdbUnpooledDataSource.java:1-120

章节来源

  • DataSourceConfig.java:1-38

案件数据源路由

CaseRoutingDataSource 继承自 DynamicRoutingDataSource,只覆盖 getDataSource(String):

  • 非 slave 的 key 直接交给父类处理;
  • slave 会读取当前线程上下文中的 caseId;
  • 若 caseId 为空,返回 400,表示“用户没开案”;
  • 根据 caseId 生成物理 key case{caseId},并在已注册数据源中查找;
  • 找不到时返回 400,表示“案件数据源未打开或已释放”。

物理 key 命名要求严格:不能使用下划线。因为 dynamic-datasource 会把下划线前的部分当作组名,可能导致负载均衡随机串库。

flowchart TD
    Enter["getDataSource(key)"] --> IsSlave{"key == slave ?"}
    IsSlave -->|否| Super["super.getDataSource(key)"]
    IsSlave -->|是| ReadCase["读取当前 caseId"]
    ReadCase --> HasCase{"caseId 是否存在?"}
    HasCase -->|否| ErrNoCase["返回 400:请先打开案件"]
    HasCase -->|是| BuildKey["构建 case{caseId}"]
    BuildKey --> FindDs{"是否已注册?"}
    FindDs -->|否| ErrNotOpen["返回 400:案件数据源未打开"]
    FindDs -->|是| ReturnDs["返回 DuckDB 数据源"]

图表来源

  • CaseRoutingDataSource.java:20-68

章节来源

  • CaseRoutingDataSource.java:12-68

案件数据源生命周期

CaseDataSourceRegistry 是 DuckDB 案件库的真正管理者。它维护一个从 caseId 到 OpenedCase 的并发映射,记录数据源、归属用户、归属 token 和打开时间。

开案流程要点:

  1. 校验是否已有相同 caseId 被打开;
  2. 限制同时打开的案件数量;
  3. 校验 DuckDB 文件路径合法且文件大小大于 0;
  4. 根据 DuckdbBudgetProperties 计算内存和线程预算;
  5. 创建 DuckdbUnpooledDataSource;
  6. 向 CaseRoutingDataSource 注册 case{caseId};
  7. 幂等补建 file_ai_profile 表;
  8. 写入内存中的已打开案件列表。

关案流程要点:

  • 从路由数据源移除物理 key;
  • 关闭 DuckDB 数据源;
  • 清理内存中的 caseId 条目。

    sequenceDiagram
    participant Client as "调用方"
    participant Registry as "CaseDataSourceRegistry"
    participant Routing as "CaseRoutingDataSource"
    participant Duck as "DuckdbUnpooledDataSource"
    participant File as "DuckDB 文件"
    
    Client->>Registry: open(caseId, dbPath, ownerUserId)
    Registry->>Registry: 校验 caseId 是否已打开
    Registry->>Registry: 检查最大打开案件数
    Registry->>File: 检查文件存在且大小大于 0
    Registry->>Duck: 构造数据源(内存/线程预算)
    Registry->>Routing: addDataSource(case{caseId}, duck)
    Registry->>Registry: 补建 file_ai_profile
    Registry-->>Client: 开案成功
    
    Client->>Registry: close(caseId)
    Registry->>Routing: removeDataSource(case{caseId})
    Registry->>Duck: close()
    Registry-->>Client: 关闭完成
    

图表来源

  • CaseDataSourceRegistry.java:40-120
  • CaseDataSourceRegistry.java:200-280
  • DuckdbUnpooledDataSource.java:60-120

章节来源

  • CaseDataSourceRegistry.java:1-120
  • CaseDataSourceRegistry.java:200-280

DuckDB 数据源实现

DuckdbUnpooledDataSource 不是传统连接池,而是一个单实例包装器:

  • 内部持有唯一的 DuckDBConnection;
  • 每次 getConnection() 调用都会通过 duplicate() 获取独立连接;
  • 可配置 memoryLimitInMiB 和 threads;
  • 连接级网络超时复用守护线程执行器,避免连接数增长导致线程泄漏。

资源预算来自 zsjz.duckdb.memory-ratio 和 zsjz.duckdb.threads-per-instance。由于 SET GLOBAL 作用域是 DuckDB 的 database instance,而不是单个连接,因此必须按“每案实例”分摊预算,否则多人同时开案会导致内存和 CPU 成倍叠加。

章节来源

  • DuckdbUnpooledDataSource.java:1-120
  • DuckdbUnpooledDataSource.java:120-200
  • application.yaml:54-80

依赖关系分析

项目通过 Maven 引入以下关键依赖:

  • PostgreSQL JDBC 驱动;
  • pgvector Java SDK;
  • DuckDB JDBC 驱动;
  • MyBatis-Plus Spring Boot 3 Starter;
  • Dynamic Datasource Spring Boot 3 Starter。

    graph LR
    App["ai-server 应用"] --> MP["MyBatis-Plus"]
    App --> DDS["dynamic-datasource-spring-boot3-starter"]
    App --> PG["postgresql"]
    App --> PGV["com.pgvector:pgvector"]
    App --> DDB["duckdb_jdbc"]
    MP --> PG
    MP --> DDB
    DDS --> App
    

图表来源

  • pom.xml(ai-server):1-200

章节来源

  • pom.xml(ai-server):1-200

pgvector 向量表约束与性能注意事项

用途区分

  • PostgreSQL 上的 pgvector 表用于存储全局向量索引,例如模板元数据向量、RAG 向量表;
  • DuckDB 不用于承载主业务向量索引;
  • 向量索引的事实来源是模板元数据,而不是案件 DuckDB。

向量表结构与字段

RagVectorMapper 定义的向量表包含:

  • doc_id:文档唯一标识;
  • content:文本内容;
  • payload:结构化 JSONB;
  • embedding:vector(${dims}) 列,维度由运行期决定。

对于 AI 模板向量索引,表名形如 rag_tpl{tag}_d{dims},其中 dims 是嵌入模型输出维度。

约束

  • doc_id 是主键;
  • 向量列类型必须是 vector,维度必须与嵌入模型一致;
  • 写入时必须把 embedding 转为 ::vector;
  • payload 通常用 ::jsonb 转换;
  • 向量表名称由服务层拼接,不能直接使用普通 MyBatis-Plus 实体注解;
  • 如果嵌入模型维度变化,应重建对应向量表,而不是混用旧表。

索引策略

代码中未显式创建额外的 pgvector 索引(例如 IVFFlat/HNSW),而是使用 pgvector 默认的 cosine 距离 <=> 进行比较检索:

  • 检索公式为 1 - (embedding <=> #{embedding}::vector);
  • 通过 threshold 过滤低分结果;
  • 通过 limit 控制 top-k;
  • 对模板索引采用“按需建表 + ON CONFLICT 覆盖写”,避免重复建表冲突。

这意味着在大规模向量表中,cosine 搜索可能退化为全表扫描。后续如需优化,应在生产环境评估是否添加 pgvector 索引类型,并验证召回质量与写入成本。

性能注意事项

  1. 向量维度必须固定:同一张向量表内所有行的 embedding 维度必须一致。
  2. 不要频繁重建整表:模板索引使用 ON CONFLICT 增量更新,避免 DROP 再 CREATE 造成的锁竞争。
  3. fail-open 降级:向量索引不可用时不应阻断主业务链路;AI 模板识别仍可通过指纹、打分和 LLM 继续工作。
  4. 嵌入模型加载成本高:本地 ONNX 模型首次加载可能较慢,应考虑预热或懒加载策略。
  5. 向量检索是辅助召回:召回结果只是候选集,最终匹配逻辑不应只依赖相似度。

章节来源

  • RagVectorMapper.java:1-113
  • AiCleanTemplateVectorIndex.java:1-200
  • application.yaml:80-160

典型用法示例

普通 CRUD 使用默认数据源

大多数业务 Mapper 不需要显式指定数据源,它们会走 MyBatis-Plus 默认装配的主数据源,即 PostgreSQL。这类操作包括:

  • 用户、权限、系统配置;
  • 模板元数据;
  • 向量索引表读写;
  • 系统日志、审计信息等。

推荐做法:

  • 不要给这些 Mapper 加 @DS;
  • 如果需要跨库写,不要在同一个事务里混写 PostgreSQL 和 DuckDB;
  • 如果确实需要明确主数据源,优先保持默认行为,而不是随意加 @DS("master")。

案件数据查询切换到 DuckDB

当业务需要读取某个案件导入的表格数据时,应先确保案件已打开,然后使用标注 @DS("slave") 的 Mapper。框架会自动把 slave 路由到 case{caseId}。

推荐流程:

  1. Controller 或上层服务先调用 CaseDataSourceRegistry.open(...);
  2. 在 CaseContextHolder 中设置当前 caseId;
  3. 调用标注 @DS("slave") 的 Mapper 读取案件表;
  4. 业务完成后调用 CaseDataSourceRegistry.close(caseId)。

注意:

  • 如果当前没有 caseId,CaseRoutingDataSource 会返回 400;
  • 如果 caseId 已关闭,也会返回 400;
  • 不要把案件 SQL 误写成 @DS("master"),否则会查 PostgreSQL。

批量清洗或临时分析切换到 DuckDB

批量清洗、临时分析、预览、快照导出等属于“计算型任务”,适合在 DuckDB 中进行:

  • 清洗中间结果可以写进案件 DuckDB;
  • 临时分析 SQL 可在 DuckDB 中执行;
  • 导出快照时,由 CaseDataSourceRegistry.exportSnapshot 导出独立 DuckDB 文件;
  • 子进程只读打开快照文件,避免与主进程写锁冲突。

    sequenceDiagram
    participant User as "用户"
    participant Service as "清洗/分析 Service"
    participant Registry as "CaseDataSourceRegistry"
    participant DS as "CaseRoutingDataSource"
    participant Duck as "DuckDB"
    
    User->>Service: 提交批量清洗任务
    Service->>Registry: open(caseId, dbPath)
    Service->>Service: 准备清洗 SQL / 数据
    Service->>DS: @DS("slave") 写入 DuckDB
    DS->>Duck: 写入临时表 / 清洗结果
    Service->>Registry: exportSnapshot(caseId, target)
    Registry->>Duck: 导出快照文件
    Service->>Registry: close(caseId)
    

图表来源

  • CaseDataSourceRegistry.java:40-120
  • CaseDataSourceRegistry.java:200-280

章节来源

  • CaseRoutingDataSource.java:20-68
  • CaseDataSourceRegistry.java:40-120
  • CaseDataSourceRegistry.java:200-280

常见问题排查

数据源选择错误

现象: 本该查 DuckDB 的案件数据却查到 PostgreSQL,或者反过来。
可能原因:

  • Mapper 误加 @DS("master");
  • 忘记标注 @DS("slave");
  • 当前线程没有 caseId;
  • 物理 key 使用了下划线,导致 group 解析异常;
  • 案件已关闭,但业务仍尝试访问。

排查建议:

  • 开启 debug 日志查看 slave -> case{caseId} 的路由信息;
  • 确认 CaseContextHolder.currentCaseId() 是否非空;
  • 确认 CaseDataSourceRegistry.isOpen(caseId);
  • 检查 @DS 值是否为 slave 或空。

章节来源

  • CaseRoutingDataSource.java:20-68

pgvector 扩展未安装

现象: 向量表创建失败、向量类型无法识别、向量检索报错。
可能原因:

  • PostgreSQL 未安装 pgvector 扩展;
  • 向量表所在 schema 不可写;
  • 向量维度与实际嵌入模型不一致;
  • 向量表名拼写错误或被误删。

排查建议:

  • 确认 PostgreSQL 已安装 pgvector;
  • 检查 RagVectorMapper 生成的向量表是否存在;
  • 检查向量列类型是否为 vector 且维度正确;
  • 检查模板向量索引是否处于 fail-open 降级状态。

章节来源

  • RagVectorMapper.java:1-113
  • AiCleanTemplateVectorIndex.java:1-200

DuckDB JDBC 连接失败

现象: 开案失败、DuckDB 连接异常、文件被占用。
可能原因:

  • DuckDB JDBC 驱动未引入;
  • DuckDB 文件路径非法;
  • DuckDB 文件不存在或大小为 0;
  • 目标目录不可写;
  • Windows 下另一个进程占用了 DuckDB 文件;
  • 同时打开的案件数超过上限。

排查建议:

  • 确认 ai-server/pom.xml 中引入了 duckdb_jdbc;
  • 检查 zsjz.duckdb.max-open-cases;
  • 检查 DuckDB 文件绝对路径是否可读且非空;
  • 避免主进程与子进程同时写入同一个 DuckDB 文件;
  • 使用 exportSnapshot 导出快照后再让子进程只读打开。

章节来源

  • pom.xml(ai-server):1-200
  • CaseDataSourceRegistry.java:40-120
  • CaseDataSourceRegistry.java:200-280

事务跨数据源限制

重要限制: PostgreSQL 和 DuckDB 是两个独立的数据库实例,不能在同一个数据库事务中跨数据源提交。
建议做法:

  • 不要把 PostgreSQL 写和 DuckDB 写放在同一个事务中;
  • 如果需要一致性,应在业务层协调两阶段提交,例如先写主库,再写 DuckDB,失败时记录补偿日志;
  • 对于清洗、分析等临时数据,允许最终一致性;
  • 不要把 DuckDB 当成 PostgreSQL 的事务后备库。

章节来源

  • application-dev.yaml:8-26
  • application-pro.yaml:8-26
  • CaseRoutingDataSource.java:20-68

案件数据源未打开或已释放

现象: 调用案件数据接口时报“请先打开案件”或“案件数据源未打开”。
可能原因:

  • 前端未成功进入案件页面;
  • 用户退出案件后未及时刷新;
  • 用户被顶下线或会话过期;
  • 开案时 DuckDB 文件无效;
  • 达到同时打开案件上限。

排查建议:

  • 检查是否调用了 CaseDataSourceRegistry.open;
  • 检查 CaseContextHolder.currentCaseId();
  • 检查 openedCount() 是否达到上限;
  • 检查 DuckDB 文件路径和大小;
  • 检查用户会话状态。

章节来源

  • CaseRoutingDataSource.java:20-68
  • CaseDataSourceRegistry.java:40-120

结论

本项目的多数据源方案以 PostgreSQL 为主业务数据源,以 DuckDB 为每案独立的临时计算数据源。通过 DataSourceConfig 替换默认路由、CaseRoutingDataSource 将 slave 映射到 case{caseId}、CaseDataSourceRegistry 管理 DuckDB 生命周期,系统在保持 MyBatis-Plus 和 Dynamic Datasource 兼容性的同时,实现了“按案件隔离”的计算数据源。

实践中应遵守以下原则:

  • 主业务数据走 PostgreSQL;
  • 案件数据查询和临时计算走 DuckDB;
  • 不要跨数据源使用单一事务;
  • 向量索引存储在 PostgreSQL/pgvector,不污染 DuckDB;
  • 开案和关案要成对调用,避免内存和文件句柄泄漏;
  • 出现串库或空数据问题时,优先检查 @DS、caseId、DuckDB 文件和路由日志。