记一次 Cassandra 删除“不干净”的问题发现与定位过程

问题描述

hugegraph-0.10.4 版本的 cassandra 后端运行 tinkerpop 的 process 测试时,有一个时而跑过时而跑不过的测试用例:ProcessBasicSuiteEarlyLimitStrategyProcessTest.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。

解决方案

  1. 修改删除语句的构造方式,将删除范围限定的小一点;
  2. 让出边表和入边表各自删除各自的。

你可能感兴趣的:(记一次 Cassandra 删除“不干净”的问题发现与定位过程)