问题描述
hugegraph-0.10.4 版本的 cassandra 后端运行 tinkerpop 的 process 测试时,有一个时而跑过时而跑不过的测试用例:ProcessBasicSuite
的 EarlyLimitStrategyProcessTest.class
。
这个测试用例只有一个测试的方法,并且这个本身本身也没有什么值得关注的点,关键在于这个测试用例创建了有 2w 多条边,然后在清理数据,准确地说是执行 VertexLabelRemoveCallable
异步任务时,报了 "undefined edgelabel xx" 的错误。详细的堆栈信息如图:
问题定位
Step 1
从日志可以看出,在删除顶点(由删除 vertexlabel 引发)时,查到了顶点关联的边,然后在反序列化该条边的过程中,却找不到这条边对应的 edgelabel。
要知道,清理元数据是有顺序的,先清理 edgelabel,再清理 vertexlabel,最后清理 propertykey,这个大流程肯定是不会有问题的。
所以日志中已经到了删除 vertexlabel 的阶段,所以肯定已经先删除过 edgelabel 了,后面报 "找不到 edgelabel" 其实是合理的。所以问题的关键在于:为什么能查到顶点关联的边。
理所当然地,猜测是不是删除 edgelabel 时,没有把边删干净。
Step 2
为了提升删除效率,cassandra 的删除 edgelabel(触发删除边数据)是下沉到 backend 层的,代码片段为:
protected void deleteEdgesByLabel(CassandraSessionPool.Session session,
Id label) {
// Edges in edges_in table will be deleted when direction is OUT
if (this.direction == Directions.IN) {
return;
}
final String OWNER_VERTEX = formatKey(HugeKeys.OWNER_VERTEX);
final String OTHER_VERTEX = formatKey(HugeKeys.OTHER_VERTEX);
// Query edges by label index
Select select = QueryBuilder.select().from(this.labelIndexTable());
select.where(formatEQ(HugeKeys.LABEL, label.asLong()));
ResultSet rs;
try {
rs = session.execute(select);
} catch (DriverException e) {
throw new BackendException("Failed to query edges " +
"with label '%s' for deleting", e, label);
}
// Delete edges
int count = 0;
for (Iterator it = rs.iterator(); it.hasNext();) {
Row row = it.next();
// Delete OUT edges from edges_out table
Object ownerVertex = row.getObject(OWNER_VERTEX);
session.add(buildDelete(label, ownerVertex, Directions.OUT));
// Delete IN edges from edges_in table
Object otherVertex = row.getObject(OTHER_VERTEX);
session.add(buildDelete(label, otherVertex, Directions.IN));
count += 2;
// COMMIT_DELETE_BATCH is 1000
if (count >= COMMIT_DELETE_BATCH - 2) {
session.commit();
count = 0;
}
}
}
private Delete buildDelete(Id label, Object ownerVertex,
Directions direction) {
Delete delete = QueryBuilder.delete().from(edgesTable(direction));
delete.where(formatEQ(HugeKeys.OWNER_VERTEX, ownerVertex));
delete.where(formatEQ(HugeKeys.DIRECTION,
EdgeId.directionToCode(direction)));
delete.where(formatEQ(HugeKeys.LABEL, label.asLong()));
return delete;
}
大致逻辑就是:根据 label 从出边表中查出所有匹配的边,提取出该边的 owner_vertex 和 other_vertex,分别作为出边和入边表的 owner_vertex,再拼上 direction 和 label 共三个字端作为条件,构造 delete 语句删除出边和入边表中符合条件的记录。
既然是怀疑边数据没有删干净,那就在所有的删除语句提交后再查一下边表,看能否查到数据。具体改动是在deleteEdgesByLabel
的 for 循环后面,增加一段查询的代码。
String select2 = "SELECT * FROM g_ie where label = 28 limit 10 allow filtering;";
ResultSet resultSet = session.execute(select2);
resultSet.forEach(row -> {
otherVertexs.forEach(ov -> {
System.out.println("原有顶点: " + IdUtil.readBinString(ov));
});
System.out.println("owner_vertex: " + Bytes.toHex(IdUtil.readBinString(row.getObject("owner_vertex")).asBytes()));
System.out.println("other_vertex: " + Bytes.toHex(IdUtil.readBinString(row.getObject("other_vertex")).asBytes()));
});
当运行失败时,果然能在控制台看到增加的打印信息,所以,边没有删干净是确定无疑的。
其实这里一开始查询语句是
SELECT * FROM g_oe
的,然后怎么跑都看不到打印信息,但是分析错误日志始终认为就是还有残留边,然后改成了g_ie
。
Step 3
既然是边没有删干净,第一反应是不是删除语句的最后一批忘了提交,观察代码,发现好像确实是有这个问题,于是在 for 循环后面增加了一个提交,代码:
if (count > 0) {
session.commit();
}
满心欢喜地以为找到了 bug ,运行,结果还是能查出来。还不死心,把if (count > 0)
的判断也去掉,强行提交一波。但是依然无果。
难道是 cassandra 本身的删除有 bug ?
Step 4
本来想着下载一个新版的 cassandra 试试,无奈网速太慢,只得一边下载,一边继续调试。
为了验证是不是 cassandra 自己的 bug,我决定把所有的删除语句全打印出来,看最后残留的边到底有没有构造过对应的删除语句。
我在session.commit()
里面加上了打印语句,代码:
this.batch.getStatements().forEach(statement -> {
System.out.println("\t" + statement);
});
这么加的时候其实感觉很虚的,因为总共有几万条边,本身又还有其他的打印信息,混在一个眼睛都花了。但是没办法,已经不知道咋办了,只能试一试。
谁知这一试还真的有发现,结果就不贴了,很长,反正能看到残留的边确实没有构造过删除语句,也就是说,那条边就是没有被删除过,这说明还是我们自己的问题,跟 cassandra 无关。
Step 5
这里又立马有了一个猜测,是不是在删除的过程中又有别的线程插入了新的入边,或者是一开始入边表就比出边表有多的记录。
分别验证。
首先把TaskManager
中的线程数调整为 1,然后又在session.commit()
里面加上了打印线程的语句,代码:
System.out.println("线程:" + Thread.currentThread().getName() + "\t批的大小:" + this.batch.size());
运行日志:
线程task-worker-1 批的大小: 1000
第一条语句:DELETE FROM g_oe WHERE OWNER_VERTEX=0x91323533383336303032333837323936323539 AND DIRECTION=-126 AND LABEL=28 AND SORT_VALUES='' AND OTHER_VERTEX=0x91323533383336303032303937383839323830;
最后一条语句:DELETE FROM g_ie WHERE OWNER_VERTEX=0x91323533383336303032323738323434333533 AND DIRECTION=-116 AND LABEL=28 AND SORT_VALUES='' AND OTHER_VERTEX=0x91323533383336303032323332313037303038;
线程task-worker-1 批的大小: 1000
...
线程task-worker-1 批的大小: 1000
...
线程task-worker-1 批的大小: 1000
...
线程task-worker-1 批的大小: 94
能看到,从开始删除之后,task-worker-1 的语句都是连续的,且语句全都是 DELETE,没有 INSERT。所以,不是别的线程插入了新的记录。
Step 6
再看看是不是一开始入边表就多插入了一些记录。
那就在删除之前查一下出边表和入边表的记录数,看是否一致,在deleteEdgesByLabel
的入口处添加代码:
// 出边和入边表的记录数
String countQuery = "select count(*) from g_oe where label = " + label.asLong() + ";";
ResultSet rs = session.execute(countQuery);
System.out.println(">> 出边记录数:" + rs.one().getObject(0));
countQuery = "select count(*) from g_ie where label = " + label.asLong() + ";";
rs = session.execute(countQuery);
System.out.println("<< 入边记录数:" + rs.one().getObject(0));
运行日志:
>> 出边记录数:7047
<< 入边记录数:7047
天啊,出边和入边数也是一样的。那还能是哪儿的问题呢?
Step 7
貌似只能是迭代的问题了,那就在加上一个迭代次数的打印吧,代码:
int iterCount = 0;
for () {
iterCount++;
}
System.out.println("查询label的迭代次数: " + iterCount);
运行日志:
查询label的迭代次数: 7018
What?怎么迭代次数和边记录数不一样,难道是一边查一边删让 cassandra 的查询跳跃了?不太可能吧,cassandra 连这都不能保证?
Step 8
这时,ZM 突然想到:是不是删除的比查询到的多。
猜测是这样:
如果某个点有很多出边,那出边表和入边表的数据类似于:
出边表:
A -> B
...
A -> C
入边表:
...
B -> A
...
C -> A
...
cassandra 的查询是一批一批获取的,因为那么多的数据肯定不能一次性全取完。如果这一次查询从出边表中获取到了 A -> B,但是没获取到 A -> C,那构造出的删除语句为:
出边:
DELETE FROM g_oe WHERE owner_vertex = A and direction = out and label = 28;
入边:
DELETE FROM g_ie WHERE owner_vertex = B and direction = in and label = 28;
但是 cassandra 的删除却能作用于所有owner_vertex = A
的记录上,这就删除了出边表中所有的A -> x
(包含A -> C
)的记录,而入边表删除了形如B -> x
的记录。
然后 cassandra 获取从出边表获取下一批的时候,已经读不到A -> C
了,自然也没办法从入边表中删除形如C -> x
的记录,所以入边表就有了残留记录。
经过验证,确实就是这个原因。
重现方式
重现这个问题的关键在于构造出“超级点”,让这个超级点的出边个数大于 cassandra 的查询 fetch_size。
解决方案
- 修改删除语句的构造方式,将删除范围限定的小一点;
- 让出边表和入边表各自删除各自的。