Training, Workshops, Softwareentwicklung

Hibernate Tutorial

Joins vs. Lazy Loading

Die Messungen in diesem Abschnitt wurden mit einer nicht getunten Postgres 9.6 in einem Docker-Container durchgeführt und sind nur als relative Anhaltspunkte zu verstehen.

Es wurden jeweils 100000 Customer Objekte mit jeweils zwei Kindobjekten in drei OneToMany Beziehungen angelegt. Davon wurden die letzten 100 vollständig geladen.

Dieses Beispiel stammt in etwas abgemilderter Form aus einem meiner ersten Gehversuche mit Hibernate.

Ich war mit einem voll aus-normalisierten Objektmodell gestartet, das sehr auf Flexibilität ausgelegt war:

07 01 dbschema

Erstes Mapping

Das Mapping im Customer sah in etwa so aus:

Mapping Customer
@Entity
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "cust_gen")
    @SequenceGenerator(name = "cust_gen", sequenceName = "customer_seq", allocationSize = 25)
    private Long id;
    private String firstname;
    private String lastname;
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "customer")
    private Set<Email> emails = new HashSet<>();
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "customer")
    private Set<PhoneNumber> phoneNumbers = new HashSet<>();
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, mappedBy = "customer")
    private Set<Address> addresses = new HashSet<>();
}

Die Anforderung war eine Menge von Objekten serialisiert (d.h. vollständig initialisiert) über eine Schnittstelle bereitzustellen.

Der erste Ansatz war eher naiv, begegnet mir aber immer wieder:

Naiver Select
doInHibernate(em -> {

    StringBuffer buffer = new StringBuffer();
    TypedQuery<Customer> query = em.createQuery("SELECT c FROM Customer c " +
            "WHERE c.id > (:maxId)-100", Customer.class);
    query.setParameter("maxId", (int) maxId);
    for (Customer customer : query.getResultList()) {
        for (Email email : customer.getEmails()) {
            buffer.append(email.getType().getName());
        }
        for (PhoneNumber phoneNumber : customer.getPhoneNumbers()) {
            buffer.append(phoneNumber.getType().getName());
        }

        for (Address address : customer.getAddresses()) {
            buffer.append(address.getType().getName());
        }
    }
});
assertSelectCount(307);

Hier schlägt das n+1 Select Problem voll zu und so ein Zugriff dauert in etwa 71ms.

Benchmark                                               Mode  Cnt   Score   Error  Units
BenchmarkJoinSelects.testTotallyNaiveSelect             avgt   30  71,208 ± 6,511  ms/op

Abhilfe sollte ein Join-Fetch schaffen:

Join Fetch
doInHibernate(em -> {

    StringBuffer buffer = new StringBuffer();
    TypedQuery<Customer> query = em.createQuery("SELECT c FROM Customer c " +
            "LEFT JOIN FETCH c.addresses a " +
            "LEFT JOIN FETCH a.type t1 " +
            "LEFT JOIN FETCH c.emails e " +
            "LEFT JOIN FETCH e.type t2 " +
            "LEFT JOIN FETCH c.phoneNumbers p " +
            "LEFT JOIN FETCH p.type t3 " +
            "WHERE c.id > (:maxId)-100", Customer.class);
    query.setParameter("maxId", (int) maxId);
    ;
    for (Customer customer : query.getResultList()) {
        for (Email email : customer.getEmails()) {
            buffer.append(email.getType().getName());
        }
        for (PhoneNumber phoneNumber : customer.getPhoneNumbers()) {
            buffer.append(phoneNumber.getType().getName());
        }

        for (Address address : customer.getAddresses()) {
            buffer.append(address.getType().getName());
        }
    }
});
assertSelectCount(1);

Und siehe da: es wurde deutlich schneller: 14,3 ms.

Benchmark                                               Mode  Cnt   Score   Error  Units
BenchmarkJoinSelects.testFullyJoinedSelect              avgt   30  14,390 ± 1,459  ms/op

Für die Value-Objekte gibt es nur sehr wenig Ausprägungen. Überlässt man diese dem Lazy-Loading müssen Sie nur einmal geholt werden und können dann aus dem 1st-Level Cache materialisiert werden.

Join Fetch mit 1st Level Cache
doInHibernate(em -> {

    StringBuffer buffer = new StringBuffer();
    TypedQuery<Customer> query = em.createQuery("SELECT c FROM Customer c " +
            "LEFT JOIN FETCH c.addresses a " +
            "LEFT JOIN FETCH c.emails e " +
            "LEFT JOIN FETCH c.phoneNumbers p " +
            "WHERE c.id > (:maxId)-100", Customer.class);
    query.setParameter("maxId", (int) maxId);

    for (Customer customer : query.getResultList()) {
        for (Email email : customer.getEmails()) {
            buffer.append(email.getType().getName());
        }
        for (PhoneNumber phoneNumber : customer.getPhoneNumbers()) {
            buffer.append(phoneNumber.getType().getName());
        }

        for (Address address : customer.getAddresses()) {
            buffer.append(address.getType().getName());
        }
    }
});
assertSelectCount(7);

Es wurde nochmal schneller: 11,8 ms.

Benchmark                                               Mode  Cnt   Score   Error  Units
BenchmarkJoinSelects.testJoinedSelectWith1stLevelCache  avgt   30  11,839 ± 1,478  ms/op-

Braucht man nicht alle Daten, so macht es Sinn das Ergebnis des Joins möglichst klein zu halten:

Join Fetch mit Restrictions
doInHibernate(em -> {

    StringBuffer buffer = new StringBuffer();
    TypedQuery<Customer> query = em.createQuery("SELECT c FROM Customer c " +
            "LEFT JOIN FETCH c.addresses a " +
            "LEFT JOIN FETCH c.emails e " +
            "LEFT JOIN FETCH c.phoneNumbers p " +
            "LEFT JOIN FETCH a.type t1 " +
            "LEFT JOIN FETCH e.type t2 " +
            "LEFT JOIN FETCH p.type t3 " +
            "WHERE t1.name = 'private' " +
            "AND t2.name = 'private' " +
            "AND t3.name = 'private' " +
            "AND c.id > (:maxId)-100", Customer.class);
    query.setParameter("maxId", (int) maxId);

    for (Customer customer : query.getResultList()) {
        for (Email email : customer.getEmails()) {
            buffer.append(email.getType().getName());
        }
        for (PhoneNumber phoneNumber : customer.getPhoneNumbers()) {
            buffer.append(phoneNumber.getType().getName());
        }

        for (Address address : customer.getAddresses()) {
            buffer.append(address.getType().getName());
        }
    }
});
assertSelectCount(1);

Noch schneller wird es mit diesem Modell eher nicht: 9,7ms.

Benchmark                                               Mode  Cnt   Score   Error  Units
BenchmarkJoinSelects.testJoinedSelectWithRestrictions   avgt   30   9,704 ± 1,044  ms/op
Benchmark                                               Mode  Cnt   Score   Error  Units
BenchmarkJoinSelects.testTotallyNaiveSelect             avgt   30  71,208 ± 6,511  ms/op
BenchmarkJoinSelects.testFullyJoinedSelect              avgt   30  14,390 ± 1,459  ms/op
BenchmarkJoinSelects.testJoinedSelectWith1stLevelCache  avgt   30  11,839 ± 1,478  ms/op
BenchmarkJoinSelects.testJoinedSelectWithRestrictions   avgt   30   9,704 ± 1,044  ms/op

Joins sind teuer. Wenn die Wertelisten nicht zwingend änderbar sein müssen, kann man Joins auf die Wertetabelle einsparen, indem man mit enums arbeitet:

07 03 dbschema
Ersetzen der Werteliste durch eine Enum
@Entity
public class Email {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "email_gen")
    @SequenceGenerator(name = "email_gen", sequenceName = "email_seq", allocationSize = 25)
    private Long id;
    private String email;
    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;
    @Enumerated(EnumType.STRING)
    private EmailType type;
}
Join Fetch mit Enums
doInHibernate(em -> {

    StringBuffer buffer = new StringBuffer();
    TypedQuery<Customer> query = em.createQuery("SELECT c FROM Customer c " +
            "LEFT JOIN FETCH c.addresses a " +
            "LEFT JOIN FETCH c.emails e " +
            "LEFT JOIN FETCH c.phoneNumbers p " +
            "WHERE c.id > (:maxId)-100", Customer.class);
    query.setParameter("maxId", (int) maxId);

    for (Customer customer : query.getResultList()) {
        for (Email email : customer.getEmails()) {
            buffer.append(email.getType().name());
        }
        for (PhoneNumber phoneNumber : customer.getPhoneNumbers()) {
            buffer.append(phoneNumber.getType().name());
        }

        for (Address address : customer.getAddresses()) {
            buffer.append(address.getType().name());
        }
    }
});

Der vollständige Select ist nun schon schneller als der letzte mit Einschränkungen: 9,1ms. (Vorher 14,4!)

Benchmark                                                      Mode  Cnt  Score   Error  Units
BenchmarkEnumSelects.testJoinedSelectWithEnums                 avgt   30  9,100 ± 1,050  ms/op

Braucht man nicht alle Daten, lohnt es sich auch hier das Ergebnis des Joins klein zu halten.

Join Fetch mit Enums und Einschränkung
doInHibernate(em -> {

    StringBuffer buffer = new StringBuffer();
    TypedQuery<Customer> query = em.createQuery("SELECT c FROM Customer c " +
            "LEFT JOIN FETCH c.addresses a " +
            "LEFT JOIN FETCH c.emails e " +
            "LEFT JOIN FETCH c.phoneNumbers p " +
            "WHERE a.type = 'PRIVATE' " +
            "AND e.type = 'PRIVATE' " +
            "AND p.type = 'PRIVATE' " +
            "AND c.id > (:maxId)-100", Customer.class);
    query.setParameter("maxId", (int) maxId);

    for (Customer customer : query.getResultList()) {
        for (Email email : customer.getEmails()) {
            buffer.append(email.getType().name());
        }
        for (PhoneNumber phoneNumber : customer.getPhoneNumbers()) {
            buffer.append(phoneNumber.getType().name());
        }

        for (Address address : customer.getAddresses()) {
            buffer.append(address.getType().name());
        }
    }
});

Das Ergebnis kann sich sehen lassen. Die 71ms sind auf 5,8 ms geschrumpft:

Benchmark                                                      Mode  Cnt  Score   Error  Units
BenchmarkEnumSelects.testJoinedSelectWithEnumsAndRestrictions  avgt   30  5,789 ± 0,640  ms/op

Wenn das immer noch nicht schnell genug ist, hilft nur noch de-normalisieren:

07 02 dbschema
Select mit Denormalisierung
doInHibernate(em -> {

    StringBuffer buffer = new StringBuffer();
    TypedQuery<Customer> query = em.createQuery("SELECT c FROM Customer c " +
            "WHERE c.id > (:maxId)-100", Customer.class);
    query.setParameter("maxId", (int) maxId);
    for (Customer customer : query.getResultList()) {

            buffer.append(customer.getPrivateEmail().getEmail());
            buffer.append(customer.getPrivatePhoneNumber().getPhonenumber());
            buffer.append(customer.getPrivateAddress().getCity());
    }

});
assertSelectCount(1);

Besser wird es wohl nicht: 1,7 ms

Benchmark                                            Mode  Cnt  Score   Error  Units
BenchmarkDenormalizedSelects.testDenormalizedSelect  avgt   30  1,699 ± 0,186  ms/op