说明
假设这样一个应用,从传统的mysql中读出原始数据,并将其合理的存储到neo4j中。以便进行模式查询 。
0 模式
neo4j的存储有几种模型,可以参考这篇文章:如何将大规模数据导入Neo4j
其中:
- 1 create 语句:我使用py2neo和cypher(通过neo4j提交)都实现过,效果和上面差不多
- 优点:非常灵活,特别是cypher
- 场景:如果有比较多的,复杂的变化,用这种方式比较好
- 2 load csv语句:这个可能更适合中等规模的业务,好像没试过
- 3 三个大批量导入(需要停库的方法),
都要提前转为csv
:
- 1 Batch Inserter/Batch Import: 适合冷库重建,以及
大批量更新
- 缺点:不熟悉java
- 2 Neo4j-import: 适合冷库重建,
只能生成新库
- 缺点:每次都是重建,不过问题也不大
1 元数据
1.1 节点
1 节点 nid
2 名称 name
3 创建时间戳 create_time
4 更新时间戳 update_time
5 是否可用(0/1) is_enable
1.2 关系
1 关系 rid
2 创建时间戳 create_time
3 更新时间戳 update_time
4 是否可用(0/1) is_enable
2 索引
节点id, nid
在数据库中可以查询当前索引:schema
建立/删除索引的命令(似乎一定要指明集合建立索引)
# 给MALE集合的name属性建立索引
CREATE INDEX ON :MALE(name);
DROP INDEX ON :MALE(name);
建立索引非常重要,否则存取速度会线性下降
3 动态导入模板
为了实现更加通用的操作,我认为最好通过两层方法进行cypher插入语句的生成。
3.1 python生成jinja模板
3.1.1 关系基础模板
有一部分的结构是固定的,可以先写一个txt文本(避免程序中出现大段的文本)rel_base.txt
with
[
{% for obj in data_list %}
{%if not loop.first%}
,
{%endif%}
{
ITER_ROWS_CONTENT
}
{% endfor %}
] as data
UNWIND data as row
merge (n1{nid:row.from})
merge (n2{nid:row.to})
create unique (n1)-[r:RELTYPE]->(n2)
set REL_ATTRS_SET
return r.rid
这个模板是为了向库中写入数据,通过nid去匹配节点(如果没有就创建该节点);找到节点后会为这两个节点建立一条唯一的关系(主数据模式,如果想记录日志就不要unique)。
里面ITER_ROWS_CONTENT, RELTYPE, REL_ATTRS_SET
分别指定了jinja对数据的填充方法(如果存在就更新)、关系类型以及更新的变量。
这里约定:
- 1 节点的id变量: nid (from, to 对应的都是节点id)
- 2 关系的id变量: rid
假设读到的是一个表格数据,需要指定要存的列,以及每列的属性(num, char)
以下函数生成ITER_ROWS_CONTENT需要的字符串
def render_iter_rows(cols, cols_type):
assert len(cols)==len(cols_type),'列的长度和属性必须一一对应'
# 字符型变量if模板
str_if_template_obj = '''{%% if obj['%s'] %%}
%s:'{{obj['%s']}}'
{%% endif %%}'''
# 数值型变量if模板
num_if_template_obj = '''{%% if obj['%s'] %%}
%s:{{obj['%s']}}
{%% endif %%}'''
attr_list = []
for i in range(len(cols)):
if cols_attr[i].lower() == 'num':
tem_var = num_if_template_obj % (cols[i],cols[i],cols[i])
else:
tem_var = str_if_template_obj % (cols[i],cols[i],cols[i])
attr_list.append(tem_var)
attr_list_str = ','.join(attr_list)
return attr_list_str
以下函数生成REL_ATTRS_SET需要的字符串
# 根据columns生成数据的update
def render_set_attrs(cols):
set_attr_list = []
for c in cols:
tem_set = 'r.%s = row.%s' %(c, c)
set_attr_list.append(tem_set)
set_attr_list_str = ','.join(set_attr_list)
return set_attr_list_str
根据以上函数生成jinja文本
ITER_ROWS_CONTENT = attr_list_str
RELTYPE='TEST'
REL_ATTRS_SET = set_attr_list_str
tem_j2_str = base_content.replace('ITER_ROWS_CONTENT',ITER_ROWS_CONTENT).replace('RELTYPE',RELTYPE).replace('REL_ATTRS_SET',REL_ATTRS_SET)
3.2 将数据灌入jinja模板生成cypher语句
通过python的操作,为本次特定的数据表生成了一份jinja语句(要通过jinja对象才能初始化为模板),内容如下
with
[
{% for obj in data_list %}
{%if not loop.first%}
,
{%endif%}
{
{% if obj['rid'] %}
rid:{{obj['rid']}}
{% endif %},{% if obj['from'] %}
from:{{obj['from']}}
{% endif %},{% if obj['to'] %}
to:{{obj['to']}}
{% endif %},{% if obj['charvar'] %}
charvar:'{{obj['charvar']}}'
{% endif %},{% if obj['numvar'] %}
numvar:{{obj['numvar']}}
{% endif %}
}
{% endfor %}
] as data
UNWIND data as row
merge (n1{nid:row.from})
merge (n2{nid:row.to})
create unique (n1)-[r:TEST]->(n2)
set r.rid = row.rid,r.from = row.from,r.to = row.to,r.charvar = row.charvar,r.numvar = row.numvar
return r.rid
接下来把这段文本变为jinjia模板,然后把数据灌入就生成了cypher语句
from jinja2 import Template
data_list = test_df.to_dict(orient='records')
template = Template(tem_j2_str)
the_cypher = template.render(data_list = data_list)
cypher语句如下(数据表中的两条数据被解开并转为cypher可执行的对象):
with
[
{
rid:1
,
from:1
,
to:2
,
charvar:'abc'
,
numvar:123
}
,
{
rid:2
,
from:2
,
to:6
,
charvar:'abcd'
,
numvar:1234
}
] as data
UNWIND data as row
merge (n1{nid:row.from})
merge (n2{nid:row.to})
create unique (n1)-[r:TEST]->(n2)
set r.rid = row.rid,r.from = row.from,r.to = row.to,r.charvar = row.charvar,r.numvar = row.numvar
return r.rid
有了cypher语句,通过graph.run就可以通过python交给数据库存入了,这种存储方式是效率最高的。
由于我们限定两个节点某一种关系只有一条边,因此以上操作是幂等的(即重复操作结果不变)
4 基本操作
4.1 查询 query
4.1.1 随机查询多层
MATCH p=()-->()-->()-->() RETURN p LIMIT 25
深度运算符 , 参考文章
match (n1:Enterprise)-[r1:INVEST*1..3]->(n:Enterprise{nid:7934})
return n1, r1, n
直接使用with的多层查询好像没匹配到就不返回了
match (n1:Enterprise)-[r1:INVEST]->(n:Enterprise{nid:7934})
with n1, r1, n
match (n)->[r2:INVEST]->(n2:Enterprise)
return n, n1,r1,r2
直接那种通过深度查询把关系绑在列表上的方法官方不推荐了(我猜是使用迭代器了)
match p = (n1:Enterprise)-[:INVEST*1..3]->(n:Enterprise{nid:3930})
with * # 这里好像没有with也没问题
return relationships(p) as r1
4.2 变更 alter
4.2.1 创建/更新 create/update
后补
4.2.2 修改 delete
后补
5 业务操作
5.1 以关系为核心
一条关系必不可少的几个元素:
- 关系类型:reltype, 关系类型,通常可以一种类型一张表或者一个集合。
- 关系id: rid ,对应一条唯一的行,或者记录号。
- 源节点 : from , 对应一个节点的nid
- 目标节点: to, 对应一个节点的nid(箭头指向的节点)
6 静态批量导入数据
6.1 neo4j的启停
切到neo4j安装目录下面的bin目录,例如YOUR_PATH/neo4j/neo4j-community-3.4.7/bin
./neo4j stop
Stopping Neo4j.... stopped
切到数据目录下删库/neo4j/neo4j-community-3.4.7/data/databases
# 删库
rm -rf graph.db
重新启动
./neo4j start
Active database: graph.db
重启的时候很干净(此时会自动重新创建graph.db)
关于neo4j的备份可以参考这篇文章
6.2 按格式制作节点和关系的样例数据
格式还是略有点坑的,没有整特别明白,看着测试结果好就行了。
首先,数据文件要整理成csv。这里要注意的是尽量规避数据字段可能存在的干扰(不要有英文逗号、不要缺失),另外如果是文本字段,尽量用双引号括起来。
import re
# 正则提取短语里的中文 + 英文
def get_alpha_str(some_str):
return ''.join(re.findall('[\u4e00-\u9fa5]|[a-z][A-Z]+', some_str))
对应的一些的代码:
import csv
import pandas as pd
company_df[col] = company_df[col].apply(lambda x: '"' + str(x) + '"')
# 如果数据里确实会有引号,那么不要加 quoting=csv.QUOTE_NONE
company_df.to_csv('company_node.csv', index=False,quoting=csv.QUOTE_NONE)
示例:
id:ID(Enterprise),:LABEL,name,estiblish_time,reg_capital,reg_status,credit_code,reg_number
e1,Enterprise,"a有限公司","2001-05-17 00:00:00","50.000000万人民币","注销","a111","a222"
e2,Enterprise,"b工程有限公司","2008-09-11 00:00:00","1000.000000万人民币","开业","b111","b222"
e3,Enterprise,"c科技有限责任公司","2003-05-14 00:00:00","500.000000万人民币","开业","c111","c222"
其次,表头要按格式填写。
以下是我猜的哈
- 表头通过
:
进行ket-value式的声明。对于节点来说,一定要有id和表名/集合名(label)。 - 在上面这个例子里,id列是节点id属性,所以在后面使用ID(Enterprise)来指明这个是Enterprise集合的ID。
- 第二列指定节点的集合(应该是可以用逗号分割同时指定多个标签,但是我没有实验)
- 其他的属性(都是字符型变量)都保留了原始的名称,并都用引号括起来。
类似地,对于关系数据:
:TYPE,:START_ID(Enterprise),:END_ID(Enterprise),rid,amount:float
Invest,e1012,e1010,"r1557",4000.0
Invest,e1014,e1010,"r1559",1000.0
Invest,e18458,e18457,"r23429",60.0
- 指明了关系类型(TYPE),起始(START)和终止(END)节点
- 没有加引号的,要么是固定的三个变量:关系、起始和终止节点,要么指明了数据类型(
amount:float
) - 为了便于编号,每个结合的节点用字母开头,数值结尾,neo4j是支持的。(我称为
abc123
类型) - 其他未加引号的属性应该会默认为字符,例如下面的
rid
我就没加引号
:TYPE,:START_ID(Person),:END_ID(Enterprise),amount,rid
Invest,p647,e327,37.5,r495
Invest,p649,e327,12.5,r496
Invest,p3265,e1604,1.0,r2543
6.3 执行导入命令
命令要写成一行,切换到neo4j的bin目录下执行
./neo4j-import --multiline-fields=true --bad-tolerance=100 --into NEO4j_path/data/databases/graph.db --id-type string --nodes YOURPATH/test_node1.csv --nodes YOURPATH/test_node2.csv --relationships YOURPATH/test_rel.csv
- multiline-fields : 我猜是允许并行写入的意思
- bad-tolerance:允许的错误条数。这个要注意,如果错误的记录数超过了限制导入会直接失败的
- into:将数据导入到哪个库(默认是graph.db)
- id-type:
abc123
是string类型的 - nodes :后面可以跟你的节点数据。(多个节点就多个nodes参数)
- relationships:后面跟你的关系数据。(多个关系就多个relationships参数)
导入成功:
如果执行多次导入,需要按以下步骤:
- 1 停服务:
cd YOUR_NEO4j/bin && ./neo4j stop
- 2 删除库
cd YOUR_NEO4j/data/databases &&rm -rf graph.db
- 3 导入
cd YOUR_NEO4j/bin && ./neo4j-import --multiline-fields=true --bad-tolerance=100 --into YOUR_NEO4j/data/databases/graph.db --id-type string --nodes Your_CSV1 --relationships Your_CSV2
- 4 重新启动
cd YOUR_NEO4j/bin && ./neo4j start
静态导入速度非常快,10分钟建完了6000万节点和1千万关系。
6.4 建立索引
导入完成后别忘了建立索引,根据实际需要查询的字段建立(cypher好像需要一条条命令执行)
CREATE INDEX ON :Person(name);
CREATE INDEX ON :Person(id);
CREATE INDEX ON :Enterprise(name);
CREATE INDEX ON :Enterprise(id);
建立索引的速度还是比较快的
查看当前库索引
7 py2neo 操作数据
这里主要谈使用cypher查询的原生处理(没有用py2neo自己的方法)。
# 查询
the_cypher = '''match SOME_PATTERN return p'''
start = time.time()
res = graph.run(the_cypher)
end = time.time()
print('Takes %.2f' %(end-start))
在6000万节点,1千万关系的条件差,两层关系的查询只花费了0.03秒。这种查询方式类似pymysql向数据查询,返回一个游标(迭代器),数据取出以后就空了。
几个地方值得注意一下:
- 1
data()
:将结果以列表方式解开,每个元素和cypher里返回的有关。例如我返回的p,那么就是一个路径 - 2
to_series(), to_data_frame()
: 与pandas对接 - 3
to_subgraph()
: 和py2neo定义的子图对接,进一步可以进行快速的图计算(或者转到networkx计算)
8 关于导入格式
在大批量插入数据时可能会遇到文本不干净的问题, 这时候可能需要对csv的分隔符进行修改。以下部分参考这篇文章,导入命令的完整参数如下
usage: neo4j-admin import [--mode=csv] [--database=<name>]
[--additional-config=<config-file-path>]
[--report-file=<filename>]
[--nodes[:Label1:Label2]=<"file1,file2,...">]
[--relationships[:RELATIONSHIP_TYPE]=<"file1,file2,...">]
[--id-type=<STRING|INTEGER|ACTUAL>]
[--input-encoding=<character-set>]
[--ignore-extra-columns[=<true|false>]]
[--ignore-duplicate-nodes[=<true|false>]]
[--ignore-missing-nodes[=<true|false>]]
[--multiline-fields[=<true|false>]]
[--delimiter=<delimiter-character>]
[--array-delimiter=<array-delimiter-character>]
[--quote=<quotation-character>]
[--max-memory=<max-memory-that-importer-can-use>]
[--f=<File containing all arguments to this import>]
[--high-io=<true/false>]
usage: neo4j-admin import --mode=database [--database=<name>]
[--additional-config=<config-file-path>]
[--from=<source-directory>]
可以看到这里有两个关于分隔符的控制。
[--delimiter=<delimiter-character>]
[--array-delimiter=<array-delimiter-character>]
没有找到很匹配的例子, 在官网文档找到这个
如果我想用类似’!@#'这样的字符串分割的化,理论上应该是–array-delimiter这样的分隔符,不过没有例子,回头等我实验一下再补结果。
导入表头支持的数据格式
使用中的一个int,long,float,double,boolean,byte,short,char,string,point,date,localtime,
time,localdatetime, datetime,和duration指定为属性的数据类型。如果未提供数据类型,则默认为string。
要定义数组类型,请追加[]到该类型。默认情况下,数组值用分隔;。可以使用来指定其他定界符--array-delimiter。
如果布尔值与文本完全匹配,则它们为truetrue。所有其他值均为假。包含定界符的值需要通过用双引号引起来或
使用带有--delimiter选项的其他定界符来转义。
9 导入时间
导入耗时较少,但是索引建立的时间较长
节点数 | 索引变量长度 | 时间 |
2000万 | <24个字 | 3m30s |
7000万 | <24个字 | 23min |