本文引用的文件
本文面向后端开发者,说明本项目中 MyBatis-Plus + Dynamic Datasource 的多数据源设计:PostgreSQL/pgvector 作为主业务数据源,DuckDB 作为每个案件的临时/本地计算数据源。文档重点包括:
本项目的多数据源能力集中在 ai-server 模块中:
common/config 下;common/datasource 下;application-dev.yaml / application-pro.yaml;ai-server/pom.xml 引入;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 |
替换 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 降级 |
章节来源
系统使用两个不同性质的数据库:
| 数据源 | 角色 | 存储位置 | 典型用途 |
|---|---|---|---|
| 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
图表来源
该设计的关键是:
master 始终指向 PostgreSQL;slave;CaseRoutingDataSource 在运行时把 slave 转换为 case{caseId};章节来源
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 实例"
图表来源
章节来源
CaseRoutingDataSource 继承自 DynamicRoutingDataSource,只覆盖 getDataSource(String):
slave 的 key 直接交给父类处理;slave 会读取当前线程上下文中的 caseId;caseId 为空,返回 400,表示“用户没开案”;caseId 生成物理 key case{caseId},并在已注册数据源中查找;物理 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 数据源"]
图表来源
章节来源
CaseDataSourceRegistry 是 DuckDB 案件库的真正管理者。它维护一个从 caseId 到 OpenedCase 的并发映射,记录数据源、归属用户、归属 token 和打开时间。
开案流程要点:
caseId 被打开;DuckdbBudgetProperties 计算内存和线程预算;DuckdbUnpooledDataSource;CaseRoutingDataSource 注册 case{caseId};file_ai_profile 表;关案流程要点:
清理内存中的 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: 关闭完成
图表来源
章节来源
DuckdbUnpooledDataSource 不是传统连接池,而是一个单实例包装器:
DuckDBConnection;getConnection() 调用都会通过 duplicate() 获取独立连接;memoryLimitInMiB 和 threads;资源预算来自 zsjz.duckdb.memory-ratio 和 zsjz.duckdb.threads-per-instance。由于 SET GLOBAL 作用域是 DuckDB 的 database instance,而不是单个连接,因此必须按“每案实例”分摊预算,否则多人同时开案会导致内存和 CPU 成倍叠加。
章节来源
项目通过 Maven 引入以下关键依赖:
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
图表来源
章节来源
RagVectorMapper 定义的向量表包含:
doc_id:文档唯一标识;content:文本内容;payload:结构化 JSONB;embedding:vector(${dims}) 列,维度由运行期决定。对于 AI 模板向量索引,表名形如 rag_tpl{tag}_d{dims},其中 dims 是嵌入模型输出维度。
doc_id 是主键;vector,维度必须与嵌入模型一致;::vector;::jsonb 转换;代码中未显式创建额外的 pgvector 索引(例如 IVFFlat/HNSW),而是使用 pgvector 默认的 cosine 距离 <=> 进行比较检索:
1 - (embedding <=> #{embedding}::vector);threshold 过滤低分结果;limit 控制 top-k;这意味着在大规模向量表中,cosine 搜索可能退化为全表扫描。后续如需优化,应在生产环境评估是否添加 pgvector 索引类型,并验证召回质量与写入成本。
ON CONFLICT 增量更新,避免 DROP 再 CREATE 造成的锁竞争。章节来源
大多数业务 Mapper 不需要显式指定数据源,它们会走 MyBatis-Plus 默认装配的主数据源,即 PostgreSQL。这类操作包括:
推荐做法:
@DS;@DS("master")。当业务需要读取某个案件导入的表格数据时,应先确保案件已打开,然后使用标注 @DS("slave") 的 Mapper。框架会自动把 slave 路由到 case{caseId}。
推荐流程:
CaseDataSourceRegistry.open(...);CaseContextHolder 中设置当前 caseId;@DS("slave") 的 Mapper 读取案件表;CaseDataSourceRegistry.close(caseId)。注意:
caseId,CaseRoutingDataSource 会返回 400;caseId 已关闭,也会返回 400;@DS("master"),否则会查 PostgreSQL。批量清洗、临时分析、预览、快照导出等属于“计算型任务”,适合在 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)
图表来源
章节来源
现象: 本该查 DuckDB 的案件数据却查到 PostgreSQL,或者反过来。
可能原因:
@DS("master");@DS("slave");caseId;排查建议:
slave -> case{caseId} 的路由信息;CaseContextHolder.currentCaseId() 是否非空;CaseDataSourceRegistry.isOpen(caseId);@DS 值是否为 slave 或空。章节来源
现象: 向量表创建失败、向量类型无法识别、向量检索报错。
可能原因:
pgvector 扩展;排查建议:
RagVectorMapper 生成的向量表是否存在;vector 且维度正确;章节来源
现象: 开案失败、DuckDB 连接异常、文件被占用。
可能原因:
排查建议:
ai-server/pom.xml 中引入了 duckdb_jdbc;zsjz.duckdb.max-open-cases;exportSnapshot 导出快照后再让子进程只读打开。章节来源
重要限制: PostgreSQL 和 DuckDB 是两个独立的数据库实例,不能在同一个数据库事务中跨数据源提交。
建议做法:
章节来源
现象: 调用案件数据接口时报“请先打开案件”或“案件数据源未打开”。
可能原因:
排查建议:
CaseDataSourceRegistry.open;CaseContextHolder.currentCaseId();openedCount() 是否达到上限;章节来源
本项目的多数据源方案以 PostgreSQL 为主业务数据源,以 DuckDB 为每案独立的临时计算数据源。通过 DataSourceConfig 替换默认路由、CaseRoutingDataSource 将 slave 映射到 case{caseId}、CaseDataSourceRegistry 管理 DuckDB 生命周期,系统在保持 MyBatis-Plus 和 Dynamic Datasource 兼容性的同时,实现了“按案件隔离”的计算数据源。
实践中应遵守以下原则:
@DS、caseId、DuckDB 文件和路由日志。