Post

JPA - Lazy Performance Differences, OneToOne Bidirectional Lazy

JPA - Lazy Performance Differences, OneToOne Bidirectional Lazy

This post was migrated from Tistory. You can find the original here.

LAZY vs EAGER

Suppose entity A has the following foreign keys. If you don’t specify a fetch type, it defaults to EAGER.

With the EAGER option, querying A using the basic methods provided by JpaRepository (findById, findAll) produces 4 JOIN queries.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    @OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST})
    @JoinColumn(name = "locate_id", referencedColumnName = "id")
    private Locate locate;

    @OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST})
    @JoinColumn(name = "riskAnalysis_id", referencedColumnName = "id")
    private RiskAnalysis riskAnalysis;

    @ManyToOne
    @JoinColumn(name = "device_id", referencedColumnName = "id")
    private Device device;

    @OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST})
    @JoinColumn(name = "condition_id", referencedColumnName = "id")
    private Condition condition;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Hibernate: 
    select
		~~~ 
    from
        data d 
    left join
        device d1_0 
            on d1_0.id=d.device_id 
    left join
        sensor s1_0 
            on s1_0.id=d1_0.sensor_id 
    left join
        locate l1_0 
            on l1_0.id=d.locate_id 
    left join
        risk_analysis ra1_0 
            on ra1_0.id=d.risk_analysis_id 
    left join
        condition c1_0 
            on c1_0.id=d.condition_id 
    where
        d.id=?

And for example, if Device also had a foreign key to Sensor, the JOIN query for that relationship would be generated too.

(It recursively triggers JOIN queries for everything.)

Now let’s apply LAZY as shown below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    @OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST}, fetch = FetchType.LAZY)
    @JoinColumn(name = "locate_id", referencedColumnName = "id")
    private Locate locate;

    @OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST}, fetch = FetchType.LAZY)
    @JoinColumn(name = "riskAnalysis_id", referencedColumnName = "id")
    private RiskAnalysis riskAnalysis;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "device_id", referencedColumnName = "id")
    private Device device;

    @OneToOne(cascade = {CascadeType.REMOVE, CascadeType.PERSIST}, fetch = FetchType.LAZY)
    @JoinColumn(name = "condition_id", referencedColumnName = "id")
    private Condition condition;
1
2
3
4
5
6
7
Hibernate: 
    select
        ~~
    from
        data d
    where
        d.id=?

There’s no JOIN, and an additional SELECT query is issued only when the FK object is actually accessed.

Performance differences based on JOIN count

The FKs are keyed on id, so an index already exists by default. (Spring Data JPA creates indexes on PKs and UKs by default.)

1
@Query("SELECT d FROM Data d WHERE d.status = :status AND d.success = :success")

Below are the timings when running a findAll with a JPQL query that has 2 WHERE conditions applied.

The exact numbers will vary depending on server spec and environment. Just look at this as an illustration of the relative gap.

   
Row countLAZY (0 JOINs) (ms)EAGER (5 JOINs) (ms)
1000600~8002100~2400
20001100~13004200~4500
30001700~19006200~6500
40002300~25008400~8700

The gap widens as the amount of data grows. The more data you’re handling, the bigger the impact of joins.

To cut down on unnecessary JOINs, it seems like a good idea to set FKs to LAZY by default.

Also, using LAZY can cause issues when returning Entity objects directly in a response (LazyInitializationException, etc.), so it’s best to always convert Entities to DTOs. Even if you haven’t finalized your design yet, converting Entities to DTOs up front makes maintenance much easier down the line.

What about LAZY on the non-owning side of a bidirectional relationship?

1
2
3
4
5
    @OneToOne(mappedBy = "Data", optional = false, cascade = {CascadeType.REMOVE, CascadeType.PERSIST}, fetch = FetchType.LAZY)
    private ExtraData extraData;

    @OneToMany(mappedBy = "Data", cascade = {CascadeType.REMOVE, CascadeType.PERSIST})
    private List<Test> testList;

@OneToMany defaults to fetch = FetchType.LAZY. (A SELECT is issued when that object is accessed.)

Note: On the non-owning side, @OneToMany is also related to the N+1 problem.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Hibernate: 
    select
        ~~~
    from
        data d1_0 
    where
        d1_0.id=?
Hibernate: 
    select
        ~~~
    from
        extra_data ed1_0 
    where
        ed1_0.data_id=?

Regardless of FetchType.LAZY/EAGER, @OneToOne issues a SELECT rather than a JOIN when queried. This holds true even when optional = false is set.

Why does this happen?

The non-owning side has no column for the FK, so it doesn’t know whether that FK is NULL or not — hence the SELECT.

So why can @OneToMany behave lazily?

   
Characteristic@OneToOne (entity proxy)@OneToMany (collection proxy)
Initialization methodInitialized as a single entity proxyInitialized as a collection proxy object
Data load timingWhen the entity field is accessedWhen a collection method is accessed

The collection proxy is a type provided by the JPA implementation (Hibernate).

You’d need to debug to see the exact data structure, but a collection proxy can represent NULL, while an entity proxy cannot.

Can represent NULL = it’s fine for NULL to come in = SELECT isn’t mandatory

Cannot represent NULL = NULL is not allowed = SELECT is mandatory

This post is licensed under CC BY-NC 4.0 by the author.