1. 为什么需要一个数据库语义网关:BI 与 Agent 之间的断层
先说个背景。这几年我一直在做数据平台相关的后端工作,传统 BI 那套东西——报表、大屏、多维分析、看板——说实话已经相当成熟了,Power BI、帆软、Tableau 各有各的生态。但最近一两年 AI Agent 火起来之后,我明显感觉到一个断层:BI 工具和 Agent 在消费数据的方式上完全是两套逻辑,而且中间缺了一个“翻译层”。
BI 时代,业务人员通过拖拽维度、度量来出报表,SQL 是报表引擎替用户生成的;到了 Agent 时代,大模型直接理解用户的问题,然后生成 SQL 去查库。听起来 Agent 更聪明了,但问题在于——大模型对数据库本身一无所知。它不知道“销售额”在你的表里到底是amount还是total_price,不知道order_status = 1代表什么,更不知道跨表 join 改怎么写才能查对口径。
如果每个 Agent 应用都自己去读 information_schema、自己定义业务口径、自己拼 SQL,那结果就是:同一个指标在十个 Agent 里查出十种结果,安全边界更是完全失控。这让我下决心用 Java 做一个轻量级的数据库语义网关,命名为 DatI。它要做的事情很明确:把数据库的“物理结构”翻译成“业务语义”,然后以两种方式对外提供服务——一种是给 BI 工具用的标准查询接口,另一种是给 Agent 用的工具调用接口。
说白了,DatI 就是一个夹在数据源和上层应用之间的中间层,把“表结构”这件事彻底接管,让上层不再直接面对裸 SQL,而是面对一套稳定、可解释、受控的语义模型。这个东西对数据平台团队、AI 应用开发者、以及正在从传统 BI 向智能化数据分析转型的团队都有参考价值。下面我把整体的设计思路、关键实现和踩过的坑都摊开讲一遍。
2. 整体架构设计与技术选型:为什么是 Java,为什么是网关
2.1 Java 技术栈的取舍逻辑
先说选型。DatI 的核心是 Java 17 + Spring Boot 3.x,这个选择不是因为我只会 Java,而是基于几个非常实际的考量。
第一,数据库生态的接入能力。DatI 要连接的几乎都是企业级数据源——MySQL、PostgreSQL、SQL Server、Oracle、ClickHouse、Doris 这些。Java 的 JDBC 是这几十年来数据库连接的事实标准,无论什么数据库,都会有对应的 JDBC Driver,这一点没有哪个语言生态比 Java 更全。更重要的是,HikariCP 这种连接池组件,在大并发场景下的稳定性和性能调优空间是经过大量生产环境验证的。
第二,高并发网关场景的底座能力。DatI 本质是一个网关,网关就意味着要处理大量并发请求、要有连接池管理、要做超时熔断、要支持灰度发布。Java 在这个领域的积累是最深厚的,Spring Boot 的自动配置、Spring AOP 做权限拦截、Spring Actuator 做健康检查,都是现成的生态。如果我用 Python 写,FastAPI 的并发能力也不错,但在数据库连接池和线程模型上,Java 的成熟度明显更高。
第三,容易被现有技术栈集成。绝大多数中大型公司的数据平台后端就是 Java 技术栈。DatI 作为一个中间件,如果本身就是 Java 写的,那么无论是以 JAR 包方式嵌入现有服务,还是以独立服务方式部署,运维和二次开发的门槛都最低。这跟选择 Rust 或者 Go 的思路不同——性能或许更好,但对于一个需要频繁定制语义模型的系统,开发效率和生态完善度才是关键。
第四,GraalVM 原生镜像给了 Java 一个“轻量级”的可能性。我一开始担心“轻量级”这个定位和 JVM 的启动速度、内存占用有冲突。实测下来,用 GraalVM 做 AOT 编译之后,DatI 的启动时间能从 4 秒降到 400 毫秒左右,内存占用也能控制在 200MB 以内。这个量级作为企业内部服务完全够用,而且换来了 Java 生态的全部能力。
2.2 轻量级架构的三层划分
DatI 的整体架构不复杂,核心就是三层:接入层、语义层、执行层。我刻意保持了架构的简洁,因为网关类的中间件最怕的就是把自己做成一个大而全的平台,最后反而没人愿意用。
接入层负责协议适配。对 BI 工具,我提供了一套符合 JDBC 规范的驱动实现,这样 Power BI、帆软这类工具可以直接通过jdbc:datI://连接 DatI,把它当成一个普通数据库来用。对 Agent,我提供 REST API 和 MCP(Model Context Protocol)Server 两种方式,Agent 可以通过标准 tool 调用访问 DatI 的查询能力,也可以让 DatI 直接注册成 MCP Server。
语义层是 DatI 的核心,也是整个项目的灵魂。这一层做的事情是:接收来自接入层的“逻辑查询”(统一用 JSON 表达的查询请求),把它翻译成目标数据库的 SQL。翻译的依据是一套预先配置好的语义模型,这包括表映射、字段映射、指标公式、维度层级、同义词、默认聚合方式、行级权限规则等。
执行层负责最终把 SQL 发到真实数据库去跑。这层包含连接池管理、SQL 改写(比如强制加 limit、注入行级权限条件)、执行超时控制、结果集流式读取、审计日志记录等。执行层的设计原则是“在尽量不改语义的情况下保证安全与可控”。
我举个具体例子说明这三层如何协作。BI 工具发来一个逻辑查询:{ "dimensions": ["region", "month"], "metrics": ["revenue"], "filters": {"year": 2025} }。接入层解析后传语义层,语义层根据模型把revenue映射成SUM(order_detail.amount * order_detail.price),把region映射成dim_region.name,同时发现year是一个时间字段的过滤条件,于是自动生成WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01'。最后执行层在这个 SQL 上加一个LIMIT 10000,再补上行级权限AND org_id = 1024,然后发送到 MySQL 执行。三层的职责边界很清晰,出了问题也好排查。
2.3 核心模块:语义模型、SQL 改写、网关路由
如果继续把架构拆细一点,DatI 其实由四个核心模块组成。
第一个是MetaService(元数据服务)。它负责从真实数据库定时同步表结构、字段注释、索引、外键关系等元信息,并且把这些信息归一化成 DatI 统一的对象模型。元数据是整个语义模型的基础,没有准确的元数据,后面的翻译都是空谈。
第二个是ModelService(语义模型服务)。它负责把物理表结构映射成业务模型。管理员通过一个 JSON 文件或者管理 API 来定义“逻辑模型”,比如把物理表ods_order_detail定义为一个逻辑模型订单明细,字段amount定义为销售金额,并设置它的聚合方式为 SUM。这个模块还支持定义指标,比如客单价 = 销售金额 / 下单用户数,语义模型服务会在翻译时自动拼接这个公式。
第三个是QueryPlanner(查询计划器)。这是最复杂的模块。它接收逻辑查询,经过合法性校验、语义解析、口径展开、权限注入四个阶段,最终生成一条目标数据库可执行的 SQL。我们后面会专门讲它的实现细节。
第四个是GatewayRouter(网关路由模块)。它负责把 DatI 对接的多个物理数据源进行统一路由,支持读写分离、同构多库、异构多库三种模式。比如你有三个 MySQL 分库,路由模块会根据业务分片键把查询路由到对应分库;如果某张表在 MySQL 和 ClickHouse 里都有,路由模块会根据查询的聚合程度选择更合适的引擎——明细查询走 MySQL,OLAP 聚合查询走 ClickHouse。
这四个模块互相独立,通过 Spring 的依赖注入组装。好处是,如果用户不想用语义模型,只想做一个纯 SQL 转发代理,那只需要把 ModelService 和 QueryPlanner 的部分功能关掉,网关就退化成一个带权限控制的 SQL 代理。这种“按需开启功能”的设计,才是真正的轻量级。
3. 核心实现细节:从元数据到语义模型再到 SQL 生成
3.1 元数据采集的设计与踩坑
元数据采集是整个 DatI 的第一环,也是第一坑。我最初以为这事情很简单——JDBC 的DatabaseMetaData接口提供了getTables、getColumns这些方法,直接遍历一遍不就行了?实际写了才发现坑很多。
第一个坑是系统表污染。MySQL 的information_schema里除了业务表,还有大量系统视图和临时表,如果不过滤,元数据模型里会混进一堆没用的东西。我现在用一套白名单机制:默认只采集 base table 类型的表,且表名不以下划线开头,再加上管理员可以配置 include/exclude 的正则。比如配置"exclude": ["tmp_.*", "bak_.*"],那些临时表和备份表就不会进入语义模型。
第二个坑是不同数据库方言的差异。MySQL 的information_schema.COLUMNS有COLUMN_COMMENT字段,但 PostgreSQL 的注释存在obj_description()函数里;Oracle 的元数据视图是ALL_TAB_COLUMNS;SQL Server 是INFORMATION_SCHEMA.COLUMNS加扩展属性。我的处理方式是抽象一个MetadataProvider接口,每种数据库写一个实现,统一返回TableMeta、ColumnMeta、IndexMeta这些标准对象。数据源注册时指定方言类型,元数据服务自动选择对应的 Provider。千万不要试图写一套通用的 SQL 去兼容所有数据库,这条路的尽头是维护地狱。
第三个坑是字段注释的重要性。我在实际项目中见过太多没有任何注释的字段表,比如col_001、f_1024这种。没有注释,语义模型构建几乎无法进行——你根本不知道这个字段对应什么业务含义。所以 DatI 在元数据采集时会在日志中给出“字段缺少注释”的警告,并且支持管理员手动补充语义描述。这个手动补充的语义描述会覆盖物理表的注释,成为语义模型的最终依据。
元数据的同步策略我也调整过多次。一开始是全量同步,每 5 分钟跑一次,数据量大之后发现开销很大。后来改成增量同步:首先全量拉一次基线快照,之后每 30 秒拉取一次信息概要,对比结构版本号,有变化才触发全量刷新。MySQL 可以用information_schema.TABLES的UPDATE_TIME来做判断,PostgreSQL 需要查pg_stat_user_tables的last_analyze。说到底,元数据同步是性能敏感路径,优化空间一定要做。
以下是我在 MetaService 中核心接口的定义,非常简单直白:
public interface MetadataProvider { String dialect(); // mysql / postgresql / sqlserver / oracle ... List<TableMeta> fetchTables(DataSource ds); TableMeta fetchTableDetail(DataSource ds, String schema, String table); List<ColumnMeta> fetchColumns(DataSource ds, String schema, String table); List<IndexMeta> fetchIndexes(DataSource ds, String schema, String table); }3.2 语义模型:从表结构到业务语言的关键一跳
有了元数据,下一步就是把物理表结构翻译成业务语义。DatI 的语义模型以“逻辑模型”为单位组织,一个逻辑模型可以对应一张物理表,也可以对应多张物理表的 join,甚至可以是一张虚拟表(比如一个自定义的 SQL 子查询)。
我设计语义模型时参考了传统多维建模里“维度-度量”的框架,因为这是 BI 领域几十年验证过的思维模型,Agent 也能理解。
一个逻辑模型的配置大概长这样:
{ "modelName": "订单分析", "table": "ods_order_detail", "joins": [ { "type": "LEFT", "refTable": "dim_region", "condition": "ods_order_detail.region_id = dim_region.id" } ], "dimensions": [ { "name": "区域", "field": "dim_region.name", "displayName": "区域名称" }, { "name": "下单月份", "field": "order_date", "dateFormat": "%Y-%m", "displayName": "月份" } ], "metrics": [ { "name": "销售金额", "expression": "SUM(ods_order_detail.amount * ods_order_detail.price)", "displayName": "销售金额", "aggregationHint": "sum" }, { "name": "客单价", "expression": "SUM(amount * price) / COUNT(DISTINCT user_id)", "displayName": "客单价" } ], "synonyms": ["订单", "销售分析", "sales"], "rowLevelSecurity": "org_id = $currentOrgId" }这个 JSON 代表了 DatI 语义模型的核心能力:dimensions定义可以分组的维度字段,metrics定义聚合度量字段,expression支持纯正的 SQL 表达式,synonyms让 Agent 在理解用户问题时能正确路由到模型,rowLevelSecurity定义了行级权限模板。
语义模型的关键设计点在于“字段映射的显式化”。我见过很多类似系统,它们会把整个表的所有字段自动映射成同名语义字段,看起来方便,实际上造成很多困惑——同名不同义、同义不同名的问题在真实环境里太常见了。DatI 的做法是必须明确指定哪些物理字段要暴露、以什么语义名称暴露,不在配置里的字段一律不暴露。这样可以有效避免 Agent 或 BI 工具查出错误字段,也天然形成了一道数据最小化暴露的屏障。
在配置时务必要小心一件事:表达式必须是物理层字段,不能引用其他语义字段。比如客单价的表达式里写的amount * price和user_id都是物理字段,而不是“销售金额”“用户数”这些语义名。这样设计是为了避免语义字段之间的循环依赖,也让表达式可以直接翻译成 SQL,减少一层解析开销。
3.3 查询计划器:语义查询到 SQL 的翻译过程
QueryPlanner 是 DatI 里技术密度最高的模块。它接收的是一个统一的逻辑查询请求,输出是一条目标数据库 SQL。整个过程分四步。
第一步是合法性校验。检查维度、度量、过滤条件里引用的字段是否都在语义模型里定义过,粒度是否匹配,请求是否超过查询复杂度限制(比如最多允许 join 5 张表)。这一步能拦截掉大量 Agent 幻觉产生的无效查询。
第二步是语义展开。这一步做的事情是:把逻辑查询里的语义字段替换成 SQL 表达式,同时补充默认的度量和分组方式。比如逻辑查询里只指定了["区域"]作为维度、["销售金额"]作为度量,语义展开后变成SELECT dim_region.name AS 区域, SUM(ods_order_detail.amount * ods_order_detail.price) AS 销售金额 FROM ods_order_detail LEFT JOIN dim_region ON ... GROUP BY dim_region.name。
第三步是口径统一。这一步要处理的是“同词不同义”的问题。比如业务上有“销售金额”和“净利润”两个指标,它们的公式不同;如果逻辑查询只传了“金额”,QueryPlanner 会根据模型的synonyms和上下文自动匹配到最精确的指标,如果无法确定就直接报错,提示用户明确语义。这比自作主张猜一个更好——在数据口径上,宁可报错也不要给错数。
第四步是权限注入和 SQL 改写。QueryPlanner 会根据当前请求的用户上下文,把行级权限模板里的变量(如$currentOrgId)替换成实际值,并注入到 WHERE 子句中。同时做基础改写:加LIMIT上限、设置查询超时时间、把不支持的方言语法替换成目标方言。
以下是一个简化的翻译伪代码,方便理解整体流程:
public String plan(LogicalQuery query, UserContext user) { validate(query); // 1. 语义字段、维度粒度校验 SqlBuilder builder = expandSemantics(query); // 2. 语义字段替换为物理表达式 ensureConsistency(builder); // 3. 指标口径统一 injectSecurity(builder, user); // 4.1 行级权限注入 builder.limit(Math.min(query.limit(), maxLimit)); // 4.2 强制 limit return builder.toSql(targetDialect); // 4.3 方言适配 }3.4 方言适配与 SQL 改写:ANTLR4 的工程实践
SQL 方言适配是整个项目里最花时间的一个环节。BI 时代你只需要兼容一种方言,因为工具自己会拼 SQL;但 Agent 时代不一样,用户很可能同时查 MySQL 里的交易系统和 ClickHouse 里的日志系统,DatI 必须保证同一套语义模型能在两种引擎上产出正确的 SQL。
我一开始的方案是维护一套模板字符串,比如 MySQL 的 limit 是LIMIT n,SQL Server 是TOP n,ClickHouse 也支持LIMIT n。后来发现这只是冰山一角,函数差异才是大头——日期格式化、字符串拼接、JSON 提取、分页语法,各有各的写法。处理这种问题的正统方案是引入 AST(抽象语法树)。
我用 ANTLR4 定义了一套完整的 SQL 语法文件(支持标准 SQL 的子集),先把逻辑 SQL 解析成 AST,再做方言相关的改写,最后根据目标方言生成对应 SQL。这个方案的好处是结构化的,而不是字符串替换的。比如日期格式化函数DATE_FORMAT(order_date, '%Y-%m'),到了 PostgreSQL 就必须变成TO_CHAR(order_date, 'YYYY-MM')。基于 AST 的改写逻辑只需要把函数节点替换成目标方言的函数节点,再递归生成 SQL 即可。
选 ANTLR4 还有一个实际的原因:它对 Java 生态支持最好,生成的解析器是纯 Java 代码,没有什么外部依赖。当然,ANTRL 的学习曲线是有的,需要理解 visitor 模式、语法规则优先级这些概念。但一旦把语法文件写好,后续扩展就非常平滑。
如果你不想引入 ANTLR,也有一个简化方案:用正则替换加语句级白名单来支持常用查询。这种方式能覆盖 80% 的常见场景,但复杂 JOIN 和子查询场景容易出错。个人建议是,如果 DatI 要真正支持多个数据源,方言适配这关躲不过去,ANTLR 是值得投入的。
4. 接入方式:BI 工具与 Agent 的统一入口
4.1 给 BI 工具提供 JDBC 驱动
对 BI 工具而言,最友好的接入方式就是提供 JDBC 驱动。因为 Power BI、帆软、Tableau 这些工具都支持自定义 JDBC 连接,你只要把 DatI 的驱动 JAR 放进去,填上连接串jdbc:datI://localhost:8080/semantic/订单分析,它就会把 DatI 当成一个数据库来访问。
这里的关键是实现java.sql.Driver、Connection、Statement、ResultSet等接口。BI 工具通常会用 JDBC 的DatabaseMetaData来获取表结构,因此 DatI 的 JDBC 驱动需要把“逻辑模型”暴露成“表”,把“维度、度量字段”暴露成“列”。这样 BI 工具的用户界面上就能直接看到“订单分析”这个主题下的“区域”“销售金额”这些字段,拖拽起来就跟使用本地数据库表一样。
驱动内部实现其实是一个 REST 客户端:JDBC 驱动把 SQL 解析成逻辑查询请求,通过 HTTP 发送到 DatI 服务端。为什么不直接在驱动里连数据库?因为如果驱动直连数据库,就绕过了语义模型和安全控制,DatI 就退化成普通代理,这不是该项目想要的效果。让所有流量都经过服务端,是保证语义一致和权限统一的前提。
我自己在对接 Power BI 的时候踩过一个坑:Power BI 会调用 JDBC 驱动的getTables和getColumns,但它的连接池会同时对驱动做并发调用,我的驱动最开始没有做线程安全,导致并发连接时偶发结果错乱。解决方式很粗暴——在驱动里所有共享状态加锁,或者用 ThreadLocal 存储会话状态。这也算是一个经验:写 JDBC 驱动时,线程安全是必须优先考虑的。
4.2 给 Agent 提供 MCP Server 与 Function Calling
Agent 接入 DatI 的方式和 BI 完全不同。BI 工具需要可拖拽的 GUI 模型,而 Agent 需要的是一组可调用、可描述、可反馈的工具函数。
DatI 实现了两个协议通道。第一个是 MCP Server。MCP(Model Context Protocol)是目前 AI Agent 工具箱接入的通用规范,DatI 启动一个 MCP Server,把“查询指标”“查看模型元数据”“执行图表查询”等能力暴露成标准 MCP 工具。Agent 接入 DatI 之后,不需要预先学习任何 SQL,只需要调用 DatI 的工具并传入 JSON 参数,DatI 负责把 JSON 翻译成 SQL 并返回结果。
以 MCP 方式接入的配置如下:
{ "mcpServers": { "datI": { "command": "java", "args": ["-jar", "datI-mcp-server.jar", "--config", "application.yml"], "env": {} } } }第二个是 Function Calling 风格的工具定义。有些 Agent 框架不走 MCP,而是使用 OpenAI 兼容的 function calling 协议。DatI 也支持暴露一组 JSON Schema 定义的工具:
{ "type": "function", "function": { "name": "query_metrics", "description": "查询业务指标数据,例如销售金额、客单价、订单数等", "parameters": { "type": "object", "properties": { "model": { "type": "string", "description": "语义模型名称,如 订单分析" }, "dimensions": { "type": "array", "items": { "type": "string" }, "description": "维度字段列表" }, "metrics": { "type": "array", "items": { "type": "string" }, "description": "度量字段列表" }, "filters": { "type": "object", "description": "过滤条件,字段名与值的映射" } }, "required": ["model"] } } }Agent 拿到这个工具定义之后,会自己决定调用哪些参数。这里有一个很重要的实践心得:一定要在工具描述里写清楚可用的字段名和示例值。大模型对工具参数的理解完全依赖描述文字的清晰度。我见过很多 Agent 系统把参数描述写得模棱两可,结果 Agent 频繁传错字段名。DatI 的模型注册 API 会自动把语义模型里的displayName、synonyms、枚举值都塞进描述文字里,让 Agent 有最大可能生成正确的调用参数。
4.3 与管理台的交互:模型配置与查询审计
除了 BI 和 Agent 这两种消费端,DatI 还需要给管理员提供一个管理界面。这个管理台不是硬性需求,但在实际部署中非常有用。
管理台负责三件事。第一是语义模型配置的维护——管理员可以在网页上创建逻辑模型、添加字段映射、编辑指标表达式,配置结果会即时生效并缓存。第二是查询审计——所有经过 DatI 的查询都会打点记录,包括用户、请求来源、查询耗时、生成的 SQL、执行状态。一旦 Agent 的某个查询结果异常,审计日志是排查的第一现场。第三是数据源的接入管理——配置数据库连接串、设置连接池大小、启停某个数据源。
我把管理台做成了一个独立的 Web 模块,默认监听 8081 端口,与网关主服务的 8080 端口分开。这样生产环境可以将管理台放在内网,而网关服务对外只暴露 JDBC/API 端口,降低攻击面。
管理台的接口设计倾向采用“先配置、后校验、再发布”的流程。因为语义模型直接决定业务数据的口径解释,一个错误的模型配置会让所有下游查询出错。因此在模型发布前,管理台会执行一组预置的“冒烟查询”来验证模型配置的正确性——比如查一个月初的汇总数据,如果返回结果为空或 SQL 报错,系统会阻止发布并给出错误提示。这个防呆设计可以为后续维护省下不少麻烦。
5. 从零实现一个可运行的查询链路
5.1 环境准备与依赖
如果你也想把 DatI 的思路落地,下面这一节可以直接照着做。需要准备的环境如下:
- JDK 17 或更高版本
- Maven 3.8+
- MySQL 8.0(示例数据源,可以换成你自己的库)
- 一个支持 MCP 的 Agent 客户端(或者直接用 curl 测试 REST API)
Maven 依赖方面,核心依赖是 Spring Boot 3.x、HikariCP、ANTLR4 runtime、以及各数据库的 JDBC Driver。用spring-boot-starter-jdbc就自带 HikariCP,非常省事。ANTLR4 需要用插件生成解析器代码,Maven 配置如下:
<plugin> <groupId>org.antlr</groupId> <artifactId>antlr4-maven-plugin</artifactId> <version>4.13.1</version> <executions> <execution> <goals> <goal>antlr4</goal> </goals> </execution> </executions> </plugin>5.2 最小化的语义模型配置
假设你有一个 MySQL 库,里面有张sales_order表,字段包括id、order_no、user_id、region、amount、order_date。我们把它定义成一个简单的语义模型:
{ "modelName": "销售订单", "table": "sales_order", "dimensions": [ { "name": "地区", "field": "region", "displayName": "地区" }, { "name": "日期", "field": "order_date", "displayName": "下单日期" } ], "metrics": [ { "name": "订单金额", "expression": "SUM(amount)", "displayName": "订单金额" } ], "synonyms": ["销售的", "订单金额", "sales_order"] }这里不需要写任何 SQL,DatI 会自动根据这些配置在收到查询请求时拼出 SQL。接入层暴露出来的语义模型就像你给数据库建了一张“业务视图”,但比物理视图多了一层可解释性——字段有中文名、有公式、有别名,这是 Agent 能理解的关键。
5.3 三种调用方式的实测对比
模型配置好之后,我们可以分别从三个入口测试 DatI 的效果。
第一个入口是直接调用 REST API。用 curl 发一个逻辑查询:
curl -X POST http://localhost:8080/api/query \ -H "Content-Type: application/json" \ -d '{ "model": "销售订单", "dimensions": ["地区"], "metrics": ["订单金额"], "filters": {"日期": {"gte": "2025-01-01", "lt": "2025-02-01"}} }'返回结果是一个 JSON 数组,包含地区和订单金额两列数据。这个接口的语义很清晰,Agent 调用的时候非常自然。
第二个入口是模拟 BI 工具的 JDBC 连接。我把 DatI 的 JDBC 驱动配置到帆软的数据连接里,然后在帆软的设计器里拖拽地区和订单金额两个字段,帆软自动生成了一条逻辑查询并发送到 DatI。实测查询耗时在 200 毫秒以内,与直连 MySQL 的差异很小,因为语义翻译本身的耗时只有几毫秒,主要开销还是数据库执行。
第三个入口是 MCP 方式。我用一个支持 MCP 的 Agent 客户端连接 DatI,然后问 Agent:“今年一月各地区的订单金额是多少?”Agent 通过 DatI 的工具列表发现销售订单模型和地区、订单金额字段,自动生成了查询参数并调用,最终返回了一段带有表格的答复。整个过程 Agent 没有接触任何 SQL,它只知道有哪些模型、哪些字段、哪些指标,这个体验是很干净的。
5.4 打通 BI 和 Agent 的“数据口径一致性”
DatI 带来的一个隐藏价值是:BI 出报表和 Agent 回答问题时,查的是同一套语义模型、同一套指标公式。这听起来理所当然,但在实际企业环境里非常难得。
过去 BI 报表的数据口径由报表工程师维护,Agent 应用的数据口径由 AI 工程师临时写 prompt 定义。两拨人各写各的,指标经常对不上。DatI 把指标定义集中在一处,无论 BI 还是 Agent 都是通过调用同一个模型拿到数据,口径天然一致。我们公司内部的报表团队和 AI 应用团队在那次联调中,终于第一次在同一个指标上达成了一致。
当然,要做到这一点的前提是语义模型的维护者必须懂业务。DatI 只是提供了工具,指标公式定错了,工具再强也白搭。所以建议在落地的组织中让数据产品经理或业务分析师来维护语义模型,而不是让后端开发完全代劳。
6. 常见问题与排查技巧实录
6.1 Agent 生成 SQL 语法对,但执行时报错
这是我被问得最多的问题。Agent 调用 DatI 传参后,数据源返回syntax error。排查步骤是这样的:
先看审计日志中生成的 SQL,确认是否因为方言不同导致语法差异,比如 MySQL 的IFNULL在 PostgreSQL 里需要写成COALESCE。如果语义表达式里写了特定方言的函数,跨数据源查询时就会踩这个坑。解决方法是把通用函数映射成标准 SQL 函数,例如用COALESCE替代IFNULL,由方言适配层在生成时转换。
但更常见的原因是 Agent 传入了模型里没有的过滤字段。比如 Agent 自动往 filters 里塞了一个“门店名称”,但你的模型里只映射了region,没有门店字段。DatI 的策略是严格校验,直接报错并提示可用的字段列表。第一次用会觉得“不够智能”,但这种方式远比静默忽略或强行执行要安全得多——起码用户能立刻知道 Agent 理解错了,而不是拿到一个缺少关键过滤条件的结果当作正确答案。
6.2 查询结果数据量过大导致内存溢出
Agent 场景很容易出现一个问题:Agent 调用查询时没有指定 limit,而底层表有上亿行数据。如果 DatI 不干预,查询会返回百万行结果,把网关内存打爆,甚至拖垮数据库。
我在执行层做了三道防线。第一道是强制 limit:所有查询在生成 SQL 时自动加上上限,默认 10000 行,管理员可以按模型调整。第二道是结果集流式处理:JDBC 层的 ResultSet 不一次性全载入内存,而是流式读取,一行处理一行转发给客户端,避免大结果集在网关层堆积。第三道是查询超时:每条查询可以配置最大执行时间,超过就由网关主动 cancel 连接。
经验之谈:Agent 场景根本不需要一次返回一万条数据,通常在 20~50 条内就能总结出结论。如果你给 Agent 的工具返回了一个巨大无比的结果集,不仅浪费 token,还会让 Agent 分析时注意力涣散。建议把 Agent 调用查询的默认 limit 设小一点,比如 100 行,同时提供翻页参数让 Agent 按需取下一批。
6.3 元数据同步失败导致语义模型缺失
元数据同步失败是一个必须处理好的问题,因为 DatI 的语义翻译强依赖元数据。常见的失败原因是数据库账号权限不足——执行元数据同步的账号可能只有读写业务表的权限,没有访问 information_schema 或系统目录的权限。解决方式是给同步账号单独授予只读系统视图的权限,比如 MySQL 需要SELECT ON information_schema.*或至少能看到目标库的表结构。
另一个隐患是数据库连接串配置错误导致的“同步成功但连接了错误实例”。我遇到过同步成功但表结构完全对不上的情况,最后发现是配置了多数据源时,A 数据源的连接串和 B 数据源的同步任务串了。建议在配置数据源时增加一个“连通性校验”的步骤,保存前用连接串执行一次轻量查询(比如SELECT 1),同时展示返回的database()或current_database()信息,让管理员确认没有连错实例。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| BI 工具连接 DatI 成功但看不到表 | JDBC 驱动 getTables 未过滤逻辑模型 | 检查驱动版本,确认逻辑模型已发布 |
| Agent 调用查询超时 | 底层数据源查询太慢或 limit 过大 | 检查执行层超时配置,同时在语义模型里缩短允许的查询范围 |
| 相同查询在不同数据源结果不一致 | 方言转换或函数实现差异 | 查看生成 SQL,将差异函数统一为标准函数 |
| 行级权限不生效 | 用户上下文未正确传递 | 确认接入层是否正确注入用户身份标识 |
| 指标结果与报表旧口径不一致 | 指标公式定义有误 | 回看语义模型 metrics 表达式,与业务方核对公式 |
| 修改语义模型后查询仍走旧逻辑 | 缓存未失效 | 管理台点击强制刷新缓存,确认缓存版本号更新 |
6.5 几个必须在生产环境注意的配置项
生产部署时,有几个配置项务必检查一遍,这是踩过多次坑后留下的清单。
首先是spring.datasource.hikari.maximum-pool-size。如果设置的连接池太小,Agent 的并发查询会把连接池打满,之后的请求全部排队。反之如果太大,数据库本身的连接数会被耗尽。我的经验值是:DatI 网关层连接池大小设置为数据库最大连接数的 1/3 左右,同时给网关服务和数据库之间加上 P99 延迟监控,出现明显变慢就排查连接池有没有打满。
其次是datI.query.max-limit和datI.query.timeout-ms。这两个是安全阀,必须设置。Agent 是“失控”的调用方,你不能指望它每次查询都恰到好处。强制 limit 和超时就是最后的防线。
最后是日志打印。网关服务必须打印完整的审计日志,包括请求来源(BI 还是 Agent)、用户标识、模型名称、生成 SQL、执行耗时、返回行数。出了问题可以快速回溯。审计日志不要只记到文件,还要考虑接入集中式日志平台,因为 Agent 场景的排查往往需要跨服务追踪调用链。
7. 后续可以扩展的方向
DatI 从最初的一个 idea 到跑通完整链路,前后花了一个多月。目前它已经在公司内部的数据分析平台上运行,支撑着 BI 报表和 AI 问答两个入口的数据查询。但它的边界还远没有到极限。
我个人目前最想做的扩展有三个方面。第一个是增强语义模型的“主动学习”能力——根据真实查询日志,自动推荐热度高的字段组合,辅助管理员完善语义模型。第二个是把网关向前推进一步,支持从数据库日志中自动化发现 slow query,并且针对慢查询生成优化建议,把网关从“查询翻译器”升级成“数据诊断器”。第三个是让 DatI 对接更多的 Agent 框架,降低任何团队把 DatI 接入自己应用的成本。
做 DatI 最大的体会是:数据和 AI 之间的连接,真正缺的不是技术,而是把两边语言对齐的“翻译器”。语义网关的轻便之处在于,它不需要改变你现有的任何数据基础设施,只需要在数据上面加一层大家都认的语义定义。这种“加一层”的思路,在 AI 落地时往往是最省力也最有效的方式。如果看完这篇你也想动手做一个类似的东西,建议从一个最小的场景开始——挑一张表、建一个模型、接一个 Agent,跑通了再往外扩。希望我踩过的这些坑能帮你少走几步弯路。