グラフデータベースの性能を測定する(1)

569
NO IMAGE

概要

グラフデータベースを使用する上で性能は非常に気になる所です。ですが、公式サイトに記載されている性能だけでは実際の業務に適用した際の性能が正しく推測出来ない事も多いです。この連載では、実際の業務の条件に近い状態でグラフデータベースの性能を測定することを目標に各種ツールを作成していきます。1回目の今回はノードと関連を追加していくシナリオをNeo4jに対して実行して、性能を計測してみます。今回作成したツール類は、githubに置いてあります(Tag: series1)。

前提

言語

Java 17

ビルドツール

Gradle 7.3.2

グラフデータベース

Neo4j 5.6.0

必要なライブラリ

以下に記載しているツールを実行するためには以下のライブラリが必要です(Gradleの依存関係で記載)。

dependencies {
    api group: 'org.slf4j', name: 'slf4j-api', version: '2.0.7'
    api group: 'io.micrometer', name: 'micrometer-core', version: '1.10.5'        
    implementation group: 'org.neo4j.driver', name: 'neo4j-java-driver', version: '5.6.0'
    testImplementation group: 'ch.qos.logback', name: 'logback-classic', version: '[1.4,1.5)'
    testImplementation group: 'ch.qos.logback', name: 'logback-core', version: '[1.4,1.5)'
    testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-api', version: '[5.0,6.0)'
    testImplementation group: 'org.junit.jupiter', name: 'junit-jupiter-engine', version: '[5.0,6.0)'
}

シナリオを表現するためのインタフェースの定義

今回はグラフデータベースとしてNeo4jのみを使用しますが、実際には複数種類のグラフデータベースに対して性能を測定する必要があります。このためには、各データベースで共通のシナリオを実行数ための仕組みが必要です。まずは、シナリオを実行するために必要なインタフェースを定義して、各データベース事にそちらのインタフェースを実装する事で共通のシナリオをの実行を容易にします。

package model;

public interface Repository {
  // 性能測定の際には、データベースへの接続は事前に生成しておく必要がある。このため、実際の操作はセッションを経由して行う。
    interface Session extends AutoCloseable {

        void addEntity(Entity entity);

        void addRelation(Relation relation);

        @Override
        void close();

    }

    Session newSession();

}

Neo4jによるRepositoryインタフェースの実装

Repositoryインタフェースを満たすNeo4jの実装は以下のようになります。
Javaを使用したNeo4jインスタンスへのアクセスについてはUsing Neo4j from Javaを参照下さい
Neo4jのクエリ(Cypher)についてはCypher Query Languageを参照下さい

package neo4j;

import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;

import org.neo4j.driver.AuthTokens;
import org.neo4j.driver.Driver;
import org.neo4j.driver.GraphDatabase;
import org.neo4j.driver.Query;
import org.neo4j.driver.Values;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import model.Entity;
import model.EntityKey;
import model.Relation;
import model.Repository;

public class Neo4jRepository implements AutoCloseable, Repository {

    private static final Logger logger = LoggerFactory.getLogger(Neo4jRepository.class);

    private final Driver driver;

    public Neo4jRepository(String uri, String user, String password) {
        this.driver = GraphDatabase.driver(uri, AuthTokens.basic(user, password));
    }

    @Override
    public Session newSession() {
        return new SessionImpl(this.driver.session());
    }

    @Override
    public void close() {
        this.driver.close();
    }

    private static Query createEntityQuery(String alias, Entity entity) {
        var properties = createEntityProperties(entity);
        var builder = new StringBuilder();
        builder.append("CREATE (");
        builder.append(alias);
        builder.append(":");
        builder.append(entity.getKey().getType());
        builder.append(" ");
        appendProperiesString(properties, s -> builder.append(s));
        builder.append(")");
        return new Query(builder.toString(), Values.value(properties));
    }

    private static Query createRelationQuery(String fromAlias, String toAlias, String relationAlias,
            Relation relation) {
        var builder = new StringBuilder();
        appendMatchString(fromAlias, relation.getKey().getFrom(), s -> builder.append(s));
        builder.append(" ");
        appendMatchString(toAlias, relation.getKey().getTo(), s -> builder.append(s));
        builder.append(" ");
        appendRelationCreateString(fromAlias, toAlias, relationAlias, relation, s -> builder.append(s));
        var properties = new HashMap<>(relation.getProperties());
        properties.put(fromAlias, relation.getKey().getFrom().getName());
        properties.put(toAlias, relation.getKey().getTo().getName());
        return new Query(builder.toString(), Values.value(properties));
    }

    private static Map<String, String> createEntityProperties(Entity entity) {
        var ret = new HashMap<String, String>(entity.getProperties());
        ret.put("key_", entity.getKey().getName());
        return ret;
    }

    private static void appendMatchString(String alias, EntityKey key, Consumer<String> out) {
        out.accept("MATCH (");
        out.accept(alias);
        out.accept(":");
        out.accept(key.getType());
        out.accept(" ");
        appendProperiesString(Map.of("key_", "$" + alias), out);
        out.accept(")");
    }

    private static void appendRelationCreateString(String fromAlias, String toAlias, String relationAlias,
            Relation relation, Consumer<String> out) {
        out.accept("CREATE (");
        out.accept(fromAlias);
        out.accept(") - [");
        out.accept(relationAlias);
        out.accept(":");
        out.accept(relation.getKey().getType());
        if (!relation.getProperties().isEmpty()) {
            out.accept(" ");
            appendProperiesString(relation.getProperties(), out);
        }
        out.accept("] -> (");
        out.accept(toAlias);
        out.accept(")");
    }

    private static void appendProperiesString(Map<String, String> properties, Consumer<String> out) {
        out.accept("{");
        boolean first = true;
        for (var e : properties.entrySet()) {
            if (!first) {
                out.accept(",");
            } else {
                first = false;
            }
            out.accept(e.getKey());
            out.accept(":");
            if (e.getValue().startsWith("$")) {
                out.accept(e.getValue());
            } else {
                out.accept("$");
                out.accept(e.getKey());
            }
        }
        out.accept("}");
    }

    private class SessionImpl implements Session {

        private final org.neo4j.driver.Session delegate;

        public SessionImpl(org.neo4j.driver.Session delegate) {
            super();
            assert delegate != null;
            this.delegate = delegate;
        }

        @Override
        public void addEntity(Entity entity) {
            this.delegate.executeWriteWithoutResult(tx -> {
                var query = createEntityQuery("e", entity);
                logger.debug("addEntity query=<{}>", query);
                tx.run(query);
            });
        }

        @Override
        public void addRelation(Relation relation) {
            this.delegate.executeWriteWithoutResult(tx -> {
                var query = createRelationQuery("f", "t", "r", relation);
                logger.debug("addRelation query=<{}>", query);
                tx.run(query);
            });
        }

        @Override
        public void close() {
            this.delegate.close();
        }
    }

}

Repositoryを使用したシナリオの定義

Scenarioインタフェース

シナリオを定義するクラスは以下のインタフェースを満たすようにします。データベースを操作するためのRepositoryと性能を測定するためのMeterRegistry(Micrometer)を引数として取ります。

package model;

import io.micrometer.core.instrument.MeterRegistry;

public interface Scenario {

    void accept(Repository repository, MeterRegistry meterRegistry);

}

ノード(Entity)を追加するシナリオ

package scenario;

import java.util.HashMap;

import io.micrometer.core.instrument.MeterRegistry;
import model.Entity;
import model.EntityKey;
import model.Repository;
import model.Scenario;

public class AddEntityScenario implements Scenario {

    private final int tryCount;

    private final int propertySize;

    public AddEntityScenario(int tryCount, int propertySize) {
        super();
        this.tryCount = tryCount;
        this.propertySize = propertySize;
    }

    @Override
    public void accept(Repository repository, MeterRegistry meterRegistry) {
        try (var session = repository.newSession()) {
            for (int i = 0; i < this.tryCount; i++) {
                var id = i;
                var properties = new HashMap<String, String>();
                for (int j = 0; j < this.propertySize; j++) {
                    properties.put("p_" + j, "p_" + j + "_" + id);
                }
                meterRegistry.timer("execution_time").record(() -> {
                    session.addEntity(new Entity(new EntityKey("Person", "key_" + id), properties));
                });
            }
        }
    }

}

関連(Relation)を追加するシナリオ

package scenario;

import java.util.HashMap;

import io.micrometer.core.instrument.MeterRegistry;
import model.Entity;
import model.EntityKey;
import model.Relation;
import model.RelationKey;
import model.Repository;
import model.Scenario;

public class AddRelationScenario implements Scenario {

    private final int tryCount;

    private final int propertySize;

    public AddRelationScenario(int tryCount, int propertySize) {
        super();
        this.tryCount = tryCount;
        this.propertySize = propertySize;
    }

    @Override
    public void accept(Repository repository, MeterRegistry meterRegistry) {
        try (var session = repository.newSession()) {
            for (int i = 0; i < this.tryCount; i++) {
                var id = i;
                var fromKey = new EntityKey("Person", "key_f_" + id);
                var toKey = new EntityKey("Person", "key_t_" + id);
                session.addEntity(new Entity(fromKey, null));
                session.addEntity(new Entity(toKey, null));
                var properties = new HashMap<String, String>();
                for (int j = 0; j < this.propertySize; j++) {
                    properties.put("p_" + j, "p_" + j + "_" + id);
                }
                meterRegistry.timer("execution_time").record(() -> {
                    session.addRelation(new Relation(new RelationKey(fromKey, toKey, "IS_FRIENDS_WITH"), properties));
                });
            }
        }
    }

}

性能計測の実行

性能計測の実行は、Junitのテストで行います。
実行前にNeo4jのインスタンスを起動しておく必要があります

package neo4j;

import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import model.Scenario;
import scenario.AddEntityScenario;
import scenario.AddRelationScenario;

public class Neo4jPerformanceTest {

    private static final Logger logger = LoggerFactory.getLogger(Neo4jPerformanceTest.class);

    @Test
    public void addEntity() {
        // 試行回数: 10回
        // プロパティ数: 1
        this.test(new AddEntityScenario(10, 1));
    }

    @Test
    public void addRelation() {
        // 試行回数: 10回
        // プロパティ数: 1
        this.test(new AddRelationScenario(10, 1));
    }

    private void test(Scenario scenario) {
    try (var sut = new Neo4jRepository("bolt://localhost:7687", "{Neo4jのユーザ}", "{Neo4jのパスワード}")) {
            var meterRegistry = new SimpleMeterRegistry();
            scenario.accept(sut, meterRegistry);
            logger.info(meterRegistry.getMetersAsString());
        }
    }

}

実行結果

ノード(Entity)の追加

16:37:19.259 [main] INFO neo4j.Neo4jPerformanceTest -- execution_time(TIMER)[]; count=10.0, total_time=0.3340058 seconds, max=0.2297709 seconds

関連(Relation)の追加

16:38:09.072 [main] INFO neo4j.Neo4jPerformanceTest -- execution_time(TIMER)[]; count=10.0, total_time=0.1623436 seconds, max=0.044935 seconds

まとめ

非常に単純なグラフデータベースの性能計測のシナリオをNeo4jに対して実行してみました。次回は検索系のシナリオを動作させてみます。