D2RQ-0.8.1实践:不写ETL把MySQL映射为知识图谱
简介:d2rq-0.8.1 是 D2RQ(Database-to-RDF Mapping Language)0.8.1 版本的发行包,面向需要把关系数据库接入知识图谱的开发者与语义网方向的学习者。它依靠可自定义的映射文件,把库中的表、字段与记录对应为 RDF 的类、属性与资源,使原本非 RDF 的数据源能通过 SPARQL 等语义查询语言被检索和推理,常用于企业数据整合与推荐系统等场景。压缩包为 zip 格式,体积约 18.59MB;上游暂未给出文件清单与类型明细,具体组成需解压后确认。目前已有 435 人浏览学习,属于方向明确的小众工具型资源。价值主要体现在可直接落地:读者能对照自身的库表结构编写 mapping 文件,把存量数据转换为 RDF 三元组,也可在本地复现映射、查询与发布流程,为知识图谱的数据接入环节积累一套可复用的实践思路。
1. d2rq-0.8.1:不写 ETL,先把 MySQL 里的表“看”成一张知识图谱
上个月帮一个做设备运维的团队看他们的知识图谱原型,本体在编辑器里画得挺漂亮,实例数据一条没有——业务数据全躺在 MySQL 二十几张表里,写抽取脚本的排期排到下个季度。这种场景我一般不让团队先动手写 ETL,而是先用 D2RQ 把关系库直接当成一张虚拟 RDF 图跑起来,本体设计对不对、SPARQL 够不够用,一天之内就能验证完。d2rq-0.8.1 是这个项目最后一个正式发行版,里面是 generate-mapping、dump-rdf、D2R Server 三件套加一个 D2RQ Engine:前者把表结构读成映射文件,中间那个把映射结果导成 RDF 文件,后两个负责在线查询。适合两类人——想快速验证图谱本体和查询链路的,以及要拿存量业务库做原型、又不想先背一套 ETL 债的。
2. 生成第一份映射文件:generate-mapping 的参数与库侧准备
2.1 环境对齐:JDK 8 与手工放置 JDBC 驱动
d2rq-0.8.1 是个有年头的包,依赖的 Jena 是 2.x 系列,自带的 Jetty 版本也老。血泪经验是别拿 JDK 17 去跑,反射和模块化那两道坎会让 D2R Server 直接起不来,JDK 8 是唯一让我省心的组合。包里不含任何数据库驱动,MySQL、PostgreSQL、Oracle 的 jar 都得自己丢进lib/目录,脚本启动时会把lib/*.jar全塞进 classpath。
# 解压到固定路径,后续命令都在这个目录下执行 unzip d2rq-0.8.1.zip -d /opt cd /opt/d2rq-0.8.1 # bin 里是启动脚本,lib 是依赖 jar,doc 里有映射语言的说明 ls bin lib # MySQL 5.7 用 5.1.x 驱动最稳;驱动不放 lib,后面一定报 ClassNotFound cp ~/jars/mysql-connector-java-5.1.49.jar lib/ # 确认 JDK 版本,1.8 是安全区 java -versionWindows 下对应的是bin\generate-mapping.bat,参数完全一样,只是路径分隔符要换。如果脚本执行时报Permission denied,先chmod +x bin/*。这一步看着琐碎,但它决定了后面所有报错是“业务问题”还是“环境问题”——我习惯先把环境跑通,再用一个只有三张表的小库验证链路,别一上来就怼生产库的几百张表。
库侧还要做两件事。第一,别用业务账号,建一个只读账号,D2RQ 生成的映射默认是只读语义,但你不会想让一个自动生成的脚本拿着写权限连生产。第二,确认目标表都有主键。没有主键的表在生成映射时会退化处理,URI 规则拿不到稳定标识,导出的实体 IRI 会变成一串行号,后面合并图谱时基本没法救。
-- 只读账号,够用就行,别给写权限 CREATE USER 'kg_reader'@'%' IDENTIFIED BY '换成你的口令'; GRANT SELECT ON shop.* TO 'kg_reader'@'%'; FLUSH PRIVILEGES; -- 先排查没有主键的表,这类表是后面 IRI 不稳定的根源 SELECT t.table_name FROM information_schema.tables t LEFT JOIN information_schema.table_constraints c ON c.table_schema = t.table_schema AND c.table_name = t.table_name AND c.constraint_type = 'PRIMARY KEY' WHERE t.table_schema = 'shop' AND c.table_name IS NULL;2.2 一条命令把表结构读成 TTL
generate-mapping 干的事很朴素:连库、读元数据、按表生成 ClassMap、按列生成 PropertyBridge、按外键生成 join,最后吐出一个 Turtle 文件。命令本身不长,坑全在参数和 JDBC URL 的写法上。
# 生成映射文件:-b 是实体 IRI 的前缀,-o 是输出路径 ./bin/generate-mapping \ -u kg_reader \ -p '你的口令' \ -d com.mysql.jdbc.Driver \ -b http://kg.example.org/ \ -o mapping.ttl \ -v \ "jdbc:mysql://127.0.0.1:3306/shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"参数逐个说清楚:
| 参数 | 含义 | 常见误用 |
|---|---|---|
-u/-p | 数据库账号口令 | 用业务高权限账号,后面忘了收回 |
-d | JDBC 驱动类名 | MySQL 8 驱动写成com.mysql.cj.jdbc.Driver才认 |
-b | 实体 IRI 的基础前缀,写进d2rq:uriPattern | 随手写 localhost,导出后 IRI 永久带着本地地址 |
-o | 输出映射文件 | 覆盖了手改过的旧映射,没有版本管理就找不回来 |
-v | 打印每张表的处理过程 | 不加的话出错只看到一句异常,不知道卡在哪张表 |
JDBC URL 一定要用引号包起来。&在 shell 里是后台执行符,不引起来后半截参数会被 shell 吞掉,现象就是“连接串里明明写了编码,导出来还是乱码”。characterEncoding和serverTimezone两个参数建议一开始就带上,前者管中文,后者管时间字段——少一个,后面 dump 出来的时间字面量就可能偏 8 小时。
跑完之后先别急着看内容,先看行数和表数量对不对得上:
# 生成的映射是一个纯文本 TTL,可以直接 diff 和版本管理 wc -l mapping.ttl grep -c "a d2rq:ClassMap" mapping.ttl # 大致对应表数量 grep -c "d2rq:join" mapping.ttl # 外键数量,为 0 说明外键没被识别d2rq:join数为 0 是个很典型的信号:要么库引擎是 MyISAM 没建外键约束,要么表之间是靠应用层维护的软关联。这种情况自动映射帮不了你,关联关系得自己在映射文件里补。
2.3 生成物怎么读:三层结构
自动生成的映射文件长这样,缩略成两张表加一个外键:
@prefix d2rq: <http://www.wiwiss.fu-berlin.de/suhl/bizer/D2RQ/0.1#> . @prefix map: <http://kg.example.org/map/> . @prefix vocab: <http://kg.example.org/vocab/> . # 第一层:数据源,一个映射文件里通常只有一个 map:database a d2rq:Database ; d2rq:jdbcDSN "jdbc:mysql://127.0.0.1:3306/shop" ; d2rq:jdbcDriver "com.mysql.jdbc.Driver" ; d2rq:username "kg_reader" ; d2rq:password "你的口令" . # 第二层:类映射,一张表一个,uriPattern 决定实体 IRI map:orders a d2rq:ClassMap ; d2rq:dataStorage map:database ; d2rq:class vocab:Orders ; d2rq:uriPattern "http://kg.example.org/resource/orders/@@orders.order_id@@" . # 第三层:属性桥,一列一个,列名带表前缀 map:orders_amount a d2rq:PropertyBridge ; d2rq:belongsToClassMap map:orders ; d2rq:property vocab:orders_amount ; d2rq:column "orders.amount" . # 外键被翻译成 join + refersToClassMap,这是自动映射里最值钱的部分 map:orders_customer a d2rq:PropertyBridge ; d2rq:belongsToClassMap map:orders ; d2rq:property vocab:orders_customer ; d2rq:join "orders.customer_id = customers.customer_id" ; d2rq:refersToClassMap map:customers .读这个文件只要抓住三个点。d2rq:uriPattern里的@@表名.列名@@是占位符,运行时会替换成真实主键值,这就是实体 IRI 的来源;d2rq:column是“取哪一列做字面量”,值类型靠列名的表前缀定位;d2rq:join加d2rq:refersToClassMap的组合表示“这条边的另一头是一个实体,而不是字符串”,它决定了导出时是对象属性还是数据属性。
这里有个容易忽略的细节:生成的vocab:前缀下全是机械命名,类名就是表名首字母大写。它能跑,但没人愿意在自己的图谱里留一堆vocab:Orders、vocab:order_id。所以生成只是起点,下一步必须动手改。
3. 改映射:从自动生成到能用的本体对齐
3.1 换掉机械命名:class 与 property 直接替换
自动生成的映射文件是纯文本,改起来没有心理负担,但得有策略。我一般分两步:先用 sed 批量替换前缀和常见字段,再手工补语义。批量那一步是为了省时间,手工那一步是为了不把错误规模化。
# 第一步:把机械命名的 vocabulary 前缀整体换成本体前缀 sed -i 's|http://kg.example.org/vocab/|http://schema.org/|g' mapping.ttl # 第二步:单点修正关键类名,schema.org 没有的类换成自己的本体 sed -i 's|schema:Orders|ex:Order|g; s|schema:Customers|ex:Customer|g' mapping.ttl # 改完先做语法检查,Turtle 少一个点都会在运行期才炸 ./bin/dump-rdf -m mapping.ttl -o /dev/null -f TURTLE -v第三行那个“导出到 /dev/null”的习惯救过我很多次。Turtle 的语法错误在浏览器里不一定暴露,但 dump-rdf 会老老实实报行号。改完映射立刻用 dump 做一次冒烟,比等到服务端起不来再回头找要快得多。
替换的边界也要注意:d2rq:property换成外部本体的 IRI 之后,属性名必须和本体里声明的完全一致,包括大小写。schema:name和schema:Name是两个东西,SPARQL 查不出来也不会报错,只会返回空结果——这是玄学感最强的一类问题,查半天 SQL 日志才发现根本没生成那条属性。
3.2 join、condition、sqlExpression:三把改映射的刀
自动映射只能表达“表里有什么”,表达不了“业务上要什么”。真正让映射可用的是这三个谓词。
d2rq:join处理关联。多对多中间表在自动映射里通常只生成两条 join,方向是散的,需要手工归并成一条从 A 到 B 的边:
# 中间表 order_item 不单独建类,直接连 orders 到 products map:orders_products a d2rq:PropertyBridge ; d2rq:belongsToClassMap map:orders ; d2rq:property ex:containsProduct ; d2rq:join "orders.order_id = order_item.order_id" ; d2rq:join "order_item.product_id = products.product_id" ; d2rq:refersToClassMap map:products .d2rq:condition处理过滤。软删除、测试账号、无效状态这些脏数据不该进图,直接在桥上加 SQL 条件,让它在下推时就过滤掉,而不是导出来再洗:
# 只导出有效订单,condition 里写的是 SQL 片段,不是 SPARQL map:orders a d2rq:ClassMap ; d2rq:dataStorage map:database ; d2rq:class ex:Order ; d2rq:condition "orders.is_deleted = 0 AND orders.status <> 'VOID'" ; d2rq:uriPattern "http://kg.example.org/resource/order/@@orders.order_id@@" .d2rq:sqlExpression处理计算和拼接。全名字段拆成两列存的表特别多,图谱里要的是一个完整名字:
# 用 SQL 表达式拼出全名,TRIM 兜住多余空格 map:customers_name a d2rq:PropertyBridge ; d2rq:belongsToClassMap map:customers ; d2rq:property ex:name ; d2rq:sqlExpression "CONCAT(TRIM(customers.first_name), ' ', TRIM(customers.last_name))" ; d2rq:datatype xsd:string .这三把刀的共同点是最终都会翻译成 SQL 下推到数据库,所以写法必须贴合你用的数据库方言。CONCAT在 MySQL 里没问题,换 Oracle 就得改成||,换成 PostgreSQL 时TRIM的行为也要重新确认。
3.3 URI 规则定死,别让同一家公司出现三个 IRI
图谱最怕的不是数据少,是同一个实体有多个 IRI。映射阶段造的 IRI,后面要花成倍的力气去消歧。我一般按三条规则定:
优先用主键,不用业务字段。统一社会信用代码看着唯一,但补录期间可能是空串,空串会生成.../company/这种没有标识的 IRI,所有空值实体撞在一起。
不要在uriPattern里拼中文和空格。URI 里出现%E5%8C%97%E4%BA%AC这类编码后,不同工具生成的转义大小写可能不一致,==比较直接失败。要拼字符串就用d2rq:valueRegex先清洗:
# 把名称里的空白和斜杠换掉,保证 IRI 干净 map:customers_iri a d2rq:PropertyBridge ; d2rq:belongsToClassMap map:customers ; d2rq:property ex:slug ; d2rq:column "customers.short_name" ; d2rq:valueRegex "\\s+|/" ; d2rq:valueReplace "-" .-b给的基础前缀一旦定下来就别改。改了等于换了一套 IRI,之前导入图库的实体全部变成孤儿节点,合并时既不是新增也不是更新,而是两份重叠数据。我一般会在项目 README 里把基础前缀写死,标注“不可变更”,这条约束比任何映射技巧都重要。
4. 两种落地方式:离线导出还是在线虚拟查询
4.1 dump-rdf:导出一份能直接进图库的 RDF
离线导出的好处是结果可控——落成一个文件,体积、行数、内容都能先看一遍再决定要不要进图库。适合做首次全量、做数据体检、做本体验证。
# 格式选 TURTLE,体积小、可读性好,出问题能直接 grep ./bin/dump-rdf -m mapping.ttl -o shop-dump.ttl -f TURTLE -v # 大库先估体积:行数大约等于三元组数,别等导一半 OOM wc -l shop-dump.ttl du -h shop-dump.ttl格式怎么选,我按下游工具来定:
| 格式 | 参数 | 什么时候用 | 代价 |
|---|---|---|---|
| TURTLE | -f TURTLE | 人工检查、进 Jena TDB、大部分图库导入 | 需要全量解析,超大文件吃内存 |
| N-TRIPLE | -f N-TRIPLE | 流式处理、按行切分、管道下游 | 冗余多,体积约为 TURTLE 的 1.5 到 2 倍 |
| RDF/XML | -f RDF/XML | 对接只认 XML 的老系统 | 最难读,出错信息也最难定位 |
| N3 | -f N3 | 少量脚本里临时用 | 生态支持少,不建议作为交付格式 |
超大库还要注意 JVM 内存。bin 下的脚本是 shell 包装,直接加-Xmx最省事的方式是编辑脚本里的 java 调用行,或者在命令前临时提高堆上限。导出过程是流式的,但 TURTLE 输出的前缀和部分缓存仍会占内存,一个两千万行的订单表,给 2G 堆比较稳。
4.2 D2R Server:把关系库挂成一个 SPARQL 端点
在线方式不用落文件,改一条数据立刻能查到,适合联调和演示。
# 默认端口 2020,-p 指定其他端口 ./bin/d2r-server -p 2020 mapping.ttl # 起来之后三个入口: # http://localhost:2020/ 浏览实体,按类分页 # http://localhost:2020/sparql 标准 SPARQL 端点,POST/GET 都收 # http://localhost:2020/snorql/ 网页版查询界面,调试很方便在线查询的代价是性能不可控。D2RQ Engine 会把 SPARQL 翻译成 SQL,简单图模式翻译得不错,一旦遇到多层 OPTIONAL、子查询、聚合,翻译出来的 SQL 会非常难看,执行时间从毫秒级跳到分钟级。我一般把它当“验证器”而不是“生产端点”:本体对了没、属性生成得对不对,用它查最快;真要对外提供服务,还是导出后进图库。
一个实操细节:FILTER里的函数能不能下推,直接决定查询是秒级还是劝退级。FILTER(?id = "123")能下推成WHERE id = '123',但FILTER(REGEXP(?name, "^北京"))大概率变成全表拉回来在内存里过滤。写查询时先看一眼服务端日志里打出来的 SQL,就知道发生了什么。
4.3 D2RQ Engine:在 Java 代码里直接拿一个 Model
要把映射接进自己的系统,用 D2RQ Engine 把映射文件包装成 Jena 的Model是最短路径,之后所有查询都走熟悉的 Jena API,底下是实时 SQL。
import com.hp.hpl.jena.rdf.model.Model; import com.hp.hpl.jena.query.QueryExecution; import com.hp.hpl.jena.query.QueryExecutionFactory; import com.hp.hpl.jena.query.ResultSet; import com.hp.hpl.jena.query.QueryFactory; import de.fuberlin.wiwiss.d2rq.jena.ModelD2RQ; public class KgProbe { public static void main(String[] args) throws Exception { // 映射文件路径、序列化格式、基础 URI;构造签名以手上 jar 为准 Model model = new ModelD2RQ("mapping.ttl", "TURTLE", "http://kg.example.org/"); // 查询照常写 SPARQL,底层翻译成 SQL 打到关系库 String q = "SELECT ?s ?name WHERE { ?s <http://schema.org/name> ?name } LIMIT 20"; QueryExecution qe = QueryExecutionFactory.create(QueryFactory.create(q), model); try { ResultSet rs = qe.execSelect(); while (rs.hasNext()) { System.out.println(rs.next()); } } finally { qe.close(); // 不 close 会一直占着连接池 } } }classpath 要把lib/下所有 jar 加上,包括 Jena、ARQ、以及对应的 JDBC 驱动。构造器的参数顺序在不同小版本里略有差异,拿不准就先确认一下:
# 看实际构造签名,比翻文档快 javap -classpath d2rq-0.8.1.jar de.fuberlin.wiwiss.d2rq.jena.ModelD2RQ这个用法我在做数据校验工具时用得最多:同一套 SPARQL,一边跑在导出的 TDB 上,一边跑在虚拟 Model 上,两边结果对不上就说明导出过程中有映射被解析错了。
5. 避坑:d2rq-0.8.1 在真实库上翻车的五个地方
5.1 起不来,报 ClassNotFound 或者认证失败
现象:generate-mapping 第一行就抛ClassNotFoundException,或者报Access denied、Client does not support authentication protocol。
原因:一是驱动 jar 没放进lib/,或者放进去但文件名带了(1)之类的后缀导致脚本扫不到;二是-d写的类名和实际驱动版本不匹配,MySQL 8 的驱动类已经变成com.mysql.cj.jdbc.Driver;三是 MySQL 8 默认的认证插件新版才支持,老驱动连不上。
解决:先把驱动确认清楚,unzip -l lib/mysql-connector-java-*.jar | head看版本;MySQL 5.7 配 5.1.x 驱动、MySQL 8 配 8.x 驱动并改类名。实在要在 8.0 上跑老驱动,就把账号的认证方式显式指定成兼容模式。这一步别硬试,版本对上了就一次过。
5.2 condition 只写在 ClassMap 上,脏数据照样进图
现象:类映射上明明加了d2rq:condition,导出的实体里还是能看到已删除记录,或者查询返回的实体没有属性值。
原因:d2rq:condition是挂在具体桥上的,ClassMap 上的条件只影响“这个类要不要出现”,不会自动继承到每条 PropertyBridge。实体出现了但没有属性,就是这个错位的典型表现。
解决:条件要写两遍——ClassMap 上写一份控制实体生成,每条需要过滤的 PropertyBridge 上再写一份。写完之后用grep -c "d2rq:condition"数一下,条件数量应该大致等于桥的数量,少了就是漏了。
5.3 字面量里出现 null,或者空串变成一条假三元组
现象:导出的文件里能 grep 到字符串null,或者某个属性值是空字符串,下游统计空值率时把数据质量判断错了。
原因:D2RQ 对 SQL NULL 的处理是“不生成三元组”,但只要你用了sqlExpression或者pattern做字符串拼接,NULL 就会参与运算,结果变成字面量null或者空串,然后被当成正常值写出去。
解决:拼接一律用COALESCE兜底,或者在桥上加d2rq:condition "orders.remark IS NOT NULL AND orders.remark <> ''"。导出后加一条体检查询,专门统计值为null的字面量数量,稳定为 0 再往下走。
5.4 导出跑一半 OOM,或者跑完发现顺序全乱
现象:dump-rdf 执行到某张大表时抛OutOfMemoryError,或者导出的文件里实体顺序和预期完全不一致,人工抽查时找不到。
原因:默认堆太小;TURTLE 输出为了合并前缀会缓存一部分内容;大表 join 时会产生比原表大得多的中间结果集。
解决:先调堆,再考虑分段。分段的做法是按主键区间拆成多次导出,每次加一个d2rq:condition "orders.order_id BETWEEN x AND y",导完再合并文件。顺序这个事不用纠结,RDF 本身无序,需要稳定顺序就在加载图库之后用 SPARQL 排序,别指望导出文件的行序。
5.5 时间和布尔字段映射出偏差
现象:时间字段导出来是带时区偏移的字符串,和业务系统里看到的差 8 小时;tinyint(1)的字段被当成布尔,但业务上它其实是 0 到 9 的状态码。
原因:JDBC URL 没指定时区,驱动按 UTC 解释;tinyint(1)在 MySQL 语义里和BOOLEAN同义,元数据上无法区分业务意图。
解决:JDBC URL 里显式加serverTimezone,并在映射里把d2rq:datatype统一指定成xsd:dateTime;状态码字段手工把xsd:boolean改成xsd:integer,同时检查有没有被自动加上d2rq:valueRegex。类型这一块改完一定要做一次抽样比对,挑几个边界值回原库查一遍。
6. 导出之后:三组 SPARQL 校验和全量替换的增量法
映射改完、文件导出,很多人到这就结束了,结果图谱里带着一堆脏数据跑了两周才发现。我一般在 dump 之后强制跑三组校验查询,都是几秒钟的事,但能挡住大部分低级错误。
# 校验一:孤立实体,有类型但没有任何属性,通常是 condition 写错位 SELECT (COUNT(?s) AS ?orphans) WHERE { ?s a <http://schema.org/Order> . FILTER NOT EXISTS { ?s ?p ?o . FILTER(?p != rdf:type) } } # 校验二:主键重复,同一个业务主键生成了两个不同 IRI SELECT ?id (COUNT(DISTINCT ?s) AS ?n) WHERE { ?s ex:orderId ?id . } GROUP BY ?id HAVING (COUNT(DISTINCT ?s) > 1) # 校验三:属性基数,本该单值的 name 出现多值,说明 join 产生了笛卡尔积 SELECT ?s (COUNT(?name) AS ?n) WHERE { ?s <http://schema.org/name> ?name . } GROUP BY ?s HAVING (COUNT(?name) > 1) LIMIT 50三个结果都应该是 0 或者空集。校验三出现大量结果时,基本可以断定某个 PropertyBridge 上的多表 join 缺了约束条件,把一对多关系放大成了多对多。
增量这块要有心理预期:d2rq-0.8.1 没有变更捕获能力,它只会按当前库状态生成全量结果。我一般用的做法是每次全量重跑一遍 dump,然后按命名图整图替换——导入时DROP GRAPH再LOAD,而不是逐条 diff。理由很实在,映射文件本身也在改,逐条 diff 的基准一直在漂移,成本远高于全量替换。真正需要增量时,把增量逻辑放在源库侧(加时间戳字段做分段导出),而不是让 D2RQ 去猜哪些行变了。
从那以后我每次动完映射文件,哪怕只改了一行d2rq:condition,都强制走一遍“dump 到 /dev/null 做语法冒烟、导出小样本、跑三组校验”这三步,再往图库里灌。这套流程花了不到十分钟,比事后在图谱里捞脏数据便宜太多。希望帮到你。
本文还有配套的精品资源,点击获取